Merge branch 'master' of https://github.com/helix-editor/helix into refactor-tree-explorer

pull/9/head
wongjiahau 2 years ago
commit 70984fd148

@ -1,3 +0,0 @@
[alias]
xtask = "run --package xtask --"
integration-test = "test --features integration --workspace --test integration"

@ -0,0 +1,3 @@
[alias]
xtask = "run --package xtask --"
integration-test = "test --features integration --profile integration --workspace --test integration"

@ -32,7 +32,7 @@ body:
id: helix-log id: helix-log
attributes: attributes:
label: Helix log label: Helix log
description: See `hx -h` for log file path description: See `hx -h` for log file path. If you can reproduce the issue run `RUST_BACKTRACE=1 hx -vv` to generate a more detailed log file.
value: | value: |
<details><summary>~/.cache/helix/helix.log</summary> <details><summary>~/.cache/helix/helix.log</summary>
@ -61,7 +61,8 @@ body:
label: Helix Version label: Helix Version
description: > description: >
Helix version (`hx -V` if using a release, `git describe` if building Helix version (`hx -V` if using a release, `git describe` if building
from master) from master).
placeholder: "helix 0.6.0 (c0dbd6dc)" **Make sure that you are using the [latest helix release](https://github.com/helix-editor/helix/releases) or a newer master build**
placeholder: "helix 22.12 (5eaa6d97)"
validations: validations:
required: true required: true

@ -0,0 +1,13 @@
---
name: Enhancement
about: Suggest an improvement
title: ''
labels: C-enhancement
assignees: ''
---
<!--
Your enhancement may already be reported!
Please search on the issue tracker before creating a new issue.
If this is an idea for a feature, please open an "Idea" Discussion instead.
-->

@ -1,13 +0,0 @@
---
name: Feature request
about: Suggest a new feature or improvement
title: ''
labels: C-enhancement
assignees: ''
---
<!-- Your feature may already be reported!
Please search on the issue tracker before creating one. -->
#### Describe your feature request

@ -4,36 +4,27 @@ on:
push: push:
branches: branches:
- master - master
merge_group:
schedule: schedule:
- cron: '00 01 * * *' - cron: '00 01 * * *'
jobs: jobs:
check: check:
name: Check name: Check (msrv)
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
rust: [stable, msrv]
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Use MSRV rust toolchain
if: matrix.rust == 'msrv'
run: cp .github/workflows/msrv-rust-toolchain.toml rust-toolchain.toml
- name: Install stable toolchain - name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1 uses: helix-editor/rust-toolchain@v1
with: with:
profile: minimal profile: minimal
override: true override: true
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Run cargo check - name: Run cargo check
uses: actions-rs/cargo@v1 run: cargo check
with:
command: check
test: test:
name: Test Suite name: Test Suite
@ -46,12 +37,9 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install stable toolchain - name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1 uses: dtolnay/rust-toolchain@1.63
with:
profile: minimal
override: true
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Cache test tree-sitter grammar - name: Cache test tree-sitter grammar
uses: actions/cache@v3 uses: actions/cache@v3
@ -61,15 +49,10 @@ jobs:
restore-keys: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars- restore-keys: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars-
- name: Run cargo test - name: Run cargo test
uses: actions-rs/cargo@v1 run: cargo test --workspace
with:
command: test
args: --workspace
- name: Run cargo integration-test - name: Run cargo integration-test
uses: actions-rs/cargo@v1 run: cargo integration-test
with:
command: integration-test
strategy: strategy:
matrix: matrix:
@ -83,25 +66,22 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install stable toolchain - name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1 uses: dtolnay/rust-toolchain@1.63
with: with:
profile: minimal
override: true
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Run cargo fmt - name: Run cargo fmt
uses: actions-rs/cargo@v1 run: cargo fmt --all --check
with:
command: fmt
args: --all -- --check
- name: Run cargo clippy - name: Run cargo clippy
uses: actions-rs/cargo@v1 run: cargo clippy --workspace --all-targets -- -D warnings
with:
command: clippy - name: Run cargo doc
args: --all-targets -- -D warnings run: cargo doc --no-deps --workspace --document-private-items
env:
RUSTDOCFLAGS: -D warnings
docs: docs:
name: Docs name: Docs
@ -111,18 +91,15 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install stable toolchain - name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1 uses: dtolnay/rust-toolchain@1.63
with:
profile: minimal
override: true
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Validate queries
run: cargo xtask query-check
- name: Generate docs - name: Generate docs
uses: actions-rs/cargo@v1 run: cargo xtask docgen
with:
command: xtask
args: docgen
- name: Check uncommitted documentation changes - name: Check uncommitted documentation changes
run: | run: |

@ -14,13 +14,13 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install nix - name: Install nix
uses: cachix/install-nix-action@v17 uses: cachix/install-nix-action@v19
- name: Authenticate with Cachix - name: Authenticate with Cachix
uses: cachix/cachix-action@v10 uses: cachix/cachix-action@v12
with: with:
name: helix name: helix
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Build nix flake - name: Build nix flake
run: nix build run: nix build -L

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

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

@ -4,6 +4,18 @@ on:
tags: tags:
- '[0-9]+.[0-9]+' - '[0-9]+.[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+' - '[0-9]+.[0-9]+.[0-9]+'
branches:
- 'patch/ci-release-*'
pull_request:
paths:
- '.github/workflows/release.yml'
env:
# Preview mode: Publishes the build output as a CI artifact instead of creating
# a release, allowing for manual inspection of the output. This mode is
# activated if the CI run was triggered by events other than pushed tags, or
# if the repository is a fork.
preview: ${{ !startsWith(github.ref, 'refs/tags/') || github.repository != 'helix-editor/helix' }}
jobs: jobs:
fetch-grammars: fetch-grammars:
@ -14,18 +26,12 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install stable toolchain - name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1 uses: dtolnay/rust-toolchain@stable
with:
profile: minimal
override: true
- uses: Swatinem/rust-cache@v1 - uses: Swatinem/rust-cache@v2
- name: Fetch tree-sitter grammars - name: Fetch tree-sitter grammars
uses: actions-rs/cargo@v1 run: cargo run --package=helix-loader --bin=hx-loader
with:
command: run
args: --package=helix-loader --bin=hx-loader
- name: Bundle grammars - name: Bundle grammars
run: tar cJf grammars.tar.xz -C runtime/grammars/sources . run: tar cJf grammars.tar.xz -C runtime/grammars/sources .
@ -38,6 +44,16 @@ jobs:
dist: dist:
name: Dist name: Dist
needs: [fetch-grammars] needs: [fetch-grammars]
env:
# For some builds, we use cross to test on 32-bit and big-endian
# systems.
CARGO: cargo
# When CARGO is set to CROSS, this is set to `--target matrix.target`.
TARGET_FLAGS:
# When CARGO is set to CROSS, TARGET_DIR includes matrix.target.
TARGET_DIR: ./target
# Emit backtraces on panics.
RUST_BACKTRACE: 1
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false # don't fail other jobs if one fails fail-fast: false # don't fail other jobs if one fails
@ -45,22 +61,27 @@ jobs:
build: [x86_64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc build: [x86_64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc
include: include:
- build: x86_64-linux - build: x86_64-linux
os: ubuntu-20.04 os: ubuntu-latest
rust: stable rust: stable
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
cross: false cross: false
# - build: aarch64-linux - build: aarch64-linux
# os: ubuntu-20.04 os: ubuntu-latest
# rust: stable rust: stable
# target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
# cross: true cross: true
- build: riscv64-linux
os: ubuntu-latest
rust: stable
target: riscv64gc-unknown-linux-gnu
cross: true
- build: x86_64-macos - build: x86_64-macos
os: macos-latest os: macos-latest
rust: stable rust: stable
target: x86_64-apple-darwin target: x86_64-apple-darwin
cross: false cross: false
- build: x86_64-windows - build: x86_64-windows
os: windows-2019 os: windows-latest
rust: stable rust: stable
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
cross: false cross: false
@ -93,44 +114,46 @@ jobs:
tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources
- name: Install ${{ matrix.rust }} toolchain - name: Install ${{ matrix.rust }} toolchain
uses: actions-rs/toolchain@v1 uses: dtolnay/rust-toolchain@master
with: with:
profile: minimal
toolchain: ${{ matrix.rust }} toolchain: ${{ matrix.rust }}
target: ${{ matrix.target }} target: ${{ matrix.target }}
override: true
- name: Run cargo test # Install a pre-release version of Cross
uses: actions-rs/cargo@v1 # TODO: We need to pre-install Cross because we need cross-rs/cross#591 to
if: "!matrix.skip_tests" # get a newer C++ compiler toolchain. Remove this step when Cross
with: # 0.3.0, which includes cross-rs/cross#591, is released.
use-cross: ${{ matrix.cross }} - name: Install Cross
command: test if: "matrix.cross"
args: --release --locked --target ${{ matrix.target }} --workspace 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: Build release binary - name: Show command used for Cargo
uses: actions-rs/cargo@v1 run: |
with: echo "cargo command is: ${{ env.CARGO }}"
use-cross: ${{ matrix.cross }} echo "target flag is: ${{ env.TARGET_FLAGS }}"
command: build
args: --release --locked --target ${{ matrix.target }}
- name: Strip release binary (linux and macos) - name: Run cargo test
if: matrix.build == 'x86_64-linux' || endsWith(matrix.build, 'macos') if: "!matrix.skip_tests"
run: strip "target/${{ matrix.target }}/release/hx" run: ${{ env.CARGO }} test --release --locked --target ${{ matrix.target }} --workspace
- name: Strip release binary (arm) - name: Set profile.release.strip = true
if: matrix.build == 'aarch64-linux' shell: bash
run: | run: |
docker run --rm -v \ cat >> .cargo/config.toml <<EOF
"$PWD/target:/target:Z" \ [profile.release]
rustembedded/cross:${{ matrix.target }} \ strip = true
aarch64-linux-gnu-strip \ EOF
/target/${{ matrix.target }}/release/hx
- name: Build release binary
run: ${{ env.CARGO }} build --release --locked --target ${{ matrix.target }}
- name: Build AppImage - name: Build AppImage
shell: bash shell: bash
if: matrix.build == 'x86_64-linux' if: matrix.build == 'aarch64-linux' || matrix.build == 'x86_64-linux'
run: | run: |
mkdir dist mkdir dist
@ -139,8 +162,10 @@ jobs:
name=${GITHUB_REF:10} name=${GITHUB_REF:10}
fi fi
build="${{ matrix.build }}"
export VERSION="$name" export VERSION="$name"
export ARCH=x86_64 export ARCH=${build%-linux}
export APP=helix export APP=helix
export OUTPUT="helix-$VERSION-$ARCH.AppImage" export OUTPUT="helix-$VERSION-$ARCH.AppImage"
export UPDATE_INFORMATION="gh-releases-zsync|$GITHUB_REPOSITORY_OWNER|helix|latest|$APP-*-$ARCH.AppImage.zsync" export UPDATE_INFORMATION="gh-releases-zsync|$GITHUB_REPOSITORY_OWNER|helix|latest|$APP-*-$ARCH.AppImage.zsync"
@ -199,16 +224,6 @@ jobs:
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
- name: Calculate tag name
run: |
name=dev
if [[ $GITHUB_REF == refs/tags/* ]]; then
name=${GITHUB_REF:10}
fi
echo ::set-output name=val::$name
echo TAG=$name >> $GITHUB_ENV
id: tagname
- name: Build archive - name: Build archive
shell: bash shell: bash
run: | run: |
@ -228,7 +243,7 @@ jobs:
if [[ $platform =~ "windows" ]]; then if [[ $platform =~ "windows" ]]; then
exe=".exe" exe=".exe"
fi fi
pkgname=helix-$TAG-$platform pkgname=helix-$GITHUB_REF_NAME-$platform
mkdir $pkgname mkdir $pkgname
cp $source/LICENSE $source/README.md $pkgname cp $source/LICENSE $source/README.md $pkgname
mkdir $pkgname/contrib mkdir $pkgname/contrib
@ -237,7 +252,7 @@ jobs:
mv bins-$platform/hx$exe $pkgname mv bins-$platform/hx$exe $pkgname
chmod +x $pkgname/hx$exe chmod +x $pkgname/hx$exe
if [[ "$platform" = "x86_64-linux" ]]; then if [[ "$platform" = "aarch64-linux" || "$platform" = "x86_64-linux" ]]; then
mv bins-$platform/helix-*.AppImage* dist/ mv bins-$platform/helix-*.AppImage* dist/
fi fi
@ -248,14 +263,22 @@ jobs:
fi fi
done done
tar cJf dist/helix-$TAG-source.tar.xz -C $source . tar cJf dist/helix-$GITHUB_REF_NAME-source.tar.xz -C $source .
mv dist $source/ mv dist $source/
- name: Upload binaries to release - name: Upload binaries to release
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
if: env.preview == 'false'
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
file: dist/* file: dist/*
file_glob: true file_glob: true
tag: ${{ steps.tagname.outputs.val }} tag: ${{ github.ref_name }}
overwrite: true overwrite: true
- name: Upload binaries as artifact
uses: actions/upload-artifact@v3
if: env.preview == 'true'
with:
name: release
path: dist/*

@ -1,3 +1,558 @@
# 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))
# 22.08 (2022-08-31)
A big _thank you_ to our contributors! This release had 87 contributors.
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.05..22.08).
Breaking changes:
- Special keymap names for `+`, `;` and `%` have been replaced with those literal characters ([#2677](https://github.com/helix-editor/helix/pull/2677), [#3556](https://github.com/helix-editor/helix/pull/3556))
- `A-Left` and `A-Right` have become `C-Left` and `C-Right` for word-wise motion ([#2500](https://github.com/helix-editor/helix/pull/2500))
- The `catppuccin` theme's name has been corrected from `catpuccin` ([#2713](https://github.com/helix-editor/helix/pull/2713))
- `catppuccin` has been replaced by its variants, `catppuccin_frappe`, `catppuccin_latte`, `catppuccin_macchiato`, `catppuccin_mocha` ([#3281](https://github.com/helix-editor/helix/pull/3281))
- `C-n` and `C-p` have been removed from the default insert mode keymap ([#3340](https://github.com/helix-editor/helix/pull/3340))
- The `extend_line` command has been replaced with `extend_line_below` and a new `extend_line` command now exists ([#3046](https://github.com/helix-editor/helix/pull/3046))
Features:
- Add an integration testing harness ([#2359](https://github.com/helix-editor/helix/pull/2359))
- Indent guides ([#1796](https://github.com/helix-editor/helix/pull/1796), [906259c](https://github.com/helix-editor/helix/commit/906259c))
- Cursorline ([#2170](https://github.com/helix-editor/helix/pull/2170), [fde9e03](https://github.com/helix-editor/helix/commit/fde9e03))
- Select all instances of the symbol under the cursor (`<space>h`) ([#2738](https://github.com/helix-editor/helix/pull/2738))
- A picker for document and workspace LSP diagnostics (`<space>g`/`<space>G`) ([#2013](https://github.com/helix-editor/helix/pull/2013), [#2984](https://github.com/helix-editor/helix/pull/2984))
- Allow styling the mode indicator per-mode ([#2676](https://github.com/helix-editor/helix/pull/2676))
- Live preview for the theme picker ([#1798](https://github.com/helix-editor/helix/pull/1798))
- Configurable statusline ([#2434](https://github.com/helix-editor/helix/pull/2434))
- LSP SignatureHelp ([#1755](https://github.com/helix-editor/helix/pull/1755), [a8b123f](https://github.com/helix-editor/helix/commit/a8b123f))
- A picker for the jumplist ([#3033](https://github.com/helix-editor/helix/pull/3033))
- Configurable external formatter binaries ([#2942](https://github.com/helix-editor/helix/pull/2942))
- Bracketed paste support ([#3233](https://github.com/helix-editor/helix/pull/3233), [12ddd03](https://github.com/helix-editor/helix/commit/12ddd03))
Commands:
- `:insert-output` and `:append-output` which insert/append output from a shell command ([#2589](https://github.com/helix-editor/helix/pull/2589))
- The `t` textobject (`]t`/`[t`/`mit`/`mat`) for navigating tests ([#2807](https://github.com/helix-editor/helix/pull/2807))
- `C-Backspace` and `C-Delete` for word-wise deletion in prompts and pickers ([#2500](https://github.com/helix-editor/helix/pull/2500))
- `A-Delete` for forward word-wise deletion in insert mode ([#2500](https://github.com/helix-editor/helix/pull/2500))
- `C-t` for toggling the preview pane in pickers ([#3021](https://github.com/helix-editor/helix/pull/3021))
- `extend_line` now extends in the direction of the cursor ([#3046](https://github.com/helix-editor/helix/pull/3046))
Usability improvements and fixes:
- Fix tree-sitter parser builds on illumos ([#2602](https://github.com/helix-editor/helix/pull/2602))
- Remove empty stratch buffer from jumplists when removing ([5ed6223](https://github.com/helix-editor/helix/commit/5ed6223))
- Fix panic on undo after `shell_append_output` ([#2625](https://github.com/helix-editor/helix/pull/2625))
- Sort LSP edits by start range ([3d91c99](https://github.com/helix-editor/helix/commit/3d91c99))
- Be more defensive about LSP URI conversions ([6de6a3e](https://github.com/helix-editor/helix/commit/6de6a3e), [378f438](https://github.com/helix-editor/helix/commit/378f438))
- Ignore SendErrors when grammar builds fail ([#2641](https://github.com/helix-editor/helix/pull/2641))
- Append `set_line_ending` to document history ([#2649](https://github.com/helix-editor/helix/pull/2649))
- Use last prompt entry when empty ([b14c258](https://github.com/helix-editor/helix/commit/b14c258), [#2870](https://github.com/helix-editor/helix/pull/2870))
- Do not add extra line breaks in markdown lists ([#2689](https://github.com/helix-editor/helix/pull/2689))
- Disable dialyzer by default for ElixirLS ([#2710](https://github.com/helix-editor/helix/pull/2710))
- Refactor textobject node capture ([#2741](https://github.com/helix-editor/helix/pull/2741))
- Prevent re-selecting the same range with `expand_selection` ([#2760](https://github.com/helix-editor/helix/pull/2760))
- Introduce `keyword.storage` highlight scope ([#2731](https://github.com/helix-editor/helix/pull/2731))
- Handle symlinks more consistently ([#2718](https://github.com/helix-editor/helix/pull/2718))
- Improve markdown list rendering ([#2687](https://github.com/helix-editor/helix/pull/2687))
- Update auto-pairs and idle-timout settings when the config is reloaded ([#2736](https://github.com/helix-editor/helix/pull/2736))
- Fix panic on closing last buffer ([#2658](https://github.com/helix-editor/helix/pull/2658))
- Prevent modifying jumplist until jumping to a reference ([#2670](https://github.com/helix-editor/helix/pull/2670))
- Ensure `:quit` and `:quit!` take no arguments ([#2654](https://github.com/helix-editor/helix/pull/2654))
- Fix crash due to cycles when replaying macros ([#2647](https://github.com/helix-editor/helix/pull/2647))
- Pass LSP FormattingOptions ([#2635](https://github.com/helix-editor/helix/pull/2635))
- Prevent showing colors when the health-check is piped ([#2836](https://github.com/helix-editor/helix/pull/2836))
- Use character indexing for mouse selection ([#2839](https://github.com/helix-editor/helix/pull/2839))
- Display the highest severity diagnostic for a line in the gutter ([#2835](https://github.com/helix-editor/helix/pull/2835))
- Default the ruler color to red background ([#2669](https://github.com/helix-editor/helix/pull/2669))
- Make `move_vertically` aware of tabs and wide characters ([#2620](https://github.com/helix-editor/helix/pull/2620))
- Enable shellwords for Windows ([#2767](https://github.com/helix-editor/helix/pull/2767))
- Add history suggestions to global search ([#2717](https://github.com/helix-editor/helix/pull/2717))
- Fix the scrollbar's length proportional to total menu items ([#2860](https://github.com/helix-editor/helix/pull/2860))
- Reset terminal modifiers for diagnostic text ([#2861](https://github.com/helix-editor/helix/pull/2861), [#2900](https://github.com/helix-editor/helix/pull/2900))
- Redetect indents and line-endings after a Language Server replaces the document ([#2778](https://github.com/helix-editor/helix/pull/2778))
- Check selection's visible width when copying on mouse click ([#2711](https://github.com/helix-editor/helix/pull/2711))
- Fix edge-case in tree-sitter `expand_selection` command ([#2877](https://github.com/helix-editor/helix/pull/2877))
- Add a single-width left margin for the completion popup ([#2728](https://github.com/helix-editor/helix/pull/2728))
- Right-align the scrollbar in the completion popup ([#2754](https://github.com/helix-editor/helix/pull/2754))
- Fix recursive macro crash and empty macro lockout ([#2902](https://github.com/helix-editor/helix/pull/2902))
- Fix backwards character deletion on other whitespaces ([#2855](https://github.com/helix-editor/helix/pull/2855))
- Add search and space/backspace bindings to view modes ([#2803](https://github.com/helix-editor/helix/pull/2803))
- Add `--vsplit` and `--hsplit` CLI arguments for opening in splits ([#2773](https://github.com/helix-editor/helix/pull/2773), [#3073](https://github.com/helix-editor/helix/pull/3073))
- Sort themes, languages and files inputs by score and name ([#2675](https://github.com/helix-editor/helix/pull/2675))
- Highlight entire rows in ([#2939](https://github.com/helix-editor/helix/pull/2939))
- Fix backwards selection duplication widening bug ([#2945](https://github.com/helix-editor/helix/pull/2945), [#3024](https://github.com/helix-editor/helix/pull/3024))
- Skip serializing Option type DAP fields ([44f5963](https://github.com/helix-editor/helix/commit/44f5963))
- Fix required `cwd` field in DAP `RunTerminalArguments` type ([85411be](https://github.com/helix-editor/helix/commit/85411be), [#3240](https://github.com/helix-editor/helix/pull/3240))
- Add LSP `workspace/applyEdit` to client capabilities ([#3012](https://github.com/helix-editor/helix/pull/3012))
- Respect count for repeating motion ([#3057](https://github.com/helix-editor/helix/pull/3057))
- Respect count for selecting next/previous match ([#3056](https://github.com/helix-editor/helix/pull/3056))
- Respect count for tree-sitter motions ([#3058](https://github.com/helix-editor/helix/pull/3058))
- Make gutters padding optional ([#2996](https://github.com/helix-editor/helix/pull/2996))
- Support pre-filling prompts ([#2459](https://github.com/helix-editor/helix/pull/2459), [#3259](https://github.com/helix-editor/helix/pull/3259))
- Add statusline element to display file line-endings ([#3113](https://github.com/helix-editor/helix/pull/3113))
- Keep jump and file history when using `:split` ([#3031](https://github.com/helix-editor/helix/pull/3031), [#3160](https://github.com/helix-editor/helix/pull/3160))
- Make tree-sitter query `; inherits <language>` feature imperative ([#2470](https://github.com/helix-editor/helix/pull/2470))
- Indent with tabs by default ([#3095](https://github.com/helix-editor/helix/pull/3095))
- Fix non-msvc grammar compilation on Windows ([#3190](https://github.com/helix-editor/helix/pull/3190))
- Add spacer element to the statusline ([#3165](https://github.com/helix-editor/helix/pull/3165), [255c173](https://github.com/helix-editor/helix/commit/255c173))
- Make gutters padding automatic ([#3163](https://github.com/helix-editor/helix/pull/3163))
- Add `code` for LSP `Diagnostic` type ([#3096](https://github.com/helix-editor/helix/pull/3096))
- Add position percentage to the statusline ([#3168](https://github.com/helix-editor/helix/pull/3168))
- Add a configurable and themable statusline separator string ([#3175](https://github.com/helix-editor/helix/pull/3175))
- Use OR of all selections when `search_selection` acts on multiple selections ([#3138](https://github.com/helix-editor/helix/pull/3138))
- Add clipboard information to logs and the healthcheck ([#3271](https://github.com/helix-editor/helix/pull/3271))
- Fix align selection behavior on tabs ([#3276](https://github.com/helix-editor/helix/pull/3276))
- Fix terminal cursor shape reset ([#3289](https://github.com/helix-editor/helix/pull/3289))
- Add an `injection.include-unnamed-children` predicate to injections queries ([#3129](https://github.com/helix-editor/helix/pull/3129))
- Add a `-c`/`--config` CLI flag for specifying config file location ([#2666](https://github.com/helix-editor/helix/pull/2666))
- Detect indent-style in `:set-language` command ([#3330](https://github.com/helix-editor/helix/pull/3330))
- Fix non-deterministic highlighting ([#3275](https://github.com/helix-editor/helix/pull/3275))
- Avoid setting the stdin handle when not necessary ([#3248](https://github.com/helix-editor/helix/pull/3248), [#3379](https://github.com/helix-editor/helix/pull/3379))
- Fix indent guide styling ([#3324](https://github.com/helix-editor/helix/pull/3324))
- Fix tab highlight when tab is partially visible ([#3313](https://github.com/helix-editor/helix/pull/3313))
- Add completion for nested settings ([#3183](https://github.com/helix-editor/helix/pull/3183))
- Advertise WorkspaceSymbolClientCapabilities LSP client capability ([#3361](https://github.com/helix-editor/helix/pull/3361))
- Remove duplicate entries from the theme picker ([#3439](https://github.com/helix-editor/helix/pull/3439))
- Shorted output for grammar fetching and building ([#3396](https://github.com/helix-editor/helix/pull/3396))
- Add a `tabpad` option for visible tab padding whitespace characters ([#3458](https://github.com/helix-editor/helix/pull/3458))
- Make DAP external terminal provider configurable ([cb7615e](https://github.com/helix-editor/helix/commit/cb7615e))
- Use health checkmark character with shorter width ([#3505](https://github.com/helix-editor/helix/pull/3505))
- Reset document mode to normal on view focus loss ([e4c9d40](https://github.com/helix-editor/helix/commit/e4c9d40))
- Render indented code-blocks in markdown ([#3503](https://github.com/helix-editor/helix/pull/3503))
- Add WezTerm to DAP terminal provider defaults ([#3588](https://github.com/helix-editor/helix/pull/3588))
- Derive `Document` language name from `languages.toml` `name` key ([#3338](https://github.com/helix-editor/helix/pull/3338))
- Fix process spawning error handling ([#3349](https://github.com/helix-editor/helix/pull/3349))
- Don't resolve links for `:o` completion ([8a4fbf6](https://github.com/helix-editor/helix/commit/8a4fbf6))
- Recalculate completion after pasting into prompt ([e77b7d1](https://github.com/helix-editor/helix/commit/e77b7d1))
- Fix extra selections with regex anchors ([#3598](https://github.com/helix-editor/helix/pull/3598))
- Move mode transition logic to `handle_keymap_event` ([#2634](https://github.com/helix-editor/helix/pull/2634))
- Add documents to view history when using the jumplist ([#3593](https://github.com/helix-editor/helix/pull/3593))
- Prevent panic when loading tree-sitter queries ([fa1dc7e](https://github.com/helix-editor/helix/commit/fa1dc7e))
- Discard LSP publishDiagnostic when LS is not initialized ([#3403](https://github.com/helix-editor/helix/pull/3403))
- Refactor tree-sitter textobject motions as repeatable motions ([#3264](https://github.com/helix-editor/helix/pull/3264))
- Avoid command execution hooks on closed docs ([#3613](https://github.com/helix-editor/helix/pull/3613))
- Share `restore_term` code between panic and normal exits ([#2612](https://github.com/helix-editor/helix/pull/2612))
- Show clipboard info in `--health` output ([#2947](https://github.com/helix-editor/helix/pull/2947))
- Recalculate completion when going through prompt history ([#3193](https://github.com/helix-editor/helix/pull/3193))
Themes:
- Update `tokyonight` and `tokyonight_storm` themes ([#2606](https://github.com/helix-editor/helix/pull/2606))
- Update `solarized_light` themes ([#2626](https://github.com/helix-editor/helix/pull/2626))
- Fix `catpuccin` `ui.popup` theme ([#2644](https://github.com/helix-editor/helix/pull/2644))
- Update selection style of `night_owl` ([#2668](https://github.com/helix-editor/helix/pull/2668))
- Fix spelling of `catppuccin` theme ([#2713](https://github.com/helix-editor/helix/pull/2713))
- Update `base16_default`'s `ui.menu` ([#2794](https://github.com/helix-editor/helix/pull/2794))
- Add `noctis_bordo` ([#2830](https://github.com/helix-editor/helix/pull/2830))
- Add `acme` ([#2876](https://github.com/helix-editor/helix/pull/2876))
- Add `meliora` ([#2884](https://github.com/helix-editor/helix/pull/2884), [#2890](https://github.com/helix-editor/helix/pull/2890))
- Add cursorline scopes to various themes ([33d287a](https://github.com/helix-editor/helix/commit/33d287a), [#2892](https://github.com/helix-editor/helix/pull/2892), [#2915](https://github.com/helix-editor/helix/pull/2915), [#2916](https://github.com/helix-editor/helix/pull/2916), [#2918](https://github.com/helix-editor/helix/pull/2918), [#2927](https://github.com/helix-editor/helix/pull/2927), [#2925](https://github.com/helix-editor/helix/pull/2925), [#2938](https://github.com/helix-editor/helix/pull/2938), [#2962](https://github.com/helix-editor/helix/pull/2962), [#3054](https://github.com/helix-editor/helix/pull/3054))
- Add mode colors to various themes ([#2926](https://github.com/helix-editor/helix/pull/2926), [#2933](https://github.com/helix-editor/helix/pull/2933), [#2929](https://github.com/helix-editor/helix/pull/2929), [#3098](https://github.com/helix-editor/helix/pull/3098), [#3104](https://github.com/helix-editor/helix/pull/3104), [#3128](https://github.com/helix-editor/helix/pull/3128), [#3135](https://github.com/helix-editor/helix/pull/3135), [#3200](https://github.com/helix-editor/helix/pull/3200))
- Add `nord_light` ([#2908](https://github.com/helix-editor/helix/pull/2908))
- Update `night_owl` ([#2929](https://github.com/helix-editor/helix/pull/2929))
- Update `autumn` ([2e70985](https://github.com/helix-editor/helix/commit/2e70985), [936ed3a](https://github.com/helix-editor/helix/commit/936ed3a))
- Update `one_dark` ([#3011](https://github.com/helix-editor/helix/pull/3011))
- Add `noctis` ([#3043](https://github.com/helix-editor/helix/pull/3043), [#3128](https://github.com/helix-editor/helix/pull/3128))
- Update `boo_berry` ([#3191](https://github.com/helix-editor/helix/pull/3191))
- Update `monokai` ([#3131](https://github.com/helix-editor/helix/pull/3131))
- Add `ayu_dark`, `ayu_light`, `ayu_mirage` ([#3184](https://github.com/helix-editor/helix/pull/3184))
- Update `onelight` ([#3226](https://github.com/helix-editor/helix/pull/3226))
- Add `base16_transparent` ([#3216](https://github.com/helix-editor/helix/pull/3216), [b565fff](https://github.com/helix-editor/helix/commit/b565fff))
- Add `flatwhite` ([#3236](https://github.com/helix-editor/helix/pull/3236))
- Update `dark_plus` ([#3302](https://github.com/helix-editor/helix/pull/3302))
- Add `doom_acario_dark` ([#3308](https://github.com/helix-editor/helix/pull/3308), [#3539](https://github.com/helix-editor/helix/pull/3539))
- Add `rose_pine_moon` ([#3229](https://github.com/helix-editor/helix/pull/3229))
- Update `spacebones_light` ([#3342](https://github.com/helix-editor/helix/pull/3342))
- Fix typos in themes ([8deaebd](https://github.com/helix-editor/helix/commit/8deaebd), [#3412](https://github.com/helix-editor/helix/pull/3412))
- Add `emacs` ([#3410](https://github.com/helix-editor/helix/pull/3410))
- Add `papercolor-light` ([#3426](https://github.com/helix-editor/helix/pull/3426), [#3470](https://github.com/helix-editor/helix/pull/3470), [#3585](https://github.com/helix-editor/helix/pull/3585))
- Add `penumbra+` ([#3398](https://github.com/helix-editor/helix/pull/3398))
- Add `fleetish` ([#3591](https://github.com/helix-editor/helix/pull/3591), [#3607](https://github.com/helix-editor/helix/pull/3607))
- Add `sonokai` ([#3595](https://github.com/helix-editor/helix/pull/3595))
- Update all themes for theme lints ([#3587](https://github.com/helix-editor/helix/pull/3587))
LSP:
- V ([#2526](https://github.com/helix-editor/helix/pull/2526))
- Prisma ([#2703](https://github.com/helix-editor/helix/pull/2703))
- Clojure ([#2780](https://github.com/helix-editor/helix/pull/2780))
- WGSL ([#2872](https://github.com/helix-editor/helix/pull/2872))
- Elvish ([#2948](https://github.com/helix-editor/helix/pull/2948))
- Idris ([#2971](https://github.com/helix-editor/helix/pull/2971))
- Fortran ([#3025](https://github.com/helix-editor/helix/pull/3025))
- Gleam ([#3139](https://github.com/helix-editor/helix/pull/3139))
- Odin ([#3214](https://github.com/helix-editor/helix/pull/3214))
New languages:
- V ([#2526](https://github.com/helix-editor/helix/pull/2526))
- EDoc ([#2640](https://github.com/helix-editor/helix/pull/2640))
- JSDoc ([#2650](https://github.com/helix-editor/helix/pull/2650))
- OpenSCAD ([#2680](https://github.com/helix-editor/helix/pull/2680))
- Prisma ([#2703](https://github.com/helix-editor/helix/pull/2703))
- Clojure ([#2780](https://github.com/helix-editor/helix/pull/2780))
- Starlark ([#2903](https://github.com/helix-editor/helix/pull/2903))
- Elvish ([#2948](https://github.com/helix-editor/helix/pull/2948))
- Fortran ([#3025](https://github.com/helix-editor/helix/pull/3025))
- Ungrammar ([#3048](https://github.com/helix-editor/helix/pull/3048))
- SCSS ([#3074](https://github.com/helix-editor/helix/pull/3074))
- Go Template ([#3091](https://github.com/helix-editor/helix/pull/3091))
- Graphviz dot ([#3241](https://github.com/helix-editor/helix/pull/3241))
- Cue ([#3262](https://github.com/helix-editor/helix/pull/3262))
- Slint ([#3355](https://github.com/helix-editor/helix/pull/3355))
- Beancount ([#3297](https://github.com/helix-editor/helix/pull/3297))
- Taskwarrior ([#3468](https://github.com/helix-editor/helix/pull/3468))
- xit ([#3521](https://github.com/helix-editor/helix/pull/3521))
- ESDL ([#3526](https://github.com/helix-editor/helix/pull/3526))
- Awk ([#3528](https://github.com/helix-editor/helix/pull/3528), [#3535](https://github.com/helix-editor/helix/pull/3535))
- Pascal ([#3542](https://github.com/helix-editor/helix/pull/3542))
Updated languages and queries:
- Nix ([#2472](https://github.com/helix-editor/helix/pull/2472))
- Elixir ([#2619](https://github.com/helix-editor/helix/pull/2619))
- CPON ([#2643](https://github.com/helix-editor/helix/pull/2643))
- Textobjects queries for Erlang, Elixir, Gleam ([#2661](https://github.com/helix-editor/helix/pull/2661))
- Capture rust closures as function textobjects ([4a27e2d](https://github.com/helix-editor/helix/commit/4a27e2d))
- Heex ([#2800](https://github.com/helix-editor/helix/pull/2800), [#3170](https://github.com/helix-editor/helix/pull/3170))
- Add `<<=` operator highlighting for Rust ([#2805](https://github.com/helix-editor/helix/pull/2805))
- Fix comment injection in JavaScript/TypeScript ([#2763](https://github.com/helix-editor/helix/pull/2763))
- Nickel ([#2859](https://github.com/helix-editor/helix/pull/2859))
- Add `Rakefile` and `Gemfile` to Ruby file-types ([#2875](https://github.com/helix-editor/helix/pull/2875))
- Erlang ([#2910](https://github.com/helix-editor/helix/pull/2910), [ac669ad](https://github.com/helix-editor/helix/commit/ac669ad))
- Markdown ([#2910](https://github.com/helix-editor/helix/pull/2910), [#3108](https://github.com/helix-editor/helix/pull/3108), [#3400](https://github.com/helix-editor/helix/pull/3400))
- Bash ([#2910](https://github.com/helix-editor/helix/pull/2910))
- Rust ([#2910](https://github.com/helix-editor/helix/pull/2910), [#3397](https://github.com/helix-editor/helix/pull/3397))
- Edoc ([#2910](https://github.com/helix-editor/helix/pull/2910))
- HTML ([#2910](https://github.com/helix-editor/helix/pull/2910))
- Make ([#2910](https://github.com/helix-editor/helix/pull/2910))
- TSQ ([#2910](https://github.com/helix-editor/helix/pull/2910), [#2960](https://github.com/helix-editor/helix/pull/2960))
- git-commit ([#2910](https://github.com/helix-editor/helix/pull/2910))
- Use default fallback for Python indents ([9ae70cc](https://github.com/helix-editor/helix/commit/9ae70cc))
- Add Haskell LSP roots ([#2954](https://github.com/helix-editor/helix/pull/2954))
- Ledger ([#2936](https://github.com/helix-editor/helix/pull/2936), [#2988](https://github.com/helix-editor/helix/pull/2988))
- Nickel ([#2987](https://github.com/helix-editor/helix/pull/2987))
- JavaScript/TypeScript ([#2961](https://github.com/helix-editor/helix/pull/2961), [#3219](https://github.com/helix-editor/helix/pull/3219), [#3213](https://github.com/helix-editor/helix/pull/3213), [#3280](https://github.com/helix-editor/helix/pull/3280), [#3301](https://github.com/helix-editor/helix/pull/3301))
- GLSL ([#3051](https://github.com/helix-editor/helix/pull/3051))
- Fix locals tracking in Rust ([#3027](https://github.com/helix-editor/helix/pull/3027), [#3212](https://github.com/helix-editor/helix/pull/3212), [#3345](https://github.com/helix-editor/helix/pull/3345))
- Verilog ([#3158](https://github.com/helix-editor/helix/pull/3158))
- Ruby ([#3173](https://github.com/helix-editor/helix/pull/3173), [#3527](https://github.com/helix-editor/helix/pull/3527))
- Svelte ([#3147](https://github.com/helix-editor/helix/pull/3147))
- Add Elixir and HEEx comment textobjects ([#3179](https://github.com/helix-editor/helix/pull/3179))
- Python ([#3103](https://github.com/helix-editor/helix/pull/3103), [#3201](https://github.com/helix-editor/helix/pull/3201), [#3284](https://github.com/helix-editor/helix/pull/3284))
- PHP ([#3317](https://github.com/helix-editor/helix/pull/3317))
- Latex ([#3370](https://github.com/helix-editor/helix/pull/3370))
- Clojure ([#3387](https://github.com/helix-editor/helix/pull/3387))
- Swift ([#3461](https://github.com/helix-editor/helix/pull/3461))
- C# ([#3480](https://github.com/helix-editor/helix/pull/3480), [#3494](https://github.com/helix-editor/helix/pull/3494))
- Org ([#3489](https://github.com/helix-editor/helix/pull/3489))
- Elm ([#3497](https://github.com/helix-editor/helix/pull/3497))
- Dart ([#3419](https://github.com/helix-editor/helix/pull/3419))
- Julia ([#3507](https://github.com/helix-editor/helix/pull/3507))
- Fix Rust textobjects ([#3590](https://github.com/helix-editor/helix/pull/3590))
- C ([00d88e5](https://github.com/helix-editor/helix/commit/00d88e5))
- Update Rust ([0ef0ef9](https://github.com/helix-editor/helix/commit/0ef0ef9))
Packaging:
- Add `rust-analyzer` to Nix flake devShell ([#2739](https://github.com/helix-editor/helix/pull/2739))
- Add cachix information to the Nix flake ([#2999](https://github.com/helix-editor/helix/pull/2999))
- Pass makeWrapperArgs to wrapProgram in the Nix flake ([#3003](https://github.com/helix-editor/helix/pull/3003))
- Add a way to override which grammars are built by Nix ([#3141](https://github.com/helix-editor/helix/pull/3141))
- Add a GitHub actions release for `aarch64-macos` ([#3137](https://github.com/helix-editor/helix/pull/3137))
- Add shell auto-completions for Elvish ([#3331](https://github.com/helix-editor/helix/pull/3331))
# 22.05 (2022-05-28) # 22.05 (2022-05-28)
An even bigger shout out than usual to all the contributors - we had a whopping An even bigger shout out than usual to all the contributors - we had a whopping
@ -415,7 +970,7 @@ Usability improvements and fixes:
- File picker configuration ([#988](https://github.com/helix-editor/helix/pull/988)) - File picker configuration ([#988](https://github.com/helix-editor/helix/pull/988))
- Fix surround cursor position calculation ([#1183](https://github.com/helix-editor/helix/pull/1183)) - Fix surround cursor position calculation ([#1183](https://github.com/helix-editor/helix/pull/1183))
- Accept count for goto_window ([#1033](https://github.com/helix-editor/helix/pull/1033)) - Accept count for goto_window ([#1033](https://github.com/helix-editor/helix/pull/1033))
- Make kill_to_line_end behave like emacs ([#1235](https://github.com/helix-editor/helix/pull/1235)) - Make kill_to_line_end behave like Emacs ([#1235](https://github.com/helix-editor/helix/pull/1235))
- Only use a single documentation popup ([#1241](https://github.com/helix-editor/helix/pull/1241)) - Only use a single documentation popup ([#1241](https://github.com/helix-editor/helix/pull/1241))
- ui: popup: Don't allow scrolling past the end of content ([`3307f44c`](https://github.com/helix-editor/helix/commit/3307f44c)) - ui: popup: Don't allow scrolling past the end of content ([`3307f44c`](https://github.com/helix-editor/helix/commit/3307f44c))
- Open files with spaces in filename, allow opening multiple files ([#1231](https://github.com/helix-editor/helix/pull/1231)) - Open files with spaces in filename, allow opening multiple files ([#1231](https://github.com/helix-editor/helix/pull/1231))
@ -653,7 +1208,7 @@ to distinguish it in bug reports..
on cargo run. `~/.config/helix/runtime` can also be used. on cargo run. `~/.config/helix/runtime` can also be used.
- Registers can now be selected via " (for example `"ay`) - Registers can now be selected via " (for example `"ay`)
- Support for Nix files was added - Support for Nix files was added
- Movement is now fully tested and matches kakoune implementation - Movement is now fully tested and matches Kakoune implementation
- A per-file LSP symbol picker was added to space+s - A per-file LSP symbol picker was added to space+s
- Selection can be replaced with yanked text via R - Selection can be replaced with yanked text via R
@ -677,7 +1232,7 @@ Keymaps:
- The runtime/ can now optionally be embedded in the binary - The runtime/ can now optionally be embedded in the binary
- Haskell syntax added - Haskell syntax added
- Window mode (ctrl-w) added - Window mode (ctrl-w) added
- Show matching bracket (vim's matchbrackets) - Show matching bracket (Vim's matchbrackets)
- Themes now support style modifiers - Themes now support style modifiers
- First user contributed theme - First user contributed theme
- Create a document if it doesn't exist yet on save - Create a document if it doesn't exist yet on save

1435
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -7,6 +7,7 @@ members = [
"helix-lsp", "helix-lsp",
"helix-dap", "helix-dap",
"helix-loader", "helix-loader",
"helix-vcs",
"xtask", "xtask",
] ]
@ -14,9 +15,6 @@ default-members = [
"helix-term" "helix-term"
] ]
[profile.dev]
split-debuginfo = "unpacked"
[profile.release] [profile.release]
lto = "thin" lto = "thin"
# debug = true # debug = true
@ -27,3 +25,9 @@ lto = "fat"
codegen-units = 1 codegen-units = 1
# strip = "debuginfo" # TODO: or strip = true # strip = "debuginfo" # TODO: or strip = true
opt-level = 3 opt-level = 3
[profile.integration]
inherits = "test"
package.helix-core.opt-level = 2
package.helix-tui.opt-level = 2
package.helix-term.opt-level = 2

@ -1,13 +1,27 @@
# Helix <div align="center">
<h1>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="logo_dark.svg">
<source media="(prefers-color-scheme: light)" srcset="logo_light.svg">
<img alt="Helix" height="128" src="logo_light.svg">
</picture>
</h1>
[![Build status](https://github.com/helix-editor/helix/actions/workflows/build.yml/badge.svg)](https://github.com/helix-editor/helix/actions) [![Build status](https://github.com/helix-editor/helix/actions/workflows/build.yml/badge.svg)](https://github.com/helix-editor/helix/actions)
[![GitHub Release](https://img.shields.io/github/v/release/helix-editor/helix)](https://github.com/helix-editor/helix/releases/latest)
[![Documentation](https://shields.io/badge/-documentation-452859)](https://docs.helix-editor.com/)
[![GitHub contributors](https://img.shields.io/github/contributors/helix-editor/helix)](https://github.com/helix-editor/helix/graphs/contributors)
[![Matrix Space](https://img.shields.io/matrix/helix-community:matrix.org)](https://matrix.to/#/#helix-community:matrix.org)
</div>
![Screenshot](./screenshot.png) ![Screenshot](./screenshot.png)
A kakoune / neovim inspired editor, written in Rust. A Kakoune / Neovim inspired editor, written in Rust.
The editing model is very heavily based on kakoune; during development I found The editing model is very heavily based on Kakoune; during development I found
myself agreeing with most of kakoune's design decisions. myself agreeing with most of Kakoune's design decisions.
For more information, see the [website](https://helix-editor.com) or For more information, see the [website](https://helix-editor.com) or
[documentation](https://docs.helix-editor.com/). [documentation](https://docs.helix-editor.com/).
@ -24,7 +38,7 @@ All shortcuts/keymaps can be found [in the documentation on the website](https:/
- Smart, incremental syntax highlighting and code editing via tree-sitter - Smart, incremental syntax highlighting and code editing via tree-sitter
It's a terminal-based editor first, but I'd like to explore a custom renderer It's a terminal-based editor first, but I'd like to explore a custom renderer
(similar to emacs) in wgpu or skulpin. (similar to Emacs) in wgpu or skulpin.
Note: Only certain languages have indentation definitions at the moment. Check Note: Only certain languages have indentation definitions at the moment. Check
`runtime/queries/<lang>/` for `indents.scm`. `runtime/queries/<lang>/` for `indents.scm`.
@ -38,23 +52,45 @@ If you would like to build from source:
```shell ```shell
git clone https://github.com/helix-editor/helix git clone https://github.com/helix-editor/helix
cd helix cd helix
cargo install --path helix-term cargo install --locked --path helix-term
``` ```
This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars. This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`.
If you want to customize your `languages.toml` config,
tree-sitter grammars may be manually fetched and built with `hx --grammar fetch` and `hx --grammar build`.
Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the Helix needs its runtime files so make sure to copy/symlink the `runtime/` directory into the
config directory (for example `~/.config/helix/runtime` on Linux/macOS, or `%AppData%/helix/runtime` on Windows). config directory (for example `~/.config/helix/runtime` on Linux/macOS, or `%AppData%/helix/runtime` on Windows).
| OS | Command | | OS | Command |
| -------------------- | -------------------------------------------- | | -------------------- | ------------------------------------------------ |
| Windows (cmd.exe) | `xcopy /e runtime %AppData%\helix\runtime` | | Windows (Cmd) | `xcopy /e /i runtime %AppData%\helix\runtime` |
| Windows (PowerShell) | `xcopy /e runtime $Env:AppData\helix\runtime` | | Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` |
| Linux/macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` | | Linux / macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` |
Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires
elevated privileges - i.e. PowerShell or Cmd must be run as administrator.
**PowerShell:**
```powershell
New-Item -ItemType Junction -Target "runtime" -Path "$Env:AppData\helix\runtime"
```
Note: "runtime" must be absolute path to the runtime directory.
**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 --locked --path helix-term`,
> tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`.
This location can be overridden via the `HELIX_RUNTIME` environment variable. If you plan on keeping the repo locally, an alternative to copying/symlinking
runtime files is to set `HELIX_RUNTIME=/path/to/helix/runtime`
(`HELIX_RUNTIME=$PWD/runtime` if you're in the helix repo directory).
Packages already solve this for you by wrapping the `hx` binary with a wrapper Packages already solve this for you by wrapping the `hx` binary with a wrapper
that sets the variable to the install dir. that sets the variable to the install dir.
@ -62,18 +98,36 @@ that sets the variable to the install dir.
> NOTE: running via cargo also doesn't require setting explicit `HELIX_RUNTIME` path, it will automatically > NOTE: running via cargo also doesn't require setting explicit `HELIX_RUNTIME` path, it will automatically
> detect the `runtime` directory in the project root. > detect the `runtime` directory in the project root.
If you want to customize your `languages.toml` config,
tree-sitter grammars may be manually fetched and built with `hx --grammar fetch` and `hx --grammar build`.
In order to use LSP features like auto-complete, you will need to In order to use LSP features like auto-complete, you will need to
[install the appropriate Language Server](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers) [install the appropriate Language Server](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers)
for a language. for a language.
[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions) [![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions)
## MacOS ## Adding Helix to your desktop environment
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
cp contrib/helix.png ~/.local/share/icons
```
To use another terminal than the default, you will need to modify the `.desktop` file. For example, to use `kitty`:
```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
```
## macOS
Helix can be installed on MacOS through homebrew via: Helix can be installed on macOS through homebrew:
``` ```
brew tap helix-editor/helix
brew install helix brew install helix
``` ```
@ -86,3 +140,7 @@ Contributing guidelines can be found [here](./docs/CONTRIBUTING.md).
Your question might already be answered on the [FAQ](https://github.com/helix-editor/helix/wiki/FAQ). Your question might already be answered on the [FAQ](https://github.com/helix-editor/helix/wiki/FAQ).
Discuss the project on the community [Matrix Space](https://matrix.to/#/#helix-community:matrix.org) (make sure to join `#helix-editor:matrix.org` if you're on a client that doesn't support Matrix Spaces yet). Discuss the project on the community [Matrix Space](https://matrix.to/#/#helix-community:matrix.org) (make sure to join `#helix-editor:matrix.org` if you're on a client that doesn't support Matrix Spaces yet).
# Credits
Thanks to [@JakeHL](https://github.com/JakeHL) for designing the logo!

@ -1 +1 @@
22.05 22.12

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

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

@ -11,7 +11,6 @@
- [Configuration](./configuration.md) - [Configuration](./configuration.md)
- [Themes](./themes.md) - [Themes](./themes.md)
- [Key Remapping](./remapping.md) - [Key Remapping](./remapping.md)
- [Hooks](./hooks.md)
- [Languages](./languages.md) - [Languages](./languages.md)
- [Guides](./guides/README.md) - [Guides](./guides/README.md)
- [Adding Languages](./guides/adding_languages.md) - [Adding Languages](./guides/adding_languages.md)

@ -1,5 +1,5 @@
# Commands # Commands
Command mode can be activated by pressing `:`, similar to vim. Built-in commands: Command mode can be activated by pressing `:`, similar to Vim. Built-in commands:
{{#include ./generated/typable-cmd.md}} {{#include ./generated/typable-cmd.md}}

@ -28,27 +28,34 @@ hidden = false
You may also specify a file to use for configuration with the `-c` or You may also specify a file to use for configuration with the `-c` or
`--config` CLI argument: `hx -c path/to/custom-config.toml`. `--config` CLI argument: `hx -c path/to/custom-config.toml`.
It is also possible to trigger configuration file reloading by sending the `USR1`
signal to the helix process, e.g. via `pkill -USR1 hx`. This is only supported
on unix operating systems.
## Editor ## Editor
### `[editor]` Section ### `[editor]` Section
| Key | Description | Default | | Key | Description | Default |
|--|--|---------| |--|--|---------|
| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling. | `3` | | `scrolloff` | Number of lines of padding around the edge of the screen when scrolling. | `5` |
| `mouse` | Enable mouse mode. | `true` | | `mouse` | Enable mouse mode. | `true` |
| `middle-click-paste` | Middle click paste support. | `true` | | `middle-click-paste` | Middle click paste support. | `true` |
| `scroll-lines` | Number of lines to scroll per scroll wheel step. | `3` | | `scroll-lines` | Number of lines to scroll per scroll wheel step. | `3` |
| `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`<br/>Windows: `["cmd", "/C"]` | | `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`<br/>Windows: `["cmd", "/C"]` |
| `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` | | `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` |
| `cursorline` | Highlight all lines with a cursor. | `false` | | `cursorline` | Highlight all lines with a cursor. | `false` |
| `gutters` | Gutters to display: Available are `diagnostics` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "line-numbers"]` | | `cursorcolumn` | Highlight all columns with a cursor. | `false` |
| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` |
| `auto-completion` | Enable automatic pop up of auto-completion. | `true` | | `auto-completion` | Enable automatic pop up of auto-completion. | `true` |
| `auto-format` | Enable automatic formatting on save. | `true` | | `auto-format` | Enable automatic formatting on save. | `true` |
| `auto-save` | Enable automatic saving on focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal. | `false` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` |
| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
| `auto-info` | Whether to display infoboxes | `true` | | `auto-info` | Whether to display infoboxes | `true` |
| `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. | `false` | | `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. | `false` |
| `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file. | `[]` | | `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file. | `[]` |
| `bufferline` | Renders a line at the top of the editor displaying open buffers. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use) | `never` |
| `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` | | `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` |
### `[editor.statusline]` Section ### `[editor.statusline]` Section
@ -67,20 +74,38 @@ left = ["mode", "spinner"]
center = ["file-name"] center = ["file-name"]
right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"] right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"]
separator = "│" separator = "│"
mode.normal = "NORMAL"
mode.insert = "INSERT"
mode.select = "SELECT"
``` ```
The `[editor.statusline]` key takes the following sub-keys:
| Key | Description | Default |
| --- | --- | --- |
| `left` | A list of elements aligned to the left of the statusline | `["mode", "spinner", "file-name"]` |
| `center` | A list of elements aligned to the middle of the statusline | `[]` |
| `right` | A list of elements aligned to the right of the statusline | `["diagnostics", "selections", "position", "file-encoding"]` |
| `separator` | The character used to separate elements in the statusline | `"│"` |
| `mode.normal` | The text shown in the `mode` element for normal mode | `"NOR"` |
| `mode.insert` | The text shown in the `mode` element for insert mode | `"INS"` |
| `mode.select` | The text shown in the `mode` element for select mode | `"SEL"` |
The following elements can be configured: The following statusline elements can be configured:
| Key | Description | | Key | Description |
| ------ | ----------- | | ------ | ----------- |
| `mode` | The current editor mode (`NOR`/`INS`/`SEL`) | | `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) |
| `spinner` | A progress spinner indicating LSP activity | | `spinner` | A progress spinner indicating LSP activity |
| `file-name` | The path/name of the opened file | | `file-name` | The path/name of the opened file |
| `file-base-name` | The basename of the opened file |
| `file-encoding` | The encoding of the opened file if it differs from UTF-8 | | `file-encoding` | The encoding of the opened file if it differs from UTF-8 |
| `file-line-ending` | The file line endings (CRLF or LF) | | `file-line-ending` | The file line endings (CRLF or LF) |
| `total-line-numbers` | The total line numbers of the opened file |
| `file-type` | The type of the opened file | | `file-type` | The type of the opened file |
| `diagnostics` | The number of warnings and/or errors | | `diagnostics` | The number of warnings and/or errors |
| `workspace-diagnostics` | The number of warnings and/or errors on workspace |
| `selections` | The number of active selections | | `selections` | The number of active selections |
| `primary-selection-length` | The number of characters currently in primary selection |
| `position` | The cursor position | | `position` | The cursor position |
| `position-percentage` | The cursor position as a percentage of the total number of lines | | `position-percentage` | The cursor position as a percentage of the total number of lines |
| `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) | | `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) |
@ -90,6 +115,7 @@ The following elements can be configured:
| Key | Description | Default | | Key | Description | Default |
| --- | ----------- | ------- | | --- | ----------- | ------- |
| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` |
| `display-messages` | Display LSP progress messages below statusline[^1] | `false` | | `display-messages` | Display LSP progress messages below statusline[^1] | `false` |
| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` | | `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` |
| `display-signature-help-docs` | Display docs under signature help popup | `true` | | `display-signature-help-docs` | Display docs under signature help popup | `true` |
@ -125,6 +151,8 @@ All git related options are only enabled in a git repository.
| Key | Description | Default | | Key | Description | Default |
|--|--|---------| |--|--|---------|
|`hidden` | Enables ignoring hidden files. | true |`hidden` | Enables ignoring hidden files. | true
|`follow-links` | Follow symlinks instead of ignoring them | true
|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | true
|`parents` | Enables reading ignore files from parent directories. | true |`parents` | Enables reading ignore files from parent directories. | true
|`ignore` | Enables reading `.ignore` files. | true |`ignore` | Enables reading `.ignore` files. | true
|`git-ignore` | Enables reading `.gitignore` files. | true |`git-ignore` | Enables reading `.gitignore` files. | true
@ -193,7 +221,7 @@ Options for rendering whitespace with visible characters. Use `:set whitespace.r
| Key | Description | Default | | Key | Description | Default |
|-----|-------------|---------| |-----|-------------|---------|
| `render` | Whether to render whitespace. May either be `"all"` or `"none"`, or a table with sub-keys `space`, `tab`, and `newline`. | `"none"` | | `render` | Whether to render whitespace. May either be `"all"` or `"none"`, or a table with sub-keys `space`, `nbsp`, `tab`, and `newline`. | `"none"` |
| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `newline` or `tabpad` | See example below | | `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `newline` or `tabpad` | See example below |
Example Example
@ -219,17 +247,92 @@ tabpad = "·" # Tabs will look like "→···" (depending on tab width)
Options for rendering vertical indent guides. Options for rendering vertical indent guides.
| Key | Description | Default | | Key | Description | Default |
| --- | --- | --- | | --- | --- | --- |
| `render` | Whether to render indent guides. | `false` | | `render` | Whether to render indent guides. | `false` |
| `character` | Literal character to use for rendering the indent guide | `│` | | `character` | Literal character to use for rendering the indent guide | `│` |
| `skip-levels` | Number of indent levels to skip | `0` |
Example: Example:
```toml ```toml
[editor.indent-guides] [editor.indent-guides]
render = true render = true
character = "╎" character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽"
skip-levels = 1
```
### `[editor.gutters]` Section
For simplicity, `editor.gutters` accepts an array of gutter types, which will
use default settings for all gutter components.
```toml
[editor]
gutters = ["diff", "diagnostics", "line-numbers", "spacer"]
```
To customize the behavior of gutters, the `[editor.gutters]` section must
be used. This section contains top level settings, as well as settings for
specific gutter components as sub-sections.
| Key | Description | Default |
| --- | --- | --- |
| `layout` | A vector of gutters to display | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` |
Example:
```toml
[editor.gutters]
layout = ["diff", "diagnostics", "line-numbers", "spacer"]
```
#### `[editor.gutters.line-numbers]` Section
Options for the line number gutter
| Key | Description | Default |
| --- | --- | --- |
| `min-width` | The minimum number of characters to use | `3` |
Example:
```toml
[editor.gutters.line-numbers]
min-width = 1
```
#### `[editor.gutters.diagnotics]` Section
Currently unused
#### `[editor.gutters.diff]` Section
Currently unused
#### `[editor.gutters.spacer]` Section
Currently unused
### `[editor.soft-wrap]` Section
Options for soft wrapping lines that exceed the view width
| Key | Description | Default |
| --- | --- | --- |
| `enable` | Whether soft wrapping is enabled. | `false` |
| `max-wrap` | Maximum free space left at the end of the line. | `20` |
| `max-indent-retain` | Maximum indentation to carry over when soft wrapping a line. | `40` |
| `wrap-indicator` | Text inserted before soft wrapped lines, highlighted with `ui.virtual.wrap` | `↪ ` |
Example:
```toml
[editor.soft-wrap]
enable = true
max-wrap = 25 # increase value to reduce forced mid-word wrapping
max-indent-retain = 0
wrap-indicator = "" # set wrap-indicator to "" to hide it
``` ```
### `[editor.explorer]` Section ### `[editor.explorer]` Section

@ -1,7 +1,7 @@
# Migrating from Vim # Migrating from Vim
Helix's editing model is strongly inspired from vim and kakoune, and a notable Helix's editing model is strongly inspired from Vim and Kakoune, and a notable
difference from vim (and the most striking similarity to kakoune) is that Helix difference from Vim (and the most striking similarity to Kakoune) is that Helix
follows the `selection → action` model. This means that the whatever you are follows the `selection → action` model. This means that the whatever you are
going to act on (a word, a paragraph, a line, etc) is selected first and the going to act on (a word, a paragraph, a line, etc) is selected first and the
action itself (delete, change, yank, etc) comes second. A cursor is simply a action itself (delete, change, yank, etc) comes second. A cursor is simply a

@ -1,60 +1,74 @@
| Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default LSP | | Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default LSP |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| astro | ✓ | | | |
| awk | ✓ | ✓ | | `awk-language-server` | | awk | ✓ | ✓ | | `awk-language-server` |
| bash | ✓ | | | `bash-language-server` | | bash | ✓ | | ✓ | `bash-language-server` |
| bass | ✓ | | | `bass` |
| beancount | ✓ | | | | | beancount | ✓ | | | |
| bibtex | ✓ | | | `texlab` |
| bicep | ✓ | | | `bicep-langserver` |
| c | ✓ | ✓ | ✓ | `clangd` | | c | ✓ | ✓ | ✓ | `clangd` |
| c-sharp | ✓ | | | `OmniSharp` | | c-sharp | ✓ | | | `OmniSharp` |
| cairo | ✓ | | | | | cairo | ✓ | | | |
| clojure | ✓ | | | `clojure-lsp` | | clojure | ✓ | | | `clojure-lsp` |
| cmake | ✓ | ✓ | ✓ | `cmake-language-server` | | cmake | ✓ | ✓ | ✓ | `cmake-language-server` |
| comment | ✓ | | | | | comment | ✓ | | | |
| common-lisp | ✓ | | | `cl-lsp` |
| cpon | ✓ | | ✓ | | | cpon | ✓ | | ✓ | |
| cpp | ✓ | ✓ | ✓ | `clangd` | | cpp | ✓ | ✓ | ✓ | `clangd` |
| crystal | ✓ | ✓ | | |
| css | ✓ | | | `vscode-css-language-server` | | css | ✓ | | | `vscode-css-language-server` |
| cue | ✓ | | | `cuelsp` | | cue | ✓ | | | `cuelsp` |
| d | ✓ | ✓ | ✓ | `serve-d` |
| dart | ✓ | | ✓ | `dart` | | dart | ✓ | | ✓ | `dart` |
| devicetree | ✓ | | ✓ | | | devicetree | ✓ | | | |
| dhall | ✓ | ✓ | | `dhall-lsp-server` |
| diff | ✓ | | | |
| dockerfile | ✓ | | | `docker-langserver` | | dockerfile | ✓ | | | `docker-langserver` |
| dot | ✓ | | | `dot-language-server` | | dot | ✓ | | | `dot-language-server` |
| edoc | ✓ | | | | | edoc | ✓ | | | |
| eex | ✓ | | | | | eex | ✓ | | | |
| ejs | ✓ | | | | | ejs | ✓ | | | |
| elixir | ✓ | ✓ | | `elixir-ls` | | elixir | ✓ | ✓ | | `elixir-ls` |
| elm | ✓ | | | `elm-language-server` | | elm | ✓ | | | `elm-language-server` |
| elvish | ✓ | | | `elvish` | | elvish | ✓ | | | `elvish` |
| env | ✓ | | | |
| erb | ✓ | | | | | erb | ✓ | | | |
| erlang | ✓ | ✓ | | `erlang_ls` | | erlang | ✓ | ✓ | | `erlang_ls` |
| esdl | ✓ | | | | | esdl | ✓ | | | |
| fish | ✓ | ✓ | ✓ | | | fish | ✓ | ✓ | ✓ | |
| fortran | ✓ | | ✓ | `fortls` | | fortran | ✓ | | ✓ | `fortls` |
| gdscript | ✓ | | ✓ | | | gdscript | ✓ | | ✓ | |
| git-attributes | ✓ | | | | | git-attributes | ✓ | | | |
| git-commit | ✓ | | | | | git-commit | ✓ | | | |
| git-config | ✓ | | | | | git-config | ✓ | | | |
| git-diff | ✓ | | | |
| git-ignore | ✓ | | | | | git-ignore | ✓ | | | |
| git-rebase | ✓ | | | | | git-rebase | ✓ | | | |
| gleam | ✓ | ✓ | | `gleam` | | gleam | ✓ | ✓ | | `gleam` |
| glsl | ✓ | ✓ | ✓ | | | glsl | ✓ | ✓ | ✓ | |
| go | ✓ | ✓ | ✓ | `gopls` | | go | ✓ | ✓ | ✓ | `gopls` |
| godot-resource | ✓ | | | |
| gomod | ✓ | | | `gopls` | | gomod | ✓ | | | `gopls` |
| gotmpl | ✓ | | | `gopls` | | gotmpl | ✓ | | | `gopls` |
| gowork | ✓ | | | `gopls` | | gowork | ✓ | | | `gopls` |
| graphql | ✓ | | | | | graphql | ✓ | | | |
| hare | ✓ | | | | | hare | ✓ | | | |
| haskell | ✓ | | | `haskell-language-server-wrapper` | | haskell | ✓ | | | `haskell-language-server-wrapper` |
| hcl | ✓ | | ✓ | `terraform-ls` | | hcl | ✓ | | ✓ | `terraform-ls` |
| heex | ✓ | ✓ | | | | heex | ✓ | ✓ | | `elixir-ls` |
| hosts | ✓ | | | |
| html | ✓ | | | `vscode-html-language-server` | | html | ✓ | | | `vscode-html-language-server` |
| idris | | | | `idris2-lsp` | | idris | | | | `idris2-lsp` |
| iex | ✓ | | | | | iex | ✓ | | | |
| java | ✓ | | | `jdtls` | | ini | ✓ | | | |
| java | ✓ | ✓ | | `jdtls` |
| javascript | ✓ | ✓ | ✓ | `typescript-language-server` | | javascript | ✓ | ✓ | ✓ | `typescript-language-server` |
| jsdoc | ✓ | | | | | jsdoc | ✓ | | | |
| json | ✓ | | ✓ | `vscode-json-language-server` | | json | ✓ | | ✓ | `vscode-json-language-server` |
| jsonnet | ✓ | | | `jsonnet-language-server` |
| jsx | ✓ | ✓ | ✓ | `typescript-language-server` | | jsx | ✓ | ✓ | ✓ | `typescript-language-server` |
| julia | ✓ | | | `julia` | | julia | ✓ | | | `julia` |
| kdl | ✓ | | | |
| kotlin | ✓ | | | `kotlin-language-server` | | kotlin | ✓ | | | `kotlin-language-server` |
| latex | ✓ | ✓ | | `texlab` | | latex | ✓ | ✓ | | `texlab` |
| lean | ✓ | | | `lean` | | lean | ✓ | | | `lean` |
@ -62,58 +76,74 @@
| llvm | ✓ | ✓ | ✓ | | | llvm | ✓ | ✓ | ✓ | |
| llvm-mir | ✓ | ✓ | ✓ | | | llvm-mir | ✓ | ✓ | ✓ | |
| llvm-mir-yaml | ✓ | | ✓ | | | llvm-mir-yaml | ✓ | | ✓ | |
| lua | ✓ | | ✓ | `lua-language-server` | | lua | ✓ | | ✓ | `lua-language-server` |
| make | ✓ | | | | | make | ✓ | | | |
| markdown | ✓ | | | | | markdown | ✓ | | | `marksman` |
| markdown.inline | ✓ | | | | | markdown.inline | ✓ | | | |
| matlab | ✓ | | | |
| mermaid | ✓ | | | |
| meson | ✓ | | ✓ | | | meson | ✓ | | ✓ | |
| mint | | | | `mint` | | mint | | | | `mint` |
| msbuild | ✓ | | ✓ | |
| nickel | ✓ | | ✓ | `nls` | | nickel | ✓ | | ✓ | `nls` |
| nix | ✓ | | | `rnix-lsp` | | nix | ✓ | | | `nil` |
| nu | ✓ | | | | | nu | ✓ | | | |
| ocaml | ✓ | | ✓ | `ocamllsp` | | ocaml | ✓ | | ✓ | `ocamllsp` |
| ocaml-interface | ✓ | | | `ocamllsp` | | ocaml-interface | ✓ | | | `ocamllsp` |
| odin | ✓ | | | `ols` | | odin | ✓ | | | `ols` |
| openscad | ✓ | | | `openscad-language-server` | | openscad | ✓ | | | `openscad-lsp` |
| org | ✓ | | | | | org | ✓ | | | |
| pascal | ✓ | ✓ | | `pasls` |
| passwd | ✓ | | | |
| pem | ✓ | | | |
| perl | ✓ | ✓ | ✓ | | | perl | ✓ | ✓ | ✓ | |
| php | ✓ | ✓ | ✓ | `intelephense` | | php | ✓ | ✓ | ✓ | `intelephense` |
| ponylang | ✓ | ✓ | ✓ | |
| prisma | ✓ | | | `prisma-language-server` | | prisma | ✓ | | | `prisma-language-server` |
| prolog | | | | `swipl` | | prolog | | | | `swipl` |
| protobuf | ✓ | | ✓ | | | protobuf | ✓ | | ✓ | |
| python | ✓ | ✓ | | `pylsp` | | purescript | ✓ | | | `purescript-language-server` |
| python | ✓ | ✓ | ✓ | `pylsp` |
| qml | ✓ | | ✓ | `qmlls` |
| r | ✓ | | | `R` | | r | ✓ | | | `R` |
| racket | | | | `racket` | | racket | | | | `racket` |
| regex | ✓ | | | | | regex | ✓ | | | |
| rescript | ✓ | ✓ | | `rescript-language-server` | | rescript | ✓ | ✓ | | `rescript-language-server` |
| rmarkdown | ✓ | | ✓ | `R` | | rmarkdown | ✓ | | ✓ | `R` |
| ron | ✓ | | ✓ | | | ron | ✓ | | ✓ | |
| ruby | ✓ | ✓ | ✓ | `solargraph` | | ruby | ✓ | ✓ | ✓ | `solargraph` |
| rust | ✓ | ✓ | ✓ | `rust-analyzer` | | rust | ✓ | ✓ | ✓ | `rust-analyzer` |
| sage | ✓ | ✓ | | |
| scala | ✓ | | ✓ | `metals` | | scala | ✓ | | ✓ | `metals` |
| scheme | ✓ | | | | | scheme | ✓ | | | |
| scss | ✓ | | | `vscode-css-language-server` | | scss | ✓ | | | `vscode-css-language-server` |
| slint | ✓ | | ✓ | `slint-lsp` | | slint | ✓ | | ✓ | `slint-lsp` |
| sml | ✓ | | | |
| solidity | ✓ | | | `solc` | | solidity | ✓ | | | `solc` |
| sql | ✓ | | | | | sql | ✓ | | | |
| sshclientconfig | ✓ | | | | | sshclientconfig | ✓ | | | |
| starlark | ✓ | ✓ | | | | starlark | ✓ | ✓ | | |
| svelte | ✓ | | | `svelteserver` | | svelte | ✓ | | | `svelteserver` |
| swift | ✓ | | | `sourcekit-lsp` | | swift | ✓ | | | `sourcekit-lsp` |
| tablegen | ✓ | ✓ | ✓ | | | tablegen | ✓ | ✓ | ✓ | |
| task | ✓ | | | | | task | ✓ | | | |
| tfvars | | | | `terraform-ls` | | tfvars | | | | `terraform-ls` |
| toml | ✓ | | | `taplo` | | toml | ✓ | | | `taplo` |
| tsq | ✓ | | | | | tsq | ✓ | | | |
| tsx | ✓ | ✓ | ✓ | `typescript-language-server` | | tsx | ✓ | ✓ | ✓ | `typescript-language-server` |
| twig | ✓ | | | | | twig | ✓ | | | |
| typescript | ✓ | ✓ | ✓ | `typescript-language-server` | | typescript | ✓ | ✓ | ✓ | `typescript-language-server` |
| ungrammar | ✓ | | | | | ungrammar | ✓ | | | |
| v | ✓ | | | `vls` | | v | ✓ | | | `v` |
| vala | ✓ | | | `vala-language-server` | | vala | ✓ | | | `vala-language-server` |
| verilog | ✓ | ✓ | | `svlangserver` | | verilog | ✓ | ✓ | | `svlangserver` |
| vhs | ✓ | | | |
| vue | ✓ | | | `vls` | | vue | ✓ | | | `vls` |
| wast | ✓ | | | |
| wat | ✓ | | | |
| wgsl | ✓ | | | `wgsl_analyzer` | | wgsl | ✓ | | | `wgsl_analyzer` |
| wit | ✓ | | ✓ | |
| xit | ✓ | | | | | xit | ✓ | | | |
| xml | ✓ | | ✓ | |
| yaml | ✓ | | ✓ | `yaml-language-server` | | yaml | ✓ | | ✓ | `yaml-language-server` |
| zig | ✓ | | ✓ | `zls` | | zig | ✓ | | ✓ | `zls` |

@ -28,7 +28,7 @@
| `:quit-all!`, `:qa!` | Force close all views ignoring unsaved changes. | | `:quit-all!`, `:qa!` | Force close all views ignoring unsaved changes. |
| `:cquit`, `:cq` | Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2). | | `:cquit`, `:cq` | Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2). |
| `:cquit!`, `:cq!` | Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2). | | `:cquit!`, `:cq!` | Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2). |
| `:theme` | Change the editor theme. | | `:theme` | Change the editor theme (show current theme if no name specified). |
| `:clipboard-yank` | Yank main selection into system clipboard. | | `:clipboard-yank` | Yank main selection into system clipboard. |
| `:clipboard-yank-join` | Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline. | | `:clipboard-yank-join` | Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline. |
| `:primary-clipboard-yank` | Yank main selection into system primary clipboard. | | `:primary-clipboard-yank` | Yank main selection into system primary clipboard. |
@ -43,7 +43,12 @@
| `:change-current-directory`, `:cd` | Change the current working directory. | | `:change-current-directory`, `:cd` | Change the current working directory. |
| `:show-directory`, `:pwd` | Show the current working directory. | | `:show-directory`, `:pwd` | Show the current working directory. |
| `:encoding` | Set encoding. Based on `https://encoding.spec.whatwg.org`. | | `:encoding` | Set encoding. Based on `https://encoding.spec.whatwg.org`. |
| `:character-info`, `:char` | Get info about the character under the primary cursor. |
| `:reload` | Discard changes and reload from the source file. | | `:reload` | Discard changes and reload from the source file. |
| `:reload-all` | Discard changes and reload all documents from the source files. |
| `:update` | Write changes only if the file has been modified. |
| `:lsp-workspace-command` | Open workspace command picker |
| `:lsp-restart` | Restarts the Language Server that is in use by the current doc |
| `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. | | `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. |
| `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. | | `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. |
| `:debug-remote`, `:dbg-tcp` | Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters. | | `:debug-remote`, `:dbg-tcp` | Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters. |
@ -56,6 +61,7 @@
| `:goto`, `:g` | Goto line number. | | `:goto`, `:g` | Goto line number. |
| `:set-language`, `:lang` | Set the language of current buffer. | | `:set-language`, `:lang` | Set the language of current buffer. |
| `:set-option`, `:set` | Set a config option at runtime.<br>For example to disable smart case search, use `:set search.smart-case false`. | | `:set-option`, `:set` | Set a config option at runtime.<br>For example to disable smart case search, use `:set search.smart-case false`. |
| `:toggle-option`, `:toggle` | Toggle a boolean config option at runtime.<br>For example to toggle smart case search, use `:toggle search.smart-case`. |
| `:get-option`, `:get` | Get the current value of a config option. | | `:get-option`, `:get` | Get the current value of a config option. |
| `:sort` | Sort ranges in selection. | | `:sort` | Sort ranges in selection. |
| `:rsort` | Sort ranges in selection in reverse order. | | `:rsort` | Sort ranges in selection in reverse order. |
@ -64,7 +70,8 @@
| `:config-reload` | Refresh user config. | | `:config-reload` | Refresh user config. |
| `:config-open` | Open the user config.toml file. | | `:config-open` | Open the user config.toml file. |
| `:log-open` | Open the helix log file. | | `:log-open` | Open the helix log file. |
| `:insert-output` | Run shell command, inserting output after each selection. | | `:insert-output` | Run shell command, inserting output before each selection. |
| `:append-output` | Run shell command, appending output after each selection. | | `:append-output` | Run shell command, appending output after each selection. |
| `:pipe` | Pipe each selection to the shell command. | | `:pipe` | Pipe each selection to the shell command. |
| `:pipe-to` | Pipe each selection to the shell command, ignoring output. |
| `:run-shell-command`, `:sh` | Run a shell command | | `:run-shell-command`, `:sh` | Run a shell command |

@ -29,7 +29,7 @@ language with the path `runtime/queries/<name>/`. The tree-sitter
gives more info on how to write queries. gives more info on how to write queries.
> NOTE: When evaluating queries, the first matching query takes > NOTE: When evaluating queries, the first matching query takes
precedence, which is different from other editors like neovim where precedence, which is different from other editors like Neovim where
the last matching query supersedes the ones before it. See the last matching query supersedes the ones before it. See
[this issue][neovim-query-precedence] for an example. [this issue][neovim-query-precedence] for an example.

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

@ -6,10 +6,9 @@ We provide pre-built binaries on the [GitHub Releases page](https://github.com/h
## OSX ## OSX
A Homebrew tap is available: Helix is available in homebrew-core:
``` ```
brew tap helix-editor/helix
brew install helix brew install helix
``` ```
@ -51,34 +50,123 @@ sudo dnf install helix
sudo xbps-install helix sudo xbps-install helix
``` ```
## Windows
Helix can be installed using [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/)
or [MSYS2](https://msys2.org/).
**Scoop:**
```
scoop install helix
```
**Chocolatey:**
```
choco install helix
```
**MSYS2:**
Choose the [proper command](https://www.msys2.org/docs/package-naming/) for your system from below:
- For 32 bit Windows 7 or above:
```
pacman -S mingw-w64-i686-helix
```
- For 64 bit Windows 7 or above:
```
pacman -S mingw-w64-x86_64-helix
```
- For 64 bit Windows 8.1 or above:
```
pacman -S mingw-w64-ucrt-x86_64-helix
```
## Build from source ## Build from source
``` ```
git clone https://github.com/helix-editor/helix git clone https://github.com/helix-editor/helix
cd helix cd helix
cargo install --path helix-term cargo install --path helix-term --locked
``` ```
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 If you are using the musl-libc instead of glibc the following environment variable must be set during the build
to ensure tree sitter grammars can be loaded correctly:
```
RUSTFLAGS="-C target-feature=-crt-static"
```
Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the
config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overridden config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overridden
via the `HELIX_RUNTIME` environment variable. via the `HELIX_RUNTIME` environment variable.
| OS | command | | OS | Command |
|-------------------|-----------| | -------------------- | ------------------------------------------------ |
|windows(cmd.exe) |`xcopy /e runtime %AppData%/helix/runtime` | | Windows (Cmd) | `xcopy /e /i runtime %AppData%\helix\runtime` |
|windows(powershell)|`xcopy /e runtime $Env:AppData\helix\runtime` | | Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` |
|linux/macos |`ln -s $PWD/runtime ~/.config/helix/runtime`| | Linux / macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` |
Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires
elevated privileges - i.e. PowerShell or Cmd must be run as administrator.
**PowerShell:**
```powershell
New-Item -ItemType Junction -Target "runtime" -Path "$Env:AppData\helix\runtime"
```
Note: "runtime" must be the absolute path to the runtime directory.
**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 --locked`,
> tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`.
If you plan on keeping the repo locally, an alternative to copying/symlinking
runtime files is to set `HELIX_RUNTIME=/path/to/helix/runtime`
(`HELIX_RUNTIME=$PWD/runtime` if you're in the helix repo directory).
To use Helix in desktop environments that supports [XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html), including Gnome and KDE, copy the provided `.desktop` file to the correct folder:
```bash
cp contrib/Helix.desktop ~/.local/share/applications
```
To use another terminal than the default, you will need to modify the `.desktop` file. For example, to use `kitty`:
```bash
sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop
sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop
```
Please note: there is no icon for Helix yet, so the system default will be used.
## Finishing up the installation ## Finishing up the installation
To make sure everything is set up as expected you should finally run the helix healthcheck via To make sure everything is set up as expected you should finally run the helix healthcheck via
``` ```
hx --health hx --health
``` ```
For more information on the information displayed in the healthcheck results refer to [Healthcheck](https://github.com/helix-editor/helix/wiki/Healthcheck).
For more information on the information displayed in the health check results refer to [Healthcheck](https://github.com/helix-editor/helix/wiki/Healthcheck).
### Building tree-sitter grammars ### Building tree-sitter grammars

@ -27,7 +27,7 @@
### Movement ### Movement
> NOTE: Unlike vim, `f`, `F`, `t` and `T` are not confined to the current line. > NOTE: Unlike Vim, `f`, `F`, `t` and `T` are not confined to the current line.
| Key | Description | Command | | Key | Description | Command |
| ----- | ----------- | ------- | | ----- | ----------- | ------- |
@ -68,8 +68,8 @@
| `` Alt-` `` | Set the selected text to upper case | `switch_to_uppercase` | | `` Alt-` `` | Set the selected text to upper case | `switch_to_uppercase` |
| `i` | Insert before selection | `insert_mode` | | `i` | Insert before selection | `insert_mode` |
| `a` | Insert after selection (append) | `append_mode` | | `a` | Insert after selection (append) | `append_mode` |
| `I` | Insert at the start of the line | `prepend_to_line` | | `I` | Insert at the start of the line | `insert_at_line_start` |
| `A` | Insert at the end of the line | `append_to_line` | | `A` | Insert at the end of the line | `insert_at_line_end` |
| `o` | Open new line below selection | `open_below` | | `o` | Open new line below selection | `open_below` |
| `O` | Open new line above selection | `open_above` | | `O` | Open new line above selection | `open_above` |
| `.` | Repeat last insert | N/A | | `.` | Repeat last insert | N/A |
@ -111,6 +111,7 @@
| `s` | Select all regex matches inside selections | `select_regex` | | `s` | Select all regex matches inside selections | `select_regex` |
| `S` | Split selection into subselections on regex matches | `split_selection` | | `S` | Split selection into subselections on regex matches | `split_selection` |
| `Alt-s` | Split selection on newlines | `split_selection_on_newline` | | `Alt-s` | Split selection on newlines | `split_selection_on_newline` |
| `Alt-_ ` | Merge consecutive selections | `merge_consecutive_selections` |
| `&` | Align selection in columns | `align_selections` | | `&` | Align selection in columns | `align_selections` |
| `_` | Trim whitespace from the selection | `trim_selections` | | `_` | Trim whitespace from the selection | `trim_selections` |
| `;` | Collapse selection onto a single cursor | `collapse_selection` | | `;` | Collapse selection onto a single cursor | `collapse_selection` |
@ -125,10 +126,11 @@
| `Alt-(` | Rotate selection contents backward | `rotate_selection_contents_backward` | | `Alt-(` | Rotate selection contents backward | `rotate_selection_contents_backward` |
| `Alt-)` | Rotate selection contents forward | `rotate_selection_contents_forward` | | `Alt-)` | Rotate selection contents forward | `rotate_selection_contents_forward` |
| `%` | Select entire file | `select_all` | | `%` | Select entire file | `select_all` |
| `x` | Select current line, if already selected, extend to next line | `extend_line` | | `x` | Select current line, if already selected, extend to next line | `extend_line_below` |
| `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` | | `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` |
| `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` | | `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` |
| `J` | Join lines inside selection | `join_selections` | | `J` | Join lines inside selection | `join_selections` |
| `Alt-J` | Join lines inside selection and select the inserted space | `join_selections_space` |
| `K` | Keep selections matching the regex | `keep_selections` | | `K` | Keep selections matching the regex | `keep_selections` |
| `Alt-K` | Remove selections matching the regex | `remove_selections` | | `Alt-K` | Remove selections matching the regex | `remove_selections` |
| `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` | | `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` |
@ -164,12 +166,18 @@ These sub-modes are accessible from normal mode and typically switch back to nor
| `Ctrl-w` | Enter [window mode](#window-mode) | N/A | | `Ctrl-w` | Enter [window mode](#window-mode) | N/A |
| `Space` | Enter [space mode](#space-mode) | N/A | | `Space` | Enter [space mode](#space-mode) | N/A |
These modes (except command mode) can be configured by
[remapping keys](https://docs.helix-editor.com/remapping.html#minor-modes).
#### View mode #### View mode
Accessed by typing `z` in [normal mode](#normal-mode).
View mode is intended for scrolling and manipulating the view without changing View mode is intended for scrolling and manipulating the view without changing
the selection. The "sticky" variant of this mode is persistent; use the Escape the selection. The "sticky" variant of this mode (accessed by typing `Z` in
key to return to normal mode after usage (useful when you're simply looking normal mode) is persistent; use the Escape key to return to normal mode after
over text and not actively editing it). usage (useful when you're simply looking over text and not actively editing
it).
| Key | Description | Command | | Key | Description | Command |
@ -187,6 +195,8 @@ over text and not actively editing it).
#### Goto mode #### Goto mode
Accessed by typing `g` in [normal mode](#normal-mode).
Jumps to various locations. Jumps to various locations.
| Key | Description | Command | | Key | Description | Command |
@ -212,9 +222,10 @@ Jumps to various locations.
#### Match mode #### Match mode
Enter this mode using `m` from normal mode. See the relevant section Accessed by typing `m` in [normal mode](#normal-mode).
in [Usage](./usage.md) for an explanation about [surround](./usage.md#surround)
and [textobject](./usage.md#textobject) usage. See the relevant section in [Usage](./usage.md) for an explanation about
[surround](./usage.md#surround) and [textobject](./usage.md#textobjects) usage.
| Key | Description | Command | | Key | Description | Command |
| ----- | ----------- | ------- | | ----- | ----------- | ------- |
@ -229,7 +240,9 @@ TODO: Mappings for selecting syntax nodes (a superset of `[`).
#### Window mode #### Window mode
This layer is similar to vim keybindings as kakoune does not support window. 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 | | Key | Description | Command |
| ----- | ------------- | ------- | | ----- | ------------- | ------- |
@ -251,19 +264,21 @@ This layer is similar to vim keybindings as kakoune does not support window.
#### Space mode #### Space mode
This layer is a kludge of mappings, mostly pickers. Accessed by typing `Space` in [normal mode](#normal-mode).
This layer is a kludge of mappings, mostly pickers.
| Key | Description | Command | | Key | Description | Command |
| ----- | ----------- | ------- | | ----- | ----------- | ------- |
| `f` | Open file picker | `file_picker` | | `f` | Open file picker | `file_picker` |
| `F` | Open file picker at current working directory | `file_picker_in_current_directory` |
| `b` | Open buffer picker | `buffer_picker` | | `b` | Open buffer picker | `buffer_picker` |
| `j` | Open jumplist picker | `jumplist_picker` | | `j` | Open jumplist picker | `jumplist_picker` |
| `k` | Show documentation for item under cursor in a [popup](#popup) (**LSP**) | `hover` | | `k` | Show documentation for item under cursor in a [popup](#popup) (**LSP**) | `hover` |
| `s` | Open document symbol picker (**LSP**) | `symbol_picker` | | `s` | Open document symbol picker (**LSP**) | `symbol_picker` |
| `S` | Open workspace symbol picker (**LSP**) | `workspace_symbol_picker` | | `S` | Open workspace symbol picker (**LSP**) | `workspace_symbol_picker` |
| `g` | Open document diagnostics picker (**LSP**) | `diagnostics_picker` | | `d` | Open document diagnostics picker (**LSP**) | `diagnostics_picker` |
| `G` | Open workspace diagnostics picker (**LSP**) | `workspace_diagnostics_picker` | `D` | Open workspace diagnostics picker (**LSP**) | `workspace_diagnostics_picker` |
| `r` | Rename symbol (**LSP**) | `rename_symbol` | | `r` | Rename symbol (**LSP**) | `rename_symbol` |
| `a` | Apply code action (**LSP**) | `code_action` | | `a` | Apply code action (**LSP**) | `code_action` |
| `'` | Open last fuzzy picker | `last_picker` | | `'` | Open last fuzzy picker | `last_picker` |
@ -278,7 +293,7 @@ This layer is a kludge of mappings, mostly pickers.
| `e` | Open or focus explorer | `toggle_or_focus_explorer` | | `e` | Open or focus explorer | `toggle_or_focus_explorer` |
| `E` | Reveal current file in explorer | `reveal_current_file` | | `E` | Reveal current file in explorer | `reveal_current_file` |
> TIP: Global search displays results in a fuzzy picker, use `space + '` to bring it back up after opening a file. > TIP: Global search displays results in a fuzzy picker, use `Space + '` to bring it back up after opening a file.
##### Popup ##### Popup
@ -295,56 +310,80 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire
| Key | Description | Command | | Key | Description | Command |
| ----- | ----------- | ------- | | ----- | ----------- | ------- |
| `[d` | Go to previous diagnostic (**LSP**) | `goto_prev_diag` |
| `]d` | Go to next diagnostic (**LSP**) | `goto_next_diag` | | `]d` | Go to next diagnostic (**LSP**) | `goto_next_diag` |
| `[D` | Go to first diagnostic in document (**LSP**) | `goto_first_diag` | | `[d` | Go to previous diagnostic (**LSP**) | `goto_prev_diag` |
| `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` | | `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` |
| `[D` | Go to first diagnostic in document (**LSP**) | `goto_first_diag` |
| `]f` | Go to next function (**TS**) | `goto_next_function` | | `]f` | Go to next function (**TS**) | `goto_next_function` |
| `[f` | Go to previous function (**TS**) | `goto_prev_function` | | `[f` | Go to previous function (**TS**) | `goto_prev_function` |
| `]c` | Go to next class (**TS**) | `goto_next_class` | | `]t` | Go to next type definition (**TS**) | `goto_next_class` |
| `[c` | Go to previous class (**TS**) | `goto_prev_class` | | `[t` | Go to previous type definition (**TS**) | `goto_prev_class` |
| `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` | | `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` |
| `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` | | `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` |
| `]o` | Go to next comment (**TS**) | `goto_next_comment` | | `]c` | Go to next comment (**TS**) | `goto_next_comment` |
| `[o` | Go to previous comment (**TS**) | `goto_prev_comment` | | `[c` | Go to previous comment (**TS**) | `goto_prev_comment` |
| `]t` | Go to next test (**TS**) | `goto_next_test` | | `]T` | Go to next test (**TS**) | `goto_next_test` |
| `]t` | Go to previous test (**TS**) | `goto_prev_test` | | `[T` | Go to previous test (**TS**) | `goto_prev_test` |
| `]p` | Go to next paragraph | `goto_next_paragraph` | | `]p` | Go to next paragraph | `goto_next_paragraph` |
| `[p` | Go to previous paragraph | `goto_prev_paragraph` | | `[p` | Go to previous paragraph | `goto_prev_paragraph` |
| `[space` | Add newline above | `add_newline_above` | | `]g` | Go to next change | `goto_next_change` |
| `]space` | Add newline below | `add_newline_below` | | `[g` | Go to previous change | `goto_prev_change` |
| `]G` | Go to last change | `goto_last_change` |
## Insert Mode | `[G` | Go to first change | `goto_first_change` |
| `]Space` | Add newline below | `add_newline_below` |
We support many readline/emacs style bindings in insert mode for | `[Space` | Add newline above | `add_newline_above` |
convenience. These can be helpful for making simple modifications
without escaping to normal mode, but beware that you will not have an ## Insert mode
undo-able "save point" until you return to normal mode.
Insert mode bindings are somewhat minimal by default. Helix is designed to
| Key | Description | Command | be a modal editor, and this is reflected in the user experience and internal
| ----- | ----------- | ------- | mechanics. For example, changes to the text are only saved for undos when
| `Escape` | Switch to normal mode | `normal_mode` | escaping from insert mode to normal mode. For this reason, new users are
| `Ctrl-x` | Autocomplete | `completion` | strongly encouraged to learn the modal editing paradigm to get the smoothest
| `Ctrl-r` | Insert a register content | `insert_register` | experience.
| `Ctrl-w`, `Alt-Backspace`, `Ctrl-Backspace` | Delete previous word | `delete_word_backward` |
| `Alt-d`, `Alt-Delete`, `Ctrl-Delete` | Delete next word | `delete_word_forward` | | Key | Description | Command |
| `Alt-b`, `Ctrl-Left` | Backward a word | `move_prev_word_end` | | ----- | ----------- | ------- |
| `Ctrl-b`, `Left` | Backward a char | `move_char_left` | | `Escape` | Switch to normal mode | `normal_mode` |
| `Alt-f`, `Ctrl-Right` | Forward a word | `move_next_word_start` | | `Ctrl-s` | Commit undo checkpoint | `commit_undo_checkpoint` |
| `Ctrl-f`, `Right` | Forward a char | `move_char_right` | | `Ctrl-x` | Autocomplete | `completion` |
| `Ctrl-e`, `End` | Move to line end | `goto_line_end_newline` | | `Ctrl-r` | Insert a register content | `insert_register` |
| `Ctrl-a`, `Home` | Move to line start | `goto_line_start` | | `Ctrl-w`, `Alt-Backspace` | Delete previous word | `delete_word_backward` |
| `Ctrl-u` | Delete to start of line | `kill_to_line_start` | | `Alt-d`, `Alt-Delete` | Delete next word | `delete_word_forward` |
| `Ctrl-k` | Delete to end of line | `kill_to_line_end` | | `Ctrl-u` | Delete to start of line | `kill_to_line_start` |
| `Ctrl-j`, `Enter` | Insert new line | `insert_newline` | | `Ctrl-k` | Delete to end of line | `kill_to_line_end` |
| `Backspace`, `Ctrl-h` | Delete previous char | `delete_char_backward` | | `Ctrl-h`, `Backspace` | Delete previous char | `delete_char_backward` |
| `Delete`, `Ctrl-d` | Delete next char | `delete_char_forward` | | `Ctrl-d`, `Delete` | Delete next char | `delete_char_forward` |
| `Ctrl-p`, `Up` | Move to previous line | `move_line_up` | | `Ctrl-j`, `Enter` | Insert new line | `insert_newline` |
| `Ctrl-n`, `Down` | Move to next line | `move_line_down` |
| `PageUp` | Move one page up | `page_up` | These keys are not recommended, but are included for new users less familiar
| `PageDown` | Move one page down | `page_down` | with modal editors.
| `Alt->` | Go to end of buffer | `goto_file_end` |
| `Alt-<` | Go to start of buffer | `goto_file_start` | | 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 = "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 ## Select / extend mode
@ -365,13 +404,12 @@ Keys to use within picker. Remapping currently not supported.
| Key | Description | | Key | Description |
| ----- | ------------- | | ----- | ------------- |
| `Tab`, `Up`, `Ctrl-p` | Previous entry | | `Shift-Tab`, `Up`, `Ctrl-p` | Previous entry |
| `Tab`, `Down`, `Ctrl-n` | Next entry |
| `PageUp`, `Ctrl-u` | Page up | | `PageUp`, `Ctrl-u` | Page up |
| `Shift-tab`, `Down`, `Ctrl-n`| Next entry |
| `PageDown`, `Ctrl-d` | Page down | | `PageDown`, `Ctrl-d` | Page down |
| `Home` | Go to first entry | | `Home` | Go to first entry |
| `End` | Go to last entry | | `End` | Go to last entry |
| `Ctrl-space` | Filter options |
| `Enter` | Open selected | | `Enter` | Open selected |
| `Ctrl-s` | Open horizontally | | `Ctrl-s` | Open horizontally |
| `Ctrl-v` | Open vertically | | `Ctrl-v` | Open vertically |
@ -395,8 +433,8 @@ Keys to use within prompt, Remapping currently not supported.
| `Alt-d`, `Alt-Delete`, `Ctrl-Delete` | Delete next word | | `Alt-d`, `Alt-Delete`, `Ctrl-Delete` | Delete next word |
| `Ctrl-u` | Delete to start of line | | `Ctrl-u` | Delete to start of line |
| `Ctrl-k` | Delete to end of line | | `Ctrl-k` | Delete to end of line |
| `backspace`, `Ctrl-h` | Delete previous char | | `Backspace`, `Ctrl-h` | Delete previous char |
| `delete`, `Ctrl-d` | Delete next char | | `Delete`, `Ctrl-d` | Delete next char |
| `Ctrl-s` | Insert a word under doc cursor, may be changed to Ctrl-r Ctrl-w later | | `Ctrl-s` | Insert a word under doc cursor, may be changed to Ctrl-r Ctrl-w later |
| `Ctrl-p`, `Up` | Select previous history | | `Ctrl-p`, `Up` | Select previous history |
| `Ctrl-n`, `Down` | Select next history | | `Ctrl-n`, `Down` | Select next history |

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

@ -11,11 +11,11 @@ this:
```toml ```toml
# At most one section each of 'keys.normal', 'keys.insert' and 'keys.select' # At most one section each of 'keys.normal', 'keys.insert' and 'keys.select'
[keys.normal] [keys.normal]
C-s = ":w" # Maps the Control-s to the typable command :w which is an alias for :write (save file) C-s = ":w" # Maps the Ctrl-s to the typable command :w which is an alias for :write (save file)
C-o = ":open ~/.config/helix/config.toml" # Maps the Control-o to opening of the helix config file C-o = ":open ~/.config/helix/config.toml" # Maps the Ctrl-o to opening of the helix config file
a = "move_char_left" # Maps the 'a' key to the move_char_left command a = "move_char_left" # Maps the 'a' key to the move_char_left command
w = "move_line_up" # Maps the 'w' key move_line_up w = "move_line_up" # Maps the 'w' key move_line_up
"C-S-esc" = "extend_line" # Maps Control-Shift-Escape to extend_line "C-S-esc" = "extend_line" # Maps Ctrl-Shift-Escape to extend_line
g = { a = "code_action" } # Maps `ga` to show possible code actions g = { a = "code_action" } # Maps `ga` to show possible code actions
"ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode "ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode
@ -25,7 +25,33 @@ j = { k = "normal_mode" } # Maps `jk` to exit insert mode
``` ```
> NOTE: Typable commands can also be remapped, remember to keep the `:` prefix to indicate it's a typable command. > NOTE: Typable commands can also be remapped, remember to keep the `:` prefix to indicate it's a typable command.
Control, Shift and Alt modifiers are encoded respectively with the prefixes ## Minor modes
Minor modes are accessed by pressing a key (usually from normal mode), giving access to dedicated bindings. Bindings
can be modified or added by nesting definitions.
```toml
[keys.insert.j]
k = "normal_mode" # Maps `jk` to exit insert mode
[keys.normal.g]
a = "code_action" # Maps `ga` to show possible code actions
# invert `j` and `k` in view mode
[keys.normal.z]
j = "scroll_up"
k = "scroll_down"
# create a new minor mode bound to `+`
[keys.normal."+"]
m = ":run-shell-command make"
c = ":run-shell-command cargo build"
t = ":run-shell-command cargo test"
```
## Special keys and modifiers
Ctrl, Shift and Alt modifiers are encoded respectively with the prefixes
`C-`, `S-` and `A-`. Special keys are encoded as follows: `C-`, `S-` and `A-`. Special keys are encoded as follows:
| Key name | Representation | | Key name | Representation |

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

@ -2,7 +2,7 @@
(Currently not fully documented, see the [keymappings](./keymap.md) list for more.) (Currently not fully documented, see the [keymappings](./keymap.md) list for more.)
See [tutor.txt](https://github.com/helix-editor/helix/blob/master/runtime/tutor.txt) (accessible via `hx --tutor` or `:tutor`) for a vimtutor-like introduction. See [tutor](https://github.com/helix-editor/helix/blob/master/runtime/tutor) (accessible via `hx --tutor` or `:tutor`) for a vimtutor-like introduction.
## Registers ## Registers
@ -53,7 +53,7 @@ Multiple characters are currently not supported, but planned.
## Syntax-tree Motions ## Syntax-tree Motions
`A-p`, `A-o`, `A-i`, and `A-n` (or `Alt` and arrow keys) move the primary `Alt-p`, `Alt-o`, `Alt-i`, and `Alt-n` (or `Alt` and arrow keys) move the primary
selection according to the selection's place in the syntax tree. Let's walk selection according to the selection's place in the syntax tree. Let's walk
through an example to get familiar with them. Many languages have a syntax like through an example to get familiar with them. Many languages have a syntax like
so for function calls: so for function calls:
@ -100,13 +100,13 @@ in the tree above.
func([arg1], arg2, arg3) func([arg1], arg2, arg3)
``` ```
Using `A-n` would select the next sibling in the syntax tree: `arg2`. Using `Alt-n` would select the next sibling in the syntax tree: `arg2`.
``` ```
func(arg1, [arg2], arg3) func(arg1, [arg2], arg3)
``` ```
While `A-o` would expand the selection to the parent node. In the tree above we While `Alt-o` would expand the selection to the parent node. In the tree above we
can see that we would select the `arguments` node. can see that we would select the `arguments` node.
``` ```
@ -114,10 +114,10 @@ func[(arg1, arg2, arg3)]
``` ```
There is also some nuanced behavior that prevents you from getting stuck on a There is also some nuanced behavior that prevents you from getting stuck on a
node with no sibling. If we have a selection on `arg1`, `A-p` would bring us node with no sibling. If we have a selection on `arg1`, `Alt-p` would bring us
to the previous child node. Since `arg1` doesn't have a sibling to its left, to the previous child node. Since `arg1` doesn't have a sibling to its left,
though, we climb the syntax tree and then take the previous selection. So `A-p` though, we climb the syntax tree and then take the previous selection. So
will move the selection over to the "func" `identifier`. `Alt-p` will move the selection over to the "func" `identifier`.
``` ```
[func](arg1, arg2, arg3) [func](arg1, arg2, arg3)
@ -125,18 +125,17 @@ will move the selection over to the "func" `identifier`.
## Textobjects ## Textobjects
Currently supported: `word`, `surround`, `function`, `class`, `parameter`.
![textobject-demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif) ![textobject-demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif)
![textobject-treesitter-demo](https://user-images.githubusercontent.com/23398472/132537398-2a2e0a54-582b-44ab-a77f-eb818942203d.gif) ![textobject-treesitter-demo](https://user-images.githubusercontent.com/23398472/132537398-2a2e0a54-582b-44ab-a77f-eb818942203d.gif)
- `ma` - Select around the object (`va` in vim, `<alt-a>` in kakoune) - `ma` - Select around the object (`va` in Vim, `<alt-a>` in Kakoune)
- `mi` - Select inside the object (`vi` in vim, `<alt-i>` in kakoune) - `mi` - Select inside the object (`vi` in Vim, `<alt-i>` in Kakoune)
| Key after `mi` or `ma` | Textobject selected | | Key after `mi` or `ma` | Textobject selected |
| --- | --- | | --- | --- |
| `w` | Word | | `w` | Word |
| `W` | WORD | | `W` | WORD |
| `p` | Paragraph |
| `(`, `[`, `'`, etc | Specified surround pairs | | `(`, `[`, `'`, etc | Specified surround pairs |
| `m` | Closest surround pair | | `m` | Closest surround pair |
| `f` | Function | | `f` | Function |
@ -144,6 +143,7 @@ Currently supported: `word`, `surround`, `function`, `class`, `parameter`.
| `a` | Argument/parameter | | `a` | Argument/parameter |
| `o` | Comment | | `o` | Comment |
| `t` | Test | | `t` | Test |
| `g` | Change |
> NOTE: `f`, `c`, etc need a tree-sitter grammar active for the current > NOTE: `f`, `c`, etc need a tree-sitter grammar active for the current
document and a special tree-sitter query file to work properly. [Only document and a special tree-sitter query file to work properly. [Only

@ -48,6 +48,18 @@
--searchresults-border-color: #888; --searchresults-border-color: #888;
--searchresults-li-bg: #252932; --searchresults-li-bg: #252932;
--search-mark-bg: #e3b171; --search-mark-bg: #e3b171;
--hljs-background: #191f26;
--hljs-color: #e6e1cf;
--hljs-quote: #5c6773;
--hljs-variable: #ff7733;
--hljs-type: #ffee99;
--hljs-title: #b8cc52;
--hljs-symbol: #ffb454;
--hljs-selector-tag: #ff7733;
--hljs-selector-tag: #36a3d9;
--hljs-selector-tag: #00568d;
--hljs-selector-tag: #91b362;
--hljs-selector-tag: #d96c75;
} }
.coal { .coal {
@ -88,6 +100,18 @@
--searchresults-border-color: #98a3ad; --searchresults-border-color: #98a3ad;
--searchresults-li-bg: #2b2b2f; --searchresults-li-bg: #2b2b2f;
--search-mark-bg: #355c7d; --search-mark-bg: #355c7d;
--hljs-background: #969896;
--hljs-color: #cc6666;
--hljs-quote: #de935f;
--hljs-variable: #f0c674;
--hljs-type: #b5bd68;
--hljs-title: #8abeb7;
--hljs-symbol: #81a2be;
--hljs-selector-tag: #b294bb;
--hljs-selector-tag: #1d1f21;
--hljs-selector-tag: #c5c8c6;
--hljs-selector-tag: #718c00;
--hljs-selector-tag: #c82829;
} }
.light { .light {
@ -128,6 +152,14 @@
--searchresults-border-color: #888; --searchresults-border-color: #888;
--searchresults-li-bg: #e4f2fe; --searchresults-li-bg: #e4f2fe;
--search-mark-bg: #a2cff5; --search-mark-bg: #a2cff5;
--hljs-background: #f6f7f6;
--hljs-color: #000;
--hljs-quote: #575757;
--hljs-variable: #d70025;
--hljs-type: #b21e00;
--hljs-title: #0030f2;
--hljs-symbol: #008200;
--hljs-selector-tag: #9d00ec;
} }
.navy { .navy {
@ -168,6 +200,19 @@
--searchresults-border-color: #5c5c68; --searchresults-border-color: #5c5c68;
--searchresults-li-bg: #242430; --searchresults-li-bg: #242430;
--search-mark-bg: #a2cff5; --search-mark-bg: #a2cff5;
--hljs-background: #969896;
--hljs-color: #cc6666;
--hljs-quote: #de935f;
--hljs-variable: #f0c674;
--hljs-type: #b5bd68;
--hljs-title: #8abeb7;
--hljs-symbol: #81a2be;
--hljs-selector-tag: #b294bb;
--hljs-selector-tag: #1d1f21;
--hljs-selector-tag: #c5c8c6;
--hljs-selector-tag: #718c00;
--hljs-selector-tag: #c82829;
} }
.rust { .rust {
@ -208,6 +253,14 @@
--searchresults-border-color: #888; --searchresults-border-color: #888;
--searchresults-li-bg: #dec2a2; --searchresults-li-bg: #dec2a2;
--search-mark-bg: #e69f67; --search-mark-bg: #e69f67;
--hljs-background: #f6f7f6;
--hljs-color: #000;
--hljs-quote: #575757;
--hljs-variable: #d70025;
--hljs-type: #b21e00;
--hljs-title: #0030f2;
--hljs-symbol: #008200;
--hljs-selector-tag: #9d00ec;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -292,7 +345,15 @@
--searchresults-header-fg: #5f5f71; --searchresults-header-fg: #5f5f71;
--searchresults-border-color: #5c5c68; --searchresults-border-color: #5c5c68;
--searchresults-li-bg: #242430; --searchresults-li-bg: #242430;
--search-mark-bg: #a2cff5; --search-mark-bg: #acff5;
--hljs-background: #2f1e2e;
--hljs-color: #a39e9b;
--hljs-quote: #8d8687;
--hljs-variable: #ef6155;
--hljs-type: #f99b15;
--hljs-title: #fec418;
--hljs-symbol: #48b685;
--hljs-selector-tag: #815ba4;
} }
.colibri { .colibri {
@ -338,5 +399,13 @@
--searchresults-border-color: #5c5c68; --searchresults-border-color: #5c5c68;
--searchresults-li-bg: #242430; --searchresults-li-bg: #242430;
--search-mark-bg: #a2cff5; --search-mark-bg: #a2cff5;
--hljs-background: #TODO;
--hljs-color: #TODO;
--hljs-quote: #TODO;
--hljs-variable: #TODO;
--hljs-type: #TODO;
--hljs-title: #TODO;
--hljs-symbol: #TODO;
--hljs-selector-tag: #TODO;
*/ */
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 42 KiB

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

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

@ -7,12 +7,12 @@ code.hljs {
padding:3px 5px padding:3px 5px
} }
.hljs { .hljs {
background:#2f1e2e; background: var(--hljs-background);
color:#a39e9b color: var(--hljs-color);
} }
.hljs-comment, .hljs-comment,
.hljs-quote { .hljs-quote {
color:#8d8687 color: var(--hljs-quote)
} }
.hljs-link, .hljs-link,
.hljs-meta, .hljs-meta,
@ -23,7 +23,7 @@ code.hljs {
.hljs-tag, .hljs-tag,
.hljs-template-variable, .hljs-template-variable,
.hljs-variable { .hljs-variable {
color:#ef6155 color: var(--hljs-variable)
} }
.hljs-built_in, .hljs-built_in,
.hljs-deletion, .hljs-deletion,
@ -31,22 +31,22 @@ code.hljs {
.hljs-number, .hljs-number,
.hljs-params, .hljs-params,
.hljs-type { .hljs-type {
color:#f99b15 color: var(--hljs-type)
} }
.hljs-attribute, .hljs-attribute,
.hljs-section, .hljs-section,
.hljs-title { .hljs-title {
color:#fec418 color: var(--hljs-title)
} }
.hljs-addition, .hljs-addition,
.hljs-bullet, .hljs-bullet,
.hljs-string, .hljs-string,
.hljs-symbol { .hljs-symbol {
color:#48b685 color: var(--hljs-symbol)
} }
.hljs-keyword, .hljs-keyword,
.hljs-selector-tag { .hljs-selector-tag {
color:#815ba4 color: var(--hljs-selector-tag)
} }
.hljs-emphasis { .hljs-emphasis {
font-style:italic font-style:italic

@ -0,0 +1,31 @@
- [x] "h" moves to parent instead of scrolling to left
- [x] "l" steps into current folder instead of scrolling to right
TODO
- [x] make focus current file works
- [x] test all explorer functionality (e.g. "/" etc)
- [x] Go to parent directory
- [x] add help
- [x] highlight ancestors of current selection
- [x] search "N" will hang
- [x] implement rename
- [x] implement close (refer how overlay works)
- [x] implement refresh
- [x] bug: delete file collapsed whole tree
- [x] update documentation
- [x] fix list view
- [x] -/+ to increase or decrease width
- [x] help page overflow
- [x] preview not showing in small screen
- [x] fix (-/+) overflow
- [x] improve filter UI/UX (should only apply to child not parent)
- [x] bug: "h" does not realign preview
- [x] bug: reveal file does not realign preview
- [] "l" goes back to previous child if any history
- [] refactor, add tree.expand_children() method
- [] search highlight matching word
- [] fix warnings
- [] Error didn't clear
- [] Remove comments
- [] Merge conflicts

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.helix_editor.Helix</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MPL-2.0</project_license>
<name>Helix</name>
<summary>A post-modern text editor</summary>
<description>
<p>
Helix is a terminal-based text editor inspired by Kakoune / Neovim and written in Rust.
</p>
<ul>
<li>Vim-like modal editing</li>
<li>Multiple selections</li>
<li>Built-in language server support</li>
<li>Smart, incremental syntax highlighting and code editing via tree-sitter</li>
</ul>
</description>
<launchable type="desktop-id">Helix.desktop</launchable>
<screenshots>
<screenshot type="default">
<caption>Helix with default theme</caption>
<image>https://github.com/helix-editor/helix/raw/d4565b4404cabc522bd60822abd374755581d751/screenshot.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://helix-editor.com/</url>
<url type="donation">https://opencollective.com/helix-editor</url>
<url type="help">https://docs.helix-editor.com/</url>
<url type="vcs-browser">https://github.com/helix-editor/helix</url>
<url type="bugtracker">https://github.com/helix-editor/helix/issues</url>
<content_rating type="oars-1.1" />
<releases>
<release version="22.12" date="2022-12-6">
<url>https://helix-editor.com/news/release-22-12-highlights/</url>
</release>
<release version="22.08" date="2022-8-31">
<url>https://helix-editor.com/news/release-22-08-highlights/</url>
</release>
<release version="22.05" date="2022-5-28">
<url>https://helix-editor.com/news/release-22-05-highlights/</url>
</release>
<release version="22.03" date="2022-3-28">
<url>https://helix-editor.com/news/release-22-03-highlights/</url>
</release>
</releases>
<requires>
<control>keyboard</control>
</requires>
<categories>
<category>Utility</category>
<category>TextEditor</category>
</categories>
<keywords>
<keyword>text</keyword>
<keyword>editor</keyword>
<keyword>development</keyword>
<keyword>programming</keyword>
</keywords>
<provides>
<binary>hx</binary>
<mediatype>text/english</mediatype>
<mediatype>text/plain</mediatype>
<mediatype>text/x-makefile</mediatype>
<mediatype>text/x-c++hdr</mediatype>
<mediatype>text/x-c++src</mediatype>
<mediatype>text/x-chdr</mediatype>
<mediatype>text/x-csrc</mediatype>
<mediatype>text/x-java</mediatype>
<mediatype>text/x-moc</mediatype>
<mediatype>text/x-pascal</mediatype>
<mediatype>text/x-tcl</mediatype>
<mediatype>text/x-tex</mediatype>
<mediatype>application/x-shellscript</mediatype>
<mediatype>text/x-c</mediatype>
<mediatype>text/x-c++</mediatype>
</provides>
</component>

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

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

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

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

@ -5,6 +5,7 @@ Helix releases are versioned in the Calendar Versioning scheme:
we'll use `<tag>` as a placeholder for the tag being published. we'll use `<tag>` as a placeholder for the tag being published.
* Merge the changelog PR * Merge the changelog PR
* Add new `<release>` entry in `contrib/Helix.appdata.xml` with release information according to the [AppStream spec](https://www.freedesktop.org/software/appstream/docs/sect-Metadata-Releases.html)
* Tag and push * Tag and push
* `git tag -s -m "<tag>" -a <tag> && git push` * `git tag -s -m "<tag>" -a <tag> && git push`
* Make sure to switch to master and pull first * Make sure to switch to master and pull first
@ -23,7 +24,7 @@ we'll use `<tag>` as a placeholder for the tag being published.
* Post to reddit * Post to reddit
* [Example post](https://www.reddit.com/r/rust/comments/uzp5ze/helix_editor_2205_released/) * [Example post](https://www.reddit.com/r/rust/comments/uzp5ze/helix_editor_2205_released/)
[homebrew formula]: https://github.com/helix-editor/homebrew-helix/blob/master/Formula/helix.rb [homebrew formula]: https://github.com/Homebrew/homebrew-core/blob/master/Formula/helix.rb
## Changelog Curation ## Changelog Curation

@ -3,11 +3,11 @@
"crane": { "crane": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1654444508, "lastModified": 1670900067,
"narHash": "sha256-4OBvQ4V7jyt7afs6iKUvRzJ1u/9eYnKzVQbeQdiamuY=", "narHash": "sha256-VXVa+KBfukhmWizaiGiHRVX/fuk66P8dgSFfkVN4/MY=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "db5482bf225acc3160899124a1df5a617cfa27b5", "rev": "59b31b41a589c0a65e4a1f86b0e5eac68081468b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -19,11 +19,11 @@
"devshell": { "devshell": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1655976588, "lastModified": 1667210711,
"narHash": "sha256-VreHyH6ITkf/1EX/8h15UqhddJnUleb0HgbC3gMkAEQ=", "narHash": "sha256-IoErjXZAkzYWHEpQqwu/DeRNJGFdR7X2OGbkhMqMrpw=",
"owner": "numtide", "owner": "numtide",
"repo": "devshell", "repo": "devshell",
"rev": "899ca4629020592a13a46783587f6e674179d1db", "rev": "96a9dd12b8a447840cc246e17a47b81a4268bba7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -35,49 +35,49 @@
"dream2nix": { "dream2nix": {
"inputs": { "inputs": {
"alejandra": [ "alejandra": [
"nixCargoIntegration", "nci"
"nixpkgs" ],
"all-cabal-json": [
"nci"
], ],
"crane": "crane", "crane": "crane",
"devshell": [ "devshell": [
"nixCargoIntegration", "nci",
"devshell" "devshell"
], ],
"flake-parts": "flake-parts",
"flake-utils-pre-commit": [ "flake-utils-pre-commit": [
"nixCargoIntegration", "nci"
"nixpkgs" ],
"ghc-utils": [
"nci"
], ],
"gomod2nix": [ "gomod2nix": [
"nixCargoIntegration", "nci"
"nixpkgs"
], ],
"mach-nix": [ "mach-nix": [
"nixCargoIntegration", "nci"
"nixpkgs"
], ],
"nixpkgs": [ "nix-pypi-fetcher": [
"nixCargoIntegration", "nci"
"nixpkgs"
], ],
"node2nix": [ "nixpkgs": [
"nixCargoIntegration", "nci",
"nixpkgs" "nixpkgs"
], ],
"poetry2nix": [ "poetry2nix": [
"nixCargoIntegration", "nci"
"nixpkgs"
], ],
"pre-commit-hooks": [ "pre-commit-hooks": [
"nixCargoIntegration", "nci"
"nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1655975833, "lastModified": 1671323629,
"narHash": "sha256-g8sdfuglIZ24oWVbntVzniNTJW+Z3n9DNL9w9Tt+UCE=", "narHash": "sha256-9KHTPjIDjfnzZ4NjpE3gGIVHVHopy6weRDYO/7Y3hF8=",
"owner": "nix-community", "owner": "nix-community",
"repo": "dream2nix", "repo": "dream2nix",
"rev": "4e75e665ec3a1cddae5266bed0dd72fce0b74a23", "rev": "2d7d68505c8619410df2c6b6463985f97cbcba6e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -86,13 +86,31 @@
"type": "github" "type": "github"
} }
}, },
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1668450977,
"narHash": "sha256-cfLhMhnvXn6x1vPm+Jow3RiFAUSCw/l1utktCw5rVA4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "d591857e9d7dd9ddbfba0ea02b43b927c3c0f1fa",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"locked": { "locked": {
"lastModified": 1637014545, "lastModified": 1659877975,
"narHash": "sha256-26IZAc5yzlD9FlDT54io1oqG/bBoyka+FJk5guaX4x4=", "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "bba5dcc8e0b20ab664967ad83d24d64cb64ec4f4", "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -101,7 +119,7 @@
"type": "github" "type": "github"
} }
}, },
"nixCargoIntegration": { "nci": {
"inputs": { "inputs": {
"devshell": "devshell", "devshell": "devshell",
"dream2nix": "dream2nix", "dream2nix": "dream2nix",
@ -113,11 +131,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1656453541, "lastModified": 1671430291,
"narHash": "sha256-ZCPVnS6zJOZJvIlwU3rKR8MBVm6A3F4/0mA7G1lQ3D0=", "narHash": "sha256-UIc7H8F3N8rK72J/Vj5YJdV72tvDvYjH+UPsOFvlcsE=",
"owner": "yusdacra", "owner": "yusdacra",
"repo": "nix-cargo-integration", "repo": "nix-cargo-integration",
"rev": "9eb74345b30cd2e536d9dac9d4435d3c475605c7", "rev": "b1b0d38b8c3b0d0e6a38638d5bbe10b0bc67522c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -128,11 +146,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1655624069, "lastModified": 1671359686,
"narHash": "sha256-7g1zwTdp35GMTERnSzZMWJ7PG3QdDE8VOX3WsnOkAtM=", "narHash": "sha256-3MpC6yZo+Xn9cPordGz2/ii6IJpP2n8LE8e/ebUXLrs=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "0d68d7c857fe301d49cdcd56130e0beea4ecd5aa", "rev": "04f574a1c0fde90b51bf68198e2297ca4e7cccf4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -142,9 +160,27 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-lib": {
"locked": {
"dir": "lib",
"lastModified": 1665349835,
"narHash": "sha256-UK4urM3iN80UXQ7EaOappDzcisYIuEURFRoGQ/yPkug=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "34c5293a71ffdb2fe054eb5288adc1882c1eb0b1",
"type": "github"
},
"original": {
"dir": "lib",
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"nixCargoIntegration": "nixCargoIntegration", "nci": "nci",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
} }
@ -157,11 +193,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1655779671, "lastModified": 1671416426,
"narHash": "sha256-6feeiGa6fb7ZPVHR71uswkmN1701TAJpwYQA8QffmRk=", "narHash": "sha256-kpSH1Jrxfk2qd0pRPJn1eQdIOseGv5JuE+YaOrqU9s4=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "8159585609a772b041cce6019d5c21d240709244", "rev": "fbaaff24f375ac25ec64268b0a0d63f91e474b7d",
"type": "github" "type": "github"
}, },
"original": { "original": {

@ -7,128 +7,171 @@
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
nixCargoIntegration = { nci = {
url = "github:yusdacra/nix-cargo-integration"; url = "github:yusdacra/nix-cargo-integration";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
inputs.rust-overlay.follows = "rust-overlay"; inputs.rust-overlay.follows = "rust-overlay";
}; };
}; };
outputs = inputs @ { outputs = {
self,
nixpkgs, nixpkgs,
nixCargoIntegration, nci,
... ...
}: let }: let
outputs = config: lib = nixpkgs.lib;
nixCargoIntegration.lib.makeOutputs { ncl = nci.lib.nci-lib;
root = ./.; mkRootPath = rel:
renameOutputs = {"helix-term" = "helix";}; builtins.path {
# Set default app to hx (binary is from helix-term release build) path = "${toString ./.}/${rel}";
# Set default package to helix-term release build name = rel;
defaultOutputs = { };
app = "hx"; filteredSource = let
package = "helix"; 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 = ./.;
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)
++ (lib.optional stdenv.isDarwin darwin.apple_sdk.frameworks.CoreFoundation);
env = [
{
name = "HELIX_RUNTIME";
eval = "$PWD/runtime";
}
{
name = "RUST_BACKTRACE";
value = "1";
}
{
name = "RUSTFLAGS";
eval =
if common.pkgs.stdenv.isLinux
then "$RUSTFLAGS\" -C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment\""
else "$RUSTFLAGS";
}
];
}; };
overrides = { };
cCompiler = common: pkgConfig = common: {
with common.pkgs; helix-term = {
if stdenv.isLinux # Wrap helix with runtime
then gcc wrapper = _: old: let
else clang; inherit (common) pkgs;
crateOverrides = common: _: { makeOverridableHelix = old: config: let
helix-term = prev: let
inherit (common) pkgs;
mkRootPath = rel:
builtins.path {
path = "${common.root}/${rel}";
name = rel;
};
grammars = pkgs.callPackage ./grammars.nix config; grammars = pkgs.callPackage ./grammars.nix config;
runtimeDir = pkgs.runCommandNoCC "helix-runtime" {} '' runtimeDir = pkgs.runCommand "helix-runtime" {} ''
mkdir -p $out mkdir -p $out
ln -s ${mkRootPath "runtime"}/* $out ln -s ${mkRootPath "runtime"}/* $out
rm -r $out/grammars rm -r $out/grammars
ln -s ${grammars} $out/grammars ln -s ${grammars} $out/grammars
''; '';
overridedAttrs = { helix-wrapped =
# disable fetching and building of tree-sitter grammars in the helix-term build.rs common.internal.pkgsSet.utils.wrapDerivation old
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";
# link languages and theme toml files since helix-term expects them (for tests)
preConfigure =
pkgs.lib.concatMapStringsSep
"\n"
(path: "ln -sf ${mkRootPath path} ..")
["languages.toml" "theme.toml" "base16_theme.toml"];
buildInputs = (prev.buildInputs or []) ++ [common.cCompiler.cc.lib];
nativeBuildInputs = [pkgs.makeWrapper];
postFixup = ''
if [ -f "$out/bin/hx" ]; then
wrapProgram "$out/bin/hx" ''${makeWrapperArgs[@]} --set HELIX_RUNTIME "${runtimeDir}"
fi
'';
};
in
overridedAttrs
// (
pkgs.lib.optionalAttrs
(config ? makeWrapperArgs)
{inherit (config) makeWrapperArgs;}
);
};
shell = common: prev: {
packages =
prev.packages
++ (
with common.pkgs;
[lld_13 lldb cargo-flamegraph rust-analyzer] ++
(lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin)
);
env =
prev.env
++ [
{ {
name = "HELIX_RUNTIME"; nativeBuildInputs = [pkgs.makeWrapper];
eval = "$PWD/runtime"; makeWrapperArgs = config.makeWrapperArgs or [];
} }
{ ''
name = "RUST_BACKTRACE"; rm -rf $out/bin
value = "1"; mkdir -p $out/bin
} ln -sf ${old}/bin/* $out/bin/
{ wrapProgram "$out/bin/hx" ''${makeWrapperArgs[@]} --set HELIX_RUNTIME "${runtimeDir}"
name = "RUSTFLAGS"; '';
value = in
if common.pkgs.stdenv.isLinux helix-wrapped
then "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment" // {override = makeOverridableHelix old;};
else ""; 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 = ncl.addBuildInputs prev [common.config.cCompiler.package.cc.lib];
# link languages and theme toml files since helix-term expects them (for tests)
preConfigure = ''
${prev.preConfigure or ""}
${
lib.concatMapStringsSep
"\n"
(path: "ln -sf ${mkRootPath path} ..")
["languages.toml" "theme.toml" "base16_theme.toml"]
}
'';
checkPhase = ":";
meta.mainProgram = "hx";
}; };
}; };
}; };
defaultOutputs = outputs {}; };
makeOverridableHelix = system: old:
old
// {
override = args:
makeOverridableHelix
system
(outputs args).packages.${system}.helix;
};
in in
defaultOutputs outputs
// { // {
packages = packages =
nixpkgs.lib.mapAttrs lib.mapAttrs
( (
system: packages: system: packages:
packages packages
// rec { // {
default = helix; helix-unwrapped = packages.helix.passthru.unwrapped;
helix = makeOverridableHelix system packages.helix; helix-unwrapped-dev = packages.helix-dev.passthru.unwrapped;
} }
) )
defaultOutputs.packages; outputs.packages;
}; };
nixConfig = { nixConfig = {

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

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

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

@ -45,7 +45,7 @@ fn find_line_comment(
// determine margin of 0 or 1 for uncommenting; if any comment token is not followed by a space, // determine margin of 0 or 1 for uncommenting; if any comment token is not followed by a space,
// a margin of 0 is used for all lines. // a margin of 0 is used for all lines.
if matches!(line_slice.get_char(pos + token_len), Some(c) if c != ' ') { if !matches!(line_slice.get_char(pos + token_len), Some(c) if c == ' ') {
margin = 0; margin = 0;
} }
@ -68,7 +68,7 @@ pub fn toggle_line_comments(doc: &Rope, selection: &Selection, token: Option<&st
let mut min_next_line = 0; let mut min_next_line = 0;
for selection in selection { for selection in selection {
let (start, end) = selection.line_range(text); let (start, end) = selection.line_range(text);
let start = start.max(min_next_line).min(text.len_lines()); let start = start.clamp(min_next_line, text.len_lines());
let end = (end + 1).min(text.len_lines()); let end = (end + 1).min(text.len_lines());
lines.extend(start..end); lines.extend(start..end);
@ -100,43 +100,52 @@ mod test {
#[test] #[test]
fn test_find_line_comment() { fn test_find_line_comment() {
use crate::State;
// four lines, two space indented, except for line 1 which is blank. // four lines, two space indented, except for line 1 which is blank.
let doc = Rope::from(" 1\n\n 2\n 3"); let mut doc = Rope::from(" 1\n\n 2\n 3");
let mut state = State::new(doc);
// select whole document // select whole document
state.selection = Selection::single(0, state.doc.len_chars() - 1); let mut selection = Selection::single(0, doc.len_chars() - 1);
let text = state.doc.slice(..); let text = doc.slice(..);
let res = find_line_comment("//", text, 0..3); let res = find_line_comment("//", text, 0..3);
// (commented = true, to_change = [line 0, line 2], min = col 2, margin = 1) // (commented = true, to_change = [line 0, line 2], min = col 2, margin = 0)
assert_eq!(res, (false, vec![0, 2], 2, 1)); assert_eq!(res, (false, vec![0, 2], 2, 0));
// comment // comment
let transaction = toggle_line_comments(&state.doc, &state.selection, None); let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut state.doc); transaction.apply(&mut doc);
state.selection = state.selection.map(transaction.changes()); selection = selection.map(transaction.changes());
assert_eq!(state.doc, " // 1\n\n // 2\n // 3"); assert_eq!(doc, " // 1\n\n // 2\n // 3");
// uncomment // uncomment
let transaction = toggle_line_comments(&state.doc, &state.selection, None); let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut state.doc); transaction.apply(&mut doc);
state.selection = state.selection.map(transaction.changes()); selection = selection.map(transaction.changes());
assert_eq!(state.doc, " 1\n\n 2\n 3"); assert_eq!(doc, " 1\n\n 2\n 3");
assert!(selection.len() == 1); // to ignore the selection unused warning
// 0 margin comments // 0 margin comments
state.doc = Rope::from(" //1\n\n //2\n //3"); doc = Rope::from(" //1\n\n //2\n //3");
// reset the selection.
selection = Selection::single(0, doc.len_chars() - 1);
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, with no space
doc = Rope::from("//");
// reset the selection. // reset the selection.
state.selection = Selection::single(0, state.doc.len_chars() - 1); selection = Selection::single(0, doc.len_chars() - 1);
let transaction = toggle_line_comments(&state.doc, &state.selection, None); let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut state.doc); transaction.apply(&mut doc);
state.selection = state.selection.map(transaction.changes()); selection = selection.map(transaction.changes());
assert_eq!(state.doc, " 1\n\n 2\n 3"); assert_eq!(doc, "");
assert!(selection.len() == 1); // to ignore the selection unused warning
// TODO: account for uncommenting with uneven comment indentation // TODO: account for uncommenting with uneven comment indentation
} }

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

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

@ -0,0 +1,384 @@
//! The `DocumentFormatter` forms the bridge between the raw document text
//! and onscreen positioning. It yields the text graphemes as an iterator
//! and traverses (part) of the document text. During that traversal it
//! handles grapheme detection, softwrapping and annotations.
//! It yields `FormattedGrapheme`s and their corresponding visual coordinates.
//!
//! As both virtual text and softwrapping can insert additional lines into the document
//! it is generally not possible to find the start of the previous visual line.
//! Instead the `DocumentFormatter` starts at the last "checkpoint" (usually a linebreak)
//! called a "block" and the caller must advance it as needed.
use std::borrow::Cow;
use std::fmt::Debug;
use std::mem::{replace, take};
#[cfg(test)]
mod test;
use unicode_segmentation::{Graphemes, UnicodeSegmentation};
use crate::graphemes::{Grapheme, GraphemeStr};
use crate::syntax::Highlight;
use crate::text_annotations::TextAnnotations;
use crate::{Position, RopeGraphemes, RopeSlice};
/// TODO make Highlight a u32 to reduce the size of this enum to a single word.
#[derive(Debug, Clone, Copy)]
pub enum GraphemeSource {
Document {
codepoints: u32,
},
/// Inline virtual text can not be highlighted with a `Highlight` iterator
/// because it's not part of the document. Instead the `Highlight`
/// is emitted right by the document formatter
VirtualText {
highlight: Option<Highlight>,
},
}
#[derive(Debug, Clone)]
pub struct FormattedGrapheme<'a> {
pub grapheme: Grapheme<'a>,
pub source: GraphemeSource,
}
impl<'a> FormattedGrapheme<'a> {
pub fn new(
g: GraphemeStr<'a>,
visual_x: usize,
tab_width: u16,
source: GraphemeSource,
) -> FormattedGrapheme<'a> {
FormattedGrapheme {
grapheme: Grapheme::new(g, visual_x, tab_width),
source,
}
}
/// Returns whether this grapheme is virtual inline text
pub fn is_virtual(&self) -> bool {
matches!(self.source, GraphemeSource::VirtualText { .. })
}
pub fn placeholder() -> Self {
FormattedGrapheme {
grapheme: Grapheme::Other { g: " ".into() },
source: GraphemeSource::Document { codepoints: 0 },
}
}
pub fn doc_chars(&self) -> usize {
match self.source {
GraphemeSource::Document { codepoints } => codepoints as usize,
GraphemeSource::VirtualText { .. } => 0,
}
}
pub fn is_whitespace(&self) -> bool {
self.grapheme.is_whitespace()
}
pub fn width(&self) -> usize {
self.grapheme.width()
}
pub fn is_word_boundary(&self) -> bool {
self.grapheme.is_word_boundary()
}
}
#[derive(Debug, Clone)]
pub struct TextFormat {
pub soft_wrap: bool,
pub tab_width: u16,
pub max_wrap: u16,
pub max_indent_retain: u16,
pub wrap_indicator: Box<str>,
pub wrap_indicator_highlight: Option<Highlight>,
pub viewport_width: u16,
}
// test implementation is basically only used for testing or when softwrap is always disabled
impl Default for TextFormat {
fn default() -> Self {
TextFormat {
soft_wrap: false,
tab_width: 4,
max_wrap: 3,
max_indent_retain: 4,
wrap_indicator: Box::from(" "),
viewport_width: 17,
wrap_indicator_highlight: None,
}
}
}
#[derive(Debug)]
pub struct DocumentFormatter<'t> {
text_fmt: &'t TextFormat,
annotations: &'t TextAnnotations,
/// The visual position at the end of the last yielded word boundary
visual_pos: Position,
graphemes: RopeGraphemes<'t>,
/// The character pos of the `graphemes` iter used for inserting annotations
char_pos: usize,
/// The line pos of the `graphemes` iter used for inserting annotations
line_pos: usize,
exhausted: bool,
/// Line breaks to be reserved for virtual text
/// at the next line break
virtual_lines: usize,
inline_anntoation_graphemes: Option<(Graphemes<'t>, Option<Highlight>)>,
// softwrap specific
/// The indentation of the current line
/// Is set to `None` if the indentation level is not yet known
/// because no non-whitespace graphemes have been encountered yet
indent_level: Option<usize>,
/// In case a long word needs to be split a single grapheme might need to be wrapped
/// while the rest of the word stays on the same line
peeked_grapheme: Option<(FormattedGrapheme<'t>, usize)>,
/// A first-in first-out (fifo) buffer for the Graphemes of any given word
word_buf: Vec<FormattedGrapheme<'t>>,
/// The index of the next grapheme that will be yielded from the `word_buf`
word_i: usize,
}
impl<'t> DocumentFormatter<'t> {
/// Creates a new formatter at the last block before `char_idx`.
/// A block is a chunk which always ends with a linebreak.
/// This is usually just a normal line break.
/// However very long lines are always wrapped at constant intervals that can be cheaply calculated
/// to avoid pathological behaviour.
pub fn new_at_prev_checkpoint(
text: RopeSlice<'t>,
text_fmt: &'t TextFormat,
annotations: &'t TextAnnotations,
char_idx: usize,
) -> (Self, usize) {
// TODO divide long lines into blocks to avoid bad performance for long lines
let block_line_idx = text.char_to_line(char_idx.min(text.len_chars()));
let block_char_idx = text.line_to_char(block_line_idx);
annotations.reset_pos(block_char_idx);
(
DocumentFormatter {
text_fmt,
annotations,
visual_pos: Position { row: 0, col: 0 },
graphemes: RopeGraphemes::new(text.slice(block_char_idx..)),
char_pos: block_char_idx,
exhausted: false,
virtual_lines: 0,
indent_level: None,
peeked_grapheme: None,
word_buf: Vec::with_capacity(64),
word_i: 0,
line_pos: block_line_idx,
inline_anntoation_graphemes: None,
},
block_char_idx,
)
}
fn next_inline_annotation_grapheme(&mut self) -> Option<(&'t str, Option<Highlight>)> {
loop {
if let Some(&mut (ref mut annotation, highlight)) =
self.inline_anntoation_graphemes.as_mut()
{
if let Some(grapheme) = annotation.next() {
return Some((grapheme, highlight));
}
}
if let Some((annotation, highlight)) =
self.annotations.next_inline_annotation_at(self.char_pos)
{
self.inline_anntoation_graphemes = Some((
UnicodeSegmentation::graphemes(&*annotation.text, true),
highlight,
))
} else {
return None;
}
}
}
fn advance_grapheme(&mut self, col: usize) -> Option<FormattedGrapheme<'t>> {
let (grapheme, source) =
if let Some((grapheme, highlight)) = self.next_inline_annotation_grapheme() {
(grapheme.into(), GraphemeSource::VirtualText { highlight })
} else if let Some(grapheme) = self.graphemes.next() {
self.virtual_lines += self.annotations.annotation_lines_at(self.char_pos);
let codepoints = grapheme.len_chars() as u32;
let overlay = self.annotations.overlay_at(self.char_pos);
let grapheme = match overlay {
Some((overlay, _)) => overlay.grapheme.as_str().into(),
None => Cow::from(grapheme).into(),
};
self.char_pos += codepoints as usize;
(grapheme, GraphemeSource::Document { codepoints })
} else {
if self.exhausted {
return None;
}
self.exhausted = true;
// EOF grapheme is required for rendering
// and correct position computations
return Some(FormattedGrapheme {
grapheme: Grapheme::Other { g: " ".into() },
source: GraphemeSource::Document { codepoints: 0 },
});
};
let grapheme = FormattedGrapheme::new(grapheme, col, self.text_fmt.tab_width, source);
Some(grapheme)
}
/// Move a word to the next visual line
fn wrap_word(&mut self, virtual_lines_before_word: usize) -> usize {
// softwrap this word to the next line
let indent_carry_over = if let Some(indent) = self.indent_level {
if indent as u16 <= self.text_fmt.max_indent_retain {
indent as u16
} else {
0
}
} else {
// ensure the indent stays 0
self.indent_level = Some(0);
0
};
self.visual_pos.col = indent_carry_over as usize;
self.virtual_lines -= virtual_lines_before_word;
self.visual_pos.row += 1 + virtual_lines_before_word;
let mut i = 0;
let mut word_width = 0;
let wrap_indicator = UnicodeSegmentation::graphemes(&*self.text_fmt.wrap_indicator, true)
.map(|g| {
i += 1;
let grapheme = FormattedGrapheme::new(
g.into(),
self.visual_pos.col + word_width,
self.text_fmt.tab_width,
GraphemeSource::VirtualText {
highlight: self.text_fmt.wrap_indicator_highlight,
},
);
word_width += grapheme.width();
grapheme
});
self.word_buf.splice(0..0, wrap_indicator);
for grapheme in &mut self.word_buf[i..] {
let visual_x = self.visual_pos.col + word_width;
grapheme
.grapheme
.change_position(visual_x, self.text_fmt.tab_width);
word_width += grapheme.width();
}
word_width
}
fn advance_to_next_word(&mut self) {
self.word_buf.clear();
let mut word_width = 0;
let virtual_lines_before_word = self.virtual_lines;
let mut virtual_lines_before_grapheme = self.virtual_lines;
loop {
// softwrap word if necessary
if word_width + self.visual_pos.col >= self.text_fmt.viewport_width as usize {
// wrapping this word would move too much text to the next line
// split the word at the line end instead
if word_width > self.text_fmt.max_wrap as usize {
// Usually we stop accomulating graphemes as soon as softwrapping becomes necessary.
// However if the last grapheme is multiple columns wide it might extend beyond the EOL.
// The condition below ensures that this grapheme is not cutoff and instead wrapped to the next line
if word_width + self.visual_pos.col > self.text_fmt.viewport_width as usize {
self.peeked_grapheme = self.word_buf.pop().map(|grapheme| {
(grapheme, self.virtual_lines - virtual_lines_before_grapheme)
});
self.virtual_lines = virtual_lines_before_grapheme;
}
return;
}
word_width = self.wrap_word(virtual_lines_before_word);
}
virtual_lines_before_grapheme = self.virtual_lines;
let grapheme = if let Some((grapheme, virtual_lines)) = self.peeked_grapheme.take() {
self.virtual_lines += virtual_lines;
grapheme
} else if let Some(grapheme) = self.advance_grapheme(self.visual_pos.col + word_width) {
grapheme
} else {
return;
};
// Track indentation
if !grapheme.is_whitespace() && self.indent_level.is_none() {
self.indent_level = Some(self.visual_pos.col);
} else if grapheme.grapheme == Grapheme::Newline {
self.indent_level = None;
}
let is_word_boundary = grapheme.is_word_boundary();
word_width += grapheme.width();
self.word_buf.push(grapheme);
if is_word_boundary {
return;
}
}
}
/// returns the document line pos of the **next** grapheme that will be yielded
pub fn line_pos(&self) -> usize {
self.line_pos
}
/// returns the visual pos of the **next** grapheme that will be yielded
pub fn visual_pos(&self) -> Position {
self.visual_pos
}
}
impl<'t> Iterator for DocumentFormatter<'t> {
type Item = (FormattedGrapheme<'t>, Position);
fn next(&mut self) -> Option<Self::Item> {
let grapheme = if self.text_fmt.soft_wrap {
if self.word_i >= self.word_buf.len() {
self.advance_to_next_word();
self.word_i = 0;
}
let grapheme = replace(
self.word_buf.get_mut(self.word_i)?,
FormattedGrapheme::placeholder(),
);
self.word_i += 1;
grapheme
} else {
self.advance_grapheme(self.visual_pos.col)?
};
let pos = self.visual_pos;
if grapheme.grapheme == Grapheme::Newline {
self.visual_pos.row += 1;
self.visual_pos.row += take(&mut self.virtual_lines);
self.visual_pos.col = 0;
self.line_pos += 1;
} else {
self.visual_pos.col += grapheme.width();
}
Some((grapheme, pos))
}
}

@ -0,0 +1,222 @@
use std::rc::Rc;
use crate::doc_formatter::{DocumentFormatter, TextFormat};
use crate::text_annotations::{InlineAnnotation, Overlay, TextAnnotations};
impl TextFormat {
fn new_test(softwrap: bool) -> Self {
TextFormat {
soft_wrap: softwrap,
tab_width: 2,
max_wrap: 3,
max_indent_retain: 4,
wrap_indicator: ".".into(),
wrap_indicator_highlight: None,
// use a prime number to allow lining up too often with repeat
viewport_width: 17,
}
}
}
impl<'t> DocumentFormatter<'t> {
fn collect_to_str(&mut self) -> String {
use std::fmt::Write;
let mut res = String::new();
let viewport_width = self.text_fmt.viewport_width;
let mut line = 0;
for (grapheme, pos) in self {
if pos.row != line {
line += 1;
assert_eq!(pos.row, line);
write!(res, "\n{}", ".".repeat(pos.col)).unwrap();
assert!(
pos.col <= viewport_width as usize,
"softwrapped failed {}<={viewport_width}",
pos.col
);
}
write!(res, "{}", grapheme.grapheme).unwrap();
}
res
}
}
fn softwrap_text(text: &str) -> String {
DocumentFormatter::new_at_prev_checkpoint(
text.into(),
&TextFormat::new_test(true),
&TextAnnotations::default(),
0,
)
.0
.collect_to_str()
}
#[test]
fn basic_softwrap() {
assert_eq!(
softwrap_text(&"foo ".repeat(10)),
"foo foo foo foo \n.foo foo foo foo \n.foo foo "
);
assert_eq!(
softwrap_text(&"fooo ".repeat(10)),
"fooo fooo fooo \n.fooo fooo fooo \n.fooo fooo fooo \n.fooo "
);
// check that we don't wrap unnecessarily
assert_eq!(softwrap_text("\t\txxxx1xxxx2xx\n"), " xxxx1xxxx2xx \n ");
}
#[test]
fn softwrap_indentation() {
assert_eq!(
softwrap_text("\t\tfoo1 foo2 foo3 foo4 foo5 foo6\n"),
" foo1 foo2 \n.....foo3 foo4 \n.....foo5 foo6 \n "
);
assert_eq!(
softwrap_text("\t\t\tfoo1 foo2 foo3 foo4 foo5 foo6\n"),
" foo1 foo2 \n.foo3 foo4 foo5 \n.foo6 \n "
);
}
#[test]
fn long_word_softwrap() {
assert_eq!(
softwrap_text("\t\txxxx1xxxx2xxxx3xxxx4xxxx5xxxx6xxxx7xxxx8xxxx9xxx\n"),
" xxxx1xxxx2xxx\n.....x3xxxx4xxxx5\n.....xxxx6xxxx7xx\n.....xx8xxxx9xxx \n "
);
assert_eq!(
softwrap_text("xxxxxxxx1xxxx2xxx\n"),
"xxxxxxxx1xxxx2xxx\n. \n "
);
assert_eq!(
softwrap_text("\t\txxxx1xxxx 2xxxx3xxxx4xxxx5xxxx6xxxx7xxxx8xxxx9xxx\n"),
" xxxx1xxxx \n.....2xxxx3xxxx4x\n.....xxx5xxxx6xxx\n.....x7xxxx8xxxx9\n.....xxx \n "
);
assert_eq!(
softwrap_text("\t\txxxx1xxx 2xxxx3xxxx4xxxx5xxxx6xxxx7xxxx8xxxx9xxx\n"),
" xxxx1xxx 2xxx\n.....x3xxxx4xxxx5\n.....xxxx6xxxx7xx\n.....xx8xxxx9xxx \n "
);
}
fn overlay_text(text: &str, char_pos: usize, softwrap: bool, overlays: &[Overlay]) -> String {
DocumentFormatter::new_at_prev_checkpoint(
text.into(),
&TextFormat::new_test(softwrap),
TextAnnotations::default().add_overlay(overlays.into(), None),
char_pos,
)
.0
.collect_to_str()
}
#[test]
fn overlay() {
assert_eq!(
overlay_text(
"foobar",
0,
false,
&[
Overlay {
char_idx: 0,
grapheme: "X".into(),
},
Overlay {
char_idx: 2,
grapheme: "\t".into(),
},
]
),
"Xo bar "
);
assert_eq!(
overlay_text(
&"foo ".repeat(10),
0,
true,
&[
Overlay {
char_idx: 2,
grapheme: "\t".into(),
},
Overlay {
char_idx: 5,
grapheme: "\t".into(),
},
Overlay {
char_idx: 16,
grapheme: "X".into(),
},
]
),
"fo f o foo \n.foo Xoo foo foo \n.foo foo foo "
);
}
fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) -> String {
DocumentFormatter::new_at_prev_checkpoint(
text.into(),
&TextFormat::new_test(softwrap),
TextAnnotations::default().add_inline_annotations(annotations.into(), None),
0,
)
.0
.collect_to_str()
}
#[test]
fn annotation() {
assert_eq!(
annotate_text(
"bar",
false,
&[InlineAnnotation {
char_idx: 0,
text: "foo".into(),
}]
),
"foobar "
);
assert_eq!(
annotate_text(
&"foo ".repeat(10),
true,
&[InlineAnnotation {
char_idx: 0,
text: "foo ".into(),
}]
),
"foo foo foo foo \n.foo foo foo foo \n.foo foo foo "
);
}
#[test]
fn annotation_and_overlay() {
assert_eq!(
DocumentFormatter::new_at_prev_checkpoint(
"bbar".into(),
&TextFormat::new_test(false),
TextAnnotations::default()
.add_inline_annotations(
Rc::new([InlineAnnotation {
char_idx: 0,
text: "fooo".into(),
}]),
None
)
.add_overlay(
Rc::new([Overlay {
char_idx: 0,
grapheme: "\t".into(),
}]),
None
),
0,
)
.0
.collect_to_str(),
"fooo bar "
);
}

@ -5,7 +5,88 @@ use ropey::{iter::Chunks, str_utils::byte_to_char_idx, RopeSlice};
use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete}; use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete};
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use std::fmt; use std::borrow::Cow;
use std::fmt::{self, Debug, Display};
use std::marker::PhantomData;
use std::ops::Deref;
use std::ptr::NonNull;
use std::{slice, str};
use crate::chars::{char_is_whitespace, char_is_word};
use crate::LineEnding;
#[inline]
pub fn tab_width_at(visual_x: usize, tab_width: u16) -> usize {
tab_width as usize - (visual_x % tab_width as usize)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Grapheme<'a> {
Newline,
Tab { width: usize },
Other { g: GraphemeStr<'a> },
}
impl<'a> Grapheme<'a> {
pub fn new(g: GraphemeStr<'a>, visual_x: usize, tab_width: u16) -> Grapheme<'a> {
match g {
g if g == "\t" => Grapheme::Tab {
width: tab_width_at(visual_x, tab_width),
},
_ if LineEnding::from_str(&g).is_some() => Grapheme::Newline,
_ => Grapheme::Other { g },
}
}
pub fn change_position(&mut self, visual_x: usize, tab_width: u16) {
if let Grapheme::Tab { width } = self {
*width = tab_width_at(visual_x, tab_width)
}
}
/// Returns the a visual width of this grapheme,
#[inline]
pub fn width(&self) -> usize {
match *self {
// width is not cached because we are dealing with
// ASCII almost all the time which already has a fastpath
// it's okay to convert to u16 here because no codepoint has a width larger
// than 2 and graphemes are usually atmost two visible codepoints wide
Grapheme::Other { ref g } => grapheme_width(g),
Grapheme::Tab { width } => width,
Grapheme::Newline => 1,
}
}
pub fn is_whitespace(&self) -> bool {
!matches!(&self, Grapheme::Other { g } if !g.chars().all(char_is_whitespace))
}
// TODO currently word boundaries are used for softwrapping.
// This works best for programming languages and well for prose.
// This could however be improved in the future by considering unicode
// character classes but
pub fn is_word_boundary(&self) -> bool {
!matches!(&self, Grapheme::Other { g,.. } if g.chars().all(char_is_word))
}
}
impl Display for Grapheme<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Grapheme::Newline => write!(f, " "),
Grapheme::Tab { width } => {
for _ in 0..width {
write!(f, " ")?;
}
Ok(())
}
Grapheme::Other { ref g } => {
write!(f, "{g}")
}
}
}
}
#[must_use] #[must_use]
pub fn grapheme_width(g: &str) -> usize { pub fn grapheme_width(g: &str) -> usize {
@ -27,6 +108,8 @@ pub fn grapheme_width(g: &str) -> usize {
// We use max(1) here because all grapeheme clusters--even illformed // We use max(1) here because all grapeheme clusters--even illformed
// ones--should have at least some width so they can be edited // ones--should have at least some width so they can be edited
// properly. // properly.
// TODO properly handle unicode width for all codepoints
// example of where unicode width is currently wrong: 🤦🏼‍♂️ (taken from https://hsivonen.fi/string-length/)
UnicodeWidthStr::width(g).max(1) UnicodeWidthStr::width(g).max(1)
} }
} }
@ -341,3 +424,101 @@ impl<'a> Iterator for RopeGraphemes<'a> {
} }
} }
} }
/// A highly compressed Cow<'a, str> that holds
/// atmost u31::MAX bytes and is readonly
pub struct GraphemeStr<'a> {
ptr: NonNull<u8>,
len: u32,
phantom: PhantomData<&'a str>,
}
impl GraphemeStr<'_> {
const MASK_OWNED: u32 = 1 << 31;
fn compute_len(&self) -> usize {
(self.len & !Self::MASK_OWNED) as usize
}
}
impl Deref for GraphemeStr<'_> {
type Target = str;
fn deref(&self) -> &Self::Target {
unsafe {
let bytes = slice::from_raw_parts(self.ptr.as_ptr(), self.compute_len());
str::from_utf8_unchecked(bytes)
}
}
}
impl Drop for GraphemeStr<'_> {
fn drop(&mut self) {
if self.len & Self::MASK_OWNED != 0 {
// free allocation
unsafe {
drop(Box::from_raw(slice::from_raw_parts_mut(
self.ptr.as_ptr(),
self.compute_len(),
)));
}
}
}
}
impl<'a> From<&'a str> for GraphemeStr<'a> {
fn from(g: &'a str) -> Self {
GraphemeStr {
ptr: unsafe { NonNull::new_unchecked(g.as_bytes().as_ptr() as *mut u8) },
len: i32::try_from(g.len()).unwrap() as u32,
phantom: PhantomData,
}
}
}
impl<'a> From<String> for GraphemeStr<'a> {
fn from(g: String) -> Self {
let len = g.len();
let ptr = Box::into_raw(g.into_bytes().into_boxed_slice()) as *mut u8;
GraphemeStr {
ptr: unsafe { NonNull::new_unchecked(ptr) },
len: i32::try_from(len).unwrap() as u32,
phantom: PhantomData,
}
}
}
impl<'a> From<Cow<'a, str>> for GraphemeStr<'a> {
fn from(g: Cow<'a, str>) -> Self {
match g {
Cow::Borrowed(g) => g.into(),
Cow::Owned(g) => g.into(),
}
}
}
impl<T: Deref<Target = str>> PartialEq<T> for GraphemeStr<'_> {
fn eq(&self, other: &T) -> bool {
self.deref() == other.deref()
}
}
impl PartialEq<str> for GraphemeStr<'_> {
fn eq(&self, other: &str) -> bool {
self.deref() == other
}
}
impl Eq for GraphemeStr<'_> {}
impl Debug for GraphemeStr<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Debug::fmt(self.deref(), f)
}
}
impl Display for GraphemeStr<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Display::fmt(self.deref(), f)
}
}
impl Clone for GraphemeStr<'_> {
fn clone(&self) -> Self {
self.deref().to_owned().into()
}
}

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

@ -1,114 +1,53 @@
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use ropey::RopeSlice;
use std::borrow::Cow;
use std::cmp;
use std::fmt::Write; use std::fmt::Write;
use super::Increment; /// Increment a Date or DateTime
use crate::{Range, Tendril}; ///
/// If just a Date is selected the day will be incremented.
/// If a DateTime is selected the second will be incremented.
pub fn increment(selected_text: &str, amount: i64) -> Option<String> {
if selected_text.is_empty() {
return None;
}
#[derive(Debug, PartialEq, Eq)] FORMATS.iter().find_map(|format| {
pub struct DateTimeIncrementor { let captures = format.regex.captures(selected_text)?;
date_time: NaiveDateTime, if captures.len() - 1 != format.fields.len() {
range: Range, return None;
fmt: &'static str, }
field: DateField,
}
impl DateTimeIncrementor { let date_time = captures.get(0)?;
pub fn from_range(text: RopeSlice, range: Range) -> Option<DateTimeIncrementor> { let has_date = format.fields.iter().any(|f| f.unit.is_date());
let range = if range.is_empty() { let has_time = format.fields.iter().any(|f| f.unit.is_time());
if range.anchor < text.len_chars() { let date_time = &selected_text[date_time.start()..date_time.end()];
// Treat empty range as a cursor range. match (has_date, has_time) {
range.put_cursor(text, range.anchor + 1, true) (true, true) => {
} else { let date_time = NaiveDateTime::parse_from_str(date_time, format.fmt).ok()?;
// The range is empty and at the end of the text. Some(
return None; date_time
.checked_add_signed(Duration::minutes(amount))?
.format(format.fmt)
.to_string(),
)
} }
} else { (true, false) => {
range let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?;
}; Some(
date.checked_add_signed(Duration::days(amount))?
FORMATS.iter().find_map(|format| { .format(format.fmt)
let from = range.from().saturating_sub(format.max_len); .to_string(),
let to = (range.from() + format.max_len).min(text.len_chars()); )
let (from_in_text, to_in_text) = (range.from() - from, range.to() - from);
let text: Cow<str> = text.slice(from..to).into();
let captures = format.regex.captures(&text)?;
if captures.len() - 1 != format.fields.len() {
return None;
} }
(false, true) => {
let date_time = captures.get(0)?; let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?;
let offset = range.from() - from_in_text; let (adjusted_time, _) = time.overflowing_add_signed(Duration::minutes(amount));
let range = Range::new(date_time.start() + offset, date_time.end() + offset); Some(adjusted_time.format(format.fmt).to_string())
}
let field = captures (false, false) => None,
.iter()
.skip(1)
.enumerate()
.find_map(|(i, capture)| {
let capture = capture?;
let capture_range = capture.range();
if capture_range.contains(&from_in_text)
&& capture_range.contains(&(to_in_text - 1))
{
Some(format.fields[i])
} else {
None
}
})?;
let has_date = format.fields.iter().any(|f| f.unit.is_date());
let has_time = format.fields.iter().any(|f| f.unit.is_time());
let date_time = &text[date_time.start()..date_time.end()];
let date_time = match (has_date, has_time) {
(true, true) => NaiveDateTime::parse_from_str(date_time, format.fmt).ok()?,
(true, false) => {
let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?;
date.and_hms(0, 0, 0)
}
(false, true) => {
let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?;
NaiveDate::from_ymd(0, 1, 1).and_time(time)
}
(false, false) => return None,
};
Some(DateTimeIncrementor {
date_time,
range,
fmt: format.fmt,
field,
})
})
}
}
impl Increment for DateTimeIncrementor {
fn increment(&self, amount: i64) -> (Range, Tendril) {
let date_time = match self.field.unit {
DateUnit::Years => add_years(self.date_time, amount),
DateUnit::Months => add_months(self.date_time, amount),
DateUnit::Days => add_duration(self.date_time, Duration::days(amount)),
DateUnit::Hours => add_duration(self.date_time, Duration::hours(amount)),
DateUnit::Minutes => add_duration(self.date_time, Duration::minutes(amount)),
DateUnit::Seconds => add_duration(self.date_time, Duration::seconds(amount)),
DateUnit::AmPm => toggle_am_pm(self.date_time),
} }
.unwrap_or(self.date_time); })
(self.range, date_time.format(self.fmt).to_string().into())
}
} }
static FORMATS: Lazy<Vec<Format>> = Lazy::new(|| { static FORMATS: Lazy<Vec<Format>> = Lazy::new(|| {
@ -144,7 +83,7 @@ impl Format {
fn new(fmt: &'static str) -> Self { fn new(fmt: &'static str) -> Self {
let mut remaining = fmt; let mut remaining = fmt;
let mut fields = Vec::new(); let mut fields = Vec::new();
let mut regex = String::new(); let mut regex = "^".to_string();
let mut max_len = 0; let mut max_len = 0;
while let Some(i) = remaining.find('%') { while let Some(i) = remaining.find('%') {
@ -166,6 +105,7 @@ impl Format {
write!(regex, "({})", field.regex).unwrap(); write!(regex, "({})", field.regex).unwrap();
remaining = &after[spec_len..]; remaining = &after[spec_len..];
} }
regex += "$";
let regex = Regex::new(&regex).unwrap(); let regex = Regex::new(&regex).unwrap();
@ -305,155 +245,47 @@ impl DateUnit {
} }
} }
fn ndays_in_month(year: i32, month: u32) -> u32 {
// The first day of the next month...
let (y, m) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
let d = NaiveDate::from_ymd(y, m, 1);
// ...is preceded by the last day of the original month.
d.pred().day()
}
fn add_months(date_time: NaiveDateTime, amount: i64) -> Option<NaiveDateTime> {
let month = (date_time.month0() as i64).checked_add(amount)?;
let year = date_time.year() + i32::try_from(month / 12).ok()?;
let year = if month.is_negative() { year - 1 } else { year };
// Normalize month
let month = month % 12;
let month = if month.is_negative() {
month + 12
} else {
month
} as u32
+ 1;
let day = cmp::min(date_time.day(), ndays_in_month(year, month));
Some(NaiveDate::from_ymd(year, month, day).and_time(date_time.time()))
}
fn add_years(date_time: NaiveDateTime, amount: i64) -> Option<NaiveDateTime> {
let year = i32::try_from((date_time.year() as i64).checked_add(amount)?).ok()?;
let ndays = ndays_in_month(year, date_time.month());
if date_time.day() > ndays {
let d = NaiveDate::from_ymd(year, date_time.month(), ndays);
Some(d.succ().and_time(date_time.time()))
} else {
date_time.with_year(year)
}
}
fn add_duration(date_time: NaiveDateTime, duration: Duration) -> Option<NaiveDateTime> {
date_time.checked_add_signed(duration)
}
fn toggle_am_pm(date_time: NaiveDateTime) -> Option<NaiveDateTime> {
if date_time.hour() < 12 {
add_duration(date_time, Duration::hours(12))
} else {
add_duration(date_time, Duration::hours(-12))
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::Rope;
#[test] #[test]
fn test_increment_date_times() { fn test_increment_date_times() {
let tests = [ let tests = [
// (original, cursor, amount, expected) // (original, cursor, amount, expected)
("2020-02-28", 0, 1, "2021-02-28"), ("2020-02-28", 1, "2020-02-29"),
("2020-02-29", 0, 1, "2021-03-01"), ("2020-02-29", 1, "2020-03-01"),
("2020-01-31", 5, 1, "2020-02-29"), ("2020-01-31", 1, "2020-02-01"),
("2020-01-20", 5, 1, "2020-02-20"), ("2020-01-20", 1, "2020-01-21"),
("2021-01-01", 5, -1, "2020-12-01"), ("2021-01-01", -1, "2020-12-31"),
("2021-01-31", 5, -2, "2020-11-30"), ("2021-01-31", -2, "2021-01-29"),
("2020-02-28", 8, 1, "2020-02-29"), ("2020-02-28", 1, "2020-02-29"),
("2021-02-28", 8, 1, "2021-03-01"), ("2021-02-28", 1, "2021-03-01"),
("2021-02-28", 0, -1, "2020-02-28"), ("2021-03-01", -1, "2021-02-28"),
("2021-03-01", 0, -1, "2020-03-01"), ("2020-02-29", -1, "2020-02-28"),
("2020-02-29", 5, -1, "2020-01-29"), ("2020-02-20", -1, "2020-02-19"),
("2020-02-20", 5, -1, "2020-01-20"), ("2021-03-01", -1, "2021-02-28"),
("2020-02-29", 8, -1, "2020-02-28"), ("1980/12/21", 100, "1981/03/31"),
("2021-03-01", 8, -1, "2021-02-28"), ("1980/12/21", -100, "1980/09/12"),
("1980/12/21", 8, 100, "1981/03/31"), ("1980/12/21", 1000, "1983/09/17"),
("1980/12/21", 8, -100, "1980/09/12"), ("1980/12/21", -1000, "1978/03/27"),
("1980/12/21", 8, 1000, "1983/09/17"), ("2021-11-24 07:12:23", 1, "2021-11-24 07:13:23"),
("1980/12/21", 8, -1000, "1978/03/27"), ("2021-11-24 07:12", 1, "2021-11-24 07:13"),
("2021-11-24 07:12:23", 0, 1, "2022-11-24 07:12:23"), ("Wed Nov 24 2021", 1, "Thu Nov 25 2021"),
("2021-11-24 07:12:23", 5, 1, "2021-12-24 07:12:23"), ("24-Nov-2021", 1, "25-Nov-2021"),
("2021-11-24 07:12:23", 8, 1, "2021-11-25 07:12:23"), ("2021 Nov 24", 1, "2021 Nov 25"),
("2021-11-24 07:12:23", 11, 1, "2021-11-24 08:12:23"), ("Nov 24, 2021", 1, "Nov 25, 2021"),
("2021-11-24 07:12:23", 14, 1, "2021-11-24 07:13:23"), ("7:21:53 am", 1, "7:22:53 am"),
("2021-11-24 07:12:23", 17, 1, "2021-11-24 07:12:24"), ("7:21:53 AM", 1, "7:22:53 AM"),
("2021/11/24 07:12:23", 0, 1, "2022/11/24 07:12:23"), ("7:21 am", 1, "7:22 am"),
("2021/11/24 07:12:23", 5, 1, "2021/12/24 07:12:23"), ("23:24:23", 1, "23:25:23"),
("2021/11/24 07:12:23", 8, 1, "2021/11/25 07:12:23"), ("23:24", 1, "23:25"),
("2021/11/24 07:12:23", 11, 1, "2021/11/24 08:12:23"), ("23:59", 1, "00:00"),
("2021/11/24 07:12:23", 14, 1, "2021/11/24 07:13:23"), ("23:59:59", 1, "00:00:59"),
("2021/11/24 07:12:23", 17, 1, "2021/11/24 07:12:24"),
("2021-11-24 07:12", 0, 1, "2022-11-24 07:12"),
("2021-11-24 07:12", 5, 1, "2021-12-24 07:12"),
("2021-11-24 07:12", 8, 1, "2021-11-25 07:12"),
("2021-11-24 07:12", 11, 1, "2021-11-24 08:12"),
("2021-11-24 07:12", 14, 1, "2021-11-24 07:13"),
("2021/11/24 07:12", 0, 1, "2022/11/24 07:12"),
("2021/11/24 07:12", 5, 1, "2021/12/24 07:12"),
("2021/11/24 07:12", 8, 1, "2021/11/25 07:12"),
("2021/11/24 07:12", 11, 1, "2021/11/24 08:12"),
("2021/11/24 07:12", 14, 1, "2021/11/24 07:13"),
("Wed Nov 24 2021", 0, 1, "Thu Nov 25 2021"),
("Wed Nov 24 2021", 4, 1, "Fri Dec 24 2021"),
("Wed Nov 24 2021", 8, 1, "Thu Nov 25 2021"),
("Wed Nov 24 2021", 11, 1, "Thu Nov 24 2022"),
("24-Nov-2021", 0, 1, "25-Nov-2021"),
("24-Nov-2021", 3, 1, "24-Dec-2021"),
("24-Nov-2021", 7, 1, "24-Nov-2022"),
("2021 Nov 24", 0, 1, "2022 Nov 24"),
("2021 Nov 24", 5, 1, "2021 Dec 24"),
("2021 Nov 24", 9, 1, "2021 Nov 25"),
("Nov 24, 2021", 0, 1, "Dec 24, 2021"),
("Nov 24, 2021", 4, 1, "Nov 25, 2021"),
("Nov 24, 2021", 8, 1, "Nov 24, 2022"),
("7:21:53 am", 0, 1, "8:21:53 am"),
("7:21:53 am", 3, 1, "7:22:53 am"),
("7:21:53 am", 5, 1, "7:21:54 am"),
("7:21:53 am", 8, 1, "7:21:53 pm"),
("7:21:53 AM", 0, 1, "8:21:53 AM"),
("7:21:53 AM", 3, 1, "7:22:53 AM"),
("7:21:53 AM", 5, 1, "7:21:54 AM"),
("7:21:53 AM", 8, 1, "7:21:53 PM"),
("7:21 am", 0, 1, "8:21 am"),
("7:21 am", 3, 1, "7:22 am"),
("7:21 am", 5, 1, "7:21 pm"),
("7:21 AM", 0, 1, "8:21 AM"),
("7:21 AM", 3, 1, "7:22 AM"),
("7:21 AM", 5, 1, "7:21 PM"),
("23:24:23", 1, 1, "00:24:23"),
("23:24:23", 3, 1, "23:25:23"),
("23:24:23", 6, 1, "23:24:24"),
("23:24", 1, 1, "00:24"),
("23:24", 3, 1, "23:25"),
]; ];
for (original, cursor, amount, expected) in tests { for (original, amount, expected) in tests {
let rope = Rope::from_str(original); assert_eq!(increment(original, amount).unwrap(), expected);
let range = Range::new(cursor, cursor + 1);
assert_eq!(
DateTimeIncrementor::from_range(rope.slice(..), range)
.unwrap()
.increment(amount)
.1,
Tendril::from(expected)
);
} }
} }
@ -482,10 +314,7 @@ mod test {
]; ];
for invalid in tests { for invalid in tests {
let rope = Rope::from_str(invalid); assert_eq!(increment(invalid, 1), None)
let range = Range::new(0, 1);
assert_eq!(DateTimeIncrementor::from_range(rope.slice(..), range), None)
} }
} }
} }

@ -0,0 +1,235 @@
const SEPARATOR: char = '_';
/// Increment an integer.
///
/// Supported bases:
/// 2 with prefix 0b
/// 8 with prefix 0o
/// 10 with no prefix
/// 16 with prefix 0x
///
/// An integer can contain `_` as a separator but may not start or end with a separator.
/// Base 10 integers can go negative, but bases 2, 8, and 16 cannot.
/// All addition and subtraction is saturating.
pub fn increment(selected_text: &str, amount: i64) -> Option<String> {
if selected_text.is_empty()
|| selected_text.ends_with(SEPARATOR)
|| selected_text.starts_with(SEPARATOR)
{
return None;
}
let radix = if selected_text.starts_with("0x") {
16
} else if selected_text.starts_with("0o") {
8
} else if selected_text.starts_with("0b") {
2
} else {
10
};
// Get separator indexes from right to left.
let separator_rtl_indexes: Vec<usize> = selected_text
.chars()
.rev()
.enumerate()
.filter_map(|(i, c)| if c == SEPARATOR { Some(i) } else { None })
.collect();
let word: String = selected_text.chars().filter(|&c| c != SEPARATOR).collect();
let mut new_text = if radix == 10 {
let number = &word;
let value = i128::from_str_radix(number, radix).ok()?;
let new_value = value.saturating_add(amount as i128);
let format_length = match (value.is_negative(), new_value.is_negative()) {
(true, false) => number.len() - 1,
(false, true) => number.len() + 1,
_ => number.len(),
} - separator_rtl_indexes.len();
if number.starts_with('0') || number.starts_with("-0") {
format!("{:01$}", new_value, format_length)
} else {
format!("{}", new_value)
}
} else {
let number = &word[2..];
let value = u128::from_str_radix(number, radix).ok()?;
let new_value = (value as i128).saturating_add(amount as i128);
let new_value = if new_value < 0 { 0 } else { new_value };
let format_length = selected_text.len() - 2 - separator_rtl_indexes.len();
match radix {
2 => format!("0b{:01$b}", new_value, format_length),
8 => format!("0o{:01$o}", new_value, format_length),
16 => {
let (lower_count, upper_count): (usize, usize) =
number.chars().fold((0, 0), |(lower, upper), c| {
(
lower + c.is_ascii_lowercase() as usize,
upper + c.is_ascii_uppercase() as usize,
)
});
if upper_count > lower_count {
format!("0x{:01$X}", new_value, format_length)
} else {
format!("0x{:01$x}", new_value, format_length)
}
}
_ => unimplemented!("radix not supported: {}", radix),
}
};
// Add separators from original number.
for &rtl_index in &separator_rtl_indexes {
if rtl_index < new_text.len() {
let new_index = new_text.len().saturating_sub(rtl_index);
if new_index > 0 {
new_text.insert(new_index, SEPARATOR);
}
}
}
// Add in additional separators if necessary.
if new_text.len() > selected_text.len() && !separator_rtl_indexes.is_empty() {
let spacing = match separator_rtl_indexes.as_slice() {
[.., b, a] => a - b - 1,
_ => separator_rtl_indexes[0],
};
let prefix_length = if radix == 10 { 0 } else { 2 };
if let Some(mut index) = new_text.find(SEPARATOR) {
while index - prefix_length > spacing {
index -= spacing;
new_text.insert(index, SEPARATOR);
}
}
}
Some(new_text)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_increment_basic_decimal_numbers() {
let tests = [
("100", 1, "101"),
("100", -1, "99"),
("99", 1, "100"),
("100", 1000, "1100"),
("100", -1000, "-900"),
("-1", 1, "0"),
("-1", 2, "1"),
("1", -1, "0"),
("1", -2, "-1"),
];
for (original, amount, expected) in tests {
assert_eq!(increment(original, amount).unwrap(), expected);
}
}
#[test]
fn test_increment_basic_hexadecimal_numbers() {
let tests = [
("0x0100", 1, "0x0101"),
("0x0100", -1, "0x00ff"),
("0x0001", -1, "0x0000"),
("0x0000", -1, "0x0000"),
("0xffffffffffffffff", 1, "0x10000000000000000"),
("0xffffffffffffffff", 2, "0x10000000000000001"),
("0xffffffffffffffff", -1, "0xfffffffffffffffe"),
("0xABCDEF1234567890", 1, "0xABCDEF1234567891"),
("0xabcdef1234567890", 1, "0xabcdef1234567891"),
];
for (original, amount, expected) in tests {
assert_eq!(increment(original, amount).unwrap(), expected);
}
}
#[test]
fn test_increment_basic_octal_numbers() {
let tests = [
("0o0107", 1, "0o0110"),
("0o0110", -1, "0o0107"),
("0o0001", -1, "0o0000"),
("0o7777", 1, "0o10000"),
("0o1000", -1, "0o0777"),
("0o0107", 10, "0o0121"),
("0o0000", -1, "0o0000"),
("0o1777777777777777777777", 1, "0o2000000000000000000000"),
("0o1777777777777777777777", 2, "0o2000000000000000000001"),
("0o1777777777777777777777", -1, "0o1777777777777777777776"),
];
for (original, amount, expected) in tests {
assert_eq!(increment(original, amount).unwrap(), expected);
}
}
#[test]
fn test_increment_basic_binary_numbers() {
let tests = [
("0b00000100", 1, "0b00000101"),
("0b00000100", -1, "0b00000011"),
("0b00000100", 2, "0b00000110"),
("0b00000100", -2, "0b00000010"),
("0b00000001", -1, "0b00000000"),
("0b00111111", 10, "0b01001001"),
("0b11111111", 1, "0b100000000"),
("0b10000000", -1, "0b01111111"),
("0b0000", -1, "0b0000"),
(
"0b1111111111111111111111111111111111111111111111111111111111111111",
1,
"0b10000000000000000000000000000000000000000000000000000000000000000",
),
(
"0b1111111111111111111111111111111111111111111111111111111111111111",
2,
"0b10000000000000000000000000000000000000000000000000000000000000001",
),
(
"0b1111111111111111111111111111111111111111111111111111111111111111",
-1,
"0b1111111111111111111111111111111111111111111111111111111111111110",
),
];
for (original, amount, expected) in tests {
assert_eq!(increment(original, amount).unwrap(), expected);
}
}
#[test]
fn test_increment_with_separators() {
let tests = [
("999_999", 1, "1_000_000"),
("1_000_000", -1, "999_999"),
("-999_999", -1, "-1_000_000"),
("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"),
("0x0000_0000", -1, "0x0000_0000"),
("0x0000_0000_0000", -1, "0x0000_0000_0000"),
("0b01111111_11111111", 1, "0b10000000_00000000"),
("0b11111111_11111111", 1, "0b1_00000000_00000000"),
];
for (original, amount, expected) in tests {
assert_eq!(increment(original, amount).unwrap(), expected);
}
}
#[test]
fn test_leading_and_trailing_separators_arent_a_match() {
assert_eq!(increment("9_", 1), None);
assert_eq!(increment("_9", 1), None);
assert_eq!(increment("_9_", 1), None);
}
}

@ -1,8 +1,10 @@
pub mod date_time; mod date_time;
pub mod number; mod integer;
use crate::{Range, Tendril}; pub fn integer(selected_text: &str, amount: i64) -> Option<String> {
integer::increment(selected_text, amount)
}
pub trait Increment { pub fn date_time(selected_text: &str, amount: i64) -> Option<String> {
fn increment(&self, amount: i64) -> (Range, Tendril); date_time::increment(selected_text, amount)
} }

@ -1,507 +0,0 @@
use std::borrow::Cow;
use ropey::RopeSlice;
use super::Increment;
use crate::{
textobject::{textobject_word, TextObject},
Range, Tendril,
};
#[derive(Debug, PartialEq, Eq)]
pub struct NumberIncrementor<'a> {
value: i64,
radix: u32,
range: Range,
text: RopeSlice<'a>,
}
impl<'a> NumberIncrementor<'a> {
/// Return information about number under rang if there is one.
pub fn from_range(text: RopeSlice, range: Range) -> Option<NumberIncrementor> {
// If the cursor is on the minus sign of a number we want to get the word textobject to the
// right of it.
let range = if range.to() < text.len_chars()
&& range.to() - range.from() <= 1
&& text.char(range.from()) == '-'
{
Range::new(range.from() + 1, range.to() + 1)
} else {
range
};
let range = textobject_word(text, range, TextObject::Inside, 1, false);
// If there is a minus sign to the left of the word object, we want to include it in the range.
let range = if range.from() > 0 && text.char(range.from() - 1) == '-' {
range.extend(range.from() - 1, range.from())
} else {
range
};
let word: String = text
.slice(range.from()..range.to())
.chars()
.filter(|&c| c != '_')
.collect();
let (radix, prefixed) = if word.starts_with("0x") {
(16, true)
} else if word.starts_with("0o") {
(8, true)
} else if word.starts_with("0b") {
(2, true)
} else {
(10, false)
};
let number = if prefixed { &word[2..] } else { &word };
let value = i128::from_str_radix(number, radix).ok()?;
if (value.is_positive() && value.leading_zeros() < 64)
|| (value.is_negative() && value.leading_ones() < 64)
{
return None;
}
let value = value as i64;
Some(NumberIncrementor {
range,
value,
radix,
text,
})
}
}
impl<'a> Increment for NumberIncrementor<'a> {
fn increment(&self, amount: i64) -> (Range, Tendril) {
let old_text: Cow<str> = self.text.slice(self.range.from()..self.range.to()).into();
let old_length = old_text.len();
let new_value = self.value.wrapping_add(amount);
// Get separator indexes from right to left.
let separator_rtl_indexes: Vec<usize> = old_text
.chars()
.rev()
.enumerate()
.filter_map(|(i, c)| if c == '_' { Some(i) } else { None })
.collect();
let format_length = if self.radix == 10 {
match (self.value.is_negative(), new_value.is_negative()) {
(true, false) => old_length - 1,
(false, true) => old_length + 1,
_ => old_text.len(),
}
} else {
old_text.len() - 2
} - separator_rtl_indexes.len();
let mut new_text = match self.radix {
2 => format!("0b{:01$b}", new_value, format_length),
8 => format!("0o{:01$o}", new_value, format_length),
10 if old_text.starts_with('0') || old_text.starts_with("-0") => {
format!("{:01$}", new_value, format_length)
}
10 => format!("{}", new_value),
16 => {
let (lower_count, upper_count): (usize, usize) =
old_text.chars().skip(2).fold((0, 0), |(lower, upper), c| {
(
lower + c.is_ascii_lowercase().then(|| 1).unwrap_or(0),
upper + c.is_ascii_uppercase().then(|| 1).unwrap_or(0),
)
});
if upper_count > lower_count {
format!("0x{:01$X}", new_value, format_length)
} else {
format!("0x{:01$x}", new_value, format_length)
}
}
_ => unimplemented!("radix not supported: {}", self.radix),
};
// Add separators from original number.
for &rtl_index in &separator_rtl_indexes {
if rtl_index < new_text.len() {
let new_index = new_text.len() - rtl_index;
new_text.insert(new_index, '_');
}
}
// Add in additional separators if necessary.
if new_text.len() > old_length && !separator_rtl_indexes.is_empty() {
let spacing = match separator_rtl_indexes.as_slice() {
[.., b, a] => a - b - 1,
_ => separator_rtl_indexes[0],
};
let prefix_length = if self.radix == 10 { 0 } else { 2 };
if let Some(mut index) = new_text.find('_') {
while index - prefix_length > spacing {
index -= spacing;
new_text.insert(index, '_');
}
}
}
(self.range, new_text.into())
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::Rope;
#[test]
fn test_decimal_at_point() {
let rope = Rope::from_str("Test text 12345 more text.");
let range = Range::point(12);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(10, 15),
value: 12345,
radix: 10,
text: rope.slice(..),
})
);
}
#[test]
fn test_uppercase_hexadecimal_at_point() {
let rope = Rope::from_str("Test text 0x123ABCDEF more text.");
let range = Range::point(12);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(10, 21),
value: 0x123ABCDEF,
radix: 16,
text: rope.slice(..),
})
);
}
#[test]
fn test_lowercase_hexadecimal_at_point() {
let rope = Rope::from_str("Test text 0xfa3b4e more text.");
let range = Range::point(12);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(10, 18),
value: 0xfa3b4e,
radix: 16,
text: rope.slice(..),
})
);
}
#[test]
fn test_octal_at_point() {
let rope = Rope::from_str("Test text 0o1074312 more text.");
let range = Range::point(12);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(10, 19),
value: 0o1074312,
radix: 8,
text: rope.slice(..),
})
);
}
#[test]
fn test_binary_at_point() {
let rope = Rope::from_str("Test text 0b10111010010101 more text.");
let range = Range::point(12);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(10, 26),
value: 0b10111010010101,
radix: 2,
text: rope.slice(..),
})
);
}
#[test]
fn test_negative_decimal_at_point() {
let rope = Rope::from_str("Test text -54321 more text.");
let range = Range::point(12);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(10, 16),
value: -54321,
radix: 10,
text: rope.slice(..),
})
);
}
#[test]
fn test_decimal_with_leading_zeroes_at_point() {
let rope = Rope::from_str("Test text 000045326 more text.");
let range = Range::point(12);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(10, 19),
value: 45326,
radix: 10,
text: rope.slice(..),
})
);
}
#[test]
fn test_negative_decimal_cursor_on_minus_sign() {
let rope = Rope::from_str("Test text -54321 more text.");
let range = Range::point(10);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(10, 16),
value: -54321,
radix: 10,
text: rope.slice(..),
})
);
}
#[test]
fn test_number_under_range_start_of_rope() {
let rope = Rope::from_str("100");
let range = Range::point(0);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(0, 3),
value: 100,
radix: 10,
text: rope.slice(..),
})
);
}
#[test]
fn test_number_under_range_end_of_rope() {
let rope = Rope::from_str("100");
let range = Range::point(2);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(0, 3),
value: 100,
radix: 10,
text: rope.slice(..),
})
);
}
#[test]
fn test_number_surrounded_by_punctuation() {
let rope = Rope::from_str(",100;");
let range = Range::point(1);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range),
Some(NumberIncrementor {
range: Range::new(1, 4),
value: 100,
radix: 10,
text: rope.slice(..),
})
);
}
#[test]
fn test_not_a_number_point() {
let rope = Rope::from_str("Test text 45326 more text.");
let range = Range::point(6);
assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None);
}
#[test]
fn test_number_too_large_at_point() {
let rope = Rope::from_str("Test text 0xFFFFFFFFFFFFFFFFF more text.");
let range = Range::point(12);
assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None);
}
#[test]
fn test_number_cursor_one_right_of_number() {
let rope = Rope::from_str("100 ");
let range = Range::point(3);
assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None);
}
#[test]
fn test_number_cursor_one_left_of_number() {
let rope = Rope::from_str(" 100");
let range = Range::point(0);
assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None);
}
#[test]
fn test_increment_basic_decimal_numbers() {
let tests = [
("100", 1, "101"),
("100", -1, "99"),
("99", 1, "100"),
("100", 1000, "1100"),
("100", -1000, "-900"),
("-1", 1, "0"),
("-1", 2, "1"),
("1", -1, "0"),
("1", -2, "-1"),
];
for (original, amount, expected) in tests {
let rope = Rope::from_str(original);
let range = Range::point(0);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range)
.unwrap()
.increment(amount)
.1,
Tendril::from(expected)
);
}
}
#[test]
fn test_increment_basic_hexadecimal_numbers() {
let tests = [
("0x0100", 1, "0x0101"),
("0x0100", -1, "0x00ff"),
("0x0001", -1, "0x0000"),
("0x0000", -1, "0xffffffffffffffff"),
("0xffffffffffffffff", 1, "0x0000000000000000"),
("0xffffffffffffffff", 2, "0x0000000000000001"),
("0xffffffffffffffff", -1, "0xfffffffffffffffe"),
("0xABCDEF1234567890", 1, "0xABCDEF1234567891"),
("0xabcdef1234567890", 1, "0xabcdef1234567891"),
];
for (original, amount, expected) in tests {
let rope = Rope::from_str(original);
let range = Range::point(0);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range)
.unwrap()
.increment(amount)
.1,
Tendril::from(expected)
);
}
}
#[test]
fn test_increment_basic_octal_numbers() {
let tests = [
("0o0107", 1, "0o0110"),
("0o0110", -1, "0o0107"),
("0o0001", -1, "0o0000"),
("0o7777", 1, "0o10000"),
("0o1000", -1, "0o0777"),
("0o0107", 10, "0o0121"),
("0o0000", -1, "0o1777777777777777777777"),
("0o1777777777777777777777", 1, "0o0000000000000000000000"),
("0o1777777777777777777777", 2, "0o0000000000000000000001"),
("0o1777777777777777777777", -1, "0o1777777777777777777776"),
];
for (original, amount, expected) in tests {
let rope = Rope::from_str(original);
let range = Range::point(0);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range)
.unwrap()
.increment(amount)
.1,
Tendril::from(expected)
);
}
}
#[test]
fn test_increment_basic_binary_numbers() {
let tests = [
("0b00000100", 1, "0b00000101"),
("0b00000100", -1, "0b00000011"),
("0b00000100", 2, "0b00000110"),
("0b00000100", -2, "0b00000010"),
("0b00000001", -1, "0b00000000"),
("0b00111111", 10, "0b01001001"),
("0b11111111", 1, "0b100000000"),
("0b10000000", -1, "0b01111111"),
(
"0b0000",
-1,
"0b1111111111111111111111111111111111111111111111111111111111111111",
),
(
"0b1111111111111111111111111111111111111111111111111111111111111111",
1,
"0b0000000000000000000000000000000000000000000000000000000000000000",
),
(
"0b1111111111111111111111111111111111111111111111111111111111111111",
2,
"0b0000000000000000000000000000000000000000000000000000000000000001",
),
(
"0b1111111111111111111111111111111111111111111111111111111111111111",
-1,
"0b1111111111111111111111111111111111111111111111111111111111111110",
),
];
for (original, amount, expected) in tests {
let rope = Rope::from_str(original);
let range = Range::point(0);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range)
.unwrap()
.increment(amount)
.1,
Tendril::from(expected)
);
}
}
#[test]
fn test_increment_with_separators() {
let tests = [
("999_999", 1, "1_000_000"),
("1_000_000", -1, "999_999"),
("-999_999", -1, "-1_000_000"),
("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"),
("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"),
("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"),
("0x0000_0000", -1, "0xffff_ffff_ffff_ffff"),
("0x0000_0000_0000", -1, "0xffff_ffff_ffff_ffff"),
("0b01111111_11111111", 1, "0b10000000_00000000"),
("0b11111111_11111111", 1, "0b1_00000000_00000000"),
];
for (original, amount, expected) in tests {
let rope = Rope::from_str(original);
let range = Range::point(0);
assert_eq!(
NumberIncrementor::from_range(rope.slice(..), range)
.unwrap()
.increment(amount)
.1,
Tendril::from(expected)
);
}
}
}

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

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

@ -6,7 +6,7 @@ pub const DEFAULT_LINE_ENDING: LineEnding = LineEnding::Crlf;
pub const DEFAULT_LINE_ENDING: LineEnding = LineEnding::LF; pub const DEFAULT_LINE_ENDING: LineEnding = LineEnding::LF;
/// Represents one of the valid Unicode line endings. /// Represents one of the valid Unicode line endings.
#[derive(PartialEq, Copy, Clone, Debug)] #[derive(PartialEq, Eq, Copy, Clone, Debug)]
pub enum LineEnding { pub enum LineEnding {
Crlf, // CarriageReturn followed by LineFeed Crlf, // CarriageReturn followed by LineFeed
LF, // U+000A -- LineFeed LF, // U+000A -- LineFeed
@ -203,6 +203,13 @@ pub fn line_end_char_index(slice: &RopeSlice, line: usize) -> usize {
.unwrap_or(0) .unwrap_or(0)
} }
pub fn line_end_byte_index(slice: &RopeSlice, line: usize) -> usize {
slice.line_to_byte(line + 1)
- get_line_ending(&slice.line(line))
.map(|le| le.as_str().len())
.unwrap_or(0)
}
/// Fetches line `line_idx` from the passed rope slice, sans any line ending. /// Fetches line `line_idx` from the passed rope slice, sans any line ending.
pub fn line_without_line_ending<'a>(slice: &'a RopeSlice, line_idx: usize) -> RopeSlice<'a> { pub fn line_without_line_ending<'a>(slice: &'a RopeSlice, line_idx: usize) -> RopeSlice<'a> {
let start = slice.line_to_char(line_idx); let start = slice.line_to_char(line_idx);

@ -4,16 +4,19 @@ use ropey::iter::Chars;
use tree_sitter::{Node, QueryCursor}; use tree_sitter::{Node, QueryCursor};
use crate::{ use crate::{
char_idx_at_visual_offset,
chars::{categorize_char, char_is_line_ending, CharCategory}, chars::{categorize_char, char_is_line_ending, CharCategory},
doc_formatter::TextFormat,
graphemes::{ graphemes::{
next_grapheme_boundary, nth_next_grapheme_boundary, nth_prev_grapheme_boundary, next_grapheme_boundary, nth_next_grapheme_boundary, nth_prev_grapheme_boundary,
prev_grapheme_boundary, prev_grapheme_boundary,
}, },
line_ending::rope_is_line_ending, line_ending::rope_is_line_ending,
pos_at_visual_coords, position::char_idx_at_visual_block_offset,
syntax::LanguageConfiguration, syntax::LanguageConfiguration,
text_annotations::TextAnnotations,
textobject::TextObject, textobject::TextObject,
visual_coords_at_pos, Position, Range, RopeSlice, visual_offset_from_block, Range, RopeSlice,
}; };
#[derive(Debug, Copy, Clone, PartialEq, Eq)] #[derive(Debug, Copy, Clone, PartialEq, Eq)]
@ -34,7 +37,8 @@ pub fn move_horizontally(
dir: Direction, dir: Direction,
count: usize, count: usize,
behaviour: Movement, behaviour: Movement,
_: usize, _: &TextFormat,
_: &mut TextAnnotations,
) -> Range { ) -> Range {
let pos = range.cursor(slice); let pos = range.cursor(slice);
@ -48,35 +52,116 @@ pub fn move_horizontally(
range.put_cursor(slice, new_pos, behaviour == Movement::Extend) range.put_cursor(slice, new_pos, behaviour == Movement::Extend)
} }
pub fn move_vertically_visual(
slice: RopeSlice,
range: Range,
dir: Direction,
count: usize,
behaviour: Movement,
text_fmt: &TextFormat,
annotations: &mut TextAnnotations,
) -> Range {
if !text_fmt.soft_wrap {
move_vertically(slice, range, dir, count, behaviour, text_fmt, annotations);
}
annotations.clear_line_annotations();
let pos = range.cursor(slice);
// Compute the current position's 2d coordinates.
let (visual_pos, block_off) = visual_offset_from_block(slice, pos, pos, text_fmt, annotations);
let new_col = range
.old_visual_position
.map_or(visual_pos.col as u32, |(_, col)| col);
// Compute the new position.
let mut row_off = match dir {
Direction::Forward => count as isize,
Direction::Backward => -(count as isize),
};
// TODO how to handle inline annotations that span an entire visual line (very unlikely).
// Compute visual offset relative to block start to avoid trasversing the block twice
row_off += visual_pos.row as isize;
let new_pos = char_idx_at_visual_offset(
slice,
block_off,
row_off,
new_col as usize,
text_fmt,
annotations,
)
.0;
// Special-case to avoid moving to the end of the last non-empty line.
if behaviour == Movement::Extend && slice.line(slice.char_to_line(new_pos)).len_chars() == 0 {
return range;
}
let mut new_range = range.put_cursor(slice, new_pos, behaviour == Movement::Extend);
new_range.old_visual_position = Some((0, new_col));
new_range
}
pub fn move_vertically( pub fn move_vertically(
slice: RopeSlice, slice: RopeSlice,
range: Range, range: Range,
dir: Direction, dir: Direction,
count: usize, count: usize,
behaviour: Movement, behaviour: Movement,
tab_width: usize, text_fmt: &TextFormat,
annotations: &mut TextAnnotations,
) -> Range { ) -> Range {
annotations.clear_line_annotations();
let pos = range.cursor(slice); let pos = range.cursor(slice);
let line_idx = slice.char_to_line(pos);
let line_start = slice.line_to_char(line_idx);
// Compute the current position's 2d coordinates. // Compute the current position's 2d coordinates.
let Position { row, col } = visual_coords_at_pos(slice, pos, tab_width); let visual_pos = visual_offset_from_block(slice, line_start, pos, text_fmt, annotations).0;
let horiz = range.horiz.unwrap_or(col as u32); let (mut new_row, new_col) = range
.old_visual_position
.map_or((visual_pos.row as u32, visual_pos.col as u32), |pos| pos);
new_row = new_row.max(visual_pos.row as u32);
let line_idx = slice.char_to_line(pos);
// Compute the new position. // Compute the new position.
let new_row = match dir { let mut new_line_idx = match dir {
Direction::Forward => (row + count).min(slice.len_lines().saturating_sub(1)), Direction::Forward => line_idx.saturating_add(count),
Direction::Backward => row.saturating_sub(count), Direction::Backward => line_idx.saturating_sub(count),
};
let line = if new_line_idx >= slice.len_lines() - 1 {
// there is no line terminator for the last line
// so the logic below is not necessary here
new_line_idx = slice.len_lines() - 1;
slice
} else {
// char_idx_at_visual_block_offset returns a one-past-the-end index
// in case it reaches the end of the slice
// to avoid moving to the nextline in that case the line terminator is removed from the line
let new_line_end = prev_grapheme_boundary(slice, slice.line_to_char(new_line_idx + 1));
slice.slice(..new_line_end)
}; };
let new_col = col.max(horiz as usize);
let new_pos = pos_at_visual_coords(slice, Position::new(new_row, new_col), tab_width); let new_line_start = line.line_to_char(new_line_idx);
let (new_pos, _) = char_idx_at_visual_block_offset(
line,
new_line_start,
new_row as usize,
new_col as usize,
text_fmt,
annotations,
);
// Special-case to avoid moving to the end of the last non-empty line. // Special-case to avoid moving to the end of the last non-empty line.
if behaviour == Movement::Extend && slice.line(new_row).len_chars() == 0 { if behaviour == Movement::Extend && slice.line(new_line_idx).len_chars() == 0 {
return range; return range;
} }
let mut new_range = range.put_cursor(slice, new_pos, behaviour == Movement::Extend); let mut new_range = range.put_cursor(slice, new_pos, behaviour == Movement::Extend);
new_range.horiz = Some(horiz); new_range.old_visual_position = Some((new_row, new_col));
new_range new_range
} }
@ -142,9 +227,15 @@ fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTar
}; };
// Do the main work. // Do the main work.
(0..count).fold(start_range, |r, _| { let mut range = start_range;
slice.chars_at(r.head).range_to_target(target, r) for _ in 0..count {
}) let next_range = slice.chars_at(range.head).range_to_target(target, range);
if range == next_range {
break;
}
range = next_range;
}
range
} }
pub fn move_prev_paragraph( pub fn move_prev_paragraph(
@ -166,6 +257,7 @@ pub fn move_prev_paragraph(
let mut lines = slice.lines_at(line); let mut lines = slice.lines_at(line);
lines.reverse(); lines.reverse();
let mut lines = lines.map(rope_is_line_ending).peekable(); let mut lines = lines.map(rope_is_line_ending).peekable();
let mut last_line = line;
for _ in 0..count { for _ in 0..count {
while lines.next_if(|&e| e).is_some() { while lines.next_if(|&e| e).is_some() {
line -= 1; line -= 1;
@ -173,6 +265,10 @@ pub fn move_prev_paragraph(
while lines.next_if(|&e| !e).is_some() { while lines.next_if(|&e| !e).is_some() {
line -= 1; line -= 1;
} }
if line == last_line {
break;
}
last_line = line;
} }
let head = slice.line_to_char(line); let head = slice.line_to_char(line);
@ -208,6 +304,7 @@ pub fn move_next_paragraph(
line += 1; line += 1;
} }
let mut lines = slice.lines_at(line).map(rope_is_line_ending).peekable(); let mut lines = slice.lines_at(line).map(rope_is_line_ending).peekable();
let mut last_line = line;
for _ in 0..count { for _ in 0..count {
while lines.next_if(|&e| !e).is_some() { while lines.next_if(|&e| !e).is_some() {
line += 1; line += 1;
@ -215,6 +312,10 @@ pub fn move_next_paragraph(
while lines.next_if(|&e| e).is_some() { while lines.next_if(|&e| e).is_some() {
line += 1; line += 1;
} }
if line == last_line {
break;
}
last_line = line;
} }
let head = slice.line_to_char(line); let head = slice.line_to_char(line);
let anchor = if behavior == Movement::Move { let anchor = if behavior == Movement::Move {
@ -270,7 +371,7 @@ pub enum WordMotionTarget {
NextWordEnd, NextWordEnd,
PrevWordStart, PrevWordStart,
PrevWordEnd, PrevWordEnd,
// A "Long word" (also known as a WORD in vim/kakoune) is strictly // A "Long word" (also known as a WORD in Vim/Kakoune) is strictly
// delimited by whitespace, and can consist of punctuation as well // delimited by whitespace, and can consist of punctuation as well
// as alphanumerics. // as alphanumerics.
NextLongWordStart, NextLongWordStart,
@ -389,6 +490,8 @@ fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> boo
} }
} }
/// Finds the range of the next or previous textobject in the syntax sub-tree of `node`.
/// Returns the range in the forwards direction.
pub fn goto_treesitter_object( pub fn goto_treesitter_object(
slice: RopeSlice, slice: RopeSlice,
range: Range, range: Range,
@ -419,8 +522,8 @@ pub fn goto_treesitter_object(
.filter(|n| n.start_byte() > byte_pos) .filter(|n| n.start_byte() > byte_pos)
.min_by_key(|n| n.start_byte())?, .min_by_key(|n| n.start_byte())?,
Direction::Backward => nodes Direction::Backward => nodes
.filter(|n| n.start_byte() < byte_pos) .filter(|n| n.end_byte() < byte_pos)
.max_by_key(|n| n.start_byte())?, .max_by_key(|n| n.end_byte())?,
}; };
let len = slice.len_bytes(); let len = slice.len_bytes();
@ -434,9 +537,16 @@ pub fn goto_treesitter_object(
let end_char = slice.byte_to_char(end_byte); let end_char = slice.byte_to_char(end_byte);
// head of range should be at beginning // head of range should be at beginning
Some(Range::new(end_char, start_char)) Some(Range::new(start_char, end_char))
}; };
(0..count).fold(range, |range, _| get_range(range).unwrap_or(range)) let mut last_range = range;
for _ in 0..count {
match get_range(last_range) {
Some(r) if r != last_range => last_range = r,
_ => break,
}
}
last_range
} }
#[cfg(test)] #[cfg(test)]
@ -471,7 +581,16 @@ mod test {
assert_eq!( assert_eq!(
coords_at_pos( coords_at_pos(
slice, slice,
move_vertically(slice, range, Direction::Forward, 1, Movement::Move, 4).head move_vertically_visual(
slice,
range,
Direction::Forward,
1,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
)
.head
), ),
(1, 3).into() (1, 3).into()
); );
@ -495,7 +614,15 @@ mod test {
]; ];
for ((direction, amount), coordinates) in moves_and_expected_coordinates { for ((direction, amount), coordinates) in moves_and_expected_coordinates {
range = move_horizontally(slice, range, direction, amount, Movement::Move, 0); range = move_horizontally(
slice,
range,
direction,
amount,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
);
assert_eq!(coords_at_pos(slice, range.head), coordinates.into()) assert_eq!(coords_at_pos(slice, range.head), coordinates.into())
} }
} }
@ -521,7 +648,15 @@ mod test {
]; ];
for ((direction, amount), coordinates) in moves_and_expected_coordinates { for ((direction, amount), coordinates) in moves_and_expected_coordinates {
range = move_horizontally(slice, range, direction, amount, Movement::Move, 0); range = move_horizontally(
slice,
range,
direction,
amount,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
);
assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
assert_eq!(range.head, range.anchor); assert_eq!(range.head, range.anchor);
} }
@ -543,7 +678,15 @@ mod test {
]; ];
for (direction, amount) in moves { for (direction, amount) in moves {
range = move_horizontally(slice, range, direction, amount, Movement::Extend, 0); range = move_horizontally(
slice,
range,
direction,
amount,
Movement::Extend,
&TextFormat::default(),
&mut TextAnnotations::default(),
);
assert_eq!(range.anchor, original_anchor); assert_eq!(range.anchor, original_anchor);
} }
} }
@ -567,7 +710,15 @@ mod test {
]; ];
for ((direction, amount), coordinates) in moves_and_expected_coordinates { for ((direction, amount), coordinates) in moves_and_expected_coordinates {
range = move_vertically(slice, range, direction, amount, Movement::Move, 4); range = move_vertically_visual(
slice,
range,
direction,
amount,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
);
assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
assert_eq!(range.head, range.anchor); assert_eq!(range.head, range.anchor);
} }
@ -601,8 +752,24 @@ mod test {
for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates { for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates {
range = match axis { range = match axis {
Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move, 0), Axis::H => move_horizontally(
Axis::V => move_vertically(slice, range, direction, amount, Movement::Move, 4), slice,
range,
direction,
amount,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
),
Axis::V => move_vertically_visual(
slice,
range,
direction,
amount,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
),
}; };
assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
assert_eq!(range.head, range.anchor); assert_eq!(range.head, range.anchor);
@ -636,8 +803,24 @@ mod test {
for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates { for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates {
range = match axis { range = match axis {
Axis::H => move_horizontally(slice, range, direction, amount, Movement::Move, 0), Axis::H => move_horizontally(
Axis::V => move_vertically(slice, range, direction, amount, Movement::Move, 4), slice,
range,
direction,
amount,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
),
Axis::V => move_vertically_visual(
slice,
range,
direction,
amount,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
),
}; };
assert_eq!(coords_at_pos(slice, range.head), coordinates.into()); assert_eq!(coords_at_pos(slice, range.head), coordinates.into());
assert_eq!(range.head, range.anchor); assert_eq!(range.head, range.anchor);

@ -1,9 +1,11 @@
use std::borrow::Cow; use std::{borrow::Cow, cmp::Ordering};
use crate::{ use crate::{
chars::char_is_line_ending, chars::char_is_line_ending,
doc_formatter::{DocumentFormatter, TextFormat},
graphemes::{ensure_grapheme_boundary_prev, grapheme_width, RopeGraphemes}, graphemes::{ensure_grapheme_boundary_prev, grapheme_width, RopeGraphemes},
line_ending::line_end_char_index, line_ending::line_end_char_index,
text_annotations::TextAnnotations,
RopeSlice, RopeSlice,
}; };
@ -73,6 +75,13 @@ pub fn coords_at_pos(text: RopeSlice, pos: usize) -> Position {
/// Takes \t, double-width characters (CJK) into account as well as text /// Takes \t, double-width characters (CJK) into account as well as text
/// not in the document in the future. /// not in the document in the future.
/// See [`coords_at_pos`] for an "objective" one. /// See [`coords_at_pos`] for an "objective" one.
///
/// This function should be used very rarely. Usually `visual_offset_from_anchor`
/// or `visual_offset_from_block` is preferable. However when you want to compute the
/// actual visual row/column in the text (not what is actually shown on screen)
/// then you should use this function. For example aligning text should ignore virtual
/// text and softwrap.
#[deprecated = "Doesn't account for softwrap or decorations, use visual_offset_from_anchor instead"]
pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Position { pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Position {
let line = text.char_to_line(pos); let line = text.char_to_line(pos);
@ -93,6 +102,82 @@ pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Po
Position::new(line, col) Position::new(line, col)
} }
/// Returns the visual offset from the start of the first visual line
/// in the block that contains anchor.
/// Text is always wrapped at blocks, they usually correspond to
/// actual line breaks but for very long lines
/// softwrapping positions are estimated with an O(1) algorithm
/// to ensure consistent performance for large lines (currently unimplemented)
///
/// Usualy you want to use `visual_offset_from_anchor` instead but this function
/// can be useful (and faster) if
/// * You already know the visual position of the block
/// * You only care about the horizontal offset (column) and not the vertical offset (row)
pub fn visual_offset_from_block(
text: RopeSlice,
anchor: usize,
pos: usize,
text_fmt: &TextFormat,
annotations: &TextAnnotations,
) -> (Position, usize) {
let mut last_pos = Position::default();
let (formatter, block_start) =
DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor);
let mut char_pos = block_start;
for (grapheme, vpos) in formatter {
last_pos = vpos;
char_pos += grapheme.doc_chars();
if char_pos > pos {
return (last_pos, block_start);
}
}
(last_pos, block_start)
}
/// Returns the visual offset from the start of the visual line
/// that contains anchor.
pub fn visual_offset_from_anchor(
text: RopeSlice,
anchor: usize,
pos: usize,
text_fmt: &TextFormat,
annotations: &TextAnnotations,
max_rows: usize,
) -> Option<(Position, usize)> {
let (formatter, block_start) =
DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor);
let mut char_pos = block_start;
let mut anchor_line = None;
let mut last_pos = Position::default();
for (grapheme, vpos) in formatter {
last_pos = vpos;
char_pos += grapheme.doc_chars();
if char_pos > anchor && anchor_line.is_none() {
anchor_line = Some(last_pos.row);
}
if char_pos > pos {
last_pos.row -= anchor_line.unwrap();
return Some((last_pos, block_start));
}
if let Some(anchor_line) = anchor_line {
if vpos.row >= anchor_line + max_rows {
return None;
}
}
}
let anchor_line = anchor_line.unwrap_or(last_pos.row);
last_pos.row -= anchor_line;
Some((last_pos, block_start))
}
/// Convert (line, column) coordinates to a character index. /// Convert (line, column) coordinates to a character index.
/// ///
/// If the `line` coordinate is beyond the end of the file, the EOF /// If the `line` coordinate is beyond the end of the file, the EOF
@ -140,6 +225,11 @@ pub fn pos_at_coords(text: RopeSlice, coords: Position, limit_before_line_ending
/// If the `column` coordinate is past the end of the given line, the /// If the `column` coordinate is past the end of the given line, the
/// line-end position (in this case, just before the line ending /// line-end position (in this case, just before the line ending
/// character) will be returned. /// character) will be returned.
/// This function should be used very rarely. Usually `char_idx_at_visual_offset` is preferable.
/// However when you want to compute a char position from the visual row/column in the text
/// (not what is actually shown on screen) then you should use this function.
/// For example aligning text should ignore virtual text and softwrap.
#[deprecated = "Doesn't account for softwrap or decorations, use char_idx_at_visual_offset instead"]
pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize) -> usize { pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize) -> usize {
let Position { mut row, col } = coords; let Position { mut row, col } = coords;
row = row.min(text.len_lines() - 1); row = row.min(text.len_lines() - 1);
@ -169,6 +259,120 @@ pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize)
line_start + col_char_offset line_start + col_char_offset
} }
/// Returns the char index on the visual line `row_offset` below the visual line of
/// the provided char index `anchor` that is closest to the supplied visual `column`.
///
/// If the targeted visual line is entirely covered by virtual text the last
/// char position before the virtual text and a virtual offset is returned instead.
///
/// If no (text) grapheme starts at exactly at the specified column the
/// start of the grapheme to the left is returned. If there is no grapheme
/// to the left (for example if the line starts with virtual text) then the positiong
/// of the next grapheme to the right is returned.
///
/// If the `line` coordinate is beyond the end of the file, the EOF
/// position will be returned.
///
/// If the `column` coordinate is past the end of the given line, the
/// line-end position (in this case, just before the line ending
/// character) will be returned.
///
/// # Returns
///
/// `(real_char_idx, virtual_lines)`
///
/// The nearest character idx "closest" (see above) to the specified visual offset
/// on the visual line is returned if the visual line contains any text:
/// If the visual line at the specified offset is a virtual line generated by a `LineAnnotation`
/// the previous char_index is returned, together with the remaining vertical offset (`virtual_lines`)
pub fn char_idx_at_visual_offset<'a>(
text: RopeSlice<'a>,
mut anchor: usize,
mut row_offset: isize,
column: usize,
text_fmt: &TextFormat,
annotations: &TextAnnotations,
) -> (usize, usize) {
// convert row relative to visual line containing anchor to row relative to a block containing anchor (anchor may change)
loop {
let (visual_pos_in_block, block_char_offset) =
visual_offset_from_block(text, anchor, anchor, text_fmt, annotations);
row_offset += visual_pos_in_block.row as isize;
anchor = block_char_offset;
if row_offset >= 0 {
break;
}
if block_char_offset == 0 {
row_offset = 0;
break;
}
// the row_offset is negative so we need to look at the previous block
// set the anchor to the last char before the current block
// this char index is also always a line earlier so increase the row_offset by 1
anchor -= 1;
row_offset += 1;
}
char_idx_at_visual_block_offset(
text,
anchor,
row_offset as usize,
column,
text_fmt,
annotations,
)
}
/// This function behaves the same as `char_idx_at_visual_offset`, except that
/// the vertical offset `row` is always computed relative to the block that contains `anchor`
/// instead of the visual line that contains `anchor`.
/// Usually `char_idx_at_visual_offset` is more useful but this function can be
/// used in some situations as an optimization when `visual_offset_from_block` was used
///
/// # Returns
///
/// `(real_char_idx, virtual_lines)`
///
/// See `char_idx_at_visual_offset` for details
pub fn char_idx_at_visual_block_offset(
text: RopeSlice,
anchor: usize,
row: usize,
column: usize,
text_fmt: &TextFormat,
annotations: &TextAnnotations,
) -> (usize, usize) {
let (formatter, mut char_idx) =
DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor);
let mut last_char_idx = char_idx;
let mut last_char_idx_on_line = None;
let mut last_row = 0;
for (grapheme, grapheme_pos) in formatter {
match grapheme_pos.row.cmp(&row) {
Ordering::Equal => {
if grapheme_pos.col + grapheme.width() > column {
if !grapheme.is_virtual() {
return (char_idx, 0);
} else if let Some(char_idx) = last_char_idx_on_line {
return (char_idx, 0);
}
} else if !grapheme.is_virtual() {
last_char_idx_on_line = Some(char_idx)
}
}
Ordering::Greater => return (last_char_idx, row - last_row),
_ => (),
}
last_char_idx = char_idx;
last_row = grapheme_pos.row;
char_idx += grapheme.doc_chars();
}
(char_idx, 0)
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
@ -228,6 +432,7 @@ mod test {
} }
#[test] #[test]
#[allow(deprecated)]
fn test_visual_coords_at_pos() { fn test_visual_coords_at_pos() {
let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ"); let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ");
let slice = text.slice(..); let slice = text.slice(..);
@ -275,6 +480,130 @@ mod test {
assert_eq!(visual_coords_at_pos(slice, 2, 8), (0, 9).into()); assert_eq!(visual_coords_at_pos(slice, 2, 8), (0, 9).into());
} }
#[test]
fn test_visual_off_from_block() {
let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ");
let slice = text.slice(..);
let annot = TextAnnotations::default();
let text_fmt = TextFormat::default();
assert_eq!(
visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0,
(0, 0).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 5, &text_fmt, &annot).0,
(0, 5).into()
); // position on \n
assert_eq!(
visual_offset_from_block(slice, 0, 6, &text_fmt, &annot).0,
(1, 0).into()
); // position on w
assert_eq!(
visual_offset_from_block(slice, 0, 7, &text_fmt, &annot).0,
(1, 1).into()
); // position on o
assert_eq!(
visual_offset_from_block(slice, 0, 10, &text_fmt, &annot).0,
(1, 4).into()
); // position on d
// Test with wide characters.
let text = Rope::from("今日はいい\n");
let slice = text.slice(..);
assert_eq!(
visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0,
(0, 0).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 1, &text_fmt, &annot).0,
(0, 2).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 2, &text_fmt, &annot).0,
(0, 4).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 3, &text_fmt, &annot).0,
(0, 6).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 4, &text_fmt, &annot).0,
(0, 8).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 5, &text_fmt, &annot).0,
(0, 10).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 6, &text_fmt, &annot).0,
(1, 0).into()
);
// Test with grapheme clusters.
let text = Rope::from("a̐éö̲\r\n");
let slice = text.slice(..);
assert_eq!(
visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0,
(0, 0).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 2, &text_fmt, &annot).0,
(0, 1).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 4, &text_fmt, &annot).0,
(0, 2).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 7, &text_fmt, &annot).0,
(0, 3).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 9, &text_fmt, &annot).0,
(1, 0).into()
);
// Test with wide-character grapheme clusters.
// TODO: account for cluster.
let text = Rope::from("किमपि\n");
let slice = text.slice(..);
assert_eq!(
visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0,
(0, 0).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 2, &text_fmt, &annot).0,
(0, 2).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 3, &text_fmt, &annot).0,
(0, 3).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 5, &text_fmt, &annot).0,
(0, 5).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 6, &text_fmt, &annot).0,
(1, 0).into()
);
// Test with tabs.
let text = Rope::from("\tHello\n");
let slice = text.slice(..);
assert_eq!(
visual_offset_from_block(slice, 0, 0, &text_fmt, &annot).0,
(0, 0).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 1, &text_fmt, &annot).0,
(0, 4).into()
);
assert_eq!(
visual_offset_from_block(slice, 0, 2, &text_fmt, &annot).0,
(0, 5).into()
);
}
#[test] #[test]
fn test_pos_at_coords() { fn test_pos_at_coords() {
let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ"); let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ");
@ -341,6 +670,7 @@ mod test {
} }
#[test] #[test]
#[allow(deprecated)]
fn test_pos_at_visual_coords() { fn test_pos_at_visual_coords() {
let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ"); let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ");
let slice = text.slice(..); let slice = text.slice(..);
@ -405,4 +735,100 @@ mod test {
assert_eq!(pos_at_visual_coords(slice, (0, 10).into(), 4), 0); assert_eq!(pos_at_visual_coords(slice, (0, 10).into(), 4), 0);
assert_eq!(pos_at_visual_coords(slice, (10, 10).into(), 4), 0); assert_eq!(pos_at_visual_coords(slice, (10, 10).into(), 4), 0);
} }
#[test]
fn test_char_idx_at_visual_row_offset() {
let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ\nfoo");
let slice = text.slice(..);
let mut text_fmt = TextFormat::default();
for i in 0isize..3isize {
for j in -2isize..=2isize {
if !(0..3).contains(&(i + j)) {
continue;
}
println!("{i} {j}");
assert_eq!(
char_idx_at_visual_offset(
slice,
slice.line_to_char(i as usize),
j,
3,
&text_fmt,
&TextAnnotations::default(),
)
.0,
slice.line_to_char((i + j) as usize) + 3
);
}
}
text_fmt.soft_wrap = true;
let mut softwrapped_text = "foo ".repeat(10);
softwrapped_text.push('\n');
let last_char = softwrapped_text.len() - 1;
let text = Rope::from(softwrapped_text.repeat(3));
let slice = text.slice(..);
assert_eq!(
char_idx_at_visual_offset(
slice,
last_char,
0,
0,
&text_fmt,
&TextAnnotations::default(),
)
.0,
32
);
assert_eq!(
char_idx_at_visual_offset(
slice,
last_char,
-1,
0,
&text_fmt,
&TextAnnotations::default(),
)
.0,
16
);
assert_eq!(
char_idx_at_visual_offset(
slice,
last_char,
-2,
0,
&text_fmt,
&TextAnnotations::default(),
)
.0,
0
);
assert_eq!(
char_idx_at_visual_offset(
slice,
softwrapped_text.len() + last_char,
-2,
0,
&text_fmt,
&TextAnnotations::default(),
)
.0,
softwrapped_text.len()
);
assert_eq!(
char_idx_at_visual_offset(
slice,
softwrapped_text.len() + last_char,
-5,
0,
&text_fmt,
&TextAnnotations::default(),
)
.0,
0
);
}
} }

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

@ -53,7 +53,9 @@ pub struct Range {
pub anchor: usize, pub anchor: usize,
/// The head of the range, moved when extending. /// The head of the range, moved when extending.
pub head: usize, pub head: usize,
pub horiz: Option<u32>, /// The previous visual offset (softwrapped lines and columns) from
/// the start of the line
pub old_visual_position: Option<(u32, u32)>,
} }
impl Range { impl Range {
@ -61,7 +63,7 @@ impl Range {
Self { Self {
anchor, anchor,
head, head,
horiz: None, old_visual_position: None,
} }
} }
@ -122,12 +124,22 @@ impl Range {
} }
} }
// flips the direction of the selection /// Flips the direction of the selection
pub fn flip(&self) -> Self { pub fn flip(&self) -> Self {
Self { Self {
anchor: self.head, anchor: self.head,
head: self.anchor, head: self.anchor,
horiz: self.horiz, old_visual_position: self.old_visual_position,
}
}
/// 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()
} }
} }
@ -175,7 +187,7 @@ impl Range {
Self { Self {
anchor, anchor,
head, head,
horiz: None, old_visual_position: None,
} }
} }
@ -188,13 +200,13 @@ impl Range {
Self { Self {
anchor: self.anchor.min(from), anchor: self.anchor.min(from),
head: self.head.max(to), head: self.head.max(to),
horiz: None, old_visual_position: None,
} }
} else { } else {
Self { Self {
anchor: self.anchor.max(to), anchor: self.anchor.max(to),
head: self.head.min(from), head: self.head.min(from),
horiz: None, old_visual_position: None,
} }
} }
} }
@ -209,13 +221,13 @@ impl Range {
Range { Range {
anchor: self.anchor.max(other.anchor), anchor: self.anchor.max(other.anchor),
head: self.head.min(other.head), head: self.head.min(other.head),
horiz: None, old_visual_position: None,
} }
} else { } else {
Range { Range {
anchor: self.from().min(other.from()), anchor: self.from().min(other.from()),
head: self.to().max(other.to()), head: self.to().max(other.to()),
horiz: None, old_visual_position: None,
} }
} }
} }
@ -269,8 +281,8 @@ impl Range {
Range { Range {
anchor: new_anchor, anchor: new_anchor,
head: new_head, head: new_head,
horiz: if new_anchor == self.anchor { old_visual_position: if new_anchor == self.anchor {
self.horiz self.old_visual_position
} else { } else {
None None
}, },
@ -296,7 +308,7 @@ impl Range {
Range { Range {
anchor: self.anchor, anchor: self.anchor,
head: next_grapheme_boundary(slice, self.head), head: next_grapheme_boundary(slice, self.head),
horiz: self.horiz, old_visual_position: self.old_visual_position,
} }
} else { } else {
*self *self
@ -368,7 +380,7 @@ impl From<(usize, usize)> for Range {
Self { Self {
anchor, anchor,
head, head,
horiz: None, old_visual_position: None,
} }
} }
} }
@ -472,7 +484,7 @@ impl Selection {
ranges: smallvec![Range { ranges: smallvec![Range {
anchor, anchor,
head, head,
horiz: None old_visual_position: None
}], }],
primary_index: 0, primary_index: 0,
} }
@ -485,28 +497,53 @@ impl Selection {
/// Normalizes a `Selection`. /// Normalizes a `Selection`.
fn normalize(mut self) -> Self { fn normalize(mut self) -> Self {
let primary = self.ranges[self.primary_index]; let mut primary = self.ranges[self.primary_index];
self.ranges.sort_unstable_by_key(Range::from); self.ranges.sort_unstable_by_key(Range::from);
self.ranges.dedup_by(|curr_range, prev_range| {
if prev_range.overlaps(curr_range) {
let new_range = curr_range.merge(*prev_range);
if prev_range == &primary || curr_range == &primary {
primary = new_range;
}
*prev_range = new_range;
true
} else {
false
}
});
self.primary_index = self self.primary_index = self
.ranges .ranges
.iter() .iter()
.position(|&range| range == primary) .position(|&range| range == primary)
.unwrap(); .unwrap();
let mut prev_i = 0; self
for i in 1..self.ranges.len() { }
if self.ranges[prev_i].overlaps(&self.ranges[i]) {
self.ranges[prev_i] = self.ranges[prev_i].merge(self.ranges[i]); // Merges all ranges that are consecutive
pub fn merge_consecutive_ranges(mut self) -> Self {
let mut primary = self.ranges[self.primary_index];
self.ranges.dedup_by(|curr_range, prev_range| {
if prev_range.to() == curr_range.from() {
let new_range = curr_range.merge(*prev_range);
if prev_range == &primary || curr_range == &primary {
primary = new_range;
}
*prev_range = new_range;
true
} else { } else {
prev_i += 1; false
self.ranges[prev_i] = self.ranges[i];
} }
if i == self.primary_index { });
self.primary_index = prev_i;
}
}
self.ranges.truncate(prev_i + 1); self.primary_index = self
.ranges
.iter()
.position(|&range| range == primary)
.unwrap();
self self
} }
@ -531,9 +568,9 @@ impl Selection {
} }
/// Takes a closure and maps each `Range` over the closure. /// Takes a closure and maps each `Range` over the closure.
pub fn transform<F>(mut self, f: F) -> Self pub fn transform<F>(mut self, mut f: F) -> Self
where where
F: Fn(Range) -> Range, F: FnMut(Range) -> Range,
{ {
for range in self.ranges.iter_mut() { for range in self.ranges.iter_mut() {
*range = f(*range) *range = f(*range)
@ -659,7 +696,13 @@ pub fn select_on_matches(
let start = text.byte_to_char(start_byte + mat.start()); let start = text.byte_to_char(start_byte + mat.start());
let end = text.byte_to_char(start_byte + mat.end()); let end = text.byte_to_char(start_byte + mat.end());
result.push(Range::new(start, end));
let range = Range::new(start, end);
// Make sure the match is not right outside of the selection.
// These invalid matches can come from using RegEx anchors like `^`, `$`
if range != Range::point(sel.to()) {
result.push(range);
}
} }
} }
@ -929,6 +972,76 @@ mod test {
assert_eq!(Range::new(6, 5).min_width_1(s), Range::new(6, 5)); assert_eq!(Range::new(6, 5).min_width_1(s), Range::new(6, 5));
} }
#[test]
fn test_select_on_matches() {
use crate::regex::{Regex, RegexBuilder};
let r = Rope::from_str("Nobody expects the Spanish inquisition");
let s = r.slice(..);
let selection = Selection::single(0, r.len_chars());
assert_eq!(
select_on_matches(s, &selection, &Regex::new(r"[A-Z][a-z]*").unwrap()),
Some(Selection::new(
smallvec![Range::new(0, 6), Range::new(19, 26)],
0
))
);
let r = Rope::from_str("This\nString\n\ncontains multiple\nlines");
let s = r.slice(..);
let start_of_line = RegexBuilder::new(r"^").multi_line(true).build().unwrap();
let end_of_line = RegexBuilder::new(r"$").multi_line(true).build().unwrap();
// line without ending
assert_eq!(
select_on_matches(s, &Selection::single(0, 4), &start_of_line),
Some(Selection::single(0, 0))
);
assert_eq!(
select_on_matches(s, &Selection::single(0, 4), &end_of_line),
None
);
// line with ending
assert_eq!(
select_on_matches(s, &Selection::single(0, 5), &start_of_line),
Some(Selection::single(0, 0))
);
assert_eq!(
select_on_matches(s, &Selection::single(0, 5), &end_of_line),
Some(Selection::single(4, 4))
);
// line with start of next line
assert_eq!(
select_on_matches(s, &Selection::single(0, 6), &start_of_line),
Some(Selection::new(
smallvec![Range::point(0), Range::point(5)],
0
))
);
assert_eq!(
select_on_matches(s, &Selection::single(0, 6), &end_of_line),
Some(Selection::single(4, 4))
);
// multiple lines
assert_eq!(
select_on_matches(
s,
&Selection::single(0, s.len_chars()),
&RegexBuilder::new(r"^[a-z ]*$")
.multi_line(true)
.build()
.unwrap()
),
Some(Selection::new(
smallvec![Range::point(12), Range::new(13, 30), Range::new(31, 36)],
0
))
);
}
#[test] #[test]
fn test_line_range() { fn test_line_range() {
let r = Rope::from_str("\r\nHi\r\nthere!"); let r = Rope::from_str("\r\nHi\r\nthere!");
@ -1046,6 +1159,52 @@ mod test {
&["", "abcd", "efg", "rs", "xyz"] &["", "abcd", "efg", "rs", "xyz"]
); );
} }
#[test]
fn test_merge_consecutive_ranges() {
let selection = Selection::new(
smallvec![
Range::new(0, 1),
Range::new(1, 10),
Range::new(15, 20),
Range::new(25, 26),
Range::new(26, 30)
],
4,
);
let result = selection.merge_consecutive_ranges();
assert_eq!(
result.ranges(),
&[Range::new(0, 10), Range::new(15, 20), Range::new(25, 30)]
);
assert_eq!(result.primary_index, 2);
let selection = Selection::new(smallvec![Range::new(0, 1)], 0);
let result = selection.merge_consecutive_ranges();
assert_eq!(result.ranges(), &[Range::new(0, 1)]);
assert_eq!(result.primary_index, 0);
let selection = Selection::new(
smallvec![
Range::new(0, 1),
Range::new(1, 5),
Range::new(5, 8),
Range::new(8, 10),
Range::new(10, 15),
Range::new(18, 25)
],
3,
);
let result = selection.merge_consecutive_ranges();
assert_eq!(result.ranges(), &[Range::new(0, 15), Range::new(18, 25)]);
assert_eq!(result.primary_index, 0);
}
#[test] #[test]
fn test_selection_contains() { fn test_selection_contains() {
fn contains(a: Vec<(usize, usize)>, b: Vec<(usize, usize)>) -> bool { fn contains(a: Vec<(usize, usize)>, b: Vec<(usize, usize)>) -> bool {

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

@ -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),
}
}
}

@ -1,6 +1,6 @@
use std::fmt::Display; use std::fmt::Display;
use crate::{search, Range, Selection}; use crate::{movement::Direction, search, Range, Selection};
use ropey::RopeSlice; use ropey::RopeSlice;
pub const PAIRS: &[(char, char)] = &[ pub const PAIRS: &[(char, char)] = &[
@ -13,7 +13,7 @@ pub const PAIRS: &[(char, char)] = &[
('', ''), ('', ''),
]; ];
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq, Eq)]
pub enum Error { pub enum Error {
PairNotFound, PairNotFound,
CursorOverlap, CursorOverlap,
@ -55,15 +55,18 @@ pub fn get_pair(ch: char) -> (char, char) {
pub fn find_nth_closest_pairs_pos( pub fn find_nth_closest_pairs_pos(
text: RopeSlice, text: RopeSlice,
range: Range, range: Range,
n: usize, mut skip: usize,
) -> Result<(usize, usize)> { ) -> Result<(usize, usize)> {
let is_open_pair = |ch| PAIRS.iter().any(|(open, _)| *open == ch); let is_open_pair = |ch| PAIRS.iter().any(|(open, _)| *open == ch);
let is_close_pair = |ch| PAIRS.iter().any(|(_, close)| *close == ch); let is_close_pair = |ch| PAIRS.iter().any(|(_, close)| *close == ch);
let mut stack = Vec::with_capacity(2); let mut stack = Vec::with_capacity(2);
let pos = range.cursor(text); let pos = range.from();
let mut close_pos = pos.saturating_sub(1);
for ch in text.chars_at(pos) { for ch in text.chars_at(pos) {
close_pos += 1;
if is_open_pair(ch) { if is_open_pair(ch) {
// Track open pairs encountered so that we can step over // Track open pairs encountered so that we can step over
// the corresponding close pairs that will come up further // the corresponding close pairs that will come up further
@ -71,20 +74,46 @@ pub fn find_nth_closest_pairs_pos(
// open pair is before the cursor position. // open pair is before the cursor position.
stack.push(ch); stack.push(ch);
continue; continue;
} else if is_close_pair(ch) { }
let (open, _) = get_pair(ch);
if stack.last() == Some(&open) { if !is_close_pair(ch) {
stack.pop(); // We don't care if this character isn't a brace pair item,
continue; // so short circuit here.
} else { continue;
// In the ideal case the stack would be empty here and the }
// current character would be the close pair that we are
// looking for. It could also be the case that the pairs let (open, close) = get_pair(ch);
// are unbalanced and we encounter a close pair that doesn't
// close the last seen open pair. In either case use this if stack.last() == Some(&open) {
// char as the auto-detected closest pair. // If we are encountering the closing pair for an opener
return find_nth_pairs_pos(text, ch, range, n); // we just found while traversing, then its inside the
// selection and should be skipped over.
stack.pop();
continue;
}
match find_nth_open_pair(text, open, close, close_pos, 1) {
// Before we accept this pair, we want to ensure that the
// pair encloses the range rather than just the cursor.
Some(open_pos)
if open_pos <= pos.saturating_add(1)
&& close_pos >= range.to().saturating_sub(1) =>
{
// Since we have special conditions for when to
// accept, we can't just pass the skip parameter on
// through to the find_nth_*_pair methods, so we
// track skips manually here.
if skip > 1 {
skip -= 1;
continue;
}
return match range.direction() {
Direction::Forward => Ok((open_pos, close_pos)),
Direction::Backward => Ok((close_pos, open_pos)),
};
} }
_ => continue,
} }
} }
@ -244,94 +273,6 @@ mod test {
use ropey::Rope; use ropey::Rope;
use smallvec::SmallVec; use smallvec::SmallVec;
#[allow(clippy::type_complexity)]
fn check_find_nth_pair_pos(
text: &str,
cases: Vec<(usize, char, usize, Result<(usize, usize)>)>,
) {
let doc = Rope::from(text);
let slice = doc.slice(..);
for (cursor_pos, ch, n, expected_range) in cases {
let range = find_nth_pairs_pos(slice, ch, (cursor_pos, cursor_pos + 1).into(), n);
assert_eq!(
range, expected_range,
"Expected {:?}, got {:?}",
expected_range, range
);
}
}
#[test]
fn test_find_nth_pairs_pos() {
check_find_nth_pair_pos(
"some (text) here",
vec![
// cursor on [t]ext
(6, '(', 1, Ok((5, 10))),
(6, ')', 1, Ok((5, 10))),
// cursor on so[m]e
(2, '(', 1, Err(Error::PairNotFound)),
// cursor on bracket itself
(5, '(', 1, Ok((5, 10))),
(10, '(', 1, Ok((5, 10))),
],
);
}
#[test]
fn test_find_nth_pairs_pos_skip() {
check_find_nth_pair_pos(
"(so (many (good) text) here)",
vec![
// cursor on go[o]d
(13, '(', 1, Ok((10, 15))),
(13, '(', 2, Ok((4, 21))),
(13, '(', 3, Ok((0, 27))),
],
);
}
#[test]
fn test_find_nth_pairs_pos_same() {
check_find_nth_pair_pos(
"'so 'many 'good' text' here'",
vec![
// cursor on go[o]d
(13, '\'', 1, Ok((10, 15))),
(13, '\'', 2, Ok((4, 21))),
(13, '\'', 3, Ok((0, 27))),
// cursor on the quotes
(10, '\'', 1, Err(Error::CursorOnAmbiguousPair)),
],
)
}
#[test]
fn test_find_nth_pairs_pos_step() {
check_find_nth_pair_pos(
"((so)((many) good (text))(here))",
vec![
// cursor on go[o]d
(15, '(', 1, Ok((5, 24))),
(15, '(', 2, Ok((0, 31))),
],
)
}
#[test]
fn test_find_nth_pairs_pos_mixed() {
check_find_nth_pair_pos(
"(so [many {good} text] here)",
vec![
// cursor on go[o]d
(13, '{', 1, Ok((10, 15))),
(13, '[', 1, Ok((4, 21))),
(13, '(', 1, Ok((0, 27))),
],
)
}
#[test] #[test]
fn test_get_surround_pos() { fn test_get_surround_pos() {
let doc = Rope::from("(some) (chars)\n(newline)"); let doc = Rope::from("(some) (chars)\n(newline)");

@ -7,14 +7,19 @@ use crate::{
Rope, RopeSlice, Tendril, Rope, RopeSlice, Tendril,
}; };
use ahash::RandomState;
use arc_swap::{ArcSwap, Guard}; use arc_swap::{ArcSwap, Guard};
use bitflags::bitflags;
use hashbrown::raw::RawTable;
use slotmap::{DefaultKey as LayerId, HopSlotMap}; use slotmap::{DefaultKey as LayerId, HopSlotMap};
use std::{ use std::{
borrow::Cow, borrow::Cow,
cell::RefCell, cell::RefCell,
collections::{HashMap, HashSet, VecDeque}, collections::{HashMap, VecDeque},
fmt, fmt,
hash::{Hash, Hasher},
mem::{replace, transmute},
path::Path, path::Path,
str::FromStr, str::FromStr,
sync::Arc, sync::Arc,
@ -59,17 +64,23 @@ pub struct Configuration {
pub language: Vec<LanguageConfiguration>, pub language: Vec<LanguageConfiguration>,
} }
impl Default for Configuration {
fn default() -> Self {
crate::config::default_syntax_loader()
}
}
// largely based on tree-sitter/cli/src/loader.rs // largely based on tree-sitter/cli/src/loader.rs
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)] #[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct LanguageConfiguration { pub struct LanguageConfiguration {
#[serde(rename = "name")] #[serde(rename = "name")]
pub language_id: String, // c-sharp, rust pub language_id: String, // c-sharp, rust
pub scope: String, // source.rust pub scope: String, // source.rust
pub file_types: Vec<String>, // filename ends_with? <Gemfile, rb, etc> pub file_types: Vec<FileType>, // filename extension or ends_with? <Gemfile, rb, etc>
#[serde(default)] #[serde(default)]
pub shebangs: Vec<String>, // interpreter(s) associated with language pub shebangs: Vec<String>, // interpreter(s) associated with language
pub roots: Vec<String>, // these indicate project roots <.git, Cargo.toml> pub roots: Vec<String>, // these indicate project roots <.git, Cargo.toml>
pub comment_token: Option<String>, pub comment_token: Option<String>,
pub max_line_length: Option<usize>, pub max_line_length: Option<usize>,
@ -117,6 +128,78 @@ pub struct LanguageConfiguration {
pub rulers: Option<Vec<u16>>, // if set, override editor's rulers pub rulers: Option<Vec<u16>>, // if set, override editor's rulers
} }
#[derive(Debug, PartialEq, Eq, Hash)]
pub enum FileType {
/// The extension of the file, either the `Path::extension` or the full
/// filename if the file does not have an extension.
Extension(String),
/// The suffix of a file. This is compared to a given file's absolute
/// path, so it can be used to detect files based on their directories.
Suffix(String),
}
impl Serialize for FileType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
match self {
FileType::Extension(extension) => serializer.serialize_str(extension),
FileType::Suffix(suffix) => {
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("suffix", &suffix.replace(std::path::MAIN_SEPARATOR, "/"))?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for FileType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
struct FileTypeVisitor;
impl<'de> serde::de::Visitor<'de> for FileTypeVisitor {
type Value = FileType;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("string or table")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(FileType::Extension(value.to_string()))
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
match map.next_entry::<String, String>()? {
Some((key, suffix)) if key == "suffix" => Ok(FileType::Suffix(
suffix.replace('/', &std::path::MAIN_SEPARATOR.to_string()),
)),
Some((key, _value)) => Err(serde::de::Error::custom(format!(
"unknown key in `file-types` list: {}",
key
))),
None => Err(serde::de::Error::custom(
"expected a `suffix` key in the `file-types` entry",
)),
}
}
}
deserializer.deserialize_any(FileTypeVisitor)
}
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct LanguageServerConfiguration { pub struct LanguageServerConfiguration {
@ -124,6 +207,8 @@ pub struct LanguageServerConfiguration {
#[serde(default)] #[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>, pub args: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub environment: HashMap<String, String>,
#[serde(default = "default_timeout")] #[serde(default = "default_timeout")]
pub timeout: u64, pub timeout: u64,
pub language_id: Option<String>, pub language_id: Option<String>,
@ -138,7 +223,7 @@ pub struct FormatterConfiguration {
pub args: Vec<String>, pub args: Vec<String>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct AdvancedCompletion { pub struct AdvancedCompletion {
pub name: Option<String>, pub name: Option<String>,
@ -146,14 +231,14 @@ pub struct AdvancedCompletion {
pub default: Option<String>, pub default: Option<String>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", untagged)] #[serde(rename_all = "kebab-case", untagged)]
pub enum DebugConfigCompletion { pub enum DebugConfigCompletion {
Named(String), Named(String),
Advanced(AdvancedCompletion), Advanced(AdvancedCompletion),
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum DebugArgumentValue { pub enum DebugArgumentValue {
String(String), String(String),
@ -161,7 +246,7 @@ pub enum DebugArgumentValue {
Boolean(bool), Boolean(bool),
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct DebugTemplate { pub struct DebugTemplate {
pub name: String, pub name: String,
@ -170,7 +255,7 @@ pub struct DebugTemplate {
pub args: HashMap<String, DebugArgumentValue>, pub args: HashMap<String, DebugArgumentValue>,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct DebugAdapterConfig { pub struct DebugAdapterConfig {
pub name: String, pub name: String,
@ -186,7 +271,7 @@ pub struct DebugAdapterConfig {
} }
// Different workarounds for adapters' differences // Different workarounds for adapters' differences
#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct DebuggerQuirks { pub struct DebuggerQuirks {
#[serde(default)] #[serde(default)]
pub absolute_paths: bool, pub absolute_paths: bool,
@ -200,7 +285,7 @@ pub struct IndentationConfiguration {
} }
/// Configuration for auto pairs /// Configuration for auto pairs
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)] #[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)]
pub enum AutoPairConfig { pub enum AutoPairConfig {
/// Enables or disables auto pairing. False means disabled. True means to use the default pairs. /// Enables or disables auto pairing. False means disabled. True means to use the default pairs.
@ -274,6 +359,26 @@ impl<'a> CapturedNode<'a> {
} }
} }
/// The maximum number of in-progress matches a TS cursor can consider at once.
/// This is set to a constant in order to avoid performance problems for medium to large files. Set with `set_match_limit`.
/// Using such a limit means that we lose valid captures, so there is fundamentally a tradeoff here.
///
///
/// Old tree sitter versions used a limit of 32 by default until this limit was removed in version `0.19.5` (must now be set manually).
/// However, this causes performance issues for medium to large files.
/// In helix, this problem caused treesitter motions to take multiple seconds to complete in medium-sized rust files (3k loc).
///
///
/// Neovim also encountered this problem and reintroduced this limit after it was removed upstream
/// (see <https://github.com/neovim/neovim/issues/14897> and <https://github.com/neovim/neovim/pull/14915>).
/// The number used here is fundamentally a tradeoff between breaking some obscure edge cases and performance.
///
///
/// Neovim chose 64 for this value somewhat arbitrarily (<https://github.com/neovim/neovim/pull/18397>).
/// 64 is too low for some languages though. In particular, it breaks some highlighting for record fields in Erlang record definitions.
/// This number can be increased if new syntax highlight breakages are found, as long as the performance penalty is not too high.
const TREE_SITTER_MATCH_LIMIT: u32 = 256;
impl TextObjectQuery { impl TextObjectQuery {
/// Run the query on the given node and return sub nodes which match given /// Run the query on the given node and return sub nodes which match given
/// capture ("function.inside", "class.around", etc). /// capture ("function.inside", "class.around", etc).
@ -314,13 +419,15 @@ impl TextObjectQuery {
.iter() .iter()
.find_map(|cap| self.query.capture_index_for_name(cap))?; .find_map(|cap| self.query.capture_index_for_name(cap))?;
cursor.set_match_limit(TREE_SITTER_MATCH_LIMIT);
let nodes = cursor let nodes = cursor
.captures(&self.query, node, RopeProvider(slice)) .captures(&self.query, node, RopeProvider(slice))
.filter_map(move |(mat, _)| { .filter_map(move |(mat, _)| {
let nodes: Vec<_> = mat let nodes: Vec<_> = mat
.captures .captures
.iter() .iter()
.filter_map(|cap| (cap.index == capture_idx).then(|| cap.node)) .filter_map(|cap| (cap.index == capture_idx).then_some(cap.node))
.collect(); .collect();
if nodes.len() > 1 { if nodes.len() > 1 {
@ -334,7 +441,7 @@ impl TextObjectQuery {
} }
} }
fn read_query(language: &str, filename: &str) -> String { pub fn read_query(language: &str, filename: &str) -> String {
static INHERITS_REGEX: Lazy<Regex> = static INHERITS_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r";+\s*inherits\s*:?\s*([a-z_,()-]+)\s*").unwrap()); Lazy::new(|| Regex::new(r";+\s*inherits\s*:?\s*([a-z_,()-]+)\s*").unwrap());
@ -353,20 +460,24 @@ fn read_query(language: &str, filename: &str) -> String {
impl LanguageConfiguration { impl LanguageConfiguration {
fn initialize_highlight(&self, scopes: &[String]) -> Option<Arc<HighlightConfiguration>> { fn initialize_highlight(&self, scopes: &[String]) -> Option<Arc<HighlightConfiguration>> {
let language = self.language_id.to_ascii_lowercase(); let highlights_query = read_query(&self.language_id, "highlights.scm");
let highlights_query = read_query(&language, "highlights.scm");
// always highlight syntax errors // always highlight syntax errors
// highlights_query += "\n(ERROR) @error"; // highlights_query += "\n(ERROR) @error";
let injections_query = read_query(&language, "injections.scm"); let injections_query = read_query(&self.language_id, "injections.scm");
let locals_query = read_query(&language, "locals.scm"); let locals_query = read_query(&self.language_id, "locals.scm");
if highlights_query.is_empty() { if highlights_query.is_empty() {
None None
} else { } else {
let language = get_language(self.grammar.as_deref().unwrap_or(&self.language_id)) let language = get_language(self.grammar.as_deref().unwrap_or(&self.language_id))
.map_err(|e| log::info!("{}", e)) .map_err(|err| {
log::error!(
"Failed to load tree-sitter parser for language {:?}: {}",
self.language_id,
err
)
})
.ok()?; .ok()?;
let config = HighlightConfiguration::new( let config = HighlightConfiguration::new(
language, language,
@ -374,7 +485,8 @@ impl LanguageConfiguration {
&injections_query, &injections_query,
&locals_query, &locals_query,
) )
.unwrap_or_else(|query_error| panic!("Could not parse queries for language {:?}. Are your grammars out of sync? Try running 'hx --grammar fetch' and 'hx --grammar build'. This query could not be parsed: {:?}", self.language_id, query_error)); .map_err(|err| log::error!("Could not parse queries for language {:?}. Are your grammars out of sync? Try running 'hx --grammar fetch' and 'hx --grammar build'. This query could not be parsed: {:?}", self.language_id, err))
.ok()?;
config.configure(scopes); config.configure(scopes);
Some(Arc::new(config)) Some(Arc::new(config))
@ -399,28 +511,15 @@ impl LanguageConfiguration {
pub fn indent_query(&self) -> Option<&Query> { pub fn indent_query(&self) -> Option<&Query> {
self.indent_query self.indent_query
.get_or_init(|| { .get_or_init(|| self.load_query("indents.scm"))
let lang_name = self.language_id.to_ascii_lowercase();
let query_text = read_query(&lang_name, "indents.scm");
if query_text.is_empty() {
return None;
}
let lang = self.highlight_config.get()?.as_ref()?.language;
Query::new(lang, &query_text).ok()
})
.as_ref() .as_ref()
} }
pub fn textobject_query(&self) -> Option<&TextObjectQuery> { pub fn textobject_query(&self) -> Option<&TextObjectQuery> {
self.textobject_query self.textobject_query
.get_or_init(|| -> Option<TextObjectQuery> { .get_or_init(|| {
let lang_name = self.language_id.to_ascii_lowercase(); self.load_query("textobjects.scm")
let query_text = read_query(&lang_name, "textobjects.scm"); .map(|query| TextObjectQuery { query })
let lang = self.highlight_config.get()?.as_ref()?.language;
let query = Query::new(lang, &query_text)
.map_err(|e| log::error!("Failed to parse textobjects.scm queries: {}", e))
.ok()?;
Some(TextObjectQuery { query })
}) })
.as_ref() .as_ref()
} }
@ -428,6 +527,24 @@ impl LanguageConfiguration {
pub fn scope(&self) -> &str { pub fn scope(&self) -> &str {
&self.scope &self.scope
} }
fn load_query(&self, kind: &str) -> Option<Query> {
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,
self.language_id,
e
)
})
.ok()
}
} }
// Expose loader as Lazy<> global since it's always static? // Expose loader as Lazy<> global since it's always static?
@ -436,7 +553,8 @@ impl LanguageConfiguration {
pub struct Loader { pub struct Loader {
// highlight_names ? // highlight_names ?
language_configs: Vec<Arc<LanguageConfiguration>>, language_configs: Vec<Arc<LanguageConfiguration>>,
language_config_ids_by_file_type: HashMap<String, usize>, // Vec<usize> language_config_ids_by_extension: HashMap<String, usize>, // Vec<usize>
language_config_ids_by_suffix: HashMap<String, usize>,
language_config_ids_by_shebang: HashMap<String, usize>, language_config_ids_by_shebang: HashMap<String, usize>,
scopes: ArcSwap<Vec<String>>, scopes: ArcSwap<Vec<String>>,
@ -446,7 +564,8 @@ impl Loader {
pub fn new(config: Configuration) -> Self { pub fn new(config: Configuration) -> Self {
let mut loader = Self { let mut loader = Self {
language_configs: Vec::new(), language_configs: Vec::new(),
language_config_ids_by_file_type: HashMap::new(), language_config_ids_by_extension: HashMap::new(),
language_config_ids_by_suffix: HashMap::new(),
language_config_ids_by_shebang: HashMap::new(), language_config_ids_by_shebang: HashMap::new(),
scopes: ArcSwap::from_pointee(Vec::new()), scopes: ArcSwap::from_pointee(Vec::new()),
}; };
@ -457,9 +576,14 @@ impl Loader {
for file_type in &config.file_types { for file_type in &config.file_types {
// entry().or_insert(Vec::new).push(language_id); // entry().or_insert(Vec::new).push(language_id);
loader match file_type {
.language_config_ids_by_file_type FileType::Extension(extension) => loader
.insert(file_type.clone(), language_id); .language_config_ids_by_extension
.insert(extension.clone(), language_id),
FileType::Suffix(suffix) => loader
.language_config_ids_by_suffix
.insert(suffix.clone(), language_id),
};
} }
for shebang in &config.shebangs { for shebang in &config.shebangs {
loader loader
@ -479,11 +603,22 @@ impl Loader {
let configuration_id = path let configuration_id = path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.and_then(|file_name| self.language_config_ids_by_file_type.get(file_name)) .and_then(|file_name| self.language_config_ids_by_extension.get(file_name))
.or_else(|| { .or_else(|| {
path.extension() path.extension()
.and_then(|extension| extension.to_str()) .and_then(|extension| extension.to_str())
.and_then(|extension| self.language_config_ids_by_file_type.get(extension)) .and_then(|extension| self.language_config_ids_by_extension.get(extension))
})
.or_else(|| {
self.language_config_ids_by_suffix
.iter()
.find_map(|(file_type, id)| {
if path.to_str()?.ends_with(file_type) {
Some(id)
} else {
None
}
})
}); });
configuration_id.and_then(|&id| self.language_configs.get(id).cloned()) configuration_id.and_then(|&id| self.language_configs.get(id).cloned())
@ -594,6 +729,7 @@ impl Syntax {
tree: None, tree: None,
config, config,
depth: 0, depth: 0,
flags: LayerUpdateFlags::empty(),
ranges: vec![Range { ranges: vec![Range {
start_byte: 0, start_byte: 0,
end_byte: usize::MAX, end_byte: usize::MAX,
@ -639,29 +775,38 @@ impl Syntax {
// Convert the changeset into tree sitter edits. // Convert the changeset into tree sitter edits.
let edits = generate_edits(old_source, changeset); let edits = generate_edits(old_source, changeset);
// This table allows inverse indexing of `layers`.
// That is by hashing a `Layer` you can find
// the `LayerId` of an existing equivalent `Layer` in `layers`.
//
// It is used to determine if a new layer exists for an injection
// or if an existing layer needs to be updated.
let mut layers_table = RawTable::with_capacity(self.layers.len());
let layers_hasher = RandomState::new();
// Use the edits to update all layers markers // Use the edits to update all layers markers
if !edits.is_empty() { fn point_add(a: Point, b: Point) -> Point {
fn point_add(a: Point, b: Point) -> Point { if b.row > 0 {
if b.row > 0 { Point::new(a.row.saturating_add(b.row), b.column)
Point::new(a.row.saturating_add(b.row), b.column) } else {
} else { Point::new(0, a.column.saturating_add(b.column))
Point::new(0, a.column.saturating_add(b.column))
}
} }
fn point_sub(a: Point, b: Point) -> Point { }
if a.row > b.row { fn point_sub(a: Point, b: Point) -> Point {
Point::new(a.row.saturating_sub(b.row), a.column) if a.row > b.row {
} else { Point::new(a.row.saturating_sub(b.row), a.column)
Point::new(0, a.column.saturating_sub(b.column)) } else {
} Point::new(0, a.column.saturating_sub(b.column))
} }
}
for layer in &mut self.layers.values_mut() { for (layer_id, layer) in self.layers.iter_mut() {
// The root layer always covers the whole range (0..usize::MAX) // The root layer always covers the whole range (0..usize::MAX)
if layer.depth == 0 { if layer.depth == 0 {
continue; layer.flags = LayerUpdateFlags::MODIFIED;
} continue;
}
if !edits.is_empty() {
for range in &mut layer.ranges { for range in &mut layer.ranges {
// Roughly based on https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720 // Roughly based on https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720
for edit in edits.iter().rev() { for edit in edits.iter().rev() {
@ -689,6 +834,8 @@ impl Syntax {
edit.new_end_position, edit.new_end_position,
point_sub(range.end_point, edit.old_end_position), point_sub(range.end_point, edit.old_end_position),
); );
layer.flags |= LayerUpdateFlags::MOVED;
} }
// if the edit starts in the space before and extends into the range // if the edit starts in the space before and extends into the range
else if edit.start_byte < range.start_byte { else if edit.start_byte < range.start_byte {
@ -703,11 +850,13 @@ impl Syntax {
edit.new_end_position, edit.new_end_position,
point_sub(range.end_point, edit.old_end_position), point_sub(range.end_point, edit.old_end_position),
); );
layer.flags = LayerUpdateFlags::MODIFIED;
} }
// If the edit is an insertion at the start of the tree, shift // If the edit is an insertion at the start of the tree, shift
else if edit.start_byte == range.start_byte && is_pure_insertion { else if edit.start_byte == range.start_byte && is_pure_insertion {
range.start_byte = edit.new_end_byte; range.start_byte = edit.new_end_byte;
range.start_point = edit.new_end_position; range.start_point = edit.new_end_position;
layer.flags |= LayerUpdateFlags::MOVED;
} else { } else {
range.end_byte = range range.end_byte = range
.end_byte .end_byte
@ -717,10 +866,17 @@ impl Syntax {
edit.new_end_position, edit.new_end_position,
point_sub(range.end_point, edit.old_end_position), point_sub(range.end_point, edit.old_end_position),
); );
layer.flags = LayerUpdateFlags::MODIFIED;
} }
} }
} }
} }
let hash = layers_hasher.hash_one(layer);
// Safety: insert_no_grow is unsafe because it assumes that the table
// has enough capacity to hold additional elements.
// This is always the case as we reserved enough capacity above.
unsafe { layers_table.insert_no_grow(hash, layer_id) };
} }
PARSER.with(|ts_parser| { PARSER.with(|ts_parser| {
@ -728,30 +884,37 @@ impl Syntax {
let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new);
// TODO: might need to set cursor range // TODO: might need to set cursor range
cursor.set_byte_range(0..usize::MAX); cursor.set_byte_range(0..usize::MAX);
cursor.set_match_limit(TREE_SITTER_MATCH_LIMIT);
let source_slice = source.slice(..); let source_slice = source.slice(..);
let mut touched = HashSet::new();
// TODO: we should be able to avoid editing & parsing layers with ranges earlier in the document before the edit
while let Some(layer_id) = queue.pop_front() { while let Some(layer_id) = queue.pop_front() {
// Mark the layer as touched
touched.insert(layer_id);
let layer = &mut self.layers[layer_id]; let layer = &mut self.layers[layer_id];
// Mark the layer as touched
layer.flags |= LayerUpdateFlags::TOUCHED;
// If a tree already exists, notify it of changes. // If a tree already exists, notify it of changes.
if let Some(tree) = &mut layer.tree { if let Some(tree) = &mut layer.tree {
for edit in edits.iter().rev() { if layer
// Apply the edits in reverse. .flags
// If we applied them in order then edit 1 would disrupt the positioning of edit 2. .intersects(LayerUpdateFlags::MODIFIED | LayerUpdateFlags::MOVED)
tree.edit(edit); {
for edit in edits.iter().rev() {
// Apply the edits in reverse.
// If we applied them in order then edit 1 would disrupt the positioning of edit 2.
tree.edit(edit);
}
} }
}
// Re-parse the tree. if layer.flags.contains(LayerUpdateFlags::MODIFIED) {
layer.parse(&mut ts_parser.parser, source)?; // Re-parse the tree.
layer.parse(&mut ts_parser.parser, source)?;
}
} else {
// always parse if this layer has never been parsed before
layer.parse(&mut ts_parser.parser, source)?;
}
// Switch to an immutable borrow. // Switch to an immutable borrow.
let layer = &self.layers[layer_id]; let layer = &self.layers[layer_id];
@ -838,25 +1001,23 @@ impl Syntax {
let depth = layer.depth + 1; let depth = layer.depth + 1;
// TODO: can't inline this since matches borrows self.layers // TODO: can't inline this since matches borrows self.layers
for (config, ranges) in injections { for (config, ranges) in injections {
// Find an existing layer let new_layer = LanguageLayer {
let layer = self tree: None,
.layers config,
.iter_mut() depth,
.find(|(_, layer)| { ranges,
layer.depth == depth && // TODO: track parent id instead flags: LayerUpdateFlags::empty(),
layer.config.language == config.language && layer.ranges == ranges };
// Find an identical existing layer
let layer = layers_table
.get(layers_hasher.hash_one(&new_layer), |&it| {
self.layers[it] == new_layer
}) })
.map(|(id, _layer)| id); .copied();
// ...or insert a new one. // ...or insert a new one.
let layer_id = layer.unwrap_or_else(|| { let layer_id = layer.unwrap_or_else(|| self.layers.insert(new_layer));
self.layers.insert(LanguageLayer {
tree: None,
config,
depth,
ranges,
})
});
queue.push_back(layer_id); queue.push_back(layer_id);
} }
@ -868,8 +1029,11 @@ impl Syntax {
// Return the cursor back in the pool. // Return the cursor back in the pool.
ts_parser.cursors.push(cursor); ts_parser.cursors.push(cursor);
// Remove all untouched layers // Reset all `LayerUpdateFlags` and remove all untouched layers
self.layers.retain(|id, _| touched.contains(&id)); self.layers.retain(|_, layer| {
replace(&mut layer.flags, LayerUpdateFlags::empty())
.contains(LayerUpdateFlags::TOUCHED)
});
Ok(()) Ok(())
}) })
@ -906,6 +1070,7 @@ impl Syntax {
// if reusing cursors & no range this resets to whole range // if reusing cursors & no range this resets to whole range
cursor_ref.set_byte_range(range.clone().unwrap_or(0..usize::MAX)); cursor_ref.set_byte_range(range.clone().unwrap_or(0..usize::MAX));
cursor_ref.set_match_limit(TREE_SITTER_MATCH_LIMIT);
let mut captures = cursor_ref let mut captures = cursor_ref
.captures( .captures(
@ -927,21 +1092,14 @@ impl Syntax {
}], }],
cursor, cursor,
_tree: None, _tree: None,
captures, captures: RefCell::new(captures),
config: layer.config.as_ref(), // TODO: just reuse `layer` config: layer.config.as_ref(), // TODO: just reuse `layer`
depth: layer.depth, // TODO: just reuse `layer` depth: layer.depth, // TODO: just reuse `layer`
ranges: &layer.ranges, // TODO: temp
}) })
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// HAXX: arrange layers by byte range, with deeper layers positioned first layers.sort_unstable_by_key(|layer| layer.sort_key());
layers.sort_by_key(|layer| {
(
layer.ranges.first().cloned(),
std::cmp::Reverse(layer.depth),
)
});
let mut result = HighlightIter { let mut result = HighlightIter {
source, source,
@ -968,6 +1126,16 @@ impl Syntax {
// TODO: Folding // TODO: Folding
} }
bitflags! {
/// Flags that track the status of a layer
/// in the `Sytaxn::update` function
struct LayerUpdateFlags : u32{
const MODIFIED = 0b001;
const MOVED = 0b010;
const TOUCHED = 0b100;
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct LanguageLayer { pub struct LanguageLayer {
// mode // mode
@ -975,7 +1143,36 @@ pub struct LanguageLayer {
pub config: Arc<HighlightConfiguration>, pub config: Arc<HighlightConfiguration>,
pub(crate) tree: Option<Tree>, pub(crate) tree: Option<Tree>,
pub ranges: Vec<Range>, pub ranges: Vec<Range>,
pub depth: usize, pub depth: u32,
flags: LayerUpdateFlags,
}
/// This PartialEq implementation only checks if that
/// two layers are theoretically identical (meaning they highlight the same text range with the same language).
/// It does not check whether the layers have the same internal treesitter
/// state.
impl PartialEq for LanguageLayer {
fn eq(&self, other: &Self) -> bool {
self.depth == other.depth
&& self.config.language == other.config.language
&& self.ranges == other.ranges
}
}
/// Hash implementation belongs to PartialEq implementation above.
/// See its documentation for details.
impl Hash for LanguageLayer {
fn hash<H: Hasher>(&self, state: &mut H) {
self.depth.hash(state);
// The transmute is necessary here because tree_sitter::Language does not derive Hash at the moment.
// However it does use #[repr] transparent so the transmute here is safe
// as `Language` (which `Grammar` is an alias for) is just a newtype wrapper around a (thin) pointer.
// This is also compatible with the PartialEq implementation of language
// as that is just a pointer comparison.
let language: *const () = unsafe { transmute(self.config.language) };
language.hash(state);
self.ranges.hash(state);
}
} }
impl LanguageLayer { impl LanguageLayer {
@ -985,7 +1182,9 @@ impl LanguageLayer {
} }
fn parse(&mut self, parser: &mut Parser, source: &Rope) -> Result<(), Error> { fn parse(&mut self, parser: &mut Parser, source: &Rope) -> Result<(), Error> {
parser.set_included_ranges(&self.ranges).unwrap(); parser
.set_included_ranges(&self.ranges)
.map_err(|_| Error::InvalidRanges)?;
parser parser
.set_language(self.config.language) .set_language(self.config.language)
@ -1121,7 +1320,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::{iter, mem, ops, str, usize}; use std::{iter, mem, ops, str, usize};
use tree_sitter::{ use tree_sitter::{
Language as Grammar, Node, Parser, Point, Query, QueryCaptures, QueryCursor, QueryError, Language as Grammar, Node, Parser, Point, Query, QueryCaptures, QueryCursor, QueryError,
QueryMatch, Range, TextProvider, Tree, QueryMatch, Range, TextProvider, Tree, TreeCursor,
}; };
const CANCELLATION_CHECK_INTERVAL: usize = 100; const CANCELLATION_CHECK_INTERVAL: usize = 100;
@ -1135,6 +1334,7 @@ pub struct Highlight(pub usize);
pub enum Error { pub enum Error {
Cancelled, Cancelled,
InvalidLanguage, InvalidLanguage,
InvalidRanges,
Unknown, Unknown,
} }
@ -1188,7 +1388,7 @@ struct HighlightIter<'a> {
layers: Vec<HighlightIterLayer<'a>>, layers: Vec<HighlightIterLayer<'a>>,
iter_count: usize, iter_count: usize,
next_event: Option<HighlightEvent>, next_event: Option<HighlightEvent>,
last_highlight_range: Option<(usize, usize, usize)>, last_highlight_range: Option<(usize, usize, u32)>,
} }
// Adapter to convert rope chunks to bytes // Adapter to convert rope chunks to bytes
@ -1217,12 +1417,11 @@ impl<'a> TextProvider<'a> for RopeProvider<'a> {
struct HighlightIterLayer<'a> { struct HighlightIterLayer<'a> {
_tree: Option<Tree>, _tree: Option<Tree>,
cursor: QueryCursor, cursor: QueryCursor,
captures: iter::Peekable<QueryCaptures<'a, 'a, RopeProvider<'a>>>, captures: RefCell<iter::Peekable<QueryCaptures<'a, 'a, RopeProvider<'a>>>>,
config: &'a HighlightConfiguration, config: &'a HighlightConfiguration,
highlight_end_stack: Vec<usize>, highlight_end_stack: Vec<usize>,
scope_stack: Vec<LocalScope<'a>>, scope_stack: Vec<LocalScope<'a>>,
depth: usize, depth: u32,
ranges: &'a [Range],
} }
impl<'a> fmt::Debug for HighlightIterLayer<'a> { impl<'a> fmt::Debug for HighlightIterLayer<'a> {
@ -1403,10 +1602,11 @@ impl<'a> HighlightIterLayer<'a> {
// First, sort scope boundaries by their byte offset in the document. At a // First, sort scope boundaries by their byte offset in the document. At a
// given position, emit scope endings before scope beginnings. Finally, emit // given position, emit scope endings before scope beginnings. Finally, emit
// scope boundaries from deeper layers first. // scope boundaries from deeper layers first.
fn sort_key(&mut self) -> Option<(usize, bool, isize)> { fn sort_key(&self) -> Option<(usize, bool, isize)> {
let depth = -(self.depth as isize); let depth = -(self.depth as isize);
let next_start = self let next_start = self
.captures .captures
.borrow_mut()
.peek() .peek()
.map(|(m, i)| m.captures[*i].node.start_byte()); .map(|(m, i)| m.captures[*i].node.start_byte());
let next_end = self.highlight_end_stack.last().cloned(); let next_end = self.highlight_end_stack.last().cloned();
@ -1631,7 +1831,8 @@ impl<'a> Iterator for HighlightIter<'a> {
// Get the next capture from whichever layer has the earliest highlight boundary. // Get the next capture from whichever layer has the earliest highlight boundary.
let range; let range;
let layer = &mut self.layers[0]; let layer = &mut self.layers[0];
if let Some((next_match, capture_index)) = layer.captures.peek() { let captures = layer.captures.get_mut();
if let Some((next_match, capture_index)) = captures.peek() {
let next_capture = next_match.captures[*capture_index]; let next_capture = next_match.captures[*capture_index];
range = next_capture.node.byte_range(); range = next_capture.node.byte_range();
@ -1654,7 +1855,7 @@ impl<'a> Iterator for HighlightIter<'a> {
return self.emit_event(self.source.len_bytes(), None); return self.emit_event(self.source.len_bytes(), None);
}; };
let (mut match_, capture_index) = layer.captures.next().unwrap(); let (mut match_, capture_index) = captures.next().unwrap();
let mut capture = match_.captures[capture_index]; let mut capture = match_.captures[capture_index];
// Remove from the local scope stack any local scopes that have already ended. // Remove from the local scope stack any local scopes that have already ended.
@ -1730,11 +1931,11 @@ impl<'a> Iterator for HighlightIter<'a> {
} }
// Continue processing any additional matches for the same node. // Continue processing any additional matches for the same node.
if let Some((next_match, next_capture_index)) = layer.captures.peek() { if let Some((next_match, next_capture_index)) = captures.peek() {
let next_capture = next_match.captures[*next_capture_index]; let next_capture = next_match.captures[*next_capture_index];
if next_capture.node == capture.node { if next_capture.node == capture.node {
capture = next_capture; capture = next_capture;
match_ = layer.captures.next().unwrap().0; match_ = captures.next().unwrap().0;
continue; continue;
} }
} }
@ -1757,11 +1958,11 @@ impl<'a> Iterator for HighlightIter<'a> {
// highlighting patterns that are disabled for local variables. // highlighting patterns that are disabled for local variables.
if definition_highlight.is_some() || reference_highlight.is_some() { if definition_highlight.is_some() || reference_highlight.is_some() {
while layer.config.non_local_variable_patterns[match_.pattern_index] { while layer.config.non_local_variable_patterns[match_.pattern_index] {
if let Some((next_match, next_capture_index)) = layer.captures.peek() { if let Some((next_match, next_capture_index)) = captures.peek() {
let next_capture = next_match.captures[*next_capture_index]; let next_capture = next_match.captures[*next_capture_index];
if next_capture.node == capture.node { if next_capture.node == capture.node {
capture = next_capture; capture = next_capture;
match_ = layer.captures.next().unwrap().0; match_ = captures.next().unwrap().0;
continue; continue;
} }
} }
@ -1776,10 +1977,10 @@ impl<'a> Iterator for HighlightIter<'a> {
// for a given node are ordered by pattern index, so these subsequent // for a given node are ordered by pattern index, so these subsequent
// captures are guaranteed to be for highlighting, not injections or // captures are guaranteed to be for highlighting, not injections or
// local variables. // local variables.
while let Some((next_match, next_capture_index)) = layer.captures.peek() { while let Some((next_match, next_capture_index)) = captures.peek() {
let next_capture = next_match.captures[*next_capture_index]; let next_capture = next_match.captures[*next_capture_index];
if next_capture.node == capture.node { if next_capture.node == capture.node {
layer.captures.next(); captures.next();
} else { } else {
break; break;
} }
@ -1990,6 +2191,68 @@ impl<I: Iterator<Item = HighlightEvent>> Iterator for Merge<I> {
} }
} }
fn node_is_visible(node: &Node) -> bool {
node.is_missing() || (node.is_named() && node.language().node_kind_is_visible(node.kind_id()))
}
pub fn pretty_print_tree<W: fmt::Write>(fmt: &mut W, node: Node) -> fmt::Result {
if node.child_count() == 0 {
if node_is_visible(&node) {
write!(fmt, "({})", node.kind())
} else {
write!(fmt, "\"{}\"", node.kind())
}
} else {
pretty_print_tree_impl(fmt, &mut node.walk(), 0)
}
}
fn pretty_print_tree_impl<W: fmt::Write>(
fmt: &mut W,
cursor: &mut TreeCursor,
depth: usize,
) -> fmt::Result {
let node = cursor.node();
let visible = node_is_visible(&node);
if visible {
let indentation_columns = depth * 2;
write!(fmt, "{:indentation_columns$}", "")?;
if let Some(field_name) = cursor.field_name() {
write!(fmt, "{}: ", field_name)?;
}
write!(fmt, "({}", node.kind())?;
}
// Handle children.
if cursor.goto_first_child() {
loop {
if node_is_visible(&cursor.node()) {
fmt.write_char('\n')?;
}
pretty_print_tree_impl(fmt, cursor, depth + 1)?;
if !cursor.goto_next_sibling() {
break;
}
}
let moved = cursor.goto_parent();
// The parent of the first child must exist, and must be `node`.
debug_assert!(moved);
debug_assert!(cursor.node() == node);
}
if visible {
fmt.write_char(')')?;
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
@ -2010,7 +2273,7 @@ mod test {
); );
let loader = Loader::new(Configuration { language: vec![] }); let loader = Loader::new(Configuration { language: vec![] });
let language = get_language("Rust").unwrap(); let language = get_language("rust").unwrap();
let query = Query::new(language, query_str).unwrap(); let query = Query::new(language, query_str).unwrap();
let textobject = TextObjectQuery { query }; let textobject = TextObjectQuery { query };
@ -2070,7 +2333,7 @@ mod test {
let loader = Loader::new(Configuration { language: vec![] }); let loader = Loader::new(Configuration { language: vec![] });
let language = get_language("Rust").unwrap(); let language = get_language("rust").unwrap();
let config = HighlightConfiguration::new( let config = HighlightConfiguration::new(
language, language,
&std::fs::read_to_string("../runtime/grammars/sources/rust/queries/highlights.scm") &std::fs::read_to_string("../runtime/grammars/sources/rust/queries/highlights.scm")
@ -2161,6 +2424,93 @@ mod test {
); );
} }
#[track_caller]
fn assert_pretty_print(
language_name: &str,
source: &str,
expected: &str,
start: usize,
end: usize,
) {
let source = Rope::from_str(source);
let loader = Loader::new(Configuration { language: vec![] });
let language = get_language(language_name).unwrap();
let config = HighlightConfiguration::new(language, "", "", "").unwrap();
let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader));
let root = syntax
.tree()
.root_node()
.descendant_for_byte_range(start, end)
.unwrap();
let mut output = String::new();
pretty_print_tree(&mut output, root).unwrap();
assert_eq!(expected, output);
}
#[test]
fn test_pretty_print() {
let source = r#"/// Hello"#;
assert_pretty_print("rust", source, "(line_comment)", 0, source.len());
// A large tree should be indented with fields:
let source = r#"fn main() {
println!("Hello, World!");
}"#;
assert_pretty_print(
"rust",
source,
concat!(
"(function_item\n",
" name: (identifier)\n",
" parameters: (parameters)\n",
" body: (block\n",
" (expression_statement\n",
" (macro_invocation\n",
" macro: (identifier)\n",
" (token_tree\n",
" (string_literal))))))",
),
0,
source.len(),
);
// Selecting a token should print just that token:
let source = r#"fn main() {}"#;
assert_pretty_print("rust", source, r#""fn""#, 0, 1);
// Error nodes are printed as errors:
let source = r#"}{"#;
assert_pretty_print("rust", source, "(ERROR)", 0, source.len());
// Fields broken under unnamed nodes are determined correctly.
// In the following source, `object` belongs to the `singleton_method`
// rule but `name` and `body` belong to an unnamed helper `_method_rest`.
// This can cause a bug with a pretty-printing implementation that
// uses `Node::field_name_for_child` to determine field names but is
// fixed when using `TreeCursor::field_name`.
let source = "def self.method_name
true
end";
assert_pretty_print(
"ruby",
source,
concat!(
"(singleton_method\n",
" object: (self)\n",
" name: (identifier)\n",
" body: (body_statement\n",
" (true)))"
),
0,
source.len(),
);
}
#[test] #[test]
fn test_load_runtime_file() { fn test_load_runtime_file() {
// Test to make sure we can load some data from the runtime directory. // Test to make sure we can load some data from the runtime directory.

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

@ -0,0 +1,271 @@
use std::cell::Cell;
use std::convert::identity;
use std::ops::Range;
use std::rc::Rc;
use crate::syntax::Highlight;
use crate::Tendril;
/// An inline annotation is continuous text shown
/// on the screen before the grapheme that starts at
/// `char_idx`
#[derive(Debug, Clone)]
pub struct InlineAnnotation {
pub text: Tendril,
pub char_idx: usize,
}
/// Represents a **single Grapheme** that is part of the document
/// that start at `char_idx` that will be replaced with
/// a different `grapheme`.
/// If `grapheme` contains multiple graphemes the text
/// will render incorrectly.
/// If you want to overlay multiple graphemes simply
/// use multiple `Overlays`.
///
/// # Examples
///
/// The following examples are valid overlays for the following text:
///
/// `aX͎̊͢͜͝͡bc`
///
/// ```
/// use helix_core::text_annotations::Overlay;
///
/// // replaces a
/// Overlay {
/// char_idx: 0,
/// grapheme: "X".into(),
/// };
///
/// // replaces X͎̊͢͜͝͡
/// Overlay{
/// char_idx: 1,
/// grapheme: "\t".into(),
/// };
///
/// // replaces b
/// Overlay{
/// char_idx: 6,
/// grapheme: "X̢̢̟͖̲͌̋̇͑͝".into(),
/// };
/// ```
///
/// The following examples are invalid uses
///
/// ```
/// use helix_core::text_annotations::Overlay;
///
/// // overlay is not aligned at grapheme boundary
/// Overlay{
/// char_idx: 3,
/// grapheme: "x".into(),
/// };
///
/// // overlay contains multiple graphemes
/// Overlay{
/// char_idx: 0,
/// grapheme: "xy".into(),
/// };
/// ```
#[derive(Debug, Clone)]
pub struct Overlay {
pub char_idx: usize,
pub grapheme: Tendril,
}
/// Line annotations allow for virtual text between normal
/// text lines. They cause `height` empty lines to be inserted
/// below the document line that contains `anchor_char_idx`.
///
/// These lines can be filled with text in the rendering code
/// as their contents have no effect beyond visual appearance.
///
/// To insert a line after a document line simply set
/// `anchor_char_idx` to `doc.line_to_char(line_idx)`
#[derive(Debug, Clone)]
pub struct LineAnnotation {
pub anchor_char_idx: usize,
pub height: usize,
}
#[derive(Debug)]
struct Layer<A, M> {
annotations: Rc<[A]>,
current_index: Cell<usize>,
metadata: M,
}
impl<A, M: Clone> Clone for Layer<A, M> {
fn clone(&self) -> Self {
Layer {
annotations: self.annotations.clone(),
current_index: self.current_index.clone(),
metadata: self.metadata.clone(),
}
}
}
impl<A, M> Layer<A, M> {
pub fn reset_pos(&self, char_idx: usize, get_char_idx: impl Fn(&A) -> usize) {
let new_index = self
.annotations
.binary_search_by_key(&char_idx, get_char_idx)
.unwrap_or_else(identity);
self.current_index.set(new_index);
}
pub fn consume(&self, char_idx: usize, get_char_idx: impl Fn(&A) -> usize) -> Option<&A> {
let annot = self.annotations.get(self.current_index.get())?;
debug_assert!(get_char_idx(annot) >= char_idx);
if get_char_idx(annot) == char_idx {
self.current_index.set(self.current_index.get() + 1);
Some(annot)
} else {
None
}
}
}
impl<A, M> From<(Rc<[A]>, M)> for Layer<A, M> {
fn from((annotations, metadata): (Rc<[A]>, M)) -> Layer<A, M> {
Layer {
annotations,
current_index: Cell::new(0),
metadata,
}
}
}
fn reset_pos<A, M>(layers: &[Layer<A, M>], pos: usize, get_pos: impl Fn(&A) -> usize) {
for layer in layers {
layer.reset_pos(pos, &get_pos)
}
}
/// Annotations that change that is displayed when the document is render.
/// Also commonly called virtual text.
#[derive(Default, Debug, Clone)]
pub struct TextAnnotations {
inline_annotations: Vec<Layer<InlineAnnotation, Option<Highlight>>>,
overlays: Vec<Layer<Overlay, Option<Highlight>>>,
line_annotations: Vec<Layer<LineAnnotation, ()>>,
}
impl TextAnnotations {
/// Prepare the TextAnnotations for iteration starting at char_idx
pub fn reset_pos(&self, char_idx: usize) {
reset_pos(&self.inline_annotations, char_idx, |annot| annot.char_idx);
reset_pos(&self.overlays, char_idx, |annot| annot.char_idx);
reset_pos(&self.line_annotations, char_idx, |annot| {
annot.anchor_char_idx
});
}
pub fn collect_overlay_highlights(
&self,
char_range: Range<usize>,
) -> Vec<(usize, Range<usize>)> {
let mut highlights = Vec::new();
self.reset_pos(char_range.start);
for char_idx in char_range {
if let Some((_, Some(highlight))) = self.overlay_at(char_idx) {
// we don't know the number of chars the original grapheme takes
// however it doesn't matter as highlight bounderies are automatically
// aligned to grapheme boundaries in the rendering code
highlights.push((highlight.0, char_idx..char_idx + 1))
}
}
highlights
}
/// Add new inline annotations.
///
/// The annotations grapheme will be rendered with `highlight`
/// patched on top of `ui.text`.
///
/// The annotations **must be sorted** by their `char_idx`.
/// Multiple annotations with the same `char_idx` are allowed,
/// they will be display in the order that they are present in the layer.
///
/// If multiple layers contain annotations at the same position
/// the annotations that belong to the layers added first will be shown first.
pub fn add_inline_annotations(
&mut self,
layer: Rc<[InlineAnnotation]>,
highlight: Option<Highlight>,
) -> &mut Self {
self.inline_annotations.push((layer, highlight).into());
self
}
/// Add new grapheme overlays.
///
/// The overlayed grapheme will be rendered with `highlight`
/// patched on top of `ui.text`.
///
/// The overlays **must be sorted** by their `char_idx`.
/// Multiple overlays with the same `char_idx` **are allowed**.
///
/// If multiple layers contain overlay at the same position
/// the overlay from the layer added last will be show.
pub fn add_overlay(&mut self, layer: Rc<[Overlay]>, highlight: Option<Highlight>) -> &mut Self {
self.overlays.push((layer, highlight).into());
self
}
/// Add new annotation lines.
///
/// The line annotations **must be sorted** by their `char_idx`.
/// Multiple line annotations with the same `char_idx` **are not allowed**.
pub fn add_line_annotation(&mut self, layer: Rc<[LineAnnotation]>) -> &mut Self {
self.line_annotations.push((layer, ()).into());
self
}
/// Removes all line annotations, useful for vertical motions
/// so that virtual text lines are automatically skipped.
pub fn clear_line_annotations(&mut self) {
self.line_annotations.clear();
}
pub(crate) fn next_inline_annotation_at(
&self,
char_idx: usize,
) -> Option<(&InlineAnnotation, Option<Highlight>)> {
self.inline_annotations.iter().find_map(|layer| {
let annotation = layer.consume(char_idx, |annot| annot.char_idx)?;
Some((annotation, layer.metadata))
})
}
pub(crate) fn overlay_at(&self, char_idx: usize) -> Option<(&Overlay, Option<Highlight>)> {
let mut overlay = None;
for layer in &self.overlays {
while let Some(new_overlay) = layer.consume(char_idx, |annot| annot.char_idx) {
overlay = Some((new_overlay, layer.metadata));
}
}
overlay
}
pub(crate) fn annotation_lines_at(&self, char_idx: usize) -> usize {
self.line_annotations
.iter()
.map(|layer| {
let mut lines = 0;
while let Some(annot) = layer.annotations.get(layer.current_index.get()) {
if annot.anchor_char_idx == char_idx {
layer.current_index.set(layer.current_index.get() + 1);
lines += annot.height
} else {
break;
}
}
lines
})
.sum()
}
}

@ -231,8 +231,20 @@ fn textobject_pair_surround_impl(
}; };
pair_pos pair_pos
.map(|(anchor, head)| match textobject { .map(|(anchor, head)| match textobject {
TextObject::Inside => Range::new(next_grapheme_boundary(slice, anchor), head), TextObject::Inside => {
TextObject::Around => Range::new(anchor, next_grapheme_boundary(slice, head)), if anchor < head {
Range::new(next_grapheme_boundary(slice, anchor), head)
} else {
Range::new(anchor, next_grapheme_boundary(slice, head))
}
}
TextObject::Around => {
if anchor < head {
Range::new(anchor, next_grapheme_boundary(slice, head))
} else {
Range::new(next_grapheme_boundary(slice, anchor), head)
}
}
TextObject::Movement => unreachable!(), TextObject::Movement => unreachable!(),
}) })
.unwrap_or(range) .unwrap_or(range)

@ -56,7 +56,7 @@ impl ChangeSet {
} }
// Changeset builder operations: delete/insert/retain // Changeset builder operations: delete/insert/retain
fn delete(&mut self, n: usize) { pub(crate) fn delete(&mut self, n: usize) {
use Operation::*; use Operation::*;
if n == 0 { if n == 0 {
return; return;
@ -71,7 +71,7 @@ impl ChangeSet {
} }
} }
fn insert(&mut self, fragment: Tendril) { pub(crate) fn insert(&mut self, fragment: Tendril) {
use Operation::*; use Operation::*;
if fragment.is_empty() { if fragment.is_empty() {
@ -93,7 +93,7 @@ impl ChangeSet {
self.changes.push(new_last); self.changes.push(new_last);
} }
fn retain(&mut self, n: usize) { pub(crate) fn retain(&mut self, n: usize) {
use Operation::*; use Operation::*;
if n == 0 { if n == 0 {
return; return;
@ -481,6 +481,11 @@ impl Transaction {
for (from, to, tendril) in changes { for (from, to, tendril) in changes {
// Verify ranges are ordered and not overlapping // Verify ranges are ordered and not overlapping
debug_assert!(last <= from); debug_assert!(last <= from);
// Verify ranges are correct
debug_assert!(
from <= to,
"Edit end must end before it starts (should {from} <= {to})"
);
// Retain from last "to" to current "from" // Retain from last "to" to current "from"
changeset.retain(from - last); changeset.retain(from - last);
@ -577,7 +582,7 @@ impl<'a> Iterator for ChangeIterator<'a> {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::State; use crate::history::State;
#[test] #[test]
fn composition() { fn composition() {
@ -704,7 +709,10 @@ mod test {
#[test] #[test]
fn optimized_composition() { fn optimized_composition() {
let mut state = State::new("".into()); let mut state = State {
doc: "".into(),
selection: Selection::point(0),
};
let t1 = Transaction::insert(&state.doc, &state.selection, Tendril::from("h")); let t1 = Transaction::insert(&state.doc, &state.selection, Tendril::from("h"));
t1.apply(&mut state.doc); t1.apply(&mut state.doc);
state.selection = state.selection.clone().map(t1.changes()); state.selection = state.selection.clone().map(t1.changes());

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

@ -28,8 +28,8 @@ fn test_treesitter_indent(file_name: &str, lang_scope: &str) {
let mut config_file = test_dir; let mut config_file = test_dir;
config_file.push("languages.toml"); config_file.push("languages.toml");
let config = std::fs::read(config_file).unwrap(); let config = std::fs::read_to_string(config_file).unwrap();
let config = toml::from_slice(&config).unwrap(); let config = toml::from_str(&config).unwrap();
let loader = Loader::new(config); let loader = Loader::new(config);
// set runtime path so we can find the queries // set runtime path so we can find the queries
@ -50,6 +50,7 @@ fn test_treesitter_indent(file_name: &str, lang_scope: &str) {
indent_query, indent_query,
&syntax, &syntax,
&IndentStyle::Spaces(4), &IndentStyle::Spaces(4),
4,
text, text,
i, i,
text.line_to_char(i) + pos, text.line_to_char(i) + pos,

@ -19,7 +19,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
thiserror = "1.0" thiserror = "1.0"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "net", "sync"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "net", "sync"] }
which = "4.2" which = "4.4"
[dev-dependencies] [dev-dependencies]
fern = "0.6" fern = "0.6"

@ -70,7 +70,7 @@ impl Client {
process: Option<Child>, process: Option<Child>,
) -> Result<(Self, UnboundedReceiver<Payload>)> { ) -> Result<(Self, UnboundedReceiver<Payload>)> {
let (server_rx, server_tx) = Transport::start(rx, tx, err, id); let (server_rx, server_tx) = Transport::start(rx, tx, err, id);
let (client_rx, client_tx) = unbounded_channel(); let (client_tx, client_rx) = unbounded_channel();
let client = Self { let client = Self {
id, id,
@ -86,9 +86,9 @@ impl Client {
quirks: DebuggerQuirks::default(), quirks: DebuggerQuirks::default(),
}; };
tokio::spawn(Self::recv(server_rx, client_rx)); tokio::spawn(Self::recv(server_rx, client_tx));
Ok((client, client_tx)) Ok((client, client_rx))
} }
pub async fn tcp( pub async fn tcp(

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

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

@ -16,10 +16,10 @@ path = "src/main.rs"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
toml = "0.5" toml = "0.7"
etcetera = "0.4" etcetera = "0.4"
tree-sitter = "0.20" tree-sitter = "0.20"
once_cell = "1.13" once_cell = "1.17"
log = "0.4" log = "0.4"
# TODO: these two should be on !wasm32 only # TODO: these two should be on !wasm32 only

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

@ -1,6 +1,9 @@
use std::str::from_utf8;
/// Default built-in languages.toml. /// Default built-in languages.toml.
pub fn default_lang_config() -> toml::Value { pub fn default_lang_config() -> toml::Value {
toml::from_slice(include_bytes!("../../languages.toml")) let default_config = include_bytes!("../../languages.toml");
toml::from_str(from_utf8(default_config).unwrap())
.expect("Could not parse built-in languages.toml to valid toml") .expect("Could not parse built-in languages.toml to valid toml")
} }
@ -11,8 +14,8 @@ pub fn user_lang_config() -> Result<toml::Value, toml::de::Error> {
.chain([crate::config_dir()].into_iter()) .chain([crate::config_dir()].into_iter())
.map(|path| path.join("languages.toml")) .map(|path| path.join("languages.toml"))
.filter_map(|file| { .filter_map(|file| {
std::fs::read(&file) std::fs::read_to_string(file)
.map(|config| toml::from_slice(&config)) .map(|config| toml::from_str(&config))
.ok() .ok()
}) })
.collect::<Result<Vec<_>, _>>()? .collect::<Result<Vec<_>, _>>()?

@ -67,8 +67,7 @@ pub fn get_language(name: &str) -> Result<Language> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn get_language(name: &str) -> Result<Language> { pub fn get_language(name: &str) -> Result<Language> {
use libloading::{Library, Symbol}; use libloading::{Library, Symbol};
let name = name.to_ascii_lowercase(); let mut library_path = crate::runtime_dir().join("grammars").join(name);
let mut library_path = crate::runtime_dir().join("grammars").join(&name);
library_path.set_extension(DYLIB_EXTENSION); library_path.set_extension(DYLIB_EXTENSION);
let library = unsafe { Library::new(&library_path) } let library = unsafe { Library::new(&library_path) }
@ -139,7 +138,7 @@ pub fn fetch_grammars() -> Result<()> {
let len = errors.len(); let len = errors.len();
println!("{} grammars failed to fetch", len); println!("{} grammars failed to fetch", len);
for (i, error) in errors.into_iter().enumerate() { for (i, error) in errors.into_iter().enumerate() {
println!("\tFailure {}/{}: {}", i, len, error); println!("\tFailure {}/{}: {}", i + 1, len, error);
} }
} }
@ -264,7 +263,7 @@ fn fetch_grammar(grammar: GrammarConfiguration) -> Result<FetchStatus> {
))?; ))?;
// create the grammar dir contains a git directory // create the grammar dir contains a git directory
if !grammar_dir.join(".git").is_dir() { if !grammar_dir.join(".git").exists() {
git(&grammar_dir, ["init"])?; git(&grammar_dir, ["init"])?;
} }
@ -430,7 +429,7 @@ fn build_tree_sitter_library(
if cfg!(all(windows, target_env = "msvc")) { if cfg!(all(windows, target_env = "msvc")) {
command command
.args(&["/nologo", "/LD", "/I"]) .args(["/nologo", "/LD", "/I"])
.arg(header_path) .arg(header_path)
.arg("/Od") .arg("/Od")
.arg("/utf-8"); .arg("/utf-8");
@ -516,5 +515,5 @@ pub fn load_runtime_file(language: &str, filename: &str) -> Result<String, std::
.join("queries") .join("queries")
.join(language) .join(language)
.join(filename); .join(filename);
std::fs::read_to_string(&path) std::fs::read_to_string(path)
} }

@ -4,6 +4,8 @@ pub mod grammar;
use etcetera::base_strategy::{choose_base_strategy, BaseStrategy}; use etcetera::base_strategy::{choose_base_strategy, BaseStrategy};
use std::path::PathBuf; use std::path::PathBuf;
pub const VERSION_AND_GIT_HASH: &str = env!("VERSION_AND_GIT_HASH");
pub static RUNTIME_DIR: once_cell::sync::Lazy<PathBuf> = once_cell::sync::Lazy::new(runtime_dir); pub static RUNTIME_DIR: once_cell::sync::Lazy<PathBuf> = once_cell::sync::Lazy::new(runtime_dir);
static CONFIG_FILE: once_cell::sync::OnceCell<PathBuf> = once_cell::sync::OnceCell::new(); static CONFIG_FILE: once_cell::sync::OnceCell<PathBuf> = once_cell::sync::OnceCell::new();
@ -42,8 +44,10 @@ pub fn runtime_dir() -> PathBuf {
} }
// fallback to location of the executable being run // fallback to location of the executable being run
// canonicalize the path in case the executable is symlinked
std::env::current_exe() std::env::current_exe()
.ok() .ok()
.and_then(|path| std::fs::canonicalize(path).ok())
.and_then(|path| path.parent().map(|path| path.to_path_buf().join(RT_DIR))) .and_then(|path| path.parent().map(|path| path.to_path_buf().join(RT_DIR)))
.unwrap() .unwrap()
} }
@ -57,7 +61,7 @@ pub fn config_dir() -> PathBuf {
} }
pub fn local_config_dirs() -> Vec<PathBuf> { pub fn local_config_dirs() -> Vec<PathBuf> {
let directories = find_root_impl(None, &[".helix".to_string()]) let directories = find_local_config_dirs()
.into_iter() .into_iter()
.map(|path| path.join(".helix")) .map(|path| path.join(".helix"))
.collect(); .collect();
@ -88,32 +92,16 @@ pub fn log_file() -> PathBuf {
cache_dir().join("helix.log") cache_dir().join("helix.log")
} }
pub fn find_root_impl(root: Option<&str>, root_markers: &[String]) -> Vec<PathBuf> { pub fn find_local_config_dirs() -> Vec<PathBuf> {
let current_dir = std::env::current_dir().expect("unable to determine current directory"); let current_dir = std::env::current_dir().expect("unable to determine current directory");
let mut directories = Vec::new(); let mut directories = Vec::new();
let root = match root { for ancestor in current_dir.ancestors() {
Some(root) => { if ancestor.join(".git").exists() {
let root = std::path::Path::new(root);
if root.is_absolute() {
root.to_path_buf()
} else {
current_dir.join(root)
}
}
None => current_dir,
};
for ancestor in root.ancestors() {
// don't go higher than repo
if ancestor.join(".git").is_dir() {
// Use workspace if detected from marker
directories.push(ancestor.to_path_buf()); directories.push(ancestor.to_path_buf());
// Don't go higher than repo if we're in one
break; break;
} else if root_markers } else if ancestor.join(".helix").is_dir() {
.iter()
.any(|marker| ancestor.join(marker).exists())
{
directories.push(ancestor.to_path_buf()); directories.push(ancestor.to_path_buf());
} }
} }
@ -191,6 +179,8 @@ pub fn merge_toml_values(left: toml::Value, right: toml::Value, merge_depth: usi
#[cfg(test)] #[cfg(test)]
mod merge_toml_tests { mod merge_toml_tests {
use std::str;
use super::merge_toml_values; use super::merge_toml_values;
use toml::Value; use toml::Value;
@ -203,8 +193,9 @@ mod merge_toml_tests {
indent = { tab-width = 4, unit = " ", test = "aaa" } indent = { tab-width = 4, unit = " ", test = "aaa" }
"#; "#;
let base: Value = toml::from_slice(include_bytes!("../../languages.toml")) let base = include_bytes!("../../languages.toml");
.expect("Couldn't parse built-in languages config"); let base = str::from_utf8(base).expect("Couldn't parse built-in languages config");
let base: Value = toml::from_str(base).expect("Couldn't parse built-in languages config");
let user: Value = toml::from_str(USER).unwrap(); let user: Value = toml::from_str(USER).unwrap();
let merged = merge_toml_values(base, user, 3); let merged = merge_toml_values(base, user, 3);
@ -236,8 +227,9 @@ mod merge_toml_tests {
language-server = { command = "deno", args = ["lsp"] } language-server = { command = "deno", args = ["lsp"] }
"#; "#;
let base: Value = toml::from_slice(include_bytes!("../../languages.toml")) let base = include_bytes!("../../languages.toml");
.expect("Couldn't parse built-in languages config"); let base = str::from_utf8(base).expect("Couldn't parse built-in languages config");
let base: Value = toml::from_str(base).expect("Couldn't parse built-in languages config");
let user: Value = toml::from_str(USER).unwrap(); let user: Value = toml::from_str(USER).unwrap();
let merged = merge_toml_values(base, user, 3); let merged = merge_toml_values(base, user, 3);

@ -13,15 +13,16 @@ homepage = "https://helix-editor.com"
[dependencies] [dependencies]
helix-core = { version = "0.6", path = "../helix-core" } helix-core = { version = "0.6", path = "../helix-core" }
helix-loader = { version = "0.6", path = "../helix-loader" }
anyhow = "1.0" anyhow = "1.0"
futures-executor = "0.3" futures-executor = "0.3"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false } futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
log = "0.4" log = "0.4"
lsp-types = { version = "0.93", features = ["proposed"] } lsp-types = { version = "0.94" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
thiserror = "1.0" thiserror = "1.0"
tokio = { version = "1.19", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } tokio = { version = "1.25", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio-stream = "0.1.9" tokio-stream = "0.1.11"
which = "4.2" which = "4.4"

@ -4,8 +4,9 @@ use crate::{
Call, Error, OffsetEncoding, Result, Call, Error, OffsetEncoding, Result,
}; };
use anyhow::anyhow;
use helix_core::{find_root, ChangeSet, Rope}; use helix_core::{find_root, ChangeSet, Rope};
use helix_loader::{self, VERSION_AND_GIT_HASH};
use lsp::PositionEncodingKind;
use lsp_types as lsp; use lsp_types as lsp;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
@ -32,9 +33,8 @@ pub struct Client {
server_tx: UnboundedSender<Payload>, server_tx: UnboundedSender<Payload>,
request_counter: AtomicU64, request_counter: AtomicU64,
pub(crate) capabilities: OnceCell<lsp::ServerCapabilities>, pub(crate) capabilities: OnceCell<lsp::ServerCapabilities>,
offset_encoding: OffsetEncoding,
config: Option<Value>, config: Option<Value>,
root_path: Option<std::path::PathBuf>, root_path: std::path::PathBuf,
root_uri: Option<lsp::Url>, root_uri: Option<lsp::Url>,
workspace_folders: Vec<lsp::WorkspaceFolder>, workspace_folders: Vec<lsp::WorkspaceFolder>,
req_timeout: u64, req_timeout: u64,
@ -42,18 +42,22 @@ pub struct Client {
impl Client { impl Client {
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[allow(clippy::too_many_arguments)]
pub fn start( pub fn start(
cmd: &str, cmd: &str,
args: &[String], args: &[String],
config: Option<Value>, config: Option<Value>,
server_environment: HashMap<String, String>,
root_markers: &[String], root_markers: &[String],
id: usize, id: usize,
req_timeout: u64, req_timeout: u64,
doc_path: Option<&std::path::PathBuf>,
) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc<Notify>)> { ) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc<Notify>)> {
// Resolve path to the binary // Resolve path to the binary
let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?; let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?;
let process = Command::new(cmd) let process = Command::new(cmd)
.envs(server_environment)
.args(args) .args(args)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@ -72,11 +76,12 @@ impl Client {
let (server_rx, server_tx, initialize_notify) = let (server_rx, server_tx, initialize_notify) =
Transport::start(reader, writer, stderr, id); Transport::start(reader, writer, stderr, id);
let root_path = find_root(None, root_markers); let root_path = find_root(
doc_path.and_then(|x| x.parent().and_then(|x| x.to_str())),
root_markers,
);
let root_uri = root_path let root_uri = lsp::Url::from_file_path(root_path.clone()).ok();
.clone()
.and_then(|root| lsp::Url::from_file_path(root).ok());
// TODO: support multiple workspace folders // TODO: support multiple workspace folders
let workspace_folders = root_uri let workspace_folders = root_uri
@ -99,7 +104,6 @@ impl Client {
server_tx, server_tx,
request_counter: AtomicU64::new(0), request_counter: AtomicU64::new(0),
capabilities: OnceCell::new(), capabilities: OnceCell::new(),
offset_encoding: OffsetEncoding::Utf8,
config, config,
req_timeout, req_timeout,
@ -142,7 +146,19 @@ impl Client {
} }
pub fn offset_encoding(&self) -> OffsetEncoding { pub fn offset_encoding(&self) -> OffsetEncoding {
self.offset_encoding self.capabilities()
.position_encoding
.as_ref()
.and_then(|encoding| match encoding.as_str() {
"utf-8" => Some(OffsetEncoding::Utf8),
"utf-16" => Some(OffsetEncoding::Utf16),
"utf-32" => Some(OffsetEncoding::Utf32),
encoding => {
log::error!("Server provided invalid position encording {encoding}, defaulting to utf-16");
None
},
})
.unwrap_or_default()
} }
pub fn config(&self) -> Option<&Value> { pub fn config(&self) -> Option<&Value> {
@ -281,10 +297,7 @@ impl Client {
workspace_folders: Some(self.workspace_folders.clone()), workspace_folders: Some(self.workspace_folders.clone()),
// root_path is obsolete, but some clients like pyright still use it so we specify both. // root_path is obsolete, but some clients like pyright still use it so we specify both.
// clients will prefer _uri if possible // clients will prefer _uri if possible
root_path: self root_path: self.root_path.to_str().map(|path| path.to_owned()),
.root_path
.clone()
.and_then(|path| path.to_str().map(|path| path.to_owned())),
root_uri: self.root_uri.clone(), root_uri: self.root_uri.clone(),
initialization_options: self.config.clone(), initialization_options: self.config.clone(),
capabilities: lsp::ClientCapabilities { capabilities: lsp::ClientCapabilities {
@ -299,6 +312,9 @@ impl Client {
dynamic_registration: Some(false), dynamic_registration: Some(false),
..Default::default() ..Default::default()
}), }),
execute_command: Some(lsp::DynamicRegistrationClientCapabilities {
dynamic_registration: Some(false),
}),
..Default::default() ..Default::default()
}), }),
text_document: Some(lsp::TextDocumentClientCapabilities { text_document: Some(lsp::TextDocumentClientCapabilities {
@ -312,6 +328,11 @@ impl Client {
String::from("additionalTextEdits"), String::from("additionalTextEdits"),
], ],
}), }),
insert_replace_support: Some(true),
deprecated_support: Some(true),
tag_support: Some(lsp::TagSupport {
value_set: vec![lsp::CompletionItemTag::DEPRECATED],
}),
..Default::default() ..Default::default()
}), }),
completion_item_kind: Some(lsp::CompletionItemKindCapability { completion_item_kind: Some(lsp::CompletionItemKindCapability {
@ -371,10 +392,21 @@ impl Client {
work_done_progress: Some(true), work_done_progress: Some(true),
..Default::default() ..Default::default()
}), }),
general: Some(lsp::GeneralClientCapabilities {
position_encodings: Some(vec![
PositionEncodingKind::UTF32,
PositionEncodingKind::UTF8,
PositionEncodingKind::UTF16,
]),
..Default::default()
}),
..Default::default() ..Default::default()
}, },
trace: None, trace: None,
client_info: None, client_info: Some(lsp::ClientInfo {
name: String::from("helix"),
version: Some(String::from(VERSION_AND_GIT_HASH)),
}),
locale: None, // TODO locale: None, // TODO
}; };
@ -543,16 +575,17 @@ impl Client {
new_text: &Rope, new_text: &Rope,
changes: &ChangeSet, changes: &ChangeSet,
) -> Option<impl Future<Output = Result<()>>> { ) -> Option<impl Future<Output = Result<()>>> {
// figure out what kind of sync the server supports
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support document sync.
let sync_capabilities = match capabilities.text_document_sync { let sync_capabilities = match capabilities.text_document_sync {
Some(lsp::TextDocumentSyncCapability::Kind(kind)) Some(
| Some(lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions { lsp::TextDocumentSyncCapability::Kind(kind)
change: Some(kind), | lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions {
.. change: Some(kind),
})) => kind, ..
}),
) => kind,
// None | SyncOptions { changes: None } // None | SyncOptions { changes: None }
_ => return None, _ => return None,
}; };
@ -567,7 +600,7 @@ impl Client {
}] }]
} }
lsp::TextDocumentSyncKind::INCREMENTAL => { lsp::TextDocumentSyncKind::INCREMENTAL => {
Self::changeset_to_changes(old_text, new_text, changes, self.offset_encoding) Self::changeset_to_changes(old_text, new_text, changes, self.offset_encoding())
} }
lsp::TextDocumentSyncKind::NONE => return None, lsp::TextDocumentSyncKind::NONE => return None,
kind => unimplemented!("{:?}", kind), kind => unimplemented!("{:?}", kind),
@ -618,7 +651,7 @@ impl Client {
Some(self.notify::<lsp::notification::DidSaveTextDocument>( Some(self.notify::<lsp::notification::DidSaveTextDocument>(
lsp::DidSaveTextDocumentParams { lsp::DidSaveTextDocumentParams {
text_document, text_document,
text: include_text.then(|| text.into()), text: include_text.then_some(text.into()),
}, },
)) ))
} }
@ -628,8 +661,12 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
// ) -> Result<Vec<lsp::CompletionItem>> { let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support completion.
capabilities.completion_provider.as_ref()?;
let params = lsp::CompletionParams { let params = lsp::CompletionParams {
text_document_position: lsp::TextDocumentPositionParams { text_document_position: lsp::TextDocumentPositionParams {
text_document, text_document,
@ -644,15 +681,25 @@ impl Client {
// lsp::CompletionContext { trigger_kind: , trigger_character: Some(), } // lsp::CompletionContext { trigger_kind: , trigger_character: Some(), }
}; };
self.call::<lsp::request::Completion>(params) Some(self.call::<lsp::request::Completion>(params))
} }
pub async fn resolve_completion_item( pub fn resolve_completion_item(
&self, &self,
completion_item: lsp::CompletionItem, completion_item: lsp::CompletionItem,
) -> Result<lsp::CompletionItem> { ) -> Option<impl Future<Output = Result<Value>>> {
self.request::<lsp::request::ResolveCompletionItem>(completion_item) let capabilities = self.capabilities.get().unwrap();
.await
// Return early if the server does not support resolving completion items.
match capabilities.completion_provider {
Some(lsp::CompletionOptions {
resolve_provider: Some(true),
..
}) => (),
_ => return None,
}
Some(self.call::<lsp::request::ResolveCompletionItem>(completion_item))
} }
pub fn text_document_signature_help( pub fn text_document_signature_help(
@ -663,7 +710,7 @@ impl Client {
) -> Option<impl Future<Output = Result<Value>>> { ) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.capabilities.get().unwrap();
// Return early if signature help is not supported // Return early if the server does not support signature help.
capabilities.signature_help_provider.as_ref()?; capabilities.signature_help_provider.as_ref()?;
let params = lsp::SignatureHelpParams { let params = lsp::SignatureHelpParams {
@ -684,7 +731,18 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support hover.
match capabilities.hover_provider {
Some(
lsp::HoverProviderCapability::Simple(true)
| lsp::HoverProviderCapability::Options(_),
) => (),
_ => return None,
}
let params = lsp::HoverParams { let params = lsp::HoverParams {
text_document_position_params: lsp::TextDocumentPositionParams { text_document_position_params: lsp::TextDocumentPositionParams {
text_document, text_document,
@ -694,7 +752,7 @@ impl Client {
// lsp::SignatureHelpContext // lsp::SignatureHelpContext
}; };
self.call::<lsp::request::HoverRequest>(params) Some(self.call::<lsp::request::HoverRequest>(params))
} }
// formatting // formatting
@ -707,13 +765,11 @@ impl Client {
) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> { ) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> {
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.capabilities.get().unwrap();
// check if we're able to format // Return early if the server does not support formatting.
match capabilities.document_formatting_provider { match capabilities.document_formatting_provider {
Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (), Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
// None | Some(false)
_ => return None, _ => return None,
}; };
// TODO: return err::unavailable so we can fall back to tree sitter formatting
// merge FormattingOptions with 'config.format' // merge FormattingOptions with 'config.format'
let config_format = self let config_format = self
@ -748,22 +804,20 @@ impl Client {
}) })
} }
pub async fn text_document_range_formatting( pub fn text_document_range_formatting(
&self, &self,
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
range: lsp::Range, range: lsp::Range,
options: lsp::FormattingOptions, options: lsp::FormattingOptions,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> anyhow::Result<Vec<lsp::TextEdit>> { ) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> {
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.capabilities.get().unwrap();
// check if we're able to format // Return early if the server does not support range formatting.
match capabilities.document_range_formatting_provider { match capabilities.document_range_formatting_provider {
Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (), Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
// None | Some(false) _ => return None,
_ => return Ok(Vec::new()),
}; };
// TODO: return err::unavailable so we can fall back to tree sitter formatting
let params = lsp::DocumentRangeFormattingParams { let params = lsp::DocumentRangeFormattingParams {
text_document, text_document,
@ -772,11 +826,13 @@ impl Client {
work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token }, work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
}; };
let response = self let request = self.call::<lsp::request::RangeFormatting>(params);
.request::<lsp::request::RangeFormatting>(params)
.await?;
Ok(response.unwrap_or_default()) Some(async move {
let json = request.await?;
let response: Option<Vec<lsp::TextEdit>> = serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
})
} }
pub fn text_document_document_highlight( pub fn text_document_document_highlight(
@ -784,7 +840,15 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support document highlight.
match capabilities.document_highlight_provider {
Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
_ => return None,
}
let params = lsp::DocumentHighlightParams { let params = lsp::DocumentHighlightParams {
text_document_position_params: lsp::TextDocumentPositionParams { text_document_position_params: lsp::TextDocumentPositionParams {
text_document, text_document,
@ -796,7 +860,7 @@ impl Client {
}, },
}; };
self.call::<lsp::request::DocumentHighlightRequest>(params) Some(self.call::<lsp::request::DocumentHighlightRequest>(params))
} }
fn goto_request< fn goto_request<
@ -829,8 +893,45 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
self.goto_request::<lsp::request::GotoDefinition>(text_document, position, work_done_token) let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-definition.
match capabilities.definition_provider {
Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
_ => return None,
}
Some(self.goto_request::<lsp::request::GotoDefinition>(
text_document,
position,
work_done_token,
))
}
pub fn goto_declaration(
&self,
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-declaration.
match capabilities.declaration_provider {
Some(
lsp::DeclarationCapability::Simple(true)
| lsp::DeclarationCapability::RegistrationOptions(_)
| lsp::DeclarationCapability::Options(_),
) => (),
_ => return None,
}
Some(self.goto_request::<lsp::request::GotoDeclaration>(
text_document,
position,
work_done_token,
))
} }
pub fn goto_type_definition( pub fn goto_type_definition(
@ -838,12 +939,23 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
self.goto_request::<lsp::request::GotoTypeDefinition>( let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-type-definition.
match capabilities.type_definition_provider {
Some(
lsp::TypeDefinitionProviderCapability::Simple(true)
| lsp::TypeDefinitionProviderCapability::Options(_),
) => (),
_ => return None,
}
Some(self.goto_request::<lsp::request::GotoTypeDefinition>(
text_document, text_document,
position, position,
work_done_token, work_done_token,
) ))
} }
pub fn goto_implementation( pub fn goto_implementation(
@ -851,12 +963,23 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
self.goto_request::<lsp::request::GotoImplementation>( let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-definition.
match capabilities.implementation_provider {
Some(
lsp::ImplementationProviderCapability::Simple(true)
| lsp::ImplementationProviderCapability::Options(_),
) => (),
_ => return None,
}
Some(self.goto_request::<lsp::request::GotoImplementation>(
text_document, text_document,
position, position,
work_done_token, work_done_token,
) ))
} }
pub fn goto_reference( pub fn goto_reference(
@ -864,7 +987,15 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-reference.
match capabilities.references_provider {
Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
_ => return None,
}
let params = lsp::ReferenceParams { let params = lsp::ReferenceParams {
text_document_position: lsp::TextDocumentPositionParams { text_document_position: lsp::TextDocumentPositionParams {
text_document, text_document,
@ -879,31 +1010,47 @@ impl Client {
}, },
}; };
self.call::<lsp::request::References>(params) Some(self.call::<lsp::request::References>(params))
} }
pub fn document_symbols( pub fn document_symbols(
&self, &self,
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support document symbols.
match capabilities.document_symbol_provider {
Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
_ => return None,
}
let params = lsp::DocumentSymbolParams { let params = lsp::DocumentSymbolParams {
text_document, text_document,
work_done_progress_params: lsp::WorkDoneProgressParams::default(), work_done_progress_params: lsp::WorkDoneProgressParams::default(),
partial_result_params: lsp::PartialResultParams::default(), partial_result_params: lsp::PartialResultParams::default(),
}; };
self.call::<lsp::request::DocumentSymbolRequest>(params) Some(self.call::<lsp::request::DocumentSymbolRequest>(params))
} }
// empty string to get all symbols // empty string to get all symbols
pub fn workspace_symbols(&self, query: String) -> impl Future<Output = Result<Value>> { pub fn workspace_symbols(&self, query: String) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support workspace symbols.
match capabilities.workspace_symbol_provider {
Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_)) => (),
_ => return None,
}
let params = lsp::WorkspaceSymbolParams { let params = lsp::WorkspaceSymbolParams {
query, query,
work_done_progress_params: lsp::WorkDoneProgressParams::default(), work_done_progress_params: lsp::WorkDoneProgressParams::default(),
partial_result_params: lsp::PartialResultParams::default(), partial_result_params: lsp::PartialResultParams::default(),
}; };
self.call::<lsp::request::WorkspaceSymbol>(params) Some(self.call::<lsp::request::WorkspaceSymbolRequest>(params))
} }
pub fn code_actions( pub fn code_actions(
@ -911,7 +1058,18 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
range: lsp::Range, range: lsp::Range,
context: lsp::CodeActionContext, context: lsp::CodeActionContext,
) -> impl Future<Output = Result<Value>> { ) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support code actions.
match capabilities.code_action_provider {
Some(
lsp::CodeActionProviderCapability::Simple(true)
| lsp::CodeActionProviderCapability::Options(_),
) => (),
_ => return None,
}
let params = lsp::CodeActionParams { let params = lsp::CodeActionParams {
text_document, text_document,
range, range,
@ -920,26 +1078,22 @@ impl Client {
partial_result_params: lsp::PartialResultParams::default(), partial_result_params: lsp::PartialResultParams::default(),
}; };
self.call::<lsp::request::CodeActionRequest>(params) Some(self.call::<lsp::request::CodeActionRequest>(params))
} }
pub async fn rename_symbol( pub fn rename_symbol(
&self, &self,
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
new_name: String, new_name: String,
) -> anyhow::Result<lsp::WorkspaceEdit> { ) -> Option<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.capabilities.get().unwrap();
// check if we're able to rename // Return early if the language server does not support renaming.
match capabilities.rename_provider { match capabilities.rename_provider {
Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (), Some(lsp::OneOf::Left(true)) | Some(lsp::OneOf::Right(_)) => (),
// None | Some(false) // None | Some(false)
_ => { _ => return None,
log::warn!("rename_symbol failed: The server does not support rename");
let err = "The server does not support rename";
return Err(anyhow!(err));
}
}; };
let params = lsp::RenameParams { let params = lsp::RenameParams {
@ -953,11 +1107,21 @@ impl Client {
}, },
}; };
let response = self.request::<lsp::request::Rename>(params).await?; let request = self.call::<lsp::request::Rename>(params);
Ok(response.unwrap_or_default())
Some(async move {
let json = request.await?;
let response: Option<lsp::WorkspaceEdit> = serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
})
} }
pub fn command(&self, command: lsp::Command) -> impl Future<Output = Result<Value>> { pub fn command(&self, command: lsp::Command) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the language server does not support executing commands.
capabilities.execute_command_provider.as_ref()?;
let params = lsp::ExecuteCommandParams { let params = lsp::ExecuteCommandParams {
command: command.command, command: command.command,
arguments: command.arguments.unwrap_or_default(), arguments: command.arguments.unwrap_or_default(),
@ -966,6 +1130,6 @@ impl Client {
}, },
}; };
self.call::<lsp::request::ExecuteCommand>(params) Some(self.call::<lsp::request::ExecuteCommand>(params))
} }
} }

@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
// https://www.jsonrpc.org/specification#error_object // https://www.jsonrpc.org/specification#error_object
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Eq, Clone)]
pub enum ErrorCode { pub enum ErrorCode {
ParseError, ParseError,
InvalidRequest, InvalidRequest,
@ -68,7 +68,7 @@ impl Serialize for ErrorCode {
} }
} }
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct Error { pub struct Error {
pub code: ErrorCode, pub code: ErrorCode,
pub message: String, pub message: String,
@ -100,7 +100,7 @@ impl std::error::Error for Error {}
// https://www.jsonrpc.org/specification#request_object // https://www.jsonrpc.org/specification#request_object
/// Request ID /// Request ID
#[derive(Debug, PartialEq, Clone, Hash, Eq, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Hash, Deserialize, Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum Id { pub enum Id {
Null, Null,
@ -109,7 +109,7 @@ pub enum Id {
} }
/// Protocol Version /// Protocol Version
#[derive(Debug, PartialEq, Clone, Copy, Hash, Eq)] #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
pub enum Version { pub enum Version {
V2, V2,
} }
@ -153,7 +153,7 @@ impl<'de> Deserialize<'de> for Version {
} }
} }
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum Params { pub enum Params {
None, None,
@ -170,6 +170,10 @@ impl Params {
serde_json::from_value(value) serde_json::from_value(value)
.map_err(|err| Error::invalid_params(format!("Invalid params: {}.", err))) .map_err(|err| Error::invalid_params(format!("Invalid params: {}.", err)))
} }
pub fn is_none(&self) -> bool {
self == &Params::None
}
} }
impl From<Params> for Value { impl From<Params> for Value {
@ -182,26 +186,26 @@ impl From<Params> for Value {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct MethodCall { pub struct MethodCall {
pub jsonrpc: Option<Version>, pub jsonrpc: Option<Version>,
pub method: String, pub method: String,
#[serde(default = "default_params")] #[serde(default = "default_params", skip_serializing_if = "Params::is_none")]
pub params: Params, pub params: Params,
pub id: Id, pub id: Id,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Notification { pub struct Notification {
pub jsonrpc: Option<Version>, pub jsonrpc: Option<Version>,
pub method: String, pub method: String,
#[serde(default = "default_params")] #[serde(default = "default_params", skip_serializing_if = "Params::is_none")]
pub params: Params, pub params: Params,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
#[serde(untagged)] #[serde(untagged)]
pub enum Call { pub enum Call {
@ -235,7 +239,7 @@ impl From<Notification> for Call {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
#[serde(untagged)] #[serde(untagged)]
pub enum Request { pub enum Request {
@ -245,7 +249,7 @@ pub enum Request {
// https://www.jsonrpc.org/specification#response_object // https://www.jsonrpc.org/specification#response_object
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct Success { pub struct Success {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub jsonrpc: Option<Version>, pub jsonrpc: Option<Version>,
@ -253,7 +257,7 @@ pub struct Success {
pub id: Id, pub id: Id,
} }
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
pub struct Failure { pub struct Failure {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub jsonrpc: Option<Version>, pub jsonrpc: Option<Version>,
@ -264,7 +268,7 @@ pub struct Failure {
// Note that failure comes first because we're not using // Note that failure comes first because we're not using
// #[serde(deny_unknown_field)]: we want a request that contains // #[serde(deny_unknown_field)]: we want a request that contains
// both `result` and `error` to be a `Failure`. // both `result` and `error` to be a `Failure`.
#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum Output { pub enum Output {
Failure(Failure), Failure(Failure),
@ -280,7 +284,7 @@ impl From<Output> for Result<Value, Error> {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum Response { pub enum Response {
Single(Output), Single(Output),
@ -334,6 +338,33 @@ fn notification_serialize() {
); );
} }
#[test]
fn serialize_skip_none_params() {
use serde_json;
let m = MethodCall {
jsonrpc: Some(Version::V2),
method: "shutdown".to_owned(),
params: Params::None,
id: Id::Num(1),
};
let serialized = serde_json::to_string(&m).unwrap();
assert_eq!(
serialized,
r#"{"jsonrpc":"2.0","method":"shutdown","id":1}"#
);
let n = Notification {
jsonrpc: Some(Version::V2),
method: "exit".to_owned(),
params: Params::None,
};
let serialized = serde_json::to_string(&n).unwrap();
assert_eq!(serialized, r#"{"jsonrpc":"2.0","method":"exit"}"#);
}
#[test] #[test]
fn success_output_deserialize() { fn success_output_deserialize() {
use serde_json; use serde_json;

@ -9,7 +9,8 @@ pub use lsp::{Position, Url};
pub use lsp_types as lsp; pub use lsp_types as lsp;
use futures_util::stream::select_all::SelectAll; use futures_util::stream::select_all::SelectAll;
use helix_core::syntax::LanguageConfiguration; use helix_core::syntax::{LanguageConfiguration, LanguageServerConfiguration};
use tokio::sync::mpsc::UnboundedReceiver;
use std::{ use std::{
collections::{hash_map::Entry, HashMap}, collections::{hash_map::Entry, HashMap},
@ -19,7 +20,6 @@ use std::{
}, },
}; };
use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
@ -38,27 +38,27 @@ pub enum Error {
Timeout, Timeout,
#[error("server closed the stream")] #[error("server closed the stream")]
StreamClosed, StreamClosed,
#[error("LSP not defined")]
LspNotDefined,
#[error("Unhandled")] #[error("Unhandled")]
Unhandled, Unhandled,
#[error(transparent)] #[error(transparent)]
Other(#[from] anyhow::Error), Other(#[from] anyhow::Error),
} }
#[derive(Clone, Copy, Debug, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Default)]
pub enum OffsetEncoding { pub enum OffsetEncoding {
/// UTF-8 code units aka bytes /// UTF-8 code units aka bytes
#[serde(rename = "utf-8")]
Utf8, Utf8,
/// UTF-32 code units aka chars
Utf32,
/// UTF-16 code units /// UTF-16 code units
#[serde(rename = "utf-16")] #[default]
Utf16, Utf16,
} }
pub mod util { pub mod util {
use super::*; use super::*;
use helix_core::{diagnostic::NumberOrString, Range, Rope, Transaction}; use helix_core::line_ending::{line_end_byte_index, line_end_char_index};
use helix_core::{diagnostic::NumberOrString, Range, Rope, Selection, Tendril, Transaction};
/// Converts a diagnostic in the document to [`lsp::Diagnostic`]. /// Converts a diagnostic in the document to [`lsp::Diagnostic`].
/// ///
@ -86,21 +86,39 @@ pub mod util {
None => None, None => None,
}; };
// TODO: add support for Diagnostic.data let new_tags: Vec<_> = diag
lsp::Diagnostic::new( .tags
range_to_lsp_range(doc, range, offset_encoding), .iter()
.map(|tag| match tag {
helix_core::diagnostic::DiagnosticTag::Unnecessary => {
lsp::DiagnosticTag::UNNECESSARY
}
helix_core::diagnostic::DiagnosticTag::Deprecated => lsp::DiagnosticTag::DEPRECATED,
})
.collect();
let tags = if !new_tags.is_empty() {
Some(new_tags)
} else {
None
};
lsp::Diagnostic {
range: range_to_lsp_range(doc, range, offset_encoding),
severity, severity,
code, code,
None, source: diag.source.clone(),
diag.message.to_owned(), message: diag.message.to_owned(),
None, related_information: None,
None, tags,
) data: diag.data.to_owned(),
..Default::default()
}
} }
/// Converts [`lsp::Position`] to a position in the document. /// Converts [`lsp::Position`] to a position in the document.
/// ///
/// Returns `None` if position exceeds document length or an operation overflows. /// Returns `None` if position.line is out of bounds or an overflow occurs
pub fn lsp_pos_to_pos( pub fn lsp_pos_to_pos(
doc: &Rope, doc: &Rope,
pos: lsp::Position, pos: lsp::Position,
@ -111,22 +129,63 @@ pub mod util {
return None; return None;
} }
match offset_encoding { // We need to be careful here to fully comply ith the LSP spec.
// Two relevant quotes from the spec:
//
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
// > If the character value is greater than the line length it defaults back
// > to the line length.
//
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocuments
// > To ensure that both client and server split the string into the same
// > line representation the protocol specifies the following end-of-line sequences:
// > \n, \r\n and \r. Positions are line end character agnostic.
// > So you can not specify a position that denotes \r|\n or \n| where | represents the character offset.
//
// This means that while the line must be in bounds the `charater`
// must be capped to the end of the line.
// Note that the end of the line here is **before** the line terminator
// so we must use `line_end_char_index` istead of `doc.line_to_char(pos_line + 1)`
//
// FIXME: Helix does not fully comply with the LSP spec for line terminators.
// The LSP standard requires that line terminators are ['\n', '\r\n', '\r'].
// Without the unicode-linebreak feature disabled, the `\r` terminator is not handled by helix.
// With the unicode-linebreak feature, helix recognizes multiple extra line break chars
// which means that positions will be decoded/encoded incorrectly in their presence
let line = match offset_encoding {
OffsetEncoding::Utf8 => { OffsetEncoding::Utf8 => {
let line = doc.line_to_char(pos_line); let line_start = doc.line_to_byte(pos_line);
let pos = line.checked_add(pos.character as usize)?; let line_end = line_end_byte_index(&doc.slice(..), pos_line);
if pos <= doc.len_chars() { line_start..line_end
Some(pos)
} else {
None
}
} }
OffsetEncoding::Utf16 => { OffsetEncoding::Utf16 => {
let line = doc.line_to_char(pos_line); // TODO directly translate line index to char-idx
let line_start = doc.char_to_utf16_cu(line); // ropey can do this just as easily as utf-8 byte translation
let pos = line_start.checked_add(pos.character as usize)?; // but the functions are just missing.
doc.try_utf16_cu_to_char(pos).ok() // Translate to char first and then utf-16 as a workaround
let line_start = doc.line_to_char(pos_line);
let line_end = line_end_char_index(&doc.slice(..), pos_line);
doc.char_to_utf16_cu(line_start)..doc.char_to_utf16_cu(line_end)
}
OffsetEncoding::Utf32 => {
let line_start = doc.line_to_char(pos_line);
let line_end = line_end_char_index(&doc.slice(..), pos_line);
line_start..line_end
} }
};
// The LSP spec demands that the offset is capped to the end of the line
let pos = line
.start
.checked_add(pos.character as usize)
.unwrap_or(line.end)
.min(line.end);
match offset_encoding {
OffsetEncoding::Utf8 => doc.try_byte_to_char(pos).ok(),
OffsetEncoding::Utf16 => doc.try_utf16_cu_to_char(pos).ok(),
OffsetEncoding::Utf32 => Some(pos),
} }
} }
@ -141,8 +200,8 @@ pub mod util {
match offset_encoding { match offset_encoding {
OffsetEncoding::Utf8 => { OffsetEncoding::Utf8 => {
let line = doc.char_to_line(pos); let line = doc.char_to_line(pos);
let line_start = doc.line_to_char(line); let line_start = doc.line_to_byte(line);
let col = pos - line_start; let col = doc.char_to_byte(pos) - line_start;
lsp::Position::new(line as u32, col as u32) lsp::Position::new(line as u32, col as u32)
} }
@ -151,6 +210,13 @@ pub mod util {
let line_start = doc.char_to_utf16_cu(doc.line_to_char(line)); let line_start = doc.char_to_utf16_cu(doc.line_to_char(line));
let col = doc.char_to_utf16_cu(pos) - line_start; let col = doc.char_to_utf16_cu(pos) - line_start;
lsp::Position::new(line as u32, col as u32)
}
OffsetEncoding::Utf32 => {
let line = doc.char_to_line(pos);
let line_start = doc.line_to_char(line);
let col = pos - line_start;
lsp::Position::new(line as u32, col as u32) lsp::Position::new(line as u32, col as u32)
} }
} }
@ -179,6 +245,42 @@ pub mod util {
Some(Range::new(start, end)) Some(Range::new(start, end))
} }
/// Creates a [Transaction] from the [lsp::TextEdit] in a completion response.
/// The transaction applies the edit to all cursors.
pub fn generate_transaction_from_completion_edit(
doc: &Rope,
selection: &Selection,
edit: lsp::TextEdit,
offset_encoding: OffsetEncoding,
) -> Transaction {
let replacement: Option<Tendril> = if edit.new_text.is_empty() {
None
} else {
Some(edit.new_text.into())
};
let text = doc.slice(..);
let primary_cursor = selection.primary().cursor(text);
let start_offset = match lsp_pos_to_pos(doc, edit.range.start, offset_encoding) {
Some(start) => start as i128 - primary_cursor as i128,
None => return Transaction::new(doc),
};
let end_offset = match lsp_pos_to_pos(doc, edit.range.end, offset_encoding) {
Some(end) => end as i128 - primary_cursor as i128,
None => return Transaction::new(doc),
};
Transaction::change_by_selection(doc, selection, |range| {
let cursor = range.cursor(text);
(
(cursor as i128 + start_offset) as usize,
(cursor as i128 + end_offset) as usize,
replacement.clone(),
)
})
}
pub fn generate_transaction_from_edits( pub fn generate_transaction_from_edits(
doc: &Rope, doc: &Rope,
mut edits: Vec<lsp::TextEdit>, mut edits: Vec<lsp::TextEdit>,
@ -188,6 +290,20 @@ pub mod util {
// in reverse order. // in reverse order.
edits.sort_unstable_by_key(|edit| edit.range.start); edits.sort_unstable_by_key(|edit| edit.range.start);
// Generate a diff if the edit is a full document replacement.
#[allow(clippy::collapsible_if)]
if edits.len() == 1 {
let is_document_replacement = edits.first().and_then(|edit| {
let start = lsp_pos_to_pos(doc, edit.range.start, offset_encoding)?;
let end = lsp_pos_to_pos(doc, edit.range.end, offset_encoding)?;
Some(start..end)
}) == Some(0..doc.len_chars());
if is_document_replacement {
let new_text = Rope::from(edits.pop().unwrap().new_text);
return helix_core::diff::compare_ropes(doc, &new_text);
}
}
Transaction::change( Transaction::change(
doc, doc,
edits.into_iter().map(|edit| { edits.into_iter().map(|edit| {
@ -252,6 +368,8 @@ impl MethodCall {
pub enum Notification { pub enum Notification {
// we inject this notification to signal the LSP is ready // we inject this notification to signal the LSP is ready
Initialized, Initialized,
// and this notification to signal that the LSP exited
Exit,
PublishDiagnostics(lsp::PublishDiagnosticsParams), PublishDiagnostics(lsp::PublishDiagnosticsParams),
ShowMessage(lsp::ShowMessageParams), ShowMessage(lsp::ShowMessageParams),
LogMessage(lsp::LogMessageParams), LogMessage(lsp::LogMessageParams),
@ -264,6 +382,7 @@ impl Notification {
let notification = match method { let notification = match method {
lsp::notification::Initialized::METHOD => Self::Initialized, lsp::notification::Initialized::METHOD => Self::Initialized,
lsp::notification::Exit::METHOD => Self::Exit,
lsp::notification::PublishDiagnostics::METHOD => { lsp::notification::PublishDiagnostics::METHOD => {
let params: lsp::PublishDiagnosticsParams = params.parse()?; let params: lsp::PublishDiagnosticsParams = params.parse()?;
Self::PublishDiagnostics(params) Self::PublishDiagnostics(params)
@ -320,57 +439,65 @@ impl Registry {
.map(|(_, client)| client.as_ref()) .map(|(_, client)| client.as_ref())
} }
pub fn get(&mut self, language_config: &LanguageConfiguration) -> Result<Arc<Client>> { pub fn remove_by_id(&mut self, id: usize) {
self.inner.retain(|_, (client_id, _)| client_id != &id)
}
pub fn restart(
&mut self,
language_config: &LanguageConfiguration,
doc_path: Option<&std::path::PathBuf>,
) -> Result<Option<Arc<Client>>> {
let config = match &language_config.language_server { let config = match &language_config.language_server {
Some(config) => config, Some(config) => config,
None => return Err(Error::LspNotDefined), None => return Ok(None),
}; };
match self.inner.entry(language_config.scope.clone()) { let scope = language_config.scope.clone();
Entry::Occupied(entry) => Ok(entry.get().1.clone()),
Entry::Vacant(entry) => { match self.inner.entry(scope) {
Entry::Vacant(_) => Ok(None),
Entry::Occupied(mut entry) => {
// initialize a new client // initialize a new client
let id = self.counter.fetch_add(1, Ordering::Relaxed); let id = self.counter.fetch_add(1, Ordering::Relaxed);
let (client, incoming, initialize_notify) = Client::start(
&config.command, let NewClientResult(client, incoming) =
&config.args, start_client(id, language_config, config, doc_path)?;
language_config.config.clone(),
&language_config.roots,
id,
config.timeout,
)?;
self.incoming.push(UnboundedReceiverStream::new(incoming)); self.incoming.push(UnboundedReceiverStream::new(incoming));
let client = Arc::new(client);
// Initialize the client asynchronously let (_, old_client) = entry.insert((id, client.clone()));
let _client = client.clone();
tokio::spawn(async move { tokio::spawn(async move {
use futures_util::TryFutureExt; let _ = old_client.force_shutdown().await;
let value = _client
.capabilities
.get_or_try_init(|| {
_client
.initialize()
.map_ok(|response| response.capabilities)
})
.await;
if let Err(e) = value {
log::error!("failed to initialize language server: {}", e);
return;
}
// next up, notify<initialized>
_client
.notify::<lsp::notification::Initialized>(lsp::InitializedParams {})
.await
.unwrap();
initialize_notify.notify_one();
}); });
Ok(Some(client))
}
}
}
pub fn get(
&mut self,
language_config: &LanguageConfiguration,
doc_path: Option<&std::path::PathBuf>,
) -> Result<Option<Arc<Client>>> {
let config = match &language_config.language_server {
Some(config) => config,
None => return Ok(None),
};
match self.inner.entry(language_config.scope.clone()) {
Entry::Occupied(entry) => Ok(Some(entry.get().1.clone())),
Entry::Vacant(entry) => {
// initialize a new client
let id = self.counter.fetch_add(1, Ordering::Relaxed);
let NewClientResult(client, incoming) =
start_client(id, language_config, config, doc_path)?;
self.incoming.push(UnboundedReceiverStream::new(incoming));
entry.insert((id, client.clone())); entry.insert((id, client.clone()));
Ok(client) Ok(Some(client))
} }
} }
} }
@ -458,6 +585,59 @@ impl LspProgressMap {
} }
} }
struct NewClientResult(Arc<Client>, UnboundedReceiver<(usize, Call)>);
/// start_client takes both a LanguageConfiguration and a LanguageServerConfiguration to ensure that
/// it is only called when it makes sense.
fn start_client(
id: usize,
config: &LanguageConfiguration,
ls_config: &LanguageServerConfiguration,
doc_path: Option<&std::path::PathBuf>,
) -> Result<NewClientResult> {
let (client, incoming, initialize_notify) = Client::start(
&ls_config.command,
&ls_config.args,
config.config.clone(),
ls_config.environment.clone(),
&config.roots,
id,
ls_config.timeout,
doc_path,
)?;
let client = Arc::new(client);
// Initialize the client asynchronously
let _client = client.clone();
tokio::spawn(async move {
use futures_util::TryFutureExt;
let value = _client
.capabilities
.get_or_try_init(|| {
_client
.initialize()
.map_ok(|response| response.capabilities)
})
.await;
if let Err(e) = value {
log::error!("failed to initialize language server: {}", e);
return;
}
// next up, notify<initialized>
_client
.notify::<lsp::notification::Initialized>(lsp::InitializedParams {})
.await
.unwrap();
initialize_notify.notify_one();
});
Ok(NewClientResult(client, incoming))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{lsp, util::*, OffsetEncoding}; use super::{lsp, util::*, OffsetEncoding};
@ -475,16 +655,55 @@ mod tests {
} }
test_case!("", (0, 0) => Some(0)); test_case!("", (0, 0) => Some(0));
test_case!("", (0, 1) => None); test_case!("", (0, 1) => Some(0));
test_case!("", (1, 0) => None); test_case!("", (1, 0) => None);
test_case!("\n\n", (0, 0) => Some(0)); test_case!("\n\n", (0, 0) => Some(0));
test_case!("\n\n", (1, 0) => Some(1)); test_case!("\n\n", (1, 0) => Some(1));
test_case!("\n\n", (1, 1) => Some(2)); test_case!("\n\n", (1, 1) => Some(1));
test_case!("\n\n", (2, 0) => Some(2)); test_case!("\n\n", (2, 0) => Some(2));
test_case!("\n\n", (3, 0) => None); test_case!("\n\n", (3, 0) => None);
test_case!("test\n\n\n\ncase", (4, 3) => Some(11)); test_case!("test\n\n\n\ncase", (4, 3) => Some(11));
test_case!("test\n\n\n\ncase", (4, 4) => Some(12)); test_case!("test\n\n\n\ncase", (4, 4) => Some(12));
test_case!("test\n\n\n\ncase", (4, 5) => None); test_case!("test\n\n\n\ncase", (4, 5) => Some(12));
test_case!("", (u32::MAX, u32::MAX) => None); test_case!("", (u32::MAX, u32::MAX) => None);
} }
#[test]
fn emoji_format_gh_4791() {
use lsp_types::{Position, Range, TextEdit};
let edits = vec![
TextEdit {
range: Range {
start: Position {
line: 0,
character: 1,
},
end: Position {
line: 1,
character: 0,
},
},
new_text: "\n ".to_string(),
},
TextEdit {
range: Range {
start: Position {
line: 1,
character: 7,
},
end: Position {
line: 2,
character: 0,
},
},
new_text: "\n ".to_string(),
},
];
let mut source = Rope::from_str("[\n\"🇺🇸\",\n\"🎄\",\n]");
let transaction = generate_transaction_from_edits(&source, edits, OffsetEncoding::Utf8);
assert!(transaction.apply(&mut source));
}
} }

@ -250,6 +250,36 @@ impl Transport {
} }
}; };
} }
Err(Error::StreamClosed) => {
// Close any outstanding requests.
for (id, tx) in transport.pending_requests.lock().await.drain() {
match tx.send(Err(Error::StreamClosed)).await {
Ok(_) => (),
Err(_) => {
error!("Could not close request on a closed channel (id={:?})", id)
}
}
}
// Hack: inject a terminated notification so we trigger code that needs to happen after exit
use lsp_types::notification::Notification as _;
let notification =
ServerMessage::Call(jsonrpc::Call::Notification(jsonrpc::Notification {
jsonrpc: None,
method: lsp_types::notification::Exit::METHOD.to_string(),
params: jsonrpc::Params::None,
}));
match transport
.process_server_message(&client_tx, notification)
.await
{
Ok(_) => {}
Err(err) => {
error!("err: <- {:?}", err);
}
}
break;
}
Err(err) => { Err(err) => {
error!("err: <- {:?}", err); error!("err: <- {:?}", err);
break; break;

@ -17,8 +17,10 @@ build = true
app = true app = true
[features] [features]
default = ["git"]
unicode-lines = ["helix-core/unicode-lines"] unicode-lines = ["helix-core/unicode-lines"]
integration = [] integration = []
git = ["helix-vcs/git"]
[[bin]] [[bin]]
name = "hx" name = "hx"
@ -29,12 +31,13 @@ helix-core = { version = "0.6", path = "../helix-core" }
helix-view = { version = "0.6", path = "../helix-view" } helix-view = { version = "0.6", path = "../helix-view" }
helix-lsp = { version = "0.6", path = "../helix-lsp" } helix-lsp = { version = "0.6", path = "../helix-lsp" }
helix-dap = { version = "0.6", path = "../helix-dap" } helix-dap = { version = "0.6", path = "../helix-dap" }
helix-vcs = { version = "0.6", path = "../helix-vcs" }
helix-loader = { version = "0.6", path = "../helix-loader" } helix-loader = { version = "0.6", path = "../helix-loader" }
anyhow = "1" anyhow = "1"
once_cell = "1.13" once_cell = "1.17"
which = "4.2" which = "4.4"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] }
tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] } tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] }
@ -42,7 +45,7 @@ crossterm = { version = "0.25", features = ["event-stream"] }
signal-hook = "0.3" signal-hook = "0.3"
tokio-stream = "0.1" tokio-stream = "0.1"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false } futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
arc-swap = { version = "1.5.1" } arc-swap = { version = "1.6.0" }
# Logging # Logging
fern = "0.6" fern = "0.6"
@ -58,7 +61,7 @@ pulldown-cmark = { version = "0.9", default-features = false }
content_inspector = "0.2.4" content_inspector = "0.2.4"
# config # config
toml = "0.5" toml = "0.7"
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@ -67,9 +70,6 @@ serde = { version = "1.0", features = ["derive"] }
grep-regex = "0.1.10" grep-regex = "0.1.10"
grep-searcher = "0.1.10" grep-searcher = "0.1.10"
# Remove once retain_mut lands in stable rust
retain_mut = "0.1.7"
[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 [target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
@ -77,6 +77,6 @@ signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
helix-loader = { version = "0.6", path = "../helix-loader" } helix-loader = { version = "0.6", path = "../helix-loader" }
[dev-dependencies] [dev-dependencies]
smallvec = "1.9" smallvec = "1.10"
indoc = "1.0.6" indoc = "2.0.0"
tempfile = "3.3.0" tempfile = "3.3.0"

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

@ -1,13 +1,22 @@
use arc_swap::{access::Map, ArcSwap}; use arc_swap::{access::Map, ArcSwap};
use futures_util::Stream; use futures_util::Stream;
use helix_core::{ use helix_core::{
config::{default_syntax_loader, user_syntax_loader}, diagnostic::{DiagnosticTag, NumberOrString},
diagnostic::NumberOrString, path::get_relative_path,
pos_at_coords, syntax, Selection, pos_at_coords, syntax, Selection,
}; };
use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap};
use helix_view::{align_view, editor::ConfigEvent, theme, tree::Layout, Align, Editor}; use helix_view::{
align_view,
document::DocumentSavedEventResult,
editor::{ConfigEvent, EditorEvent},
graphics::Rect,
theme,
tree::Layout,
Align, Editor,
};
use serde_json::json; use serde_json::json;
use tui::backend::Backend;
use crate::{ use crate::{
args::Args, args::Args,
@ -19,7 +28,7 @@ use crate::{
ui::{self, overlay::overlayed}, ui::{self, overlay::overlayed},
}; };
use log::{error, warn}; use log::{debug, error, warn};
use std::{ use std::{
io::{stdin, stdout, Write}, io::{stdin, stdout, Write},
sync::Arc, sync::Arc,
@ -29,7 +38,10 @@ use std::{
use anyhow::{Context, Error}; use anyhow::{Context, Error};
use crossterm::{ use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent}, event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, Event as CrosstermEvent,
},
execute, terminal, execute, terminal,
tty::IsTty, tty::IsTty,
}; };
@ -43,8 +55,21 @@ type Signals = futures_util::stream::Empty<()>;
const LSP_DEADLINE: Duration = Duration::from_millis(16); const LSP_DEADLINE: Duration = Duration::from_millis(16);
#[cfg(not(feature = "integration"))]
use tui::backend::CrosstermBackend;
#[cfg(feature = "integration")]
use tui::backend::TestBackend;
#[cfg(not(feature = "integration"))]
type Terminal = tui::terminal::Terminal<CrosstermBackend<std::io::Stdout>>;
#[cfg(feature = "integration")]
type Terminal = tui::terminal::Terminal<TestBackend>;
pub struct Application { pub struct Application {
compositor: Compositor, compositor: Compositor,
terminal: Terminal,
pub editor: Editor, pub editor: Editor,
config: Arc<ArcSwap<Config>>, config: Arc<ArcSwap<Config>>,
@ -82,8 +107,29 @@ fn setup_integration_logging() {
.apply(); .apply();
} }
fn restore_term() -> Result<(), Error> {
let mut stdout = stdout();
// reset cursor shape
write!(stdout, "\x1B[0 q")?;
// Ignore errors on disabling, this might trigger on windows if we call
// disable without calling enable previously
let _ = execute!(stdout, DisableMouseCapture);
execute!(
stdout,
DisableBracketedPaste,
DisableFocusChange,
terminal::LeaveAlternateScreen
)?;
terminal::disable_raw_mode()?;
Ok(())
}
impl Application { impl Application {
pub fn new(args: Args, config: Config) -> Result<Self, Error> { pub fn new(
args: Args,
config: Config,
syn_loader_conf: syntax::Configuration,
) -> Result<Self, Error> {
#[cfg(feature = "integration")] #[cfg(feature = "integration")]
setup_integration_logging(); setup_integration_logging();
@ -110,23 +156,23 @@ impl Application {
}) })
.unwrap_or_else(|| theme_loader.default_theme(true_color)); .unwrap_or_else(|| theme_loader.default_theme(true_color));
let syn_loader_conf = user_syntax_loader().unwrap_or_else(|err| {
eprintln!("Bad language config: {}", err);
eprintln!("Press <ENTER> to continue with default language config");
use std::io::Read;
// This waits for an enter press.
let _ = std::io::stdin().read(&mut []);
default_syntax_loader()
});
let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf));
let mut compositor = Compositor::new().context("build compositor")?; #[cfg(not(feature = "integration"))]
let backend = CrosstermBackend::new(stdout());
#[cfg(feature = "integration")]
let backend = TestBackend::new(120, 150);
let terminal = Terminal::new(backend)?;
let area = terminal.size().expect("couldn't get terminal size");
let mut compositor = Compositor::new(area);
let config = Arc::new(ArcSwap::from_pointee(config)); let config = Arc::new(ArcSwap::from_pointee(config));
let mut editor = Editor::new( let mut editor = Editor::new(
compositor.size(), area,
theme_loader.clone(), theme_loader.clone(),
syn_loader.clone(), syn_loader.clone(),
Box::new(Map::new(Arc::clone(&config), |config: &Config| { Arc::new(Map::new(Arc::clone(&config), |config: &Config| {
&config.editor &config.editor
})), })),
); );
@ -138,14 +184,14 @@ impl Application {
compositor.push(editor_view); compositor.push(editor_view);
if args.load_tutor { if args.load_tutor {
let path = helix_loader::runtime_dir().join("tutor.txt"); let path = helix_loader::runtime_dir().join("tutor");
editor.open(&path, Action::VerticalSplit)?; editor.open(&path, Action::VerticalSplit)?;
// Unset path to prevent accidentally saving to the original tutor file. // Unset path to prevent accidentally saving to the original tutor file.
doc_mut!(editor).set_path(None)?; doc_mut!(editor).set_path(None)?;
} else if !args.files.is_empty() { } else if !args.files.is_empty() {
let first = &args.files[0].0; // we know it's not empty let first = &args.files[0].0; // we know it's not empty
if first.is_dir() { if first.is_dir() {
std::env::set_current_dir(&first).context("set current dir")?; std::env::set_current_dir(first).context("set current dir")?;
editor.new_file(Action::VerticalSplit); editor.new_file(Action::VerticalSplit);
let picker = ui::file_picker(".".into(), &config.load().editor); let picker = ui::file_picker(".".into(), &config.load().editor);
compositor.push(Box::new(overlayed(picker))); compositor.push(Box::new(overlayed(picker)));
@ -176,12 +222,16 @@ impl Application {
// `--vsplit` or `--hsplit` are used, the file which is // `--vsplit` or `--hsplit` are used, the file which is
// opened last is focused on. // opened last is focused on.
let view_id = editor.tree.focus; let view_id = editor.tree.focus;
let doc = editor.document_mut(doc_id).unwrap(); let doc = doc_mut!(editor, &doc_id);
let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true));
doc.set_selection(view_id, pos); doc.set_selection(view_id, pos);
} }
} }
editor.set_status(format!("Loaded {} files.", nr_of_files)); editor.set_status(format!(
"Loaded {} file{}.",
nr_of_files,
if nr_of_files == 1 { "" } else { "s" } // avoid "Loaded 1 files." grammo
));
// align the view to center after all files are loaded, // align the view to center after all files are loaded,
// does not affect views without pos since it is at the top // does not affect views without pos since it is at the top
let (view, doc) = current!(editor); let (view, doc) = current!(editor);
@ -205,11 +255,12 @@ impl Application {
#[cfg(windows)] #[cfg(windows)]
let signals = futures_util::stream::empty(); let signals = futures_util::stream::empty();
#[cfg(not(windows))] #[cfg(not(windows))]
let signals = let signals = Signals::new([signal::SIGTSTP, signal::SIGCONT, signal::SIGUSR1])
Signals::new(&[signal::SIGTSTP, signal::SIGCONT]).context("build signal handler")?; .context("build signal handler")?;
let app = Self { let app = Self {
compositor, compositor,
terminal,
editor, editor,
config, config,
@ -226,23 +277,47 @@ impl Application {
Ok(app) Ok(app)
} }
fn render(&mut self) { async fn render(&mut self) {
let compositor = &mut self.compositor;
let mut cx = crate::compositor::Context { let mut cx = crate::compositor::Context {
editor: &mut self.editor, editor: &mut self.editor,
jobs: &mut self.jobs, jobs: &mut self.jobs,
scroll: None, scroll: None,
}; };
compositor.render(&mut cx); // Acquire mutable access to the redraw_handle lock
// to ensure that there are no tasks running that want to block rendering
drop(cx.editor.redraw_handle.1.write().await);
cx.editor.needs_redraw = false;
{
// exhaust any leftover redraw notifications
let notify = cx.editor.redraw_handle.0.notified();
tokio::pin!(notify);
notify.enable();
}
let area = self
.terminal
.autoresize()
.expect("Unable to determine terminal size");
// TODO: need to recalculate view tree if necessary
let surface = self.terminal.current_buffer_mut();
self.compositor.render(area, surface, &mut cx);
let (pos, kind) = self.compositor.cursor(area, &self.editor);
// reset cursor cache
self.editor.cursor_cache.set(None);
let pos = pos.map(|pos| (pos.col as u16, pos.row as u16));
self.terminal.draw(pos, kind).unwrap();
} }
pub async fn event_loop<S>(&mut self, input_stream: &mut S) pub async fn event_loop<S>(&mut self, input_stream: &mut S)
where where
S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin,
{ {
self.render(); self.render().await;
self.last_render = Instant::now(); self.last_render = Instant::now();
loop { loop {
@ -256,9 +331,6 @@ impl Application {
where where
S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin,
{ {
#[cfg(feature = "integration")]
let mut idle_handled = false;
loop { loop {
if self.editor.should_close() { if self.editor.should_close() {
return false; return false;
@ -270,59 +342,35 @@ impl Application {
biased; biased;
Some(event) = input_stream.next() => { Some(event) = input_stream.next() => {
self.handle_terminal_events(event); self.handle_terminal_events(event).await;
} }
Some(signal) = self.signals.next() => { Some(signal) = self.signals.next() => {
self.handle_signals(signal).await; self.handle_signals(signal).await;
} }
Some((id, call)) = self.editor.language_servers.incoming.next() => {
self.handle_language_server_message(call, id).await;
// limit render calls for fast language server messages
let last = self.editor.language_servers.incoming.is_empty();
if last || self.last_render.elapsed() > LSP_DEADLINE {
self.render();
self.last_render = Instant::now();
}
}
Some(payload) = self.editor.debugger_events.next() => {
let needs_render = self.editor.handle_debugger_message(payload).await;
if needs_render {
self.render();
}
}
Some(config_event) = self.editor.config_events.1.recv() => {
self.handle_config_events(config_event);
self.render();
}
Some(callback) = self.jobs.futures.next() => { Some(callback) = self.jobs.futures.next() => {
self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback);
self.render(); self.render().await;
} }
Some(callback) = self.jobs.wait_futures.next() => { Some(callback) = self.jobs.wait_futures.next() => {
self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback);
self.render(); self.render().await;
} }
_ = &mut self.editor.idle_timer => { event = self.editor.wait_event() => {
// idle timeout let _idle_handled = self.handle_editor_event(event).await;
self.editor.clear_idle_timer();
self.handle_idle_timeout();
#[cfg(feature = "integration")] #[cfg(feature = "integration")]
{ {
idle_handled = true; if _idle_handled {
return true;
}
} }
} }
} }
// for integration tests only, reset the idle timer after every // for integration tests only, reset the idle timer after every
// event to make a signal when test events are done processing // event to signal when test events are done processing
#[cfg(feature = "integration")] #[cfg(feature = "integration")]
{ {
if idle_handled {
return true;
}
self.editor.reset_idle_timer(); self.editor.reset_idle_timer();
} }
} }
@ -345,31 +393,67 @@ impl Application {
// Update all the relevant members in the editor after updating // Update all the relevant members in the editor after updating
// the configuration. // the configuration.
self.editor.refresh_config(); self.editor.refresh_config();
// reset view position in case softwrap was enabled/disabled
let scrolloff = self.editor.config().scrolloff;
for (view, _) in self.editor.tree.views_mut() {
let doc = &self.editor.documents[&view.doc];
view.ensure_cursor_in_view(doc, scrolloff)
}
} }
fn refresh_config(&mut self) { /// refresh language config after config change
let config = Config::load_default().unwrap_or_else(|err| { fn refresh_language_config(&mut self) -> Result<(), Error> {
self.editor.set_error(err.to_string()); let syntax_config = helix_core::config::user_syntax_loader()
Config::default() .map_err(|err| anyhow::anyhow!("Failed to load language config: {}", err))?;
});
// Refresh theme self.syn_loader = std::sync::Arc::new(syntax::Loader::new(syntax_config));
self.editor.syn_loader = self.syn_loader.clone();
for document in self.editor.documents.values_mut() {
document.detect_language(self.syn_loader.clone());
}
Ok(())
}
/// Refresh theme after config change
fn refresh_theme(&mut self, config: &Config) -> Result<(), Error> {
if let Some(theme) = config.theme.clone() { if let Some(theme) = config.theme.clone() {
let true_color = self.true_color(); let true_color = self.true_color();
self.editor.set_theme( let theme = self
self.theme_loader .theme_loader
.load(&theme) .load(&theme)
.map_err(|e| { .map_err(|err| anyhow::anyhow!("Failed to load theme `{}`: {}", theme, err))?;
log::warn!("failed to load theme `{}` - {}", theme, e);
e if true_color || theme.is_16_color() {
}) self.editor.set_theme(theme);
.ok() } else {
.filter(|theme| (true_color || theme.is_16_color())) anyhow::bail!("theme requires truecolor support, which is not available")
.unwrap_or_else(|| self.theme_loader.default_theme(true_color)), }
);
} }
self.config.store(Arc::new(config)); Ok(())
}
fn refresh_config(&mut self) {
let mut refresh_config = || -> Result<(), Error> {
let default_config = Config::load_default()
.map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?;
self.refresh_language_config()?;
self.refresh_theme(&default_config)?;
// Store new config
self.config.store(Arc::new(default_config));
Ok(())
};
match refresh_config() {
Ok(_) => {
self.editor.set_status("Config refreshed");
}
Err(err) => {
self.editor.set_error(err.to_string());
}
}
} }
fn true_color(&self) -> bool { fn true_color(&self) -> bool {
@ -382,61 +466,179 @@ impl Application {
#[cfg(not(windows))] #[cfg(not(windows))]
pub async fn handle_signals(&mut self, signal: i32) { pub async fn handle_signals(&mut self, signal: i32) {
use helix_view::graphics::Rect;
match signal { match signal {
signal::SIGTSTP => { signal::SIGTSTP => {
self.compositor.save_cursor(); // restore cursor
self.restore_term().unwrap(); 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(); low_level::emulate_default_handler(signal::SIGTSTP).unwrap();
} }
signal::SIGCONT => { signal::SIGCONT => {
self.claim_term().await.unwrap(); self.claim_term().await.unwrap();
// redraw the terminal // redraw the terminal
let Rect { width, height, .. } = self.compositor.size(); let area = self.terminal.size().expect("couldn't get terminal size");
self.compositor.resize(width, height); self.compositor.resize(area);
self.compositor.load_cursor(); self.terminal.clear().expect("couldn't clear terminal");
self.render();
self.render().await;
}
signal::SIGUSR1 => {
self.refresh_config();
self.render().await;
} }
_ => unreachable!(), _ => unreachable!(),
} }
} }
pub fn handle_idle_timeout(&mut self) { pub async fn handle_idle_timeout(&mut self) {
use crate::compositor::EventResult;
let editor_view = self
.compositor
.find::<ui::EditorView>()
.expect("expected at least one EditorView");
let mut cx = crate::compositor::Context { let mut cx = crate::compositor::Context {
editor: &mut self.editor, editor: &mut self.editor,
jobs: &mut self.jobs, jobs: &mut self.jobs,
scroll: None, scroll: None,
}; };
if let EventResult::Consumed(_) = editor_view.handle_idle_timeout(&mut cx) { let should_render = self.compositor.handle_event(&Event::IdleTimeout, &mut cx);
self.render(); if should_render || self.editor.needs_redraw {
self.render().await;
}
}
pub fn handle_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
));
} }
pub fn handle_terminal_events(&mut self, event: Result<CrosstermEvent, crossterm::ErrorKind>) { #[inline(always)]
pub async fn handle_editor_event(&mut self, event: EditorEvent) -> bool {
log::debug!("received editor event: {:?}", event);
match event {
EditorEvent::DocumentSaved(event) => {
self.handle_document_write(event);
self.render().await;
}
EditorEvent::ConfigEvent(event) => {
self.handle_config_events(event);
self.render().await;
}
EditorEvent::LanguageServerMessage((id, call)) => {
self.handle_language_server_message(call, id).await;
// limit render calls for fast language server messages
let last = self.editor.language_servers.incoming.is_empty();
if last || self.last_render.elapsed() > LSP_DEADLINE {
self.render().await;
self.last_render = Instant::now();
}
}
EditorEvent::DebuggerEvent(payload) => {
let needs_render = self.editor.handle_debugger_message(payload).await;
if needs_render {
self.render().await;
}
}
EditorEvent::IdleTimer => {
self.editor.clear_idle_timer();
self.handle_idle_timeout().await;
#[cfg(feature = "integration")]
{
return true;
}
}
}
false
}
pub async fn handle_terminal_events(
&mut self,
event: Result<CrosstermEvent, crossterm::ErrorKind>,
) {
let mut cx = crate::compositor::Context { let mut cx = crate::compositor::Context {
editor: &mut self.editor, editor: &mut self.editor,
jobs: &mut self.jobs, jobs: &mut self.jobs,
scroll: None, scroll: None,
}; };
// Handle key events // Handle key events
let should_redraw = match event { let should_redraw = match event.unwrap() {
Ok(CrosstermEvent::Resize(width, height)) => { CrosstermEvent::Resize(width, height) => {
self.compositor.resize(width, height); self.terminal
.resize(Rect::new(0, 0, width, height))
.expect("Unable to resize terminal");
let area = self.terminal.size().expect("couldn't get terminal size");
self.compositor.resize(area);
self.compositor self.compositor
.handle_event(Event::Resize(width, height), &mut cx) .handle_event(&Event::Resize(width, height), &mut cx)
} }
Ok(event) => self.compositor.handle_event(event.into(), &mut cx), event => self.compositor.handle_event(&event.into(), &mut cx),
Err(x) => panic!("{}", x),
}; };
if should_redraw && !self.editor.should_close() { if should_redraw && !self.editor.should_close() {
self.render(); self.render().await;
} }
} }
@ -484,11 +686,16 @@ impl Application {
// trigger textDocument/didOpen for docs that are already open // trigger textDocument/didOpen for docs that are already open
for doc in docs { for doc in docs {
let url = match doc.url() {
Some(url) => url,
None => continue, // skip documents with no path
};
let language_id = let language_id =
doc.language_id().map(ToOwned::to_owned).unwrap_or_default(); doc.language_id().map(ToOwned::to_owned).unwrap_or_default();
tokio::spawn(language_server.text_document_did_open( tokio::spawn(language_server.text_document_did_open(
doc.url().unwrap(), url,
doc.version(), doc.version(),
doc.text(), doc.text(),
language_id, language_id,
@ -510,7 +717,12 @@ impl Application {
use helix_core::diagnostic::{Diagnostic, Range, Severity::*}; use helix_core::diagnostic::{Diagnostic, Range, Severity::*};
use lsp::DiagnosticSeverity; use lsp::DiagnosticSeverity;
let language_server = doc.language_server().unwrap(); let language_server = if let Some(language_server) = doc.language_server() {
language_server
} else {
log::warn!("Discarding diagnostic because language server is not initialized: {:?}", diagnostic);
return None;
};
// TODO: convert inside server // TODO: convert inside server
let start = if let Some(start) = lsp_pos_to_pos( let start = if let Some(start) = lsp_pos_to_pos(
@ -567,13 +779,29 @@ impl Application {
None => None, None => None,
}; };
let tags = if let Some(ref tags) = diagnostic.tags {
let new_tags = tags.iter().filter_map(|tag| {
match *tag {
lsp::DiagnosticTag::DEPRECATED => Some(DiagnosticTag::Deprecated),
lsp::DiagnosticTag::UNNECESSARY => Some(DiagnosticTag::Unnecessary),
_ => None
}
}).collect();
new_tags
} else {
Vec::new()
};
Some(Diagnostic { Some(Diagnostic {
range: Range { start, end }, range: Range { start, end },
line: diagnostic.range.start.line as usize, line: diagnostic.range.start.line as usize,
message: diagnostic.message.clone(), message: diagnostic.message.clone(),
severity, severity,
code, code,
// source tags,
source: diagnostic.source.clone(),
data: diagnostic.data.clone(),
}) })
}) })
.collect(); .collect();
@ -686,6 +914,32 @@ impl Application {
Notification::ProgressMessage(_params) => { Notification::ProgressMessage(_params) => {
// do nothing // do nothing
} }
Notification::Exit => {
self.editor.set_status("Language server exited");
// Clear any diagnostics for documents with this server open.
let urls: Vec<_> = self
.editor
.documents_mut()
.filter_map(|doc| {
if doc.language_server().map(|server| server.id())
== Some(server_id)
{
doc.set_diagnostics(Vec::new());
doc.url()
} else {
None
}
})
.collect();
for url in urls {
self.editor.diagnostics.remove(&url);
}
// Remove the language server from the registry.
self.editor.language_servers.remove_by_id(server_id);
}
} }
} }
Call::MethodCall(helix_lsp::jsonrpc::MethodCall { Call::MethodCall(helix_lsp::jsonrpc::MethodCall {
@ -786,9 +1040,18 @@ impl Application {
} }
async fn claim_term(&mut self) -> Result<(), Error> { async fn claim_term(&mut self) -> Result<(), Error> {
use helix_view::graphics::CursorKind;
terminal::enable_raw_mode()?; terminal::enable_raw_mode()?;
if self.terminal.cursor_kind() == CursorKind::Hidden {
self.terminal.backend_mut().hide_cursor().ok();
}
let mut stdout = stdout(); let mut stdout = stdout();
execute!(stdout, terminal::EnterAlternateScreen)?; execute!(
stdout,
terminal::EnterAlternateScreen,
EnableBracketedPaste,
EnableFocusChange
)?;
execute!(stdout, terminal::Clear(terminal::ClearType::All))?; execute!(stdout, terminal::Clear(terminal::ClearType::All))?;
if self.config.load().editor.mouse { if self.config.load().editor.mouse {
execute!(stdout, EnableMouseCapture)?; execute!(stdout, EnableMouseCapture)?;
@ -796,18 +1059,6 @@ impl Application {
Ok(()) Ok(())
} }
fn restore_term(&mut self) -> Result<(), Error> {
let mut stdout = stdout();
// reset cursor shape
write!(stdout, "\x1B[0 q")?;
// Ignore errors on disabling, this might trigger on windows if we call
// disable without calling enable previously
let _ = execute!(stdout, DisableMouseCapture);
execute!(stdout, terminal::LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
pub async fn run<S>(&mut self, input_stream: &mut S) -> Result<i32, Error> pub async fn run<S>(&mut self, input_stream: &mut S) -> Result<i32, Error>
where where
S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin,
@ -819,27 +1070,58 @@ impl Application {
std::panic::set_hook(Box::new(move |info| { std::panic::set_hook(Box::new(move |info| {
// We can't handle errors properly inside this closure. And it's // We can't handle errors properly inside this closure. And it's
// probably not a good idea to `unwrap()` inside a panic handler. // probably not a good idea to `unwrap()` inside a panic handler.
// So we just ignore the `Result`s. // So we just ignore the `Result`.
let _ = execute!(std::io::stdout(), DisableMouseCapture); let _ = restore_term();
let _ = execute!(std::io::stdout(), terminal::LeaveAlternateScreen);
let _ = terminal::disable_raw_mode();
hook(info); hook(info);
})); }));
self.event_loop(input_stream).await; self.event_loop(input_stream).await;
self.close().await?;
self.restore_term()?; 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) Ok(self.editor.exit_code)
} }
pub async fn close(&mut self) -> anyhow::Result<()> { pub async fn close(&mut self) -> Vec<anyhow::Error> {
self.jobs.finish().await?; // [NOTE] we intentionally do not return early for errors because we
// want to try to run as much cleanup as we can, regardless of
// errors along the way
let mut errs = Vec::new();
if let Err(err) = self
.jobs
.finish(&mut self.editor, Some(&mut self.compositor))
.await
{
log::error!("Error executing job: {}", err);
errs.push(err);
};
if let Err(err) = self.editor.flush_writes().await {
log::error!("Error writing: {}", err);
errs.push(err);
}
if self.editor.close_language_servers(None).await.is_err() { if self.editor.close_language_servers(None).await.is_err() {
log::error!("Timed out waiting for language servers to shutdown"); log::error!("Timed out waiting for language servers to shutdown");
}; errs.push(anyhow::format_err!(
"Timed out waiting for language servers to shutdown"
));
}
Ok(()) errs
} }
} }

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

File diff suppressed because it is too large Load Diff

@ -12,7 +12,7 @@ use helix_view::editor::Breakpoint;
use serde_json::{to_value, Value}; use serde_json::{to_value, Value};
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
use tui::text::Spans; use tui::{text::Spans, widgets::Row};
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future; use std::future::Future;
@ -25,7 +25,7 @@ use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select
impl ui::menu::Item for StackFrame { impl ui::menu::Item for StackFrame {
type Data = (); type Data = ();
fn label(&self, _data: &Self::Data) -> Spans { fn format(&self, _data: &Self::Data) -> Row {
self.name.as_str().into() // TODO: include thread_states in the label self.name.as_str().into() // TODO: include thread_states in the label
} }
} }
@ -33,7 +33,7 @@ impl ui::menu::Item for StackFrame {
impl ui::menu::Item for DebugTemplate { impl ui::menu::Item for DebugTemplate {
type Data = (); type Data = ();
fn label(&self, _data: &Self::Data) -> Spans { fn format(&self, _data: &Self::Data) -> Row {
self.name.as_str().into() self.name.as_str().into()
} }
} }
@ -41,7 +41,7 @@ impl ui::menu::Item for DebugTemplate {
impl ui::menu::Item for Thread { impl ui::menu::Item for Thread {
type Data = ThreadStates; type Data = ThreadStates;
fn label(&self, thread_states: &Self::Data) -> Spans { fn format(&self, thread_states: &Self::Data) -> Row {
format!( format!(
"{} ({})", "{} ({})",
self.name, self.name,
@ -85,7 +85,7 @@ fn thread_picker(
frame.line.saturating_sub(1), frame.line.saturating_sub(1),
frame.end_line.unwrap_or(frame.line).saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1),
)); ));
Some((path, pos)) Some((path.into(), pos))
}, },
); );
compositor.push(Box::new(picker)); compositor.push(Box::new(picker));
@ -118,11 +118,14 @@ fn dap_callback<T, F>(
let callback = Box::pin(async move { let callback = Box::pin(async move {
let json = call.await?; let json = call.await?;
let response = serde_json::from_value(json)?; let response = serde_json::from_value(json)?;
let call: Callback = Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { let call: Callback = Callback::EditorCompositor(Box::new(
callback(editor, compositor, response) move |editor: &mut Editor, compositor: &mut Compositor| {
}); callback(editor, compositor, response)
},
));
Ok(call) Ok(call)
}); });
jobs.callback(callback); jobs.callback(callback);
} }
@ -274,10 +277,11 @@ pub fn dap_launch(cx: &mut Context) {
let completions = template.completion.clone(); let completions = template.completion.clone();
let name = template.name.clone(); let name = template.name.clone();
let callback = Box::pin(async move { let callback = Box::pin(async move {
let call: Callback = Box::new(move |_editor, compositor| { let call: Callback =
let prompt = debug_parameter_prompt(completions, name, Vec::new()); Callback::EditorCompositor(Box::new(move |_editor, compositor| {
compositor.push(Box::new(prompt)); let prompt = debug_parameter_prompt(completions, name, Vec::new());
}); compositor.push(Box::new(prompt));
}));
Ok(call) Ok(call)
}); });
cx.jobs.callback(callback); cx.jobs.callback(callback);
@ -332,10 +336,11 @@ fn debug_parameter_prompt(
let config_name = config_name.clone(); let config_name = config_name.clone();
let params = params.clone(); let params = params.clone();
let callback = Box::pin(async move { let callback = Box::pin(async move {
let call: Callback = Box::new(move |_editor, compositor| { let call: Callback =
let prompt = debug_parameter_prompt(completions, config_name, params); Callback::EditorCompositor(Box::new(move |_editor, compositor| {
compositor.push(Box::new(prompt)); let prompt = debug_parameter_prompt(completions, config_name, params);
}); compositor.push(Box::new(prompt));
}));
Ok(call) Ok(call)
}); });
cx.jobs.callback(callback); cx.jobs.callback(callback);
@ -582,7 +587,7 @@ pub fn dap_edit_condition(cx: &mut Context) {
None => return, None => return,
}; };
let callback = Box::pin(async move { let callback = Box::pin(async move {
let call: Callback = Box::new(move |_editor, compositor| { let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| {
let mut prompt = Prompt::new( let mut prompt = Prompt::new(
"condition:".into(), "condition:".into(),
None, None,
@ -607,10 +612,10 @@ pub fn dap_edit_condition(cx: &mut Context) {
}, },
); );
if let Some(condition) = breakpoint.condition { if let Some(condition) = breakpoint.condition {
prompt.insert_str(&condition) prompt.insert_str(&condition, editor)
} }
compositor.push(Box::new(prompt)); compositor.push(Box::new(prompt));
}); }));
Ok(call) Ok(call)
}); });
cx.jobs.callback(callback); cx.jobs.callback(callback);
@ -624,7 +629,7 @@ pub fn dap_edit_log(cx: &mut Context) {
None => return, None => return,
}; };
let callback = Box::pin(async move { let callback = Box::pin(async move {
let call: Callback = Box::new(move |_editor, compositor| { let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| {
let mut prompt = Prompt::new( let mut prompt = Prompt::new(
"log-message:".into(), "log-message:".into(),
None, None,
@ -648,10 +653,10 @@ pub fn dap_edit_log(cx: &mut Context) {
}, },
); );
if let Some(log_message) = breakpoint.log_message { if let Some(log_message) = breakpoint.log_message {
prompt.insert_str(&log_message); prompt.insert_str(&log_message, editor);
} }
compositor.push(Box::new(prompt)); compositor.push(Box::new(prompt));
}); }));
Ok(call) Ok(call)
}); });
cx.jobs.callback(callback); cx.jobs.callback(callback);
@ -701,7 +706,7 @@ pub fn dap_switch_stack_frame(cx: &mut Context) {
.and_then(|source| source.path.clone()) .and_then(|source| source.path.clone())
.map(|path| { .map(|path| {
( (
path, path.into(),
Some(( Some((
frame.line.saturating_sub(1), frame.line.saturating_sub(1),
frame.end_line.unwrap_or(frame.line).saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1),

@ -1,24 +1,34 @@
use futures_util::FutureExt;
use helix_lsp::{ use helix_lsp::{
block_on, block_on,
lsp::{self, DiagnosticSeverity, NumberOrString}, lsp::{
self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity,
NumberOrString,
},
util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range},
OffsetEncoding, OffsetEncoding,
}; };
use tui::text::{Span, Spans}; use tui::{
text::{Span, Spans},
widgets::Row,
};
use super::{align_view, push_jump, Align, Context, Editor, Open}; use super::{align_view, push_jump, Align, Context, Editor, Open};
use helix_core::{path, Selection}; use helix_core::{path, Selection};
use helix_view::{editor::Action, theme::Style}; use helix_view::{document::Mode, editor::Action, theme::Style};
use crate::{ use crate::{
compositor::{self, Compositor}, compositor::{self, Compositor},
ui::{ ui::{
self, lsp::SignatureHelp, overlay::overlayed, FileLocation, FilePicker, Popup, PromptEvent, self, lsp::SignatureHelp, overlay::overlayed, DynamicPicker, FileLocation, FilePicker,
Popup, PromptEvent,
}, },
}; };
use std::{borrow::Cow, collections::BTreeMap, path::PathBuf, sync::Arc}; use std::{
borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt::Write, path::PathBuf, sync::Arc,
};
/// Gets the language server that is attached to a document, and /// Gets the language server that is attached to a document, and
/// if it's not active displays a status message. Using this macro /// if it's not active displays a status message. Using this macro
@ -42,24 +52,33 @@ impl ui::menu::Item for lsp::Location {
/// Current working directory. /// Current working directory.
type Data = PathBuf; type Data = PathBuf;
fn label(&self, cwdir: &Self::Data) -> Spans { fn format(&self, cwdir: &Self::Data) -> Row {
let file: Cow<'_, str> = (self.uri.scheme() == "file") // The preallocation here will overallocate a few characters since it will account for the
.then(|| { // URL's scheme, which is not used most of the time since that scheme will be "file://".
self.uri // Those extra chars will be used to avoid allocating when writing the line number (in the
.to_file_path() // common case where it has 5 digits or less, which should be enough for a cast majority
.map(|path| { // of usages).
// strip root prefix let mut res = String::with_capacity(self.uri.as_str().len());
path.strip_prefix(&cwdir)
.map(|path| path.to_path_buf()) if self.uri.scheme() == "file" {
.unwrap_or(path) // With the preallocation above and UTF-8 paths already, this closure will do one (1)
}) // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`.
.map(|path| Cow::from(path.to_string_lossy().into_owned())) let mut write_path_to_res = || -> Option<()> {
.ok() let path = self.uri.to_file_path().ok()?;
}) res.push_str(&path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy());
.flatten() Some(())
.unwrap_or_else(|| self.uri.as_str().into()); };
let line = self.range.start.line; write_path_to_res();
format!("{}:{}", file, line).into() } else {
// Never allocates since we declared the string with this capacity already.
res.push_str(self.uri.as_str());
}
// Most commonly, this will not allocate, especially on Unix systems where the root prefix
// is a simple `/` and not `C:\` (with whatever drive letter)
write!(&mut res, ":{}", self.range.start.line)
.expect("Will only failed if allocating fail");
res.into()
} }
} }
@ -67,16 +86,14 @@ impl ui::menu::Item for lsp::SymbolInformation {
/// Path to currently focussed document /// Path to currently focussed document
type Data = Option<lsp::Url>; type Data = Option<lsp::Url>;
fn label(&self, current_doc_path: &Self::Data) -> Spans { fn format(&self, current_doc_path: &Self::Data) -> Row {
if current_doc_path.as_ref() == Some(&self.location.uri) { if current_doc_path.as_ref() == Some(&self.location.uri) {
self.name.as_str().into() self.name.as_str().into()
} else { } else {
match self.location.uri.to_file_path() { match self.location.uri.to_file_path() {
Ok(path) => { Ok(path) => {
let relative_path = helix_core::path::get_relative_path(path.as_path()) let get_relative_path = path::get_relative_path(path.as_path());
.to_string_lossy() format!("{} ({})", &self.name, get_relative_path.to_string_lossy()).into()
.into_owned();
format!("{} ({})", &self.name, relative_path).into()
} }
Err(_) => format!("{} ({})", &self.name, &self.location.uri).into(), Err(_) => format!("{} ({})", &self.name, &self.location.uri).into(),
} }
@ -99,7 +116,7 @@ struct PickerDiagnostic {
impl ui::menu::Item for PickerDiagnostic { impl ui::menu::Item for PickerDiagnostic {
type Data = (DiagnosticStyles, DiagnosticsFormat); type Data = (DiagnosticStyles, DiagnosticsFormat);
fn label(&self, (styles, format): &Self::Data) -> Spans { fn format(&self, (styles, format): &Self::Data) -> Row {
let mut style = self let mut style = self
.diag .diag
.severity .severity
@ -115,24 +132,21 @@ impl ui::menu::Item for PickerDiagnostic {
// remove background as it is distracting in the picker list // remove background as it is distracting in the picker list
style.bg = None; style.bg = None;
let code = self let code: Cow<'_, str> = self
.diag .diag
.code .code
.as_ref() .as_ref()
.map(|c| match c { .map(|c| match c {
NumberOrString::Number(n) => n.to_string(), NumberOrString::Number(n) => n.to_string().into(),
NumberOrString::String(s) => s.to_string(), NumberOrString::String(s) => s.as_str().into(),
}) })
.map(|code| format!(" ({})", code))
.unwrap_or_default(); .unwrap_or_default();
let path = match format { let path = match format {
DiagnosticsFormat::HideSourcePath => String::new(), DiagnosticsFormat::HideSourcePath => String::new(),
DiagnosticsFormat::ShowSourcePath => { DiagnosticsFormat::ShowSourcePath => {
let path = path::get_truncated_path(self.url.path()) let path = path::get_truncated_path(self.url.path());
.to_string_lossy() format!("{}: ", path.to_string_lossy())
.into_owned();
format!("{}: ", path)
} }
}; };
@ -141,6 +155,7 @@ impl ui::menu::Item for PickerDiagnostic {
Span::styled(&self.diag.message, style), Span::styled(&self.diag.message, style),
Span::styled(code, style), Span::styled(code, style),
]) ])
.into()
} }
} }
@ -150,7 +165,7 @@ fn location_to_file_location(location: &lsp::Location) -> FileLocation {
location.range.start.line as usize, location.range.start.line as usize,
location.range.end.line as usize, location.range.end.line as usize,
)); ));
(path, line) (path.into(), line)
} }
// TODO: share with symbol picker(symbol.location) // TODO: share with symbol picker(symbol.location)
@ -211,7 +226,6 @@ fn sym_picker(
Ok(path) => path, Ok(path) => path,
Err(_) => { Err(_) => {
let err = format!("unable to convert URI to filepath: {}", uri); let err = format!("unable to convert URI to filepath: {}", uri);
log::error!("{}", err);
cx.editor.set_error(err); cx.editor.set_error(err);
return; return;
} }
@ -328,7 +342,14 @@ pub fn symbol_picker(cx: &mut Context) {
let current_url = doc.url(); let current_url = doc.url();
let offset_encoding = language_server.offset_encoding(); let offset_encoding = language_server.offset_encoding();
let future = language_server.document_symbols(doc.identifier()); let future = match language_server.document_symbols(doc.identifier()) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support document symbols");
return;
}
};
cx.callback( cx.callback(
future, future,
@ -360,15 +381,55 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
let current_url = doc.url(); let current_url = doc.url();
let language_server = language_server!(cx.editor, doc); let language_server = language_server!(cx.editor, doc);
let offset_encoding = language_server.offset_encoding(); let offset_encoding = language_server.offset_encoding();
let future = language_server.workspace_symbols("".to_string()); let future = match language_server.workspace_symbols("".to_string()) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support workspace symbols");
return;
}
};
cx.callback( cx.callback(
future, future,
move |_editor, compositor, response: Option<Vec<lsp::SymbolInformation>>| { move |_editor, compositor, response: Option<Vec<lsp::SymbolInformation>>| {
if let Some(symbols) = response { let symbols = response.unwrap_or_default();
let picker = sym_picker(symbols, current_url, offset_encoding); let picker = sym_picker(symbols, current_url, offset_encoding);
compositor.push(Box::new(overlayed(picker))) let get_symbols = |query: String, editor: &mut Editor| {
} let doc = doc!(editor);
let language_server = match doc.language_server() {
Some(s) => s,
None => {
// This should not generally happen since the picker will not
// even open in the first place if there is no server.
return async move { Err(anyhow::anyhow!("LSP not active")) }.boxed();
}
};
let symbol_request = match language_server.workspace_symbols(query) {
Some(future) => future,
None => {
// This should also not happen since the language server must have
// supported workspace symbols before to reach this block.
return async move {
Err(anyhow::anyhow!(
"Language server does not support workspace symbols"
))
}
.boxed();
}
};
let future = async move {
let json = symbol_request.await?;
let response: Option<Vec<lsp::SymbolInformation>> =
serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
};
future.boxed()
};
let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols));
compositor.push(Box::new(overlayed(dyn_picker)))
}, },
) )
} }
@ -413,7 +474,7 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) {
impl ui::menu::Item for lsp::CodeActionOrCommand { impl ui::menu::Item for lsp::CodeActionOrCommand {
type Data = (); type Data = ();
fn label(&self, _data: &Self::Data) -> Spans { fn format(&self, _data: &Self::Data) -> Row {
match self { match self {
lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(),
lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(),
@ -421,6 +482,63 @@ impl ui::menu::Item for lsp::CodeActionOrCommand {
} }
} }
/// Determines the category of the `CodeAction` using the `CodeAction::kind` field.
/// Returns a number that represent these categories.
/// Categories with a lower number should be displayed first.
///
///
/// While the `kind` field is defined as open ended in the LSP spec (any value may be used)
/// in practice a closed set of common values (mostly suggested in the LSP spec) are used.
/// VSCode displays each of these categories seperatly (seperated by a heading in the codeactions picker)
/// to make them easier to navigate. Helix does not display these headings to the user.
/// However it does sort code actions by their categories to achieve the same order as the VScode picker,
/// just without the headings.
///
/// The order used here is modeled after the [vscode sourcecode](https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeActionWidget.ts>)
fn action_category(action: &CodeActionOrCommand) -> u32 {
if let CodeActionOrCommand::CodeAction(CodeAction {
kind: Some(kind), ..
}) = action
{
let mut components = kind.as_str().split('.');
match components.next() {
Some("quickfix") => 0,
Some("refactor") => match components.next() {
Some("extract") => 1,
Some("inline") => 2,
Some("rewrite") => 3,
Some("move") => 4,
Some("surround") => 5,
_ => 7,
},
Some("source") => 6,
_ => 7,
}
} else {
7
}
}
fn action_prefered(action: &CodeActionOrCommand) -> bool {
matches!(
action,
CodeActionOrCommand::CodeAction(CodeAction {
is_preferred: Some(true),
..
})
)
}
fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool {
matches!(
action,
CodeActionOrCommand::CodeAction(CodeAction {
diagnostics: Some(diagnostics),
..
}) if !diagnostics.is_empty()
)
}
pub fn code_action(cx: &mut Context) { pub fn code_action(cx: &mut Context) {
let (view, doc) = current!(cx.editor); let (view, doc) = current!(cx.editor);
@ -431,7 +549,7 @@ pub fn code_action(cx: &mut Context) {
let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding);
let future = language_server.code_actions( let future = match language_server.code_actions(
doc.identifier(), doc.identifier(),
range, range,
// Filter and convert overlapping diagnostics // Filter and convert overlapping diagnostics
@ -446,21 +564,74 @@ pub fn code_action(cx: &mut Context) {
.map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding)) .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding))
.collect(), .collect(),
only: None, only: None,
trigger_kind: Some(CodeActionTriggerKind::INVOKED),
}, },
); ) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support code actions");
return;
}
};
cx.callback( cx.callback(
future, future,
move |editor, compositor, response: Option<lsp::CodeActionResponse>| { move |editor, compositor, response: Option<lsp::CodeActionResponse>| {
let actions = match response { let mut actions = match response {
Some(a) => a, Some(a) => a,
None => return, None => return,
}; };
// remove disabled code actions
actions.retain(|action| {
matches!(
action,
CodeActionOrCommand::Command(_)
| CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. })
)
});
if actions.is_empty() { if actions.is_empty() {
editor.set_status("No code actions available"); editor.set_status("No code actions available");
return; return;
} }
// Sort codeactions into a useful order. This behaviour is only partially described in the LSP spec.
// Many details are modeled after vscode because langauge servers are usually tested against it.
// VScode sorts the codeaction two times:
//
// First the codeactions that fix some diagnostics are moved to the front.
// If both codeactions fix some diagnostics (or both fix none) the codeaction
// that is marked with `is_preffered` is shown first. The codeactions are then shown in seperate
// submenus that only contain a certain category (see `action_category`) of actions.
//
// Below this done in in a single sorting step
actions.sort_by(|action1, action2| {
// sort actions by category
let order = action_category(action1).cmp(&action_category(action2));
if order != Ordering::Equal {
return order;
}
// within the categories sort by relevancy.
// Modeled after the `codeActionsComparator` function in vscode:
// https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeAction.ts
// if one code action fixes a diagnostic but the other one doesn't show it first
let order = action_fixes_diagnostics(action1)
.cmp(&action_fixes_diagnostics(action2))
.reverse();
if order != Ordering::Equal {
return order;
}
// if one of the codeactions is marked as prefered show it first
// otherwise keep the original LSP sorting
action_prefered(action1)
.cmp(&action_prefered(action2))
.reverse()
});
let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| { let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| {
if event != PromptEvent::Validate { if event != PromptEvent::Validate {
return; return;
@ -491,21 +662,35 @@ pub fn code_action(cx: &mut Context) {
}); });
picker.move_down(); // pre-select the first item picker.move_down(); // pre-select the first item
let popup = let popup = Popup::new("code-action", picker).with_scrollbar(false);
Popup::new("code-action", picker).margin(helix_view::graphics::Margin::all(1));
compositor.replace_or_push("code-action", popup); compositor.replace_or_push("code-action", popup);
}, },
) )
} }
impl ui::menu::Item for lsp::Command {
type Data = ();
fn format(&self, _data: &Self::Data) -> Row {
self.title.as_str().into()
}
}
pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) {
let doc = doc!(editor); let doc = doc!(editor);
let language_server = language_server!(editor, doc); let language_server = language_server!(editor, doc);
// the command is executed on the server and communicated back // the command is executed on the server and communicated back
// to the client asynchronously using workspace edits // to the client asynchronously using workspace edits
let command_future = language_server.command(cmd); let future = match language_server.command(cmd) {
Some(future) => future,
None => {
editor.set_error("Language server does not support executing commands");
return;
}
};
tokio::spawn(async move { tokio::spawn(async move {
let res = command_future.await; let res = future.await;
if let Err(e) = res { if let Err(e) = res {
log::error!("execute LSP command: {}", e); log::error!("execute LSP command: {}", e);
@ -528,7 +713,7 @@ pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> {
// Create directory if it does not exist // Create directory if it does not exist
if let Some(dir) = path.parent() { if let Some(dir) = path.parent() {
if !dir.is_dir() { if !dir.is_dir() {
fs::create_dir_all(&dir)?; fs::create_dir_all(dir)?;
} }
} }
@ -564,7 +749,7 @@ pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> {
if ignore_if_exists && to.exists() { if ignore_if_exists && to.exists() {
Ok(()) Ok(())
} else { } else {
fs::rename(&from, &to) fs::rename(from, &to)
} }
} }
} }
@ -597,9 +782,7 @@ pub fn apply_workspace_edit(
} }
}; };
let doc = editor let doc = doc_mut!(editor, &doc_id);
.document_mut(doc_id)
.expect("Document for document_changes not found");
// Need to determine a view for apply/append_changes_to_history // Need to determine a view for apply/append_changes_to_history
let selections = doc.selections(); let selections = doc.selections();
@ -620,8 +803,9 @@ pub fn apply_workspace_edit(
text_edits, text_edits,
offset_encoding, offset_encoding,
); );
doc.apply(&transaction, view_id); let view = view_mut!(editor, view_id);
doc.append_changes_to_history(view_id); doc.apply(&transaction, view.id);
doc.append_changes_to_history(view);
}; };
if let Some(ref changes) = workspace_edit.changes { if let Some(ref changes) = workspace_edit.changes {
@ -734,6 +918,31 @@ fn to_locations(definitions: Option<lsp::GotoDefinitionResponse>) -> Vec<lsp::Lo
} }
} }
pub fn goto_declaration(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let language_server = language_server!(cx.editor, doc);
let offset_encoding = language_server.offset_encoding();
let pos = doc.position(view.id, offset_encoding);
let future = match language_server.goto_declaration(doc.identifier(), pos, None) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support goto-declaration");
return;
}
};
cx.callback(
future,
move |editor, compositor, response: Option<lsp::GotoDefinitionResponse>| {
let items = to_locations(response);
goto_impl(editor, compositor, items, offset_encoding);
},
);
}
pub fn goto_definition(cx: &mut Context) { pub fn goto_definition(cx: &mut Context) {
let (view, doc) = current!(cx.editor); let (view, doc) = current!(cx.editor);
let language_server = language_server!(cx.editor, doc); let language_server = language_server!(cx.editor, doc);
@ -741,7 +950,14 @@ pub fn goto_definition(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let pos = doc.position(view.id, offset_encoding);
let future = language_server.goto_definition(doc.identifier(), pos, None); let future = match language_server.goto_definition(doc.identifier(), pos, None) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support goto-definition");
return;
}
};
cx.callback( cx.callback(
future, future,
@ -759,7 +975,14 @@ pub fn goto_type_definition(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let pos = doc.position(view.id, offset_encoding);
let future = language_server.goto_type_definition(doc.identifier(), pos, None); let future = match language_server.goto_type_definition(doc.identifier(), pos, None) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support goto-type-definition");
return;
}
};
cx.callback( cx.callback(
future, future,
@ -777,7 +1000,14 @@ pub fn goto_implementation(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let pos = doc.position(view.id, offset_encoding);
let future = language_server.goto_implementation(doc.identifier(), pos, None); let future = match language_server.goto_implementation(doc.identifier(), pos, None) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support goto-implementation");
return;
}
};
cx.callback( cx.callback(
future, future,
@ -795,7 +1025,14 @@ pub fn goto_reference(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let pos = doc.position(view.id, offset_encoding);
let future = language_server.goto_reference(doc.identifier(), pos, None); let future = match language_server.goto_reference(doc.identifier(), pos, None) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support goto-reference");
return;
}
};
cx.callback( cx.callback(
future, future,
@ -806,7 +1043,7 @@ pub fn goto_reference(cx: &mut Context) {
); );
} }
#[derive(PartialEq)] #[derive(PartialEq, Eq)]
pub enum SignatureHelpInvoked { pub enum SignatureHelpInvoked {
Manual, Manual,
Automatic, Automatic,
@ -838,7 +1075,13 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) {
let future = match language_server.text_document_signature_help(doc.identifier(), pos, None) { let future = match language_server.text_document_signature_help(doc.identifier(), pos, None) {
Some(f) => f, Some(f) => f,
None => return, None => {
if was_manually_invoked {
cx.editor
.set_error("Language server does not support signature-help");
}
return;
}
}; };
cx.callback( cx.callback(
@ -853,6 +1096,14 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) {
return; return;
} }
// If the signature help invocation is automatic, don't show it outside of Insert Mode:
// it very probably means the server was a little slow to respond and the user has
// already moved on to something else, making a signature help popup will just be an
// annoyance, see https://github.com/helix-editor/helix/issues/3112
if !was_manually_invoked && editor.mode != Mode::Insert {
return;
}
let response = match response { let response = match response {
// According to the spec the response should be None if there // According to the spec the response should be None if there
// are no signatures, but some servers don't follow this. // are no signatures, but some servers don't follow this.
@ -863,10 +1114,7 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) {
} }
}; };
let doc = doc!(editor); let doc = doc!(editor);
let language = doc let language = doc.language_name().unwrap_or("");
.language()
.and_then(|scope| scope.strip_prefix("source."))
.unwrap_or("");
let signature = match response let signature = match response
.signatures .signatures
@ -934,7 +1182,14 @@ pub fn hover(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let pos = doc.position(view.id, offset_encoding);
let future = language_server.text_document_hover(doc.identifier(), pos, None); let future = match language_server.text_document_hover(doc.identifier(), pos, None) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support hover");
return;
}
};
cx.callback( cx.callback(
future, future,
@ -1004,8 +1259,16 @@ pub fn rename_symbol(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let pos = doc.position(view.id, offset_encoding);
let task = language_server.rename_symbol(doc.identifier(), pos, input.to_string()); let future =
match block_on(task) { match language_server.rename_symbol(doc.identifier(), pos, input.to_string()) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support symbol renaming");
return;
}
};
match block_on(future) {
Ok(edits) => apply_workspace_edit(cx.editor, offset_encoding, &edits), Ok(edits) => apply_workspace_edit(cx.editor, offset_encoding, &edits),
Err(err) => cx.editor.set_error(err.to_string()), Err(err) => cx.editor.set_error(err.to_string()),
} }
@ -1020,7 +1283,15 @@ pub fn select_references_to_symbol_under_cursor(cx: &mut Context) {
let pos = doc.position(view.id, offset_encoding); let pos = doc.position(view.id, offset_encoding);
let future = language_server.text_document_document_highlight(doc.identifier(), pos, None); let future = match language_server.text_document_document_highlight(doc.identifier(), pos, None)
{
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support document highlight");
return;
}
};
cx.callback( cx.callback(
future, future,

File diff suppressed because it is too large Load Diff

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

Loading…
Cancel
Save