Compare commits

..

4 Commits

Author SHA1 Message Date
Michael Davis
13b76ea797 Add a function for getting language config by injection layer 2024-01-11 14:34:12 -05:00
Michael Davis
3014a2ae9b Add language ID in HighlightConfiguration 2024-01-11 14:34:10 -05:00
Michael Davis
10b9c38ed9 Track languages by ID in the Loader 2024-01-11 14:25:32 -05:00
Michael Davis
4e1aeb1b99 Rename LanguageConfig language_id as language_name 2024-01-11 14:20:28 -05:00
989 changed files with 21792 additions and 74076 deletions

View File

@@ -1,17 +1,3 @@
# we use tokio_unstable to enable runtime::Handle::id so we can separate
# globals from multiple parallel tests. If that function ever does get removed
# its possible to replace (with some additional overhead and effort)
# Annoyingly build.rustflags doesn't work here because it gets overwritten
# if people have their own global target.<..> config (for example to enable mold)
# specifying flags this way is more robust as they get merged
# This still gets overwritten by RUST_FLAGS though, luckily it shouldn't be necessary
# to set those most of the time. If downstream does overwrite this its not a huge
# deal since it will only break tests anyway
[target."cfg(all())"]
rustflags = ["--cfg", "tokio_unstable", "-C", "target-feature=-crt-static"]
[alias]
xtask = "run --package xtask --"
integration-test = "test --features integration --profile integration --workspace --test integration"

3
.envrc
View File

@@ -1,8 +1,7 @@
watch_file shell.nix
watch_file default.nix
watch_file flake.lock
watch_file rust-toolchain.toml
# try to use flakes, if it fails use normal nix (ie. shell.nix)
use flake || use nix
eval "$shellHook"
eval "$shellHook"

1
.gitattributes vendored
View File

@@ -8,5 +8,4 @@
*.md text diff=markdown
book/theme/highlight.js linguist-vendored
runtime/queries/**/*.scm linguist-language=Tree-sitter-Query
Cargo.lock text

4
.github/ISSUE_TEMPLATE/blank_issue.md vendored Normal file
View File

@@ -0,0 +1,4 @@
---
name: Blank Issue
about: Create a blank issue.
---

View File

@@ -7,11 +7,6 @@ updates:
directory: "/"
schedule:
interval: "weekly"
groups:
rust-dependencies:
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"

View File

@@ -6,46 +6,31 @@ on:
- master
merge_group:
schedule:
- cron: "00 01 * * *"
env:
MSRV: "1.82"
# This key can be changed to bust the cache of tree-sitter grammars.
GRAMMAR_CACHE_VERSION: ""
- cron: '00 01 * * *'
jobs:
check:
name: Check (msrv)
runs-on: ubuntu-latest
if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule'
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Install MSRV toolchain
uses: dtolnay/rust-toolchain@master
- name: Install stable toolchain
uses: helix-editor/rust-toolchain@v1
with:
toolchain: ${{ env.MSRV }}
profile: minimal
override: true
- uses: Swatinem/rust-cache@v2
with:
shared-key: "build"
- name: Cache tree-sitter grammars
uses: actions/cache@v4
with:
path: runtime/grammars
key: ${{ runner.os }}-${{ runner.arch }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-
- name: Run cargo check
run: cargo check
test:
name: Test Suite
runs-on: ${{ matrix.os }}
if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule'
timeout-minutes: 30
env:
RUST_BACKTRACE: 1
HELIX_LOG_LEVEL: info
@@ -53,21 +38,19 @@ jobs:
- name: Checkout sources
uses: actions/checkout@v4
- name: Install MSRV toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.MSRV }}
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@1.70
- uses: Swatinem/rust-cache@v2
with:
shared-key: "build"
- name: Cache tree-sitter grammars
uses: actions/cache@v4
- name: Cache test tree-sitter grammar
uses: actions/cache@v3
with:
path: runtime/grammars
key: ${{ runner.os }}-${{ runner.arch }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-
key: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars-
- name: Run cargo test
run: cargo test --workspace
@@ -77,33 +60,24 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest, ubuntu-24.04-arm]
os: [ubuntu-latest, macos-latest, windows-latest]
lints:
name: Lints
runs-on: ubuntu-latest
if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule'
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Install MSRV toolchain
uses: dtolnay/rust-toolchain@master
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@1.70
with:
toolchain: ${{ env.MSRV }}
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
shared-key: "build"
- name: Cache tree-sitter grammars
uses: actions/cache@v4
with:
path: runtime/grammars
key: ${{ runner.os }}-${{ runner.arch }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-
- name: Run cargo fmt
run: cargo fmt --all --check
@@ -118,33 +92,20 @@ jobs:
docs:
name: Docs
runs-on: ubuntu-latest
if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule'
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Install MSRV toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.MSRV }}
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@1.70
- uses: Swatinem/rust-cache@v2
with:
shared-key: "build"
- name: Cache tree-sitter grammars
uses: actions/cache@v4
with:
path: runtime/grammars
key: ${{ runner.os }}-${{ runner.arch }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-
- name: Validate queries
run: cargo xtask query-check
- name: Validate themes
run: cargo xtask theme-check
- name: Generate docs
run: cargo xtask docgen
@@ -154,3 +115,4 @@ jobs:
git diff-files --quiet \
|| (echo "Run 'cargo xtask docgen', commit the changes and push again" \
&& exit 1)

View File

@@ -14,10 +14,10 @@ jobs:
uses: actions/checkout@v4
- name: Install nix
uses: cachix/install-nix-action@v31
uses: cachix/install-nix-action@v24
- name: Authenticate with Cachix
uses: cachix/cachix-action@v16
uses: cachix/cachix-action@v13
with:
name: helix
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: GitHub Pages
name: Github Pages
on:
push:
@@ -14,10 +14,10 @@ jobs:
- uses: actions/checkout@v4
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v2
uses: peaceiris/actions-mdbook@v1
with:
# mdbook-version: 'latest'
mdbook-version: '0.4.43'
mdbook-version: 'latest'
# mdbook-version: '0.4.8'
- run: mdbook build book
@@ -27,14 +27,14 @@ jobs:
echo "OUTDIR=$OUTDIR" >> $GITHUB_ENV
- name: Deploy stable
uses: peaceiris/actions-gh-pages@v4
uses: peaceiris/actions-gh-pages@v3
if: startswith(github.ref, 'refs/tags/')
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./book/book
- name: Deploy
uses: peaceiris/actions-gh-pages@v4
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./book/book

View File

@@ -58,20 +58,20 @@ jobs:
strategy:
fail-fast: false # don't fail other jobs if one fails
matrix:
build: [x86_64-linux, aarch64-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:
- build: x86_64-linux
os: ubuntu-24.04
os: ubuntu-latest
rust: stable
target: x86_64-unknown-linux-gnu
cross: false
- build: aarch64-linux
os: ubuntu-24.04-arm
os: ubuntu-latest
rust: stable
target: aarch64-unknown-linux-gnu
cross: false
cross: true
# - build: riscv64-linux
# os: ubuntu-22.04
# os: ubuntu-latest
# rust: stable
# target: riscv64gc-unknown-linux-gnu
# cross: true
@@ -147,8 +147,16 @@ jobs:
if: "!matrix.skip_tests"
run: ${{ env.CARGO }} test --release --locked --target ${{ matrix.target }} --workspace
- name: Set profile.release.strip = true
shell: bash
run: |
cat >> .cargo/config.toml <<EOF
[profile.release]
strip = true
EOF
- name: Build release binary
run: ${{ env.CARGO }} build --profile opt --locked --target ${{ matrix.target }}
run: ${{ env.CARGO }} build --release --locked --target ${{ matrix.target }}
- name: Build AppImage
shell: bash
@@ -175,7 +183,7 @@ jobs:
mkdir -p "$APP.AppDir"/usr/{bin,lib/helix}
cp "target/${{ matrix.target }}/opt/hx" "$APP.AppDir/usr/bin/hx"
cp "target/${{ matrix.target }}/release/hx" "$APP.AppDir/usr/bin/hx"
rm -rf runtime/grammars/sources
cp -r runtime "$APP.AppDir/usr/lib/helix/runtime"
@@ -198,25 +206,14 @@ jobs:
mv "$APP-$VERSION-$ARCH.AppImage" \
"$APP-$VERSION-$ARCH.AppImage.zsync" dist
- name: Build Deb
shell: bash
if: matrix.build == 'x86_64-linux'
run: |
cargo install cargo-deb
mkdir -p target/release
cp target/${{ matrix.target }}/opt/hx target/release/
cargo deb --no-build
mkdir -p dist
mv target/debian/*.deb dist/
- name: Build archive
shell: bash
run: |
mkdir -p dist
if [ "${{ matrix.os }}" = "windows-2019" ]; then
cp "target/${{ matrix.target }}/opt/hx.exe" "dist/"
cp "target/${{ matrix.target }}/release/hx.exe" "dist/"
else
cp "target/${{ matrix.target }}/opt/hx" "dist/"
cp "target/${{ matrix.target }}/release/hx" "dist/"
fi
if [ -d runtime/grammars/sources ]; then
rm -rf runtime/grammars/sources
@@ -244,7 +241,6 @@ jobs:
set -ex
source="$(pwd)"
tag=${GITHUB_REF_NAME//\//}
mkdir -p runtime/grammars/sources
tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources
rm -rf grammars
@@ -258,7 +254,7 @@ jobs:
if [[ $platform =~ "windows" ]]; then
exe=".exe"
fi
pkgname=helix-$tag-$platform
pkgname=helix-$GITHUB_REF_NAME-$platform
mkdir -p $pkgname
cp $source/LICENSE $source/README.md $pkgname
mkdir $pkgname/contrib
@@ -269,7 +265,6 @@ jobs:
if [[ "$platform" = "x86_64-linux" ]]; then
mv bins-$platform/helix-*.AppImage* dist/
mv bins-$platform/*.deb dist/
fi
if [ "$exe" = "" ]; then
@@ -279,7 +274,7 @@ jobs:
fi
done
tar cJf dist/helix-$tag-source.tar.xz -C $source .
tar cJf dist/helix-$GITHUB_REF_NAME-source.tar.xz -C $source .
mv dist $source/
- name: Upload binaries to release

1
.gitignore vendored
View File

@@ -3,4 +3,3 @@ target
helix-term/rustfmt.toml
result
runtime/grammars
.DS_Store

2
.ignore Normal file
View File

@@ -0,0 +1,2 @@
# Things that we don't want ripgrep to search that we do want in git
# https://github.com/BurntSushi/ripgrep/blob/master/GUIDE.md#automatic-filtering

File diff suppressed because it is too large Load Diff

2357
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,14 +5,12 @@ members = [
"helix-view",
"helix-term",
"helix-tui",
"helix-lsp-types",
"helix-lsp",
"helix-event",
"helix-dap",
"helix-loader",
"helix-vcs",
"helix-parsec",
"helix-stdx",
"xtask",
]
@@ -22,12 +20,13 @@ default-members = [
[profile.release]
lto = "thin"
# debug = true
[profile.opt]
inherits = "release"
lto = "fat"
codegen-units = 1
strip = true
# strip = "debuginfo" # TODO: or strip = true
opt-level = 3
[profile.integration]
@@ -37,27 +36,15 @@ package.helix-tui.opt-level = 2
package.helix-term.opt-level = 2
[workspace.dependencies]
tree-house = { version = "0.3.0", default-features = false }
nucleo = "0.5.0"
slotmap = "1.0.7"
thiserror = "2.0"
tempfile = "3.20.0"
bitflags = "2.9"
unicode-segmentation = "1.2"
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
foldhash = "0.1"
parking_lot = "0.12"
futures-executor = "0.3"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
tokio-stream = "0.1.17"
toml = "0.9"
tree-sitter = { version = "0.20", git = "https://github.com/tree-sitter/tree-sitter", rev = "ab09ae20d640711174b8da8a654f6b3dec93da1a" }
nucleo = "0.2.0"
[workspace.package]
version = "25.7.0"
version = "23.10.0"
edition = "2021"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
categories = ["editor"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
license = "MPL-2.0"
rust-version = "1.82"
rust-version = "1.70"

View File

@@ -37,8 +37,8 @@ All shortcuts/keymaps can be found [in the documentation on the website](https:/
- Built-in language server support
- Smart, incremental syntax highlighting and code editing via tree-sitter
Although it's primarily a terminal-based editor, I am interested in exploring
a custom renderer (similar to Emacs) using wgpu or skulpin.
It's a terminal-based editor first, but I'd like to explore a custom renderer
(similar to Emacs) in wgpu or skulpin.
Note: Only certain languages have indentation definitions at the moment. Check
`runtime/queries/<lang>/` for `indents.scm`.
@@ -47,7 +47,7 @@ Note: Only certain languages have indentation definitions at the moment. Check
[Installation documentation](https://docs.helix-editor.com/install.html).
[![Packaging status](https://repology.org/badge/vertical-allrepos/helix-editor.svg?exclude_unsupported=1)](https://repology.org/project/helix-editor/versions)
[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions)
# Contributing

View File

@@ -28,11 +28,6 @@
"label" = "magenta"
"namespace" = "magenta"
"ui.help" = { fg = "white", bg = "black" }
"ui.statusline.insert" = { fg = "black", bg = "green" }
"ui.statusline.select" = { fg = "black", bg = "blue" }
"ui.virtual" = { fg = "gray", modifiers = ["italic"] }
"ui.virtual.jump-label" = { fg = "blue", modifiers = ["bold", "underlined"] }
"ui.virtual.ruler" = { bg = "black" }
"markup.heading" = "blue"
"markup.list" = "red"

View File

@@ -1,6 +1,7 @@
[book]
authors = ["Blaž Hrastnik"]
language = "en"
multilingual = false
src = "src"
[output.html]

View File

@@ -3,23 +3,12 @@
[Helix](./title-page.md)
- [Installation](./install.md)
- [Package Managers](./package-managers.md)
- [Building from source](./building-from-source.md)
- [Usage](./usage.md)
- [Registers](./registers.md)
- [Surround](./surround.md)
- [Textobjects](./textobjects.md)
- [Syntax aware motions](./syntax-aware-motions.md)
- [Pickers](./pickers.md)
- [Keymap](./keymap.md)
- [Command line](./command-line.md)
- [Commands](./commands.md)
- [Language support](./lang-support.md)
- [Ecosystem](./ecosystem.md)
- [Migrating from Vim](./from-vim.md)
- [Helix mode in other software](./other-software.md)
- [Migrating from Vim](./from-vim.md)
- [Configuration](./configuration.md)
- [Editor](./editor.md)
- [Themes](./themes.md)
- [Key remapping](./remapping.md)
- [Languages](./languages.md)

View File

@@ -1,208 +0,0 @@
## Building from source
- [Configuring Helix's runtime files](#configuring-helixs-runtime-files)
- [Linux and macOS](#linux-and-macos)
- [Windows](#windows)
- [Multiple runtime directories](#multiple-runtime-directories)
- [Note to packagers](#note-to-packagers)
- [Validating the installation](#validating-the-installation)
- [Configure the desktop shortcut](#configure-the-desktop-shortcut)
- [Building the Debian package](#building-the-debian-package)
Requirements:
Clone the Helix GitHub repository into a directory of your choice. The
examples in this documentation assume installation into either `~/src/` on
Linux and macOS, or `%userprofile%\src\` on Windows.
- The [Rust toolchain](https://www.rust-lang.org/tools/install)
- The [Git version control system](https://git-scm.com/)
- A C++14 compatible compiler to build the tree-sitter grammars, for example GCC or Clang
If you are using the `musl-libc` standard library instead of `glibc` the following environment variable must be set during the build to ensure tree-sitter grammars can be loaded correctly:
```sh
RUSTFLAGS="-C target-feature=-crt-static"
```
1. Clone the repository:
```sh
git clone https://github.com/helix-editor/helix
cd helix
```
2. Compile from source:
```sh
# Reproducible
cargo install --path helix-term --locked
```
```sh
# Optimized
cargo install \
--profile opt \
--config 'build.rustflags="-C target-cpu=native"' \
--path helix-term \
--locked
```
Either command will create the `hx` executable and construct the tree-sitter
grammars in the local `runtime` folder.
> 💡 If you do not want to fetch or build grammars, set an environment variable `HELIX_DISABLE_AUTO_GRAMMAR_BUILD`
> 💡 Tree-sitter grammars can be fetched and compiled if not pre-packaged. Fetch
> grammars with `hx --grammar fetch` and compile them with
> `hx --grammar build`. This will install them in
> the `runtime` directory within the user's helix config directory (more
> [details below](#multiple-runtime-directories)).
### Configuring Helix's runtime files
#### Linux and macOS
The **runtime** directory is one below the Helix source, so either export a
`HELIX_RUNTIME` environment variable to point to that directory and add it to
your `~/.bashrc` or equivalent:
```sh
export HELIX_RUNTIME=~/src/helix/runtime
```
Or, create a symbolic link:
```sh
ln -Tsf $PWD/runtime ~/.config/helix/runtime
```
#### Windows
Either set the `HELIX_RUNTIME` environment variable to point to the runtime files using the Windows setting (search for
`Edit environment variables for your account`) or use the `setx` command in
Cmd:
```sh
setx HELIX_RUNTIME "%userprofile%\src\helix\runtime"
```
> 💡 `%userprofile%` resolves to your user directory like
> `C:\Users\Your-Name\` for example.
Or, create a symlink in `%appdata%\helix\` that links to the source code directory:
| Method | Command |
| ---------- | -------------------------------------------------------------------------------------- |
| PowerShell | `New-Item -ItemType Junction -Target "runtime" -Path "$Env:AppData\helix\runtime"` |
| Cmd | `cd %appdata%\helix` <br/> `mklink /D runtime "%userprofile%\src\helix\runtime"` |
> 💡 On Windows, creating a symbolic link may require running PowerShell or
> Cmd as an administrator.
#### Multiple runtime directories
When Helix finds multiple runtime directories it will search through them for files in the
following order:
1. `runtime/` sibling directory to `$CARGO_MANIFEST_DIR` directory (this is intended for
developing and testing helix only).
2. `runtime/` subdirectory of OS-dependent helix user config directory.
3. `$HELIX_RUNTIME`
4. Distribution-specific fallback directory (set at compile time—not run time—
with the `HELIX_DEFAULT_RUNTIME` environment variable)
5. `runtime/` subdirectory of path to Helix executable.
This order also sets the priority for selecting which file will be used if multiple runtime
directories have files with the same name.
#### Note to packagers
If you are making a package of Helix for end users, to provide a good out of
the box experience, you should set the `HELIX_DEFAULT_RUNTIME` environment
variable at build time (before invoking `cargo build`) to a directory which
will store the final runtime files after installation. For example, say you want
to package the runtime into `/usr/lib/helix/runtime`. The rough steps a build
script could follow are:
1. `export HELIX_DEFAULT_RUNTIME=/usr/lib/helix/runtime`
1. `cargo build --profile opt --locked`
1. `cp -r runtime $BUILD_DIR/usr/lib/helix/`
1. `cp target/opt/hx $BUILD_DIR/usr/bin/hx`
This way the resulting `hx` binary will always look for its runtime directory in
`/usr/lib/helix/runtime` if the user has no custom runtime in `~/.config/helix`
or `HELIX_RUNTIME`.
### Validating the installation
To make sure everything is set up as expected you should run the Helix health
check:
```sh
hx --health
```
For more information on the health check results refer to
[Health check](https://github.com/helix-editor/helix/wiki/Healthcheck).
### Configure the desktop shortcut
If your desktop environment supports the
[XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html)
you can configure Helix to show up in the application menu by copying the
provided `.desktop` and icon files to their correct folders:
```sh
cp contrib/Helix.desktop ~/.local/share/applications
cp contrib/helix.png ~/.icons # or ~/.local/share/icons
```
It is recommended to convert the links in the `.desktop` file to absolute paths to avoid potential problems:
```sh
sed -i -e "s|Exec=hx %F|Exec=$(readlink -f ~/.cargo/bin/hx) %F|g" \
-e "s|Icon=helix|Icon=$(readlink -f ~/.icons/helix.png)|g" ~/.local/share/applications/Helix.desktop
```
To use another terminal than the system default, you can modify the `.desktop`
file. For example, to use `kitty`:
```sh
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
```
### Building the Debian package
If the `.deb` file provided on the release page uses a `libc` version higher
than that used by your Debian, Ubuntu, or Mint system, you can build the package
from source to match your system's dependencies.
Install `cargo-deb`, the tool used for building the `.deb` file:
```sh
cargo install cargo-deb
```
After cloning and entering the Helix repository as previously described,
use the following command to build the release binary and package it into a `.deb` file in a single step.
```sh
cargo deb -- --locked
```
> 💡 This locks you into the `--release` profile. But you can also build helix in any way you like.
> As long as you leave a `target/release/hx` file, it will get packaged with `cargo deb --no-build`
> 💡 Don't worry about the following:
> ```
> warning: Failed to find dependency specification
> ```
> Cargo deb just reports which packaged files it didn't derive dependencies for. But
> so far the dependency deriving seams very good, even if some of the grammar files are skipped.
You can find the resulted `.deb` in `target/debian/`. It should contain everything it needs, including the
- completions for bash, fish, zsh
- .desktop file
- icon (though desktop environments might use their own since the name of the package is correctly `helix`)
- launcher to the binary with the runtime

View File

@@ -1,86 +0,0 @@
# Command line
- [Quoting](#quoting)
- [Flags](#flags)
- [Expansions](#expansions)
- [Exceptions](#exceptions)
The command line is used for executing [typable commands](./commands.md#typable-commands) like `:write` or `:quit`. Press `:` to activate the command line.
Typable commands optionally accept arguments. `:write` for example accepts an optional path to write the file contents. The command line also supports a quoting syntax for arguments, flags to modify command behaviors, and _expansions_ - a way to insert values from the editor. Most commands support these features but some have custom parsing rules (see the [exceptions](#exceptions) below).
## Quoting
By default, command arguments are split on tabs and space characters. `:open README.md CHANGELOG.md` for example should open two files, `README.md` and `CHANGELOG.md`. Arguments that contain spaces can be surrounded in single quotes (`'`) or backticks (`` ` ``) to prevent the space from separating the argument, like `:open 'a b.txt'`.
Double quotes may be used the same way, but double quotes _expand_ their inner content. `:echo "%{cursor_line}"` for example may print `1` because of the expansion for the `cursor_line` variable. `:echo '%{cursor_line}'` though prints `%{cursor_line}` literally: content within single quotes or backticks is interpreted as-is.
On Unix systems the backslash character may be used to escape certain characters depending on where it is used. Within an argument which isn't surround in quotes, the backslash can be used to escape the space or tab characters: `:open a\ b.txt` is equivalent to `:open 'a b.txt'`. The backslash may also be used to escape quote characters (`'`, `` ` ``, `"`) or the percent token (`%`) when used at the beginning of an argument. `:echo \%%sh{foo}` for example prints `%sh{foo}` instead of invoking a `foo` shell command and `:echo \"quote` prints `"quote`. The backslash character is treated literally in any other situation on Unix systems and always on Windows: `:echo \n` always prints `\n`.
## Flags
Command flags are optional switches that can be used to alter the behavior of a command. For example the `:sort` command accepts an optional `--reverse` (or `-r` for short) flag which causes the sort command to reverse the sorting direction. Typing the `-` character shows completions for the current command's flags, if any.
The `--` flag specifies the end of flags. All arguments after `--` are treated as positional arguments: `:open -- -a.txt` opens a file called `-a.txt`.
## Expansions
Expansions are patterns that Helix recognizes and replaces within the command line. Helix recognizes anything starting with a percent token (`%`) as an expansion, for example `%sh{echo hi!}`. Expansions are particularly useful when used in commands like `:echo` or `:noop` for executing simple scripts. For example:
```toml
[keys.normal]
# Print the current line's git blame information to the statusline.
space.B = ":echo %sh{git blame -L %{cursor_line},+1 %{buffer_name}}"
```
Expansions take the form `%[<kind>]<open><contents><close>`. In `%sh{echo hi!}`, for example, the kind is `sh` - the shell expansion - and the contents are "echo hi!", with `{` and `}` acting as opening and closing delimiters. The following open/close characters are recognized as expansion delimiter pairs: `(`/`)`, `[`/`]`, `{`/`}` and `<`/`>`. Plus the single characters `'`, `"` or `|` may be used instead: `%{cursor_line}` is equivalent to `%<cursor_line>`, `%[cursor_line]` or `%|cursor_line|`.
To escape a percent character instead of treating it as an expansion, use two percent characters consecutively. To execute a shell command like `date -u +'%Y-%m-%d'`, double the percent characters: `:echo %sh{date -u +'%%Y-%%m-%%d'}`.
When no `<kind>` is provided, Helix will expand a **variable**. For example `%{cursor_line}` can be used as in argument to insert the line number. `:echo %{cursor_line}` for instance may print `1` to the statusline.
The following variables are supported:
| Name | Description |
|--- |--- |
| `cursor_line` | The line number of the primary cursor in the currently focused document, starting at 1. |
| `cursor_column` | The column number of the primary cursor in the currently focused document, starting at 1. This is counted as the number of grapheme clusters from the start of the line rather than bytes or codepoints. |
| `buffer_name` | The relative path of the currently focused document. `[scratch]` is expanded instead for scratch buffers. |
| `line_ending` | A string containing the line ending of the currently focused document. For example on Unix systems this is usually a line-feed character (`\n`) but on Windows systems this may be a carriage-return plus a line-feed (`\r\n`). The line ending kind of the currently focused document can be inspected with the `:line-ending` command. |
| `language` | A string containing the language name of the currently focused document.|
| `selection` | A string containing the contents of the primary selection of the currently focused document. |
| `selection_line_start` | The line number of the start of the primary selection in the currently focused document, starting at 1. |
| `selection_line_end` | The line number of the end of the primary selection in the currently focused document, starting at 1. |
Aside from editor variables, the following expansions may be used:
* Unicode `%u{..}`. The contents may contain up to six hexadecimal numbers corresponding to a Unicode codepoint value. For example `:echo %u{25CF}` prints `●` to the statusline.
* Shell `%sh{..}`. The contents are passed to the configured shell command. For example `:echo %sh{echo "20 * 5" | bc}` may print `100` on the statusline on when using a shell with `echo` and the `bc` calculator installed. Shell expansions are evaluated recursively. `%sh{echo '%{buffer_name}:%{cursor_line}'}` for example executes a command like `echo 'README.md:1'`: the variables within the `%sh{..}` expansion are evaluated before executing the shell command.
As mentioned above, double quotes can be used to surround arguments containing spaces but also support expansions within the quoted content unlike singe quotes or backticks. For example `:echo "circle: %u{25CF}"` prints `circle: ●` to the statusline while `:echo 'circle: %u{25CF}'` prints `circle: %u{25CF}`.
Note that expansions are only evaluated once the Enter key is pressed in command mode.
## Exceptions
The following commands support expansions but otherwise pass the given argument directly to the shell program without interpreting quotes:
* `:insert-output`
* `:append-output`
* `:pipe`
* `:pipe-to`
* `:run-shell-command`
For example executing `:sh echo "%{buffer_name}:%{cursor_column}"` would pass text like `echo "README.md:1"` as an argument to the shell program: the expansions are evaluated but not the quotes. As mentioned above, percent characters can be used in shell commands by doubling the percent character. To insert the output of a command like `date -u +'%Y-%m-%d'` use `:insert-output date -u +'%%Y-%%m-%%d'`.
The `:set-option` and `:toggle-option` commands use regular parsing for the first argument - the config option name - and parse the rest depending on the config option's type. `:set-option` interprets the second argument as a string for string config options and parses everything else as JSON.
`:toggle-option`'s behavior depends on the JSON type of the config option supplied as the first argument:
* Booleans: only the config option name should be provided. For example `:toggle-option auto-format` will flip the `auto-format` option.
* Strings: the rest of the command line is parsed with regular quoting rules. For example `:toggle-option indent-heuristic hybrid tree-sitter simple` cycles through "hybrid", "tree-sitter" and "simple" values on each invocation of the command.
* Numbers, arrays and objects: the rest of the command line is parsed as a stream of JSON values. For example `:toggle-option rulers [81] [51, 73]` cycles through `[81]` and `[51, 73]`.
When providing multiple values to `:toggle-option` there should be no duplicates. `:toggle-option indent-heuristic hybrid simple tree-sitter simple` for example would only toggle between "hybrid" and "tree-sitter" values.
`:lsp-workspace-command` works similarly to `:toggle-option`. The first argument (if present) is parsed according to normal rules. The rest of the line is parsed as JSON values. Unlike `:toggle-option`, string arguments for a command must be quoted. For example `:lsp-workspace-command lsp.Command "foo" "bar"`.

View File

@@ -1,16 +1,5 @@
# Commands
- [Typable commands](#typable-commands)
- [Static commands](#static-commands)
## Typable commands
Typable commands are used from command mode and may take arguments. Command mode can be activated by pressing `:`. The built-in typable commands are:
Command mode can be activated by pressing `:`. The built-in commands are:
{{#include ./generated/typable-cmd.md}}
## Static Commands
Static commands take no arguments and can be bound to keys. Static commands can also be executed from the command picker (`<space>?`). The built-in static commands are:
{{#include ./generated/static-cmd.md}}

View File

@@ -27,9 +27,350 @@ hidden = false
You can use a custom configuration file by specifying it with the `-c` or
`--config` command line argument, for example `hx -c path/to/custom-config.toml`.
You can reload the config file by issuing the `:config-reload` command. Alternatively, on Unix operating systems, you can reload it by sending the USR1
signal to the Helix process, such as by using the command `pkill -USR1 hx`.
Additionally, you can reload the configuration file by sending the USR1
signal to the Helix process on Unix operating systems, such as by using the command `pkill -USR1 hx`.
Finally, you can have a `config.toml` local to a project by putting it under a `.helix` directory in your repository.
Its settings will be merged with the configuration directory `config.toml` and the built-in configuration.
## Editor
### `[editor]` Section
| Key | Description | Default |
|--|--|---------|
| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling | `5` |
| `mouse` | Enable mouse mode | `true` |
| `middle-click-paste` | Middle click paste support | `true` |
| `scroll-lines` | Number of lines to scroll per scroll wheel step | `3` |
| `shell` | Shell to use when running external commands | Unix: `["sh", "-c"]`<br/>Windows: `["cmd", "/C"]` |
| `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers | `absolute` |
| `cursorline` | Highlight all lines with a cursor | `false` |
| `cursorcolumn` | Highlight all columns with a cursor | `false` |
| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` |
| `auto-completion` | Enable automatic pop up of auto-completion | `true` |
| `auto-format` | Enable automatic formatting on save | `true` |
| `auto-save` | Enable automatic saving on the 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 | `250` |
| `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` |
| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` |
| `auto-info` | Whether to display info boxes | `true` |
| `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative | `false` |
| `undercurl` | Set to `true` to override automatic detection of terminal undercurl 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 | `[]` |
| `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` |
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
| `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` |
| `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` |
| `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` |
| `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid`
### `[editor.statusline]` Section
Allows configuring the statusline at the bottom of the editor.
The configuration distinguishes between three areas of the status line:
`[ ... ... LEFT ... ... | ... ... ... ... CENTER ... ... ... ... | ... ... RIGHT ... ... ]`
Statusline elements can be defined as follows:
```toml
[editor.statusline]
left = ["mode", "spinner"]
center = ["file-name"]
right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"]
separator = "│"
mode.normal = "NORMAL"
mode.insert = "INSERT"
mode.select = "SELECT"
```
The `[editor.statusline]` key takes the following sub-keys:
| Key | Description | Default |
| --- | --- | --- |
| `left` | A list of elements aligned to the left of the statusline | `["mode", "spinner", "file-name", "read-only-indicator", "file-modification-indicator"]` |
| `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", "register", "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 statusline elements can be configured:
| Key | Description |
| ------ | ----------- |
| `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) |
| `spinner` | A progress spinner indicating LSP activity |
| `file-name` | The path/name of the opened file |
| `file-base-name` | The basename of the opened file |
| `file-modification-indicator` | The indicator to show whether the file is modified (a `[+]` appears when there are unsaved changes) |
| `file-encoding` | The encoding of the opened file if it differs from UTF-8 |
| `file-line-ending` | The file line endings (CRLF or LF) |
| `read-only-indicator` | An indicator that shows `[readonly]` when a file cannot be written |
| `total-line-numbers` | The total line numbers of the opened file |
| `file-type` | The type of the opened file |
| `diagnostics` | The number of warnings and/or errors |
| `workspace-diagnostics` | The number of warnings and/or errors on workspace |
| `selections` | The number of active selections |
| `primary-selection-length` | The number of characters currently in primary selection |
| `position` | The cursor position |
| `position-percentage` | The cursor position as a percentage of the total number of lines |
| `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) |
| `spacer` | Inserts a space between elements (multiple/contiguous spacers may be specified) |
| `version-control` | The current branch name or detached commit hash of the opened workspace |
| `register` | The current selected register |
### `[editor.lsp]` Section
| Key | Description | Default |
| --- | ----------- | ------- |
| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` |
| `display-messages` | Display LSP progress messages below statusline[^1] | `false` |
| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` |
| `display-inlay-hints` | Display inlay hints[^2] | `false` |
| `display-signature-help-docs` | Display docs under signature help popup | `true` |
| `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` |
| `goto-reference-include-declaration` | Include declaration in the goto references popup. | `true` |
[^1]: By default, a progress spinner is shown in the statusline beside the file path.
[^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them!
### `[editor.cursor-shape]` Section
Defines the shape of cursor in each mode.
Valid values for these options are `block`, `bar`, `underline`, or `hidden`.
> 💡 Due to limitations of the terminal environment, only the primary cursor can
> change shape.
| Key | Description | Default |
| --- | ----------- | ------- |
| `normal` | Cursor shape in [normal mode][normal mode] | `block` |
| `insert` | Cursor shape in [insert mode][insert mode] | `block` |
| `select` | Cursor shape in [select mode][select mode] | `block` |
[normal mode]: ./keymap.md#normal-mode
[insert mode]: ./keymap.md#insert-mode
[select mode]: ./keymap.md#select--extend-mode
### `[editor.file-picker]` Section
Set options for file picker and global search. Ignoring a file means it is
not visible in the Helix file picker and global search.
All git related options are only enabled in a git repository.
| Key | Description | Default |
|--|--|---------|
|`hidden` | Enables ignoring hidden files | `true`
|`follow-symlinks` | Follow symlinks instead of ignoring them | `true`
|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | `true`
|`parents` | Enables reading ignore files from parent directories | `true`
|`ignore` | Enables reading `.ignore` files | `true`
|`git-ignore` | Enables reading `.gitignore` files | `true`
|`git-global` | Enables reading global `.gitignore`, whose path is specified in git's config: `core.excludesfile` option | `true`
|`git-exclude` | Enables reading `.git/info/exclude` files | `true`
|`max-depth` | Set with an integer value for maximum depth to recurse | Defaults to `None`.
Ignore files can be placed locally as `.ignore` or put in your home directory as `~/.ignore`. They support the usual ignore and negative ignore (unignore) rules used in `.gitignore` files.
Additionally, you can use Helix-specific ignore files by creating a local `.helix/ignore` file in the current workspace or a global `ignore` file located in your Helix config directory:
- Linux and Mac: `~/.config/helix/ignore`
- Windows: `%AppData%\helix\ignore`
Example:
```ini
# unignore in file picker and global search
!.github/
!.gitignore
!.gitattributes
```
### `[editor.auto-pairs]` Section
Enables automatic insertion of pairs to parentheses, brackets, etc. Can be a
simple boolean value, or a specific mapping of pairs of single characters.
To disable auto-pairs altogether, set `auto-pairs` to `false`:
```toml
[editor]
auto-pairs = false # defaults to `true`
```
The default pairs are <code>(){}[]''""``</code>, but these can be customized by
setting `auto-pairs` to a TOML table:
```toml
[editor.auto-pairs]
'(' = ')'
'{' = '}'
'[' = ']'
'"' = '"'
'`' = '`'
'<' = '>'
```
Additionally, this setting can be used in a language config. Unless
the editor setting is `false`, this will override the editor config in
documents with this language.
Example `languages.toml` that adds <> and removes ''
```toml
[[language]]
name = "rust"
[language.auto-pairs]
'(' = ')'
'{' = '}'
'[' = ']'
'"' = '"'
'`' = '`'
'<' = '>'
```
### `[editor.search]` Section
Search specific options.
| Key | Description | Default |
|--|--|---------|
| `smart-case` | Enable smart case regex searching (case-insensitive unless pattern contains upper case characters) | `true` |
| `wrap-around`| Whether the search should wrap after depleting the matches | `true` |
### `[editor.whitespace]` Section
Options for rendering whitespace with visible characters. Use `:set whitespace.render all` to temporarily enable visible whitespace.
| Key | Description | Default |
|-----|-------------|---------|
| `render` | Whether to render whitespace. May either be `"all"` or `"none"`, or a table with sub-keys `space`, `nbsp`, `tab`, and `newline` | `"none"` |
| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `newline` or `tabpad` | See example below |
Example
```toml
[editor.whitespace]
render = "all"
# or control each character
[editor.whitespace.render]
space = "all"
tab = "all"
newline = "none"
[editor.whitespace.characters]
space = "·"
nbsp = "⍽"
tab = "→"
newline = "⏎"
tabpad = "·" # Tabs will look like "→···" (depending on tab width)
```
### `[editor.indent-guides]` Section
Options for rendering vertical indent guides.
| Key | Description | Default |
| --- | --- | --- |
| `render` | Whether to render indent guides | `false` |
| `character` | Literal character to use for rendering the indent guide | `│` |
| `skip-levels` | Number of indent levels to skip | `0` |
Example:
```toml
[editor.indent-guides]
render = true
character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽"
skip-levels = 1
```
### `[editor.gutters]` Section
For simplicity, `editor.gutters` accepts an array of gutter types, which will
use default settings for all gutter components.
```toml
[editor]
gutters = ["diff", "diagnostics", "line-numbers", "spacer"]
```
To customize the behavior of gutters, the `[editor.gutters]` section must
be used. This section contains top level settings, as well as settings for
specific gutter components as subsections.
| 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.diagnostics]` 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` | `↪ ` |
| `wrap-at-text-width` | Soft wrap at `text-width` instead of using the full viewport size. | `false` |
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.smart-tab]` Section
| Key | Description | Default |
|------------|-------------|---------|
| `enable` | If set to true, then when the cursor is in a position with non-whitespace to its left, instead of inserting a tab, it will run `move_parent_node_end`. If there is only whitespace to the left, then it inserts a tab as normal. With the default bindings, to explicitly insert a tab character, press Shift-tab. | `true` |
| `supersede-menu` | Normally, when a menu is on screen, such as when auto complete is triggered, the tab key is bound to cycling through the items. This means when menus are on screen, one cannot use the tab key to trigger the `smart-tab` command. If this option is set to true, the `smart-tab` command always takes precedence, which means one cannot use the tab key to cycle through menu items. One of the other bindings must be used instead, such as arrow keys or `C-n`/`C-p`. | `false` |

View File

@@ -1,3 +0,0 @@
# Ecosystem
This section has information related to the wider Helix ecosystem.

View File

@@ -1,469 +0,0 @@
## Editor
- [`[editor]` Section](#editor-section)
- [`[editor.clipboard-provider]` Section](#editorclipboard-provider-section)
- [`[editor.statusline]` Section](#editorstatusline-section)
- [`[editor.lsp]` Section](#editorlsp-section)
- [`[editor.cursor-shape]` Section](#editorcursor-shape-section)
- [`[editor.file-picker]` Section](#editorfile-picker-section)
- [`[editor.auto-pairs]` Section](#editorauto-pairs-section)
- [`[editor.auto-save]` Section](#editorauto-save-section)
- [`[editor.search]` Section](#editorsearch-section)
- [`[editor.whitespace]` Section](#editorwhitespace-section)
- [`[editor.indent-guides]` Section](#editorindent-guides-section)
- [`[editor.gutters]` Section](#editorgutters-section)
- [`[editor.gutters.line-numbers]` Section](#editorguttersline-numbers-section)
- [`[editor.gutters.diagnostics]` Section](#editorguttersdiagnostics-section)
- [`[editor.gutters.diff]` Section](#editorguttersdiff-section)
- [`[editor.gutters.spacer]` Section](#editorguttersspacer-section)
- [`[editor.soft-wrap]` Section](#editorsoft-wrap-section)
- [`[editor.smart-tab]` Section](#editorsmart-tab-section)
- [`[editor.inline-diagnostics]` Section](#editorinline-diagnostics-section)
### `[editor]` Section
| Key | Description | Default |
|--|--|---------|
| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling | `5` |
| `mouse` | Enable mouse mode | `true` |
| `default-yank-register` | Default register used for yank/paste | `"` |
| `middle-click-paste` | Middle click paste support | `true` |
| `scroll-lines` | Number of lines to scroll per scroll wheel step | `3` |
| `shell` | Shell to use when running external commands | Unix: `["sh", "-c"]`<br/>Windows: `["cmd", "/C"]` |
| `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers | `absolute` |
| `cursorline` | Highlight all lines with a cursor | `false` |
| `cursorcolumn` | Highlight all columns with a cursor | `false` |
| `continue-comments` | if helix should automatically add a line comment token if you create a new line inside a comment. | `true` |
| `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` |
| `path-completion` | Enable filepath completion. Show files and directories if an existing path at the cursor was recognized, either absolute or relative to the current opened document or current working directory (if the buffer is not yet saved). Defaults to true. | `true` |
| `auto-format` | Enable automatic formatting on save | `true` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` |
| `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant. | `250` |
| `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` |
| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` |
| `auto-info` | Whether to display info boxes | `true` |
| `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative | `false` |
| `undercurl` | Set to `true` to override automatic detection of terminal undercurl 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 | `[]` |
| `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` |
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
| `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` |
| `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` |
| `atomic-save` | Whether to use atomic operations to write documents to disk. This prevents data loss if the editor is interrupted while writing the file, but may confuse some file watching/hot reloading programs. | `true` |
| `trim-final-newlines` | Whether to automatically remove line-endings after the final one on write | `false` |
| `trim-trailing-whitespace` | Whether to automatically remove whitespace preceding line endings on write | `false` |
| `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` |
| `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid`
| `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | `"abcdefghijklmnopqrstuvwxyz"`
| `end-of-line-diagnostics` | Minimum severity of diagnostics to render at the end of the line. Set to `disable` to disable entirely. Refer to the setting about `inline-diagnostics` for more details | "disable"
| `clipboard-provider` | Which API to use for clipboard interaction. One of `pasteboard` (MacOS), `wayland`, `x-clip`, `x-sel`, `win-32-yank`, `termux`, `tmux`, `windows`, `termcode`, `none`, or a custom command set. | Platform and environment specific. |
| `editor-config` | Whether to read settings from [EditorConfig](https://editorconfig.org) files | `true` |
### `[editor.clipboard-provider]` Section
Helix can be configured either to use a builtin clipboard configuration or to use
a provided command.
For instance, setting it to use OSC 52 termcodes, the configuration would be:
```toml
[editor]
clipboard-provider = "termcode"
```
Alternatively, Helix can be configured to use arbitrary commands for clipboard integration:
```toml
[editor.clipboard-provider.custom]
yank = { command = "cat", args = ["test.txt"] }
paste = { command = "tee", args = ["test.txt"] }
primary-yank = { command = "cat", args = ["test-primary.txt"] } # optional
primary-paste = { command = "tee", args = ["test-primary.txt"] } # optional
```
For custom commands the contents of the yank/paste is communicated over stdin/stdout.
### `[editor.statusline]` Section
Allows configuring the statusline at the bottom of the editor.
The configuration distinguishes between three areas of the status line:
`[ ... ... LEFT ... ... | ... ... ... CENTER ... ... ... | ... ... RIGHT ... ... ]`
Statusline elements can be defined as follows:
```toml
[editor.statusline]
left = ["mode", "spinner"]
center = ["file-name"]
right = ["diagnostics", "selections", "position", "file-encoding", "file-line-ending", "file-type"]
separator = "│"
mode.normal = "NORMAL"
mode.insert = "INSERT"
mode.select = "SELECT"
diagnostics = ["warning", "error"]
workspace-diagnostics = ["warning", "error"]
```
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", "read-only-indicator", "file-modification-indicator"]` |
| `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", "register", "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"` |
| `diagnostics` | A list of severities which are displayed for the current buffer | `["warning", "error"]` |
| `workspace-diagnostics` | A list of severities which are displayed for the workspace | `["warning", "error"]` |
The following statusline elements can be configured:
| Key | Description |
| ------ | ----------- |
| `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) |
| `spinner` | A progress spinner indicating LSP activity |
| `file-name` | The path/name of the opened file |
| `file-absolute-path` | The absolute path/name of the opened file |
| `file-base-name` | The basename of the opened file |
| `file-modification-indicator` | The indicator to show whether the file is modified (a `[+]` appears when there are unsaved changes) |
| `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-indent-style` | The file indentation style |
| `read-only-indicator` | An indicator that shows `[readonly]` when a file cannot be written |
| `total-line-numbers` | The total line numbers of the opened file |
| `file-type` | The type of the opened file |
| `diagnostics` | The number of warnings and/or errors |
| `workspace-diagnostics` | The number of warnings and/or errors on workspace |
| `selections` | The primary selection index out of the number of active selections |
| `primary-selection-length` | The number of characters currently in primary selection |
| `position` | The cursor position |
| `position-percentage` | The cursor position as a percentage of the total number of lines |
| `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) |
| `spacer` | Inserts a space between elements (multiple/contiguous spacers may be specified) |
| `version-control` | The current branch name or detached commit hash of the opened workspace |
| `register` | The current selected register |
### `[editor.lsp]` Section
| Key | Description | Default |
| --- | ----------- | ------- |
| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` |
| `display-messages` | Display LSP `window/showMessage` messages below statusline[^1] | `true` |
| `display-progress-messages` | Display LSP progress messages below statusline[^1] | `false` |
| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` |
| `display-inlay-hints` | Display inlay hints[^2] | `false` |
| `inlay-hints-length-limit` | Maximum displayed length (non-zero number) of inlay hints | Unset by default |
| `display-color-swatches` | Show color swatches next to colors | `true` |
| `display-signature-help-docs` | Display docs under signature help popup | `true` |
| `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` |
| `goto-reference-include-declaration` | Include declaration in the goto references popup. | `true` |
[^1]: By default, a progress spinner is shown in the statusline beside the file path.
[^2]: You may also have to activate them in the language server config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them!
### `[editor.cursor-shape]` Section
Defines the shape of cursor in each mode.
Valid values for these options are `block`, `bar`, `underline`, or `hidden`.
> 💡 Due to limitations of the terminal environment, only the primary cursor can
> change shape.
| Key | Description | Default |
| --- | ----------- | ------- |
| `normal` | Cursor shape in [normal mode][normal mode] | `block` |
| `insert` | Cursor shape in [insert mode][insert mode] | `block` |
| `select` | Cursor shape in [select mode][select mode] | `block` |
[normal mode]: ./keymap.md#normal-mode
[insert mode]: ./keymap.md#insert-mode
[select mode]: ./keymap.md#select--extend-mode
### `[editor.file-picker]` Section
Set options for file picker and global search. Ignoring a file means it is
not visible in the Helix file picker and global search.
All git related options are only enabled in a git repository.
| Key | Description | Default |
|--|--|---------|
|`hidden` | Enables ignoring hidden files | `true`
|`follow-symlinks` | Follow symlinks instead of ignoring them | `true`
|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | `true`
|`parents` | Enables reading ignore files from parent directories | `true`
|`ignore` | Enables reading `.ignore` files | `true`
|`git-ignore` | Enables reading `.gitignore` files | `true`
|`git-global` | Enables reading global `.gitignore`, whose path is specified in git's config: `core.excludesfile` option | `true`
|`git-exclude` | Enables reading `.git/info/exclude` files | `true`
|`max-depth` | Set with an integer value for maximum depth to recurse | Unset by default
Ignore files can be placed locally as `.ignore` or put in your home directory as `~/.ignore`. They support the usual ignore and negative ignore (unignore) rules used in `.gitignore` files.
Additionally, you can use Helix-specific ignore files by creating a local `.helix/ignore` file in the current workspace or a global `ignore` file located in your Helix config directory:
- Linux and Mac: `~/.config/helix/ignore`
- Windows: `%AppData%\helix\ignore`
Example:
```ini
# unignore in file picker and global search
!.github/
!.gitignore
!.gitattributes
```
### `[editor.auto-pairs]` Section
Enables automatic insertion of pairs to parentheses, brackets, etc. Can be a
simple boolean value, or a specific mapping of pairs of single characters.
To disable auto-pairs altogether, set `auto-pairs` to `false`:
```toml
[editor]
auto-pairs = false # defaults to `true`
```
The default pairs are <code>(){}[]''""``</code>, but these can be customized by
setting `auto-pairs` to a TOML table:
```toml
[editor.auto-pairs]
'(' = ')'
'{' = '}'
'[' = ']'
'"' = '"'
'`' = '`'
'<' = '>'
```
Additionally, this setting can be used in a language config. Unless
the editor setting is `false`, this will override the editor config in
documents with this language.
Example `languages.toml` that adds `<>` and removes `''`
```toml
[[language]]
name = "rust"
[language.auto-pairs]
'(' = ')'
'{' = '}'
'[' = ']'
'"' = '"'
'`' = '`'
'<' = '>'
```
### `[editor.auto-save]` Section
Control auto save behavior.
| Key | Description | Default |
|--|--|---------|
| `focus-lost` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` |
| `after-delay.enable` | Enable automatic saving after `auto-save.after-delay.timeout` milliseconds have passed since last edit. | `false` |
| `after-delay.timeout` | Time in milliseconds since last edit before auto save timer triggers. | `3000` |
### `[editor.search]` Section
Search specific options.
| Key | Description | Default |
|--|--|---------|
| `smart-case` | Enable smart case regex searching (case-insensitive unless pattern contains upper case characters) | `true` |
| `wrap-around`| Whether the search should wrap after depleting the matches | `true` |
### `[editor.whitespace]` Section
Options for rendering whitespace with visible characters. Use `:set whitespace.render all` to temporarily enable visible whitespace.
| Key | Description | Default |
|-----|-------------|---------|
| `render` | Whether to render whitespace. May either be `all` or `none`, or a table with sub-keys `space`, `nbsp`, `nnbsp`, `tab`, and `newline` | `none` |
| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `nnbsp`, `newline` or `tabpad` | See example below |
Example
```toml
[editor.whitespace]
render = "all"
# or control each character
[editor.whitespace.render]
space = "all"
tab = "all"
nbsp = "none"
nnbsp = "none"
newline = "none"
[editor.whitespace.characters]
space = "·"
nbsp = "⍽"
nnbsp = "␣"
tab = "→"
newline = "⏎"
tabpad = "·" # Tabs will look like "→···" (depending on tab width)
```
### `[editor.indent-guides]` Section
Options for rendering vertical indent guides.
| Key | Description | Default |
| --- | --- | --- |
| `render` | Whether to render indent guides | `false` |
| `character` | Literal character to use for rendering the indent guide | `│` |
| `skip-levels` | Number of indent levels to skip | `0` |
Example:
```toml
[editor.indent-guides]
render = true
character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽"
skip-levels = 1
```
### `[editor.gutters]` Section
For simplicity, `editor.gutters` accepts an array of gutter types, which will
use default settings for all gutter components.
```toml
[editor]
gutters = ["diff", "diagnostics", "line-numbers", "spacer"]
```
To customize the behavior of gutters, the `[editor.gutters]` section must
be used. This section contains top level settings, as well as settings for
specific gutter components as subsections.
| 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.diagnostics]` Section
Currently unused
#### `[editor.gutters.diff]` Section
The `diff` gutter option displays colored bars indicating whether a `git` diff represents that a line was added, removed or changed.
These colors are controlled by the theme attributes `diff.plus`, `diff.minus` and `diff.delta`.
Other diff providers will eventually be supported by a future plugin system.
There are currently no options for this section.
#### `[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` | `↪ ` |
| `wrap-at-text-width` | Soft wrap at `text-width` instead of using the full viewport size. | `false` |
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.smart-tab]` Section
Options for navigating and editing using tab key.
| Key | Description | Default |
|------------|-------------|---------|
| `enable` | If set to true, then when the cursor is in a position with non-whitespace to its left, instead of inserting a tab, it will run `move_parent_node_end`. If there is only whitespace to the left, then it inserts a tab as normal. With the default bindings, to explicitly insert a tab character, press Shift-tab. | `true` |
| `supersede-menu` | Normally, when a menu is on screen, such as when auto complete is triggered, the tab key is bound to cycling through the items. This means when menus are on screen, one cannot use the tab key to trigger the `smart-tab` command. If this option is set to true, the `smart-tab` command always takes precedence, which means one cannot use the tab key to cycle through menu items. One of the other bindings must be used instead, such as arrow keys or `C-n`/`C-p`. | `false` |
Due to lack of support for S-tab in some terminals, the default keybindings don't fully embrace smart-tab editing experience. If you enjoy smart-tab navigation and a terminal that supports the [Enhanced Keyboard protocol](https://github.com/helix-editor/helix/wiki/Terminal-Support#enhanced-keyboard-protocol), consider setting extra keybindings:
```
[keys.normal]
tab = "move_parent_node_end"
S-tab = "move_parent_node_start"
[keys.insert]
S-tab = "move_parent_node_start"
[keys.select]
tab = "extend_parent_node_end"
S-tab = "extend_parent_node_start"
```
### `[editor.inline-diagnostics]` Section
Options for rendering diagnostics inside the text like shown below
```
fn main() {
let foo = bar;
└─ no such value in this scope
}
````
| Key | Description | Default |
|------------|-------------|---------|
| `cursor-line` | The minimum severity that a diagnostic must have to be shown inline on the line that contains the primary cursor. Set to `disable` to not show any diagnostics inline. This option does not have any effect when in insert-mode and will only take effect 350ms after moving the cursor to a different line. | `"disable"` |
| `other-lines` | The minimum severity that a diagnostic must have to be shown inline on a line that does not contain the cursor-line. Set to `disable` to not show any diagnostics inline. | `"disable"` |
| `prefix-len` | How many horizontal bars `─` are rendered before the diagnostic text. | `1` |
| `max-wrap` | Equivalent of the `editor.soft-wrap.max-wrap` option for diagnostics. | `20` |
| `max-diagnostics` | Maximum number of diagnostics to render inline for a given line | `10` |
The allowed values for `cursor-line` and `other-lines` are: `error`, `warning`, `info`, `hint`.
The (first) diagnostic with the highest severity that is not shown inline is rendered at the end of the line (as long as its severity is higher than the `end-of-line-diagnostics` config option):
```
fn main() {
let baz = 1;
let foo = bar; a local variable with a similar name exists: baz
└─ no such value in this scope
}
```

View File

@@ -1,143 +1,94 @@
| Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default language servers |
| Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default LSP |
| --- | --- | --- | --- | --- |
| ada | ✓ | ✓ | | `ada_language_server` |
| adl | ✓ | ✓ | ✓ | |
| agda | ✓ | | | |
| alloy | ✓ | | | |
| amber | ✓ | | | `amber-lsp` |
| astro | ✓ | | | `astro-ls` |
| astro | ✓ | | | |
| awk | ✓ | ✓ | | `awk-language-server` |
| bash | ✓ | ✓ | ✓ | `bash-language-server` |
| bass | ✓ | | | `bass` |
| beancount | ✓ | | | `beancount-language-server` |
| beancount | ✓ | | | |
| bibtex | ✓ | | | `texlab` |
| bicep | ✓ | | | `bicep-langserver` |
| bitbake | ✓ | | | `bitbake-language-server` |
| blade | ✓ | | | |
| blueprint | ✓ | | | `blueprint-compiler` |
| c | ✓ | ✓ | ✓ | `clangd` |
| c-sharp | ✓ | ✓ | | `OmniSharp` |
| cabal | | | | `haskell-language-server-wrapper` |
| caddyfile | ✓ | ✓ | ✓ | |
| cairo | ✓ | ✓ | ✓ | `cairo-language-server` |
| capnp | ✓ | | ✓ | |
| cel | ✓ | | | |
| circom | ✓ | | | `circom-lsp` |
| clarity | ✓ | | | `clarinet` |
| clojure | ✓ | | | `clojure-lsp` |
| cmake | ✓ | ✓ | ✓ | `neocmakelsp`, `cmake-language-server` |
| codeql | ✓ | ✓ | | `codeql` |
| cmake | ✓ | ✓ | ✓ | `cmake-language-server` |
| comment | ✓ | | | |
| common-lisp | ✓ | | ✓ | `cl-lsp` |
| cpon | ✓ | | ✓ | |
| cpp | ✓ | ✓ | ✓ | `clangd` |
| crystal | ✓ | ✓ | | `crystalline`, `ameba-ls` |
| css | ✓ | | | `vscode-css-language-server` |
| csv | ✓ | | | |
| crystal | ✓ | ✓ | | `crystalline` |
| css | ✓ | | | `vscode-css-language-server` |
| cue | ✓ | | | `cuelsp` |
| cylc | ✓ | ✓ | ✓ | |
| d | ✓ | ✓ | ✓ | `serve-d` |
| dart | ✓ | | ✓ | `dart` |
| dart | ✓ | | ✓ | `dart` |
| dbml | ✓ | | | |
| debian | ✓ | | | |
| devicetree | ✓ | | | `dts-lsp` |
| devicetree | ✓ | | | |
| dhall | ✓ | ✓ | | `dhall-lsp-server` |
| diff | ✓ | | | |
| djot | ✓ | | | |
| docker-compose | ✓ | ✓ | ✓ | `docker-compose-langserver`, `yaml-language-server` |
| dockerfile | ✓ | ✓ | | `docker-langserver` |
| dockerfile | ✓ | | | `docker-langserver` |
| dot | ✓ | | | `dot-language-server` |
| dtd | ✓ | | | |
| dune | ✓ | | | |
| dunstrc | ✓ | | | |
| earthfile | ✓ | ✓ | ✓ | `earthlyls` |
| edoc | ✓ | | | |
| eex | ✓ | | | |
| ejs | ✓ | | | |
| elisp | ✓ | | | |
| elixir | ✓ | ✓ | ✓ | `elixir-ls` |
| elm | ✓ | ✓ | | `elm-language-server` |
| elvish | ✓ | | | `elvish` |
| env | ✓ | | | |
| env | ✓ | | | |
| erb | ✓ | | | |
| erlang | ✓ | ✓ | | `erlang_ls`, `elp` |
| erlang | ✓ | ✓ | | `erlang_ls` |
| esdl | ✓ | | | |
| fennel | ✓ | | | `fennel-ls` |
| fga | ✓ | ✓ | ✓ | |
| fidl | ✓ | | | |
| fish | ✓ | ✓ | ✓ | `fish-lsp` |
| fish | ✓ | | | |
| forth | ✓ | | | `forth-lsp` |
| fortran | ✓ | | ✓ | `fortls` |
| fsharp | ✓ | | | `fsautocomplete` |
| gas | ✓ | ✓ | | `asm-lsp` |
| gas | ✓ | ✓ | | |
| gdscript | ✓ | ✓ | ✓ | |
| gemini | ✓ | | | |
| gherkin | ✓ | | | |
| ghostty | ✓ | | | |
| git-attributes | ✓ | | | |
| git-commit | ✓ | ✓ | | |
| git-config | ✓ | | | |
| git-config | ✓ | | | |
| git-ignore | ✓ | | | |
| git-notes | ✓ | | | |
| git-rebase | ✓ | | | |
| gjs | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` |
| gleam | ✓ | ✓ | | `gleam` |
| glimmer | ✓ | | | `ember-language-server` |
| glsl | ✓ | ✓ | ✓ | `glsl_analyzer` |
| glsl | ✓ | ✓ | | |
| gn | ✓ | | | |
| go | ✓ | ✓ | ✓ | `gopls`, `golangci-lint-langserver` |
| godot-resource | ✓ | | | |
| godot-resource | ✓ | | | |
| gomod | ✓ | | | `gopls` |
| gotmpl | ✓ | | | `gopls` |
| gowork | ✓ | | | `gopls` |
| gpr | ✓ | | | `ada_language_server` |
| graphql | ✓ | ✓ | | `graphql-lsp` |
| gren | ✓ | ✓ | | |
| groovy | ✓ | | | |
| gts | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` |
| graphql | ✓ | | | `graphql-lsp` |
| hare | ✓ | | | |
| haskell | ✓ | ✓ | | `haskell-language-server-wrapper` |
| haskell-persistent | ✓ | | | |
| hcl | ✓ | | ✓ | `terraform-ls` |
| hcl | ✓ | | ✓ | `terraform-ls` |
| heex | ✓ | ✓ | | `elixir-ls` |
| helm | ✓ | | | `helm_ls` |
| hocon | ✓ | ✓ | ✓ | |
| hoon | ✓ | | | |
| hocon | ✓ | | | |
| hosts | ✓ | | | |
| html | ✓ | | | `vscode-html-language-server`, `superhtml` |
| htmldjango | ✓ | | | `djlsp`, `vscode-html-language-server`, `superhtml` |
| hurl | ✓ | ✓ | ✓ | |
| hyprlang | ✓ | | ✓ | `hyprls` |
| html | ✓ | | | `vscode-html-language-server` |
| hurl | ✓ | | | |
| idris | | | | `idris2-lsp` |
| iex | ✓ | | | |
| ini | ✓ | | | |
| ink | ✓ | | | |
| inko | ✓ | ✓ | ✓ | |
| janet | ✓ | | | |
| java | ✓ | ✓ | ✓ | `jdtls` |
| javascript | ✓ | ✓ | ✓ | `typescript-language-server` |
| jinja | ✓ | | | |
| jjconfig | ✓ | ✓ | ✓ | `taplo`, `tombi` |
| jjdescription | ✓ | | | |
| jjrevset | ✓ | | | |
| jjtemplate | ✓ | | | |
| jq | ✓ | ✓ | | `jq-lsp` |
| jsdoc | ✓ | | | |
| json | ✓ | | ✓ | `vscode-json-language-server` |
| json-ld | ✓ | ✓ | ✓ | `vscode-json-language-server` |
| json | ✓ | | ✓ | `vscode-json-language-server` |
| json5 | ✓ | | | |
| jsonc | ✓ | | ✓ | `vscode-json-language-server` |
| jsonnet | ✓ | | | `jsonnet-language-server` |
| jsx | ✓ | ✓ | ✓ | `typescript-language-server` |
| julia | ✓ | ✓ | ✓ | `julia` |
| just | ✓ | ✓ | ✓ | `just-lsp` |
| just | ✓ | ✓ | ✓ | |
| kdl | ✓ | ✓ | ✓ | |
| koka | ✓ | | | `koka` |
| kotlin | ✓ | ✓ | ✓ | `kotlin-language-server` |
| koto | ✓ | ✓ | ✓ | `koto-ls` |
| kotlin | ✓ | | | `kotlin-language-server` |
| latex | ✓ | ✓ | | `texlab` |
| ld | ✓ | | ✓ | |
| ldif | ✓ | | | |
| lean | ✓ | | | `lean` |
| ledger | ✓ | | | |
| llvm | ✓ | ✓ | ✓ | |
@@ -146,32 +97,24 @@
| log | ✓ | | | |
| lpf | ✓ | | | |
| lua | ✓ | ✓ | ✓ | `lua-language-server` |
| luau | ✓ | ✓ | ✓ | `luau-lsp` |
| mail | ✓ | ✓ | | |
| make | ✓ | | ✓ | |
| make | ✓ | | | |
| markdoc | ✓ | | | `markdoc-ls` |
| markdown | ✓ | | | `marksman`, `markdown-oxide` |
| markdown-rustdoc | ✓ | | | |
| markdown | ✓ | | | `marksman` |
| markdown.inline | ✓ | | | |
| matlab | ✓ | ✓ | ✓ | |
| mermaid | ✓ | | | |
| meson | ✓ | | ✓ | `mesonlsp` |
| meson | ✓ | | ✓ | |
| mint | | | | `mint` |
| mojo | ✓ | ✓ | ✓ | `pixi` |
| move | ✓ | | | |
| msbuild | ✓ | | ✓ | |
| nasm | ✓ | ✓ | | `asm-lsp` |
| nestedtext | ✓ | ✓ | ✓ | |
| nginx | ✓ | | | |
| nasm | ✓ | ✓ | | |
| nickel | ✓ | | ✓ | `nls` |
| nim | ✓ | ✓ | ✓ | `nimlangserver` |
| nix | ✓ | | | `nil`, `nixd` |
| nix | ✓ | | | `nil` |
| nu | ✓ | | | `nu` |
| nunjucks | ✓ | | | |
| ocaml | ✓ | | ✓ | `ocamllsp` |
| ocaml-interface | ✓ | | | `ocamllsp` |
| odin | ✓ | | ✓ | `ols` |
| ohm | ✓ | ✓ | ✓ | |
| odin | ✓ | | ✓ | `ols` |
| opencl | ✓ | ✓ | ✓ | `clangd` |
| openscad | ✓ | | | `openscad-lsp` |
| org | ✓ | | | |
@@ -179,26 +122,17 @@
| passwd | ✓ | | | |
| pem | ✓ | | | |
| perl | ✓ | ✓ | ✓ | `perlnavigator` |
| pest | ✓ | ✓ | ✓ | `pest-language-server` |
| php | ✓ | ✓ | ✓ | `intelephense` |
| php-only | ✓ | | | |
| pkgbuild | ✓ | ✓ | ✓ | `termux-language-server`, `bash-language-server` |
| pkl | ✓ | | ✓ | `pkl-lsp` |
| po | ✓ | ✓ | | |
| pod | ✓ | | | |
| ponylang | ✓ | ✓ | ✓ | |
| powershell | ✓ | | | |
| prisma | | | | `prisma-language-server` |
| prolog | ✓ | | ✓ | `swipl` |
| properties | ✓ | ✓ | | |
| protobuf | ✓ | ✓ | ✓ | `buf`, `pb`, `protols` |
| prisma | ✓ | | | `prisma-language-server` |
| prolog | | | | `swipl` |
| protobuf | ✓ | | ✓ | `bufls`, `pb` |
| prql | ✓ | | | |
| pug | ✓ | | | |
| purescript | ✓ | ✓ | | `purescript-language-server` |
| python | ✓ | ✓ | ✓ | `ty`, `ruff`, `jedi-language-server`, `pylsp` |
| qml | ✓ | | ✓ | `qmlls` |
| quarto | ✓ | | ✓ | |
| quint | ✓ | | | `quint-language-server` |
| python | ✓ | ✓ | ✓ | `pylsp` |
| qml | ✓ | | ✓ | `qmlls` |
| r | ✓ | | | `R` |
| racket | ✓ | | ✓ | `racket` |
| regex | ✓ | | | |
@@ -208,75 +142,53 @@
| robot | ✓ | | | `robotframework_ls` |
| ron | ✓ | | ✓ | |
| rst | ✓ | | | |
| ruby | ✓ | ✓ | ✓ | `ruby-lsp`, `solargraph` |
| ruby | ✓ | ✓ | ✓ | `solargraph` |
| rust | ✓ | ✓ | ✓ | `rust-analyzer` |
| rust-format-args | ✓ | | | |
| rust-format-args-macro | ✓ | ✓ | ✓ | |
| sage | ✓ | ✓ | | |
| scala | ✓ | ✓ | ✓ | `metals` |
| scheme | ✓ | | ✓ | |
| scss | ✓ | | | `vscode-css-language-server` |
| slang | ✓ | | ✓ | `slangd` |
| slint | ✓ | ✓ | ✓ | `slint-lsp` |
| slint | ✓ | | ✓ | `slint-lsp` |
| smali | ✓ | | ✓ | |
| smithy | ✓ | | | `cs` |
| sml | ✓ | | | |
| snakemake | ✓ | | | `pylsp` |
| solidity | ✓ | | | `solc` |
| sourcepawn | ✓ | ✓ | | `sourcepawn-studio` |
| spade | ✓ | | ✓ | `spade-language-server` |
| spicedb | ✓ | | | |
| sql | ✓ | ✓ | | |
| solidity | ✓ | | | `solc` |
| sql | ✓ | | | |
| sshclientconfig | ✓ | | | |
| starlark | ✓ | ✓ | | `starpls` |
| starlark | ✓ | ✓ | | |
| strace | ✓ | | | |
| supercollider | ✓ | | | |
| svelte | ✓ | | ✓ | `svelteserver` |
| sway | ✓ | ✓ | ✓ | `forc` |
| swift | ✓ | | | `sourcekit-lsp` |
| systemd | ✓ | | | `systemd-lsp` |
| swift | ✓ | | | `sourcekit-lsp` |
| t32 | ✓ | | | |
| tablegen | ✓ | ✓ | ✓ | |
| tact | ✓ | ✓ | ✓ | |
| task | ✓ | | | |
| tcl | ✓ | | ✓ | |
| teal | ✓ | | | `teal-language-server` |
| templ | ✓ | | | `templ` |
| tera | ✓ | | | |
| textproto | ✓ | ✓ | ✓ | |
| tfvars | ✓ | | ✓ | `terraform-ls` |
| thrift | ✓ | | | |
| tlaplus | ✓ | | | |
| todotxt | ✓ | | | |
| toml | ✓ | | | `taplo`, `tombi` |
| tsq | ✓ | | | `ts_query_ls` |
| toml | ✓ | | | `taplo` |
| tsq | ✓ | | | |
| tsx | ✓ | ✓ | ✓ | `typescript-language-server` |
| twig | ✓ | | | |
| typescript | ✓ | ✓ | ✓ | `typescript-language-server` |
| typespec | ✓ | | | `tsp-server` |
| typst | ✓ | | | `tinymist` |
| typst | ✓ | | | `typst-lsp` |
| ungrammar | ✓ | | | |
| unison | ✓ | | | |
| unison | ✓ | | | |
| uxntal | ✓ | | | |
| v | ✓ | ✓ | ✓ | `v-analyzer` |
| vala | ✓ | | | `vala-language-server` |
| vento | ✓ | | | |
| vala | ✓ | | | `vala-language-server` |
| verilog | ✓ | ✓ | | `svlangserver` |
| vhdl | ✓ | | | `vhdl_ls` |
| vhs | ✓ | | | |
| vue | ✓ | | | `vue-language-server` |
| wast | ✓ | | | |
| wat | ✓ | | | `wat_server` |
| wat | ✓ | | | |
| webc | ✓ | | | |
| werk | ✓ | | | |
| wesl | ✓ | ✓ | | |
| wgsl | ✓ | | | `wgsl-analyzer` |
| wgsl | ✓ | | | `wgsl_analyzer` |
| wit | ✓ | | ✓ | |
| wren | ✓ | ✓ | ✓ | |
| xit | ✓ | | | |
| xml | ✓ | | ✓ | |
| xtc | ✓ | | | |
| yaml | ✓ | ✓ | ✓ | `yaml-language-server`, `ansible-language-server` |
| yara | ✓ | | | `yls` |
| yaml | ✓ | | | `yaml-language-server`, `ansible-language-server` |
| yuck | ✓ | | | |
| zig | ✓ | ✓ | ✓ | `zls` |

View File

@@ -1,307 +0,0 @@
| Name | Description | Default keybinds |
| --- | --- | --- |
| `no_op` | Do nothing | |
| `move_char_left` | Move left | normal: `` h ``, `` <left> ``, insert: `` <left> `` |
| `move_char_right` | Move right | normal: `` l ``, `` <right> ``, insert: `` <right> `` |
| `move_line_up` | Move up | normal: `` gk `` |
| `move_line_down` | Move down | normal: `` gj `` |
| `move_visual_line_up` | Move up | normal: `` k ``, `` <up> ``, insert: `` <up> `` |
| `move_visual_line_down` | Move down | normal: `` j ``, `` <down> ``, insert: `` <down> `` |
| `extend_char_left` | Extend left | select: `` h ``, `` <left> `` |
| `extend_char_right` | Extend right | select: `` l ``, `` <right> `` |
| `extend_line_up` | Extend up | select: `` gk `` |
| `extend_line_down` | Extend down | select: `` gj `` |
| `extend_visual_line_up` | Extend up | select: `` k ``, `` <up> `` |
| `extend_visual_line_down` | Extend down | select: `` j ``, `` <down> `` |
| `copy_selection_on_next_line` | Copy selection on next line | normal: `` C ``, select: `` C `` |
| `copy_selection_on_prev_line` | Copy selection on previous line | normal: `` <A-C> ``, select: `` <A-C> `` |
| `move_next_word_start` | Move to start of next word | normal: `` w `` |
| `move_prev_word_start` | Move to start of previous word | normal: `` b `` |
| `move_next_word_end` | Move to end of next word | normal: `` e `` |
| `move_prev_word_end` | Move to end of previous word | |
| `move_next_long_word_start` | Move to start of next long word | normal: `` W `` |
| `move_prev_long_word_start` | Move to start of previous long word | normal: `` B `` |
| `move_next_long_word_end` | Move to end of next long word | normal: `` E `` |
| `move_prev_long_word_end` | Move to end of previous long word | |
| `move_next_sub_word_start` | Move to start of next sub word | |
| `move_prev_sub_word_start` | Move to start of previous sub word | |
| `move_next_sub_word_end` | Move to end of next sub word | |
| `move_prev_sub_word_end` | Move to end of previous sub word | |
| `move_parent_node_end` | Move to end of the parent node | normal: `` <A-e> `` |
| `move_parent_node_start` | Move to beginning of the parent node | normal: `` <A-b> `` |
| `extend_next_word_start` | Extend to start of next word | select: `` w `` |
| `extend_prev_word_start` | Extend to start of previous word | select: `` b `` |
| `extend_next_word_end` | Extend to end of next word | select: `` e `` |
| `extend_prev_word_end` | Extend to end of previous word | |
| `extend_next_long_word_start` | Extend to start of next long word | select: `` W `` |
| `extend_prev_long_word_start` | Extend to start of previous long word | select: `` B `` |
| `extend_next_long_word_end` | Extend to end of next long word | select: `` E `` |
| `extend_prev_long_word_end` | Extend to end of prev long word | |
| `extend_next_sub_word_start` | Extend to start of next sub word | |
| `extend_prev_sub_word_start` | Extend to start of previous sub word | |
| `extend_next_sub_word_end` | Extend to end of next sub word | |
| `extend_prev_sub_word_end` | Extend to end of prev sub word | |
| `extend_parent_node_end` | Extend to end of the parent node | select: `` <A-e> `` |
| `extend_parent_node_start` | Extend to beginning of the parent node | select: `` <A-b> `` |
| `find_till_char` | Move till next occurrence of char | normal: `` t `` |
| `find_next_char` | Move to next occurrence of char | normal: `` f `` |
| `extend_till_char` | Extend till next occurrence of char | select: `` t `` |
| `extend_next_char` | Extend to next occurrence of char | select: `` f `` |
| `till_prev_char` | Move till previous occurrence of char | normal: `` T `` |
| `find_prev_char` | Move to previous occurrence of char | normal: `` F `` |
| `extend_till_prev_char` | Extend till previous occurrence of char | select: `` T `` |
| `extend_prev_char` | Extend to previous occurrence of char | select: `` F `` |
| `repeat_last_motion` | Repeat last motion | normal: `` <A-.> ``, select: `` <A-.> `` |
| `replace` | Replace with new char | normal: `` r ``, select: `` r `` |
| `switch_case` | Switch (toggle) case | normal: `` ~ ``, select: `` ~ `` |
| `switch_to_uppercase` | Switch to uppercase | normal: `` <A-`> ``, select: `` <A-`> `` |
| `switch_to_lowercase` | Switch to lowercase | normal: `` ` ``, select: `` ` `` |
| `page_up` | Move page up | normal: `` <C-b> ``, `` Z<C-b> ``, `` z<C-b> ``, `` <pageup> ``, `` Z<pageup> ``, `` z<pageup> ``, select: `` <C-b> ``, `` Z<C-b> ``, `` z<C-b> ``, `` <pageup> ``, `` Z<pageup> ``, `` z<pageup> ``, insert: `` <pageup> `` |
| `page_down` | Move page down | normal: `` <C-f> ``, `` Z<C-f> ``, `` z<C-f> ``, `` <pagedown> ``, `` Z<pagedown> ``, `` z<pagedown> ``, select: `` <C-f> ``, `` Z<C-f> ``, `` z<C-f> ``, `` <pagedown> ``, `` Z<pagedown> ``, `` z<pagedown> ``, insert: `` <pagedown> `` |
| `half_page_up` | Move half page up | |
| `half_page_down` | Move half page down | |
| `page_cursor_up` | Move page and cursor up | |
| `page_cursor_down` | Move page and cursor down | |
| `page_cursor_half_up` | Move page and cursor half up | normal: `` <C-u> ``, `` Z<C-u> ``, `` z<C-u> ``, `` Z<backspace> ``, `` z<backspace> ``, select: `` <C-u> ``, `` Z<C-u> ``, `` z<C-u> ``, `` Z<backspace> ``, `` z<backspace> `` |
| `page_cursor_half_down` | Move page and cursor half down | normal: `` <C-d> ``, `` Z<C-d> ``, `` z<C-d> ``, `` Z<space> ``, `` z<space> ``, select: `` <C-d> ``, `` Z<C-d> ``, `` z<C-d> ``, `` Z<space> ``, `` z<space> `` |
| `select_all` | Select whole document | normal: `` % ``, select: `` % `` |
| `select_regex` | Select all regex matches inside selections | normal: `` s ``, select: `` s `` |
| `split_selection` | Split selections on regex matches | normal: `` S ``, select: `` S `` |
| `split_selection_on_newline` | Split selection on newlines | normal: `` <A-s> ``, select: `` <A-s> `` |
| `merge_selections` | Merge selections | normal: `` <A-minus> ``, select: `` <A-minus> `` |
| `merge_consecutive_selections` | Merge consecutive selections | normal: `` <A-_> ``, select: `` <A-_> `` |
| `search` | Search for regex pattern | normal: `` / ``, `` Z/ ``, `` z/ ``, select: `` / ``, `` Z/ ``, `` z/ `` |
| `rsearch` | Reverse search for regex pattern | normal: `` ? ``, `` Z? ``, `` z? ``, select: `` ? ``, `` Z? ``, `` z? `` |
| `search_next` | Select next search match | normal: `` n ``, `` Zn ``, `` zn ``, select: `` Zn ``, `` zn `` |
| `search_prev` | Select previous search match | normal: `` N ``, `` ZN ``, `` zN ``, select: `` ZN ``, `` zN `` |
| `extend_search_next` | Add next search match to selection | select: `` n `` |
| `extend_search_prev` | Add previous search match to selection | select: `` N `` |
| `search_selection` | Use current selection as search pattern | normal: `` <A-*> ``, select: `` <A-*> `` |
| `search_selection_detect_word_boundaries` | Use current selection as the search pattern, automatically wrapping with `\b` on word boundaries | normal: `` * ``, select: `` * `` |
| `make_search_word_bounded` | Modify current search to make it word bounded | |
| `global_search` | Global search in workspace folder | normal: `` <space>/ ``, select: `` <space>/ `` |
| `extend_line` | Select current line, if already selected, extend to another line based on the anchor | |
| `extend_line_below` | Select current line, if already selected, extend to next line | normal: `` x ``, select: `` x `` |
| `extend_line_above` | Select current line, if already selected, extend to previous line | |
| `select_line_above` | Select current line, if already selected, extend or shrink line above based on the anchor | |
| `select_line_below` | Select current line, if already selected, extend or shrink line below based on the anchor | |
| `extend_to_line_bounds` | Extend selection to line bounds | normal: `` X ``, select: `` X `` |
| `shrink_to_line_bounds` | Shrink selection to line bounds | normal: `` <A-x> ``, select: `` <A-x> `` |
| `delete_selection` | Delete selection | normal: `` d ``, select: `` d `` |
| `delete_selection_noyank` | Delete selection without yanking | normal: `` <A-d> ``, select: `` <A-d> `` |
| `change_selection` | Change selection | normal: `` c ``, select: `` c `` |
| `change_selection_noyank` | Change selection without yanking | normal: `` <A-c> ``, select: `` <A-c> `` |
| `collapse_selection` | Collapse selection into single cursor | normal: `` ; ``, select: `` ; `` |
| `flip_selections` | Flip selection cursor and anchor | normal: `` <A-;> ``, select: `` <A-;> `` |
| `ensure_selections_forward` | Ensure all selections face forward | normal: `` <A-:> ``, select: `` <A-:> `` |
| `insert_mode` | Insert before selection | normal: `` i ``, select: `` i `` |
| `append_mode` | Append after selection | normal: `` a ``, select: `` a `` |
| `command_mode` | Enter command mode | normal: `` : ``, select: `` : `` |
| `file_picker` | Open file picker | normal: `` <space>f ``, select: `` <space>f `` |
| `file_picker_in_current_buffer_directory` | Open file picker at current buffer's directory | |
| `file_picker_in_current_directory` | Open file picker at current working directory | normal: `` <space>F ``, select: `` <space>F `` |
| `file_explorer` | Open file explorer in workspace root | normal: `` <space>e ``, select: `` <space>e `` |
| `file_explorer_in_current_buffer_directory` | Open file explorer at current buffer's directory | normal: `` <space>E ``, select: `` <space>E `` |
| `file_explorer_in_current_directory` | Open file explorer at current working directory | |
| `code_action` | Perform code action | normal: `` <space>a ``, select: `` <space>a `` |
| `buffer_picker` | Open buffer picker | normal: `` <space>b ``, select: `` <space>b `` |
| `jumplist_picker` | Open jumplist picker | normal: `` <space>j ``, select: `` <space>j `` |
| `symbol_picker` | Open symbol picker | normal: `` <space>s ``, select: `` <space>s `` |
| `changed_file_picker` | Open changed file picker | normal: `` <space>g ``, select: `` <space>g `` |
| `select_references_to_symbol_under_cursor` | Select symbol references | normal: `` <space>h ``, select: `` <space>h `` |
| `workspace_symbol_picker` | Open workspace symbol picker | normal: `` <space>S ``, select: `` <space>S `` |
| `diagnostics_picker` | Open diagnostic picker | normal: `` <space>d ``, select: `` <space>d `` |
| `workspace_diagnostics_picker` | Open workspace diagnostic picker | normal: `` <space>D ``, select: `` <space>D `` |
| `last_picker` | Open last picker | normal: `` <space>' ``, select: `` <space>' `` |
| `insert_at_line_start` | Insert at start of line | normal: `` I ``, select: `` I `` |
| `insert_at_line_end` | Insert at end of line | normal: `` A ``, select: `` A `` |
| `open_below` | Open new line below selection | normal: `` o ``, select: `` o `` |
| `open_above` | Open new line above selection | normal: `` O ``, select: `` O `` |
| `normal_mode` | Enter normal mode | normal: `` <esc> ``, select: `` v ``, insert: `` <esc> `` |
| `select_mode` | Enter selection extend mode | normal: `` v `` |
| `exit_select_mode` | Exit selection mode | select: `` <esc> `` |
| `goto_definition` | Goto definition | normal: `` gd ``, select: `` gd `` |
| `goto_declaration` | Goto declaration | normal: `` gD ``, select: `` gD `` |
| `add_newline_above` | Add newline above | normal: `` [<space> ``, select: `` [<space> `` |
| `add_newline_below` | Add newline below | normal: `` ]<space> ``, select: `` ]<space> `` |
| `goto_type_definition` | Goto type definition | normal: `` gy ``, select: `` gy `` |
| `goto_implementation` | Goto implementation | normal: `` gi ``, select: `` gi `` |
| `goto_file_start` | Goto line number <n> else file start | normal: `` gg `` |
| `goto_file_end` | Goto file end | |
| `extend_to_file_start` | Extend to line number<n> else file start | select: `` gg `` |
| `extend_to_file_end` | Extend to file end | |
| `goto_file` | Goto files/URLs in selections | normal: `` gf ``, select: `` gf `` |
| `goto_file_hsplit` | Goto files in selections (hsplit) | normal: `` <C-w>f ``, `` <space>wf ``, select: `` <C-w>f ``, `` <space>wf `` |
| `goto_file_vsplit` | Goto files in selections (vsplit) | normal: `` <C-w>F ``, `` <space>wF ``, select: `` <C-w>F ``, `` <space>wF `` |
| `goto_reference` | Goto references | normal: `` gr ``, select: `` gr `` |
| `goto_window_top` | Goto window top | normal: `` gt ``, select: `` gt `` |
| `goto_window_center` | Goto window center | normal: `` gc ``, select: `` gc `` |
| `goto_window_bottom` | Goto window bottom | normal: `` gb ``, select: `` gb `` |
| `goto_last_accessed_file` | Goto last accessed file | normal: `` ga ``, select: `` ga `` |
| `goto_last_modified_file` | Goto last modified file | normal: `` gm ``, select: `` gm `` |
| `goto_last_modification` | Goto last modification | normal: `` g. ``, select: `` g. `` |
| `goto_line` | Goto line | normal: `` G ``, select: `` G `` |
| `goto_last_line` | Goto last line | normal: `` ge `` |
| `extend_to_last_line` | Extend to last line | select: `` ge `` |
| `goto_first_diag` | Goto first diagnostic | normal: `` [D ``, select: `` [D `` |
| `goto_last_diag` | Goto last diagnostic | normal: `` ]D ``, select: `` ]D `` |
| `goto_next_diag` | Goto next diagnostic | normal: `` ]d ``, select: `` ]d `` |
| `goto_prev_diag` | Goto previous diagnostic | normal: `` [d ``, select: `` [d `` |
| `goto_next_change` | Goto next change | normal: `` ]g ``, select: `` ]g `` |
| `goto_prev_change` | Goto previous change | normal: `` [g ``, select: `` [g `` |
| `goto_first_change` | Goto first change | normal: `` [G ``, select: `` [G `` |
| `goto_last_change` | Goto last change | normal: `` ]G ``, select: `` ]G `` |
| `goto_line_start` | Goto line start | normal: `` gh ``, `` <home> ``, select: `` gh ``, insert: `` <home> `` |
| `goto_line_end` | Goto line end | normal: `` gl ``, `` <end> ``, select: `` gl `` |
| `goto_column` | Goto column | normal: `` g\| `` |
| `extend_to_column` | Extend to column | select: `` g\| `` |
| `goto_next_buffer` | Goto next buffer | normal: `` gn ``, select: `` gn `` |
| `goto_previous_buffer` | Goto previous buffer | normal: `` gp ``, select: `` gp `` |
| `goto_line_end_newline` | Goto newline at line end | insert: `` <end> `` |
| `goto_first_nonwhitespace` | Goto first non-blank in line | normal: `` gs ``, select: `` gs `` |
| `trim_selections` | Trim whitespace from selections | normal: `` _ ``, select: `` _ `` |
| `extend_to_line_start` | Extend to line start | select: `` <home> `` |
| `extend_to_first_nonwhitespace` | Extend to first non-blank in line | |
| `extend_to_line_end` | Extend to line end | select: `` <end> `` |
| `extend_to_line_end_newline` | Extend to line end | |
| `signature_help` | Show signature help | |
| `smart_tab` | Insert tab if all cursors have all whitespace to their left; otherwise, run a separate command. | insert: `` <tab> `` |
| `insert_tab` | Insert tab char | insert: `` <S-tab> `` |
| `insert_newline` | Insert newline char | insert: `` <C-j> ``, `` <ret> `` |
| `delete_char_backward` | Delete previous char | insert: `` <C-h> ``, `` <backspace> ``, `` <S-backspace> `` |
| `delete_char_forward` | Delete next char | insert: `` <C-d> ``, `` <del> `` |
| `delete_word_backward` | Delete previous word | insert: `` <C-w> ``, `` <A-backspace> `` |
| `delete_word_forward` | Delete next word | insert: `` <A-d> ``, `` <A-del> `` |
| `kill_to_line_start` | Delete till start of line | insert: `` <C-u> `` |
| `kill_to_line_end` | Delete till end of line | insert: `` <C-k> `` |
| `undo` | Undo change | normal: `` u ``, select: `` u `` |
| `redo` | Redo change | normal: `` U ``, select: `` U `` |
| `earlier` | Move backward in history | normal: `` <A-u> ``, select: `` <A-u> `` |
| `later` | Move forward in history | normal: `` <A-U> ``, select: `` <A-U> `` |
| `commit_undo_checkpoint` | Commit changes to new checkpoint | insert: `` <C-s> `` |
| `yank` | Yank selection | normal: `` y ``, select: `` y `` |
| `yank_to_clipboard` | Yank selections to clipboard | normal: `` <space>y ``, select: `` <space>y `` |
| `yank_to_primary_clipboard` | Yank selections to primary clipboard | |
| `yank_joined` | Join and yank selections | |
| `yank_joined_to_clipboard` | Join and yank selections to clipboard | |
| `yank_main_selection_to_clipboard` | Yank main selection to clipboard | normal: `` <space>Y ``, select: `` <space>Y `` |
| `yank_joined_to_primary_clipboard` | Join and yank selections to primary clipboard | |
| `yank_main_selection_to_primary_clipboard` | Yank main selection to primary clipboard | |
| `replace_with_yanked` | Replace with yanked text | normal: `` R ``, select: `` R `` |
| `replace_selections_with_clipboard` | Replace selections by clipboard content | normal: `` <space>R ``, select: `` <space>R `` |
| `replace_selections_with_primary_clipboard` | Replace selections by primary clipboard | |
| `paste_after` | Paste after selection | normal: `` p ``, select: `` p `` |
| `paste_before` | Paste before selection | normal: `` P ``, select: `` P `` |
| `paste_clipboard_after` | Paste clipboard after selections | normal: `` <space>p ``, select: `` <space>p `` |
| `paste_clipboard_before` | Paste clipboard before selections | normal: `` <space>P ``, select: `` <space>P `` |
| `paste_primary_clipboard_after` | Paste primary clipboard after selections | |
| `paste_primary_clipboard_before` | Paste primary clipboard before selections | |
| `indent` | Indent selection | normal: `` <gt> ``, select: `` <gt> `` |
| `unindent` | Unindent selection | normal: `` <lt> ``, select: `` <lt> `` |
| `format_selections` | Format selection | normal: `` = ``, select: `` = `` |
| `join_selections` | Join lines inside selection | normal: `` J ``, select: `` J `` |
| `join_selections_space` | Join lines inside selection and select spaces | normal: `` <A-J> ``, select: `` <A-J> `` |
| `keep_selections` | Keep selections matching regex | normal: `` K ``, select: `` K `` |
| `remove_selections` | Remove selections matching regex | normal: `` <A-K> ``, select: `` <A-K> `` |
| `align_selections` | Align selections in column | normal: `` & ``, select: `` & `` |
| `keep_primary_selection` | Keep primary selection | normal: `` , ``, select: `` , `` |
| `remove_primary_selection` | Remove primary selection | normal: `` <A-,> ``, select: `` <A-,> `` |
| `completion` | Invoke completion popup | insert: `` <C-x> `` |
| `hover` | Show docs for item under cursor | normal: `` <space>k ``, select: `` <space>k `` |
| `toggle_comments` | Comment/uncomment selections | normal: `` <C-c> ``, `` <space>c ``, select: `` <C-c> ``, `` <space>c `` |
| `toggle_line_comments` | Line comment/uncomment selections | normal: `` <space><A-c> ``, select: `` <space><A-c> `` |
| `toggle_block_comments` | Block comment/uncomment selections | normal: `` <space>C ``, select: `` <space>C `` |
| `rotate_selections_forward` | Rotate selections forward | normal: `` ) ``, select: `` ) `` |
| `rotate_selections_backward` | Rotate selections backward | normal: `` ( ``, select: `` ( `` |
| `rotate_selection_contents_forward` | Rotate selection contents forward | normal: `` <A-)> ``, select: `` <A-)> `` |
| `rotate_selection_contents_backward` | Rotate selections contents backward | normal: `` <A-(> ``, select: `` <A-(> `` |
| `reverse_selection_contents` | Reverse selections contents | |
| `expand_selection` | Expand selection to parent syntax node | normal: `` <A-o> ``, `` <A-up> ``, select: `` <A-o> ``, `` <A-up> `` |
| `shrink_selection` | Shrink selection to previously expanded syntax node | normal: `` <A-i> ``, `` <A-down> ``, select: `` <A-i> ``, `` <A-down> `` |
| `select_next_sibling` | Select next sibling in the syntax tree | normal: `` <A-n> ``, `` <A-right> ``, select: `` <A-n> ``, `` <A-right> `` |
| `select_prev_sibling` | Select previous sibling the in syntax tree | normal: `` <A-p> ``, `` <A-left> ``, select: `` <A-p> ``, `` <A-left> `` |
| `select_all_siblings` | Select all siblings of the current node | normal: `` <A-a> ``, select: `` <A-a> `` |
| `select_all_children` | Select all children of the current node | normal: `` <A-I> ``, `` <S-A-down> ``, select: `` <A-I> ``, `` <S-A-down> `` |
| `jump_forward` | Jump forward on jumplist | normal: `` <C-i> ``, `` <tab> ``, select: `` <C-i> ``, `` <tab> `` |
| `jump_backward` | Jump backward on jumplist | normal: `` <C-o> ``, select: `` <C-o> `` |
| `save_selection` | Save current selection to jumplist | normal: `` <C-s> ``, select: `` <C-s> `` |
| `jump_view_right` | Jump to right split | normal: `` <C-w>l ``, `` <space>wl ``, `` <C-w><C-l> ``, `` <C-w><right> ``, `` <space>w<C-l> ``, `` <space>w<right> ``, select: `` <C-w>l ``, `` <space>wl ``, `` <C-w><C-l> ``, `` <C-w><right> ``, `` <space>w<C-l> ``, `` <space>w<right> `` |
| `jump_view_left` | Jump to left split | normal: `` <C-w>h ``, `` <space>wh ``, `` <C-w><C-h> ``, `` <C-w><left> ``, `` <space>w<C-h> ``, `` <space>w<left> ``, select: `` <C-w>h ``, `` <space>wh ``, `` <C-w><C-h> ``, `` <C-w><left> ``, `` <space>w<C-h> ``, `` <space>w<left> `` |
| `jump_view_up` | Jump to split above | normal: `` <C-w>k ``, `` <C-w><up> ``, `` <space>wk ``, `` <C-w><C-k> ``, `` <space>w<up> ``, `` <space>w<C-k> ``, select: `` <C-w>k ``, `` <C-w><up> ``, `` <space>wk ``, `` <C-w><C-k> ``, `` <space>w<up> ``, `` <space>w<C-k> `` |
| `jump_view_down` | Jump to split below | normal: `` <C-w>j ``, `` <space>wj ``, `` <C-w><C-j> ``, `` <C-w><down> ``, `` <space>w<C-j> ``, `` <space>w<down> ``, select: `` <C-w>j ``, `` <space>wj ``, `` <C-w><C-j> ``, `` <C-w><down> ``, `` <space>w<C-j> ``, `` <space>w<down> `` |
| `swap_view_right` | Swap with right split | normal: `` <C-w>L ``, `` <space>wL ``, select: `` <C-w>L ``, `` <space>wL `` |
| `swap_view_left` | Swap with left split | normal: `` <C-w>H ``, `` <space>wH ``, select: `` <C-w>H ``, `` <space>wH `` |
| `swap_view_up` | Swap with split above | normal: `` <C-w>K ``, `` <space>wK ``, select: `` <C-w>K ``, `` <space>wK `` |
| `swap_view_down` | Swap with split below | normal: `` <C-w>J ``, `` <space>wJ ``, select: `` <C-w>J ``, `` <space>wJ `` |
| `transpose_view` | Transpose splits | normal: `` <C-w>t ``, `` <space>wt ``, `` <C-w><C-t> ``, `` <space>w<C-t> ``, select: `` <C-w>t ``, `` <space>wt ``, `` <C-w><C-t> ``, `` <space>w<C-t> `` |
| `rotate_view` | Goto next window | normal: `` <C-w>w ``, `` <space>ww ``, `` <C-w><C-w> ``, `` <space>w<C-w> ``, select: `` <C-w>w ``, `` <space>ww ``, `` <C-w><C-w> ``, `` <space>w<C-w> `` |
| `rotate_view_reverse` | Goto previous window | |
| `hsplit` | Horizontal bottom split | normal: `` <C-w>s ``, `` <space>ws ``, `` <C-w><C-s> ``, `` <space>w<C-s> ``, select: `` <C-w>s ``, `` <space>ws ``, `` <C-w><C-s> ``, `` <space>w<C-s> `` |
| `hsplit_new` | Horizontal bottom split scratch buffer | normal: `` <C-w>ns ``, `` <space>wns ``, `` <C-w>n<C-s> ``, `` <space>wn<C-s> ``, select: `` <C-w>ns ``, `` <space>wns ``, `` <C-w>n<C-s> ``, `` <space>wn<C-s> `` |
| `vsplit` | Vertical right split | normal: `` <C-w>v ``, `` <space>wv ``, `` <C-w><C-v> ``, `` <space>w<C-v> ``, select: `` <C-w>v ``, `` <space>wv ``, `` <C-w><C-v> ``, `` <space>w<C-v> `` |
| `vsplit_new` | Vertical right split scratch buffer | normal: `` <C-w>nv ``, `` <space>wnv ``, `` <C-w>n<C-v> ``, `` <space>wn<C-v> ``, select: `` <C-w>nv ``, `` <space>wnv ``, `` <C-w>n<C-v> ``, `` <space>wn<C-v> `` |
| `wclose` | Close window | normal: `` <C-w>q ``, `` <space>wq ``, `` <C-w><C-q> ``, `` <space>w<C-q> ``, select: `` <C-w>q ``, `` <space>wq ``, `` <C-w><C-q> ``, `` <space>w<C-q> `` |
| `wonly` | Close windows except current | normal: `` <C-w>o ``, `` <space>wo ``, `` <C-w><C-o> ``, `` <space>w<C-o> ``, select: `` <C-w>o ``, `` <space>wo ``, `` <C-w><C-o> ``, `` <space>w<C-o> `` |
| `select_register` | Select register | normal: `` " ``, select: `` " `` |
| `insert_register` | Insert register | insert: `` <C-r> `` |
| `copy_between_registers` | Copy between two registers | |
| `align_view_middle` | Align view middle | normal: `` Zm ``, `` zm ``, select: `` Zm ``, `` zm `` |
| `align_view_top` | Align view top | normal: `` Zt ``, `` zt ``, select: `` Zt ``, `` zt `` |
| `align_view_center` | Align view center | normal: `` Zc ``, `` Zz ``, `` zc ``, `` zz ``, select: `` Zc ``, `` Zz ``, `` zc ``, `` zz `` |
| `align_view_bottom` | Align view bottom | normal: `` Zb ``, `` zb ``, select: `` Zb ``, `` zb `` |
| `scroll_up` | Scroll view up | normal: `` Zk ``, `` zk ``, `` Z<up> ``, `` z<up> ``, select: `` Zk ``, `` zk ``, `` Z<up> ``, `` z<up> `` |
| `scroll_down` | Scroll view down | normal: `` Zj ``, `` zj ``, `` Z<down> ``, `` z<down> ``, select: `` Zj ``, `` zj ``, `` Z<down> ``, `` z<down> `` |
| `match_brackets` | Goto matching bracket | normal: `` mm ``, select: `` mm `` |
| `surround_add` | Surround add | normal: `` ms ``, select: `` ms `` |
| `surround_replace` | Surround replace | normal: `` mr ``, select: `` mr `` |
| `surround_delete` | Surround delete | normal: `` md ``, select: `` md `` |
| `select_textobject_around` | Select around object | normal: `` ma ``, select: `` ma `` |
| `select_textobject_inner` | Select inside object | normal: `` mi ``, select: `` mi `` |
| `goto_next_function` | Goto next function | normal: `` ]f ``, select: `` ]f `` |
| `goto_prev_function` | Goto previous function | normal: `` [f ``, select: `` [f `` |
| `goto_next_class` | Goto next type definition | normal: `` ]t ``, select: `` ]t `` |
| `goto_prev_class` | Goto previous type definition | normal: `` [t ``, select: `` [t `` |
| `goto_next_parameter` | Goto next parameter | normal: `` ]a ``, select: `` ]a `` |
| `goto_prev_parameter` | Goto previous parameter | normal: `` [a ``, select: `` [a `` |
| `goto_next_comment` | Goto next comment | normal: `` ]c ``, select: `` ]c `` |
| `goto_prev_comment` | Goto previous comment | normal: `` [c ``, select: `` [c `` |
| `goto_next_test` | Goto next test | normal: `` ]T ``, select: `` ]T `` |
| `goto_prev_test` | Goto previous test | normal: `` [T ``, select: `` [T `` |
| `goto_next_entry` | Goto next pairing | normal: `` ]e ``, select: `` ]e `` |
| `goto_prev_entry` | Goto previous pairing | normal: `` [e ``, select: `` [e `` |
| `goto_next_paragraph` | Goto next paragraph | normal: `` ]p ``, select: `` ]p `` |
| `goto_prev_paragraph` | Goto previous paragraph | normal: `` [p ``, select: `` [p `` |
| `dap_launch` | Launch debug target | normal: `` <space>Gl ``, select: `` <space>Gl `` |
| `dap_restart` | Restart debugging session | normal: `` <space>Gr ``, select: `` <space>Gr `` |
| `dap_toggle_breakpoint` | Toggle breakpoint | normal: `` <space>Gb ``, select: `` <space>Gb `` |
| `dap_continue` | Continue program execution | normal: `` <space>Gc ``, select: `` <space>Gc `` |
| `dap_pause` | Pause program execution | normal: `` <space>Gh ``, select: `` <space>Gh `` |
| `dap_step_in` | Step in | normal: `` <space>Gi ``, select: `` <space>Gi `` |
| `dap_step_out` | Step out | normal: `` <space>Go ``, select: `` <space>Go `` |
| `dap_next` | Step to next | normal: `` <space>Gn ``, select: `` <space>Gn `` |
| `dap_variables` | List variables | normal: `` <space>Gv ``, select: `` <space>Gv `` |
| `dap_terminate` | End debug session | normal: `` <space>Gt ``, select: `` <space>Gt `` |
| `dap_edit_condition` | Edit breakpoint condition on current line | normal: `` <space>G<C-c> ``, select: `` <space>G<C-c> `` |
| `dap_edit_log` | Edit breakpoint log message on current line | normal: `` <space>G<C-l> ``, select: `` <space>G<C-l> `` |
| `dap_switch_thread` | Switch current thread | normal: `` <space>Gst ``, select: `` <space>Gst `` |
| `dap_switch_stack_frame` | Switch stack frame | normal: `` <space>Gsf ``, select: `` <space>Gsf `` |
| `dap_enable_exceptions` | Enable exception breakpoints | normal: `` <space>Ge ``, select: `` <space>Ge `` |
| `dap_disable_exceptions` | Disable exception breakpoints | normal: `` <space>GE ``, select: `` <space>GE `` |
| `shell_pipe` | Pipe selections through shell command | normal: `` \| ``, select: `` \| `` |
| `shell_pipe_to` | Pipe selections into shell command ignoring output | normal: `` <A-\|> ``, select: `` <A-\|> `` |
| `shell_insert_output` | Insert shell command output before selections | normal: `` ! ``, select: `` ! `` |
| `shell_append_output` | Append shell command output after selections | normal: `` <A-!> ``, select: `` <A-!> `` |
| `shell_keep_pipe` | Filter selections with shell predicate | normal: `` $ ``, select: `` $ `` |
| `suspend` | Suspend and return to shell | normal: `` <C-z> ``, select: `` <C-z> `` |
| `rename_symbol` | Rename symbol | normal: `` <space>r ``, select: `` <space>r `` |
| `increment` | Increment item under cursor | normal: `` <C-a> ``, select: `` <C-a> `` |
| `decrement` | Decrement item under cursor | normal: `` <C-x> ``, select: `` <C-x> `` |
| `record_macro` | Record macro | normal: `` Q ``, select: `` Q `` |
| `replay_macro` | Replay macro | normal: `` q ``, select: `` q `` |
| `command_palette` | Open command palette | normal: `` <space>? ``, select: `` <space>? `` |
| `goto_word` | Jump to a two-character label | normal: `` gw `` |
| `extend_to_word` | Extend to a two-character label | select: `` gw `` |
| `goto_next_tabstop` | Goto next snippet placeholder | |
| `goto_prev_tabstop` | Goto next snippet placeholder | |
| `rotate_selections_first` | Make the first selection your primary one | |
| `rotate_selections_last` | Make the last selection your primary one | |

View File

@@ -2,7 +2,7 @@
| --- | --- |
| `:quit`, `:q` | Close the current view. |
| `:quit!`, `:q!` | Force close the current view, ignoring unsaved changes. |
| `:open`, `:o`, `:edit`, `:e` | Open a file from disk into the current view. |
| `:open`, `:o` | Open a file from disk into the current view. |
| `:buffer-close`, `:bc`, `:bclose` | Close the current buffer. |
| `:buffer-close!`, `:bc!`, `:bclose!` | Close the current buffer forcefully, ignoring unsaved changes. |
| `:buffer-close-others`, `:bco`, `:bcloseother` | Close all buffers but the currently focused one. |
@@ -16,7 +16,7 @@
| `:write-buffer-close`, `:wbc` | Write changes to disk and closes the buffer. Accepts an optional path (:write-buffer-close some/path.txt) |
| `:write-buffer-close!`, `:wbc!` | Force write changes to disk creating necessary subdirectories and closes the buffer. Accepts an optional path (:write-buffer-close! some/path.txt) |
| `:new`, `:n` | Create a new scratch buffer. |
| `:format`, `:fmt` | Format the file using an external formatter or language server. |
| `:format`, `:fmt` | Format the file using the LSP formatter. |
| `:indent-style` | Set the indentation style for editing. ('t' for tabs or 1-16 for number of spaces.) |
| `:line-ending` | Set the document's default line ending. Options: crlf, lf. |
| `:earlier`, `:ear` | Jump back to an earlier point in edit history. Accepts a number of steps or a time span. |
@@ -52,8 +52,8 @@
| `:reload-all`, `:rla` | Discard changes and reload all documents from the source files. |
| `:update`, `:u` | Write changes only if the file has been modified. |
| `:lsp-workspace-command` | Open workspace command picker |
| `:lsp-restart` | Restarts the given language servers, or all language servers that are used by the current file if no arguments are supplied |
| `:lsp-stop` | Stops the given language servers, or all language servers that are used by the current file if no arguments are supplied |
| `:lsp-restart` | Restarts the language servers used by the current doc |
| `:lsp-stop` | Stops the language servers that are used by the current doc |
| `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. |
| `:tree-sitter-highlight-name` | Display name of tree-sitter highlight scope under the cursor. |
| `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. |
@@ -67,25 +67,22 @@
| `:goto`, `:g` | Goto line number. |
| `:set-language`, `:lang` | Set the language of current buffer (show current language if no value specified). |
| `: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 config option at runtime.<br>For example to toggle smart case search, use `:toggle search.smart-case`. |
| `: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. |
| `:sort` | Sort ranges in selection. |
| `:rsort` | Sort ranges in selection in reverse order. |
| `:reflow` | Hard-wrap the current selection of lines to a given width. |
| `:tree-sitter-subtree`, `:ts-subtree` | Display the smallest tree-sitter subtree that spans the primary selection, primarily for debugging queries. |
| `:tree-sitter-subtree`, `:ts-subtree` | Display tree sitter subtree under cursor, primarily for debugging queries. |
| `:config-reload` | Refresh user config. |
| `:config-open` | Open the user config.toml file. |
| `:config-open-workspace` | Open the workspace config.toml file. |
| `:log-open` | Open the helix log file. |
| `:insert-output` | Run shell command, inserting output before each selection. |
| `:append-output` | Run shell command, appending output after each selection. |
| `:pipe`, `:\|` | Pipe each selection to the shell command. |
| `:pipe` | 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 |
| `:reset-diff-change`, `:diffget`, `:diffg` | Reset the diff change at the cursor position. |
| `:clear-register` | Clear given register. If no argument is provided, clear all registers. |
| `:redraw` | Clear and re-render the whole UI |
| `:move`, `:mv` | Move the current buffer and its corresponding file to a different path |
| `:yank-diagnostic` | Yank diagnostic(s) under primary cursor to register, or clipboard by default |
| `:read`, `:r` | Load a file into buffer |
| `:echo` | Prints the given arguments to the statusline. |
| `:noop` | Does nothing. |
| `:move` | Move the current buffer and its corresponding file to a different path |

View File

@@ -1,4 +1,4 @@
## Adding new languages to Helix
# Adding new languages to Helix
In order to add a new language to Helix, you will need to follow the steps
below.
@@ -16,7 +16,7 @@ below.
> 💡 If you are adding a new Language Server configuration, make sure to update
> the
> [Language Server Wiki](https://github.com/helix-editor/helix/wiki/Language-Server-Configurations)
> [Language Server Wiki](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers)
> with the installation instructions.
## Grammar configuration
@@ -34,10 +34,16 @@ below.
2. Create a new directory for the language with the path
`runtime/queries/<name>/`.
3. Refer to the
[tree-sitter website](https://tree-sitter.github.io/tree-sitter/3-syntax-highlighting.html#highlights)
[tree-sitter website](https://tree-sitter.github.io/tree-sitter/syntax-highlighting#queries)
for more information on writing queries.
4. A list of highlight captures can be found [on the themes page](https://docs.helix-editor.com/themes.html#scopes).
> 💡 In Helix, the first matching query takes precedence when evaluating
> queries, which is different from other editors such as Neovim where the last
> matching query supersedes the ones before it. See
> [this issue](https://github.com/helix-editor/helix/pull/1170#issuecomment-997294090)
> for an example.
## Common issues
- If you encounter errors when running Helix after switching branches, you may

View File

@@ -1,4 +1,4 @@
## Adding indent queries
# Adding indent queries
Helix uses tree-sitter to correctly indent new lines. This requires a tree-
sitter grammar and an `indent.scm` query file placed in `runtime/queries/
@@ -315,10 +315,6 @@ The first argument (a capture) must/must not be equal to the second argument
The first argument (a capture) must/must not match the regex given in the
second argument (a string).
- `#any-of?`/`#not-any-of?`:
The first argument (a capture) must/must not be one of the other arguments
(strings).
Additionally, we support some custom predicates for indent queries:
- `#not-kind-eq?`:
@@ -370,4 +366,4 @@ Everything up to and including the closing brace gets an indent level of 1.
Then, on the closing brace, we encounter an outdent with a scope of "all", which
means the first line is included, and the indent level is cancelled out on this
line. (Note these scopes are the defaults for `@indent` and `@outdent`—they are
written explicitly for demonstration.)
written explicitly for demonstration.)

View File

@@ -1,19 +1,14 @@
## Adding Injection Queries
# Adding Injection Queries
Writing language injection queries allows one to highlight a specific node as a different language.
In addition to the [standard][upstream-docs] language injection options used by tree-sitter, there
are a few Helix specific extensions that allow for more control.
An example of a simple query that would highlight all strings as bash in Nix:
And example of a simple query that would highlight all strings as bash in Nix:
```scm
((string_expression (string_fragment) @injection.content)
(#set! injection.language "bash"))
```
Another example is this query, which highlights links in comments and keywords like "TODO", by reusing the dedicated "comment" language:
```
((comment) @injection.content
(#set! injection.language "comment"))
```
## Capture Types
@@ -59,7 +54,4 @@ The first argument (a capture) must be equal to the second argument
The first argument (a capture) must match the regex given in the
second argument (a string).
- `#any-of?` (standard):
The first argument (a capture) must be one of the other arguments (strings).
[upstream-docs]: https://tree-sitter.github.io/tree-sitter/3-syntax-highlighting.html#language-injection
[upstream-docs]: http://tree-sitter.github.io/tree-sitter/syntax-highlighting#language-injection

View File

@@ -1,4 +1,4 @@
## Adding textobject queries
# Adding textobject queries
Helix supports textobjects that are language specific, such as functions, classes, etc.
These textobjects require an accompanying tree-sitter grammar and a `textobjects.scm` query file
@@ -23,11 +23,8 @@ The following [captures][tree-sitter-captures] are recognized:
| `test.inside` |
| `test.around` |
| `parameter.inside` |
| `parameter.around` |
| `comment.inside` |
| `comment.around` |
| `entry.inside` |
| `entry.around` |
[Example query files][textobject-examples] can be found in the helix GitHub repository.
@@ -45,6 +42,6 @@ in its `textobjects.scm` file, function navigation should also work automaticall
`function.movement` should be defined only if the node captured by `function.around`
doesn't make sense in a navigation context.
[tree-sitter-queries]: https://tree-sitter.github.io/tree-sitter/using-parsers/queries/1-syntax.html
[tree-sitter-captures]: https://tree-sitter.github.io/tree-sitter/using-parsers/queries/2-operators.html#capturing-nodes
[textobject-examples]: https://github.com/search?q=repo%3Ahelix-editor%2Fhelix+path%3A%2A%2A/textobjects.scm&type=Code&ref=advsearch&l=&l=
[tree-sitter-queries]: https://tree-sitter.github.io/tree-sitter/using-parsers#query-syntax
[tree-sitter-captures]: https://tree-sitter.github.io/tree-sitter/using-parsers#capturing-nodes
[textobject-examples]: https://github.com/search?q=repo%3Ahelix-editor%2Fhelix+filename%3Atextobjects.scm&type=Code&ref=advsearch&l=&l=

View File

@@ -1,24 +1,322 @@
# Installing Helix
The typical way to install Helix is via [your operating system's package manager](./package-managers.md).
<!--toc:start-->
- [Pre-built binaries](#pre-built-binaries)
- [Linux, macOS, Windows and OpenBSD packaging status](#linux-macos-windows-and-openbsd-packaging-status)
- [Linux](#linux)
- [Ubuntu](#ubuntu)
- [Fedora/RHEL](#fedorarhel)
- [Arch Linux extra](#arch-linux-extra)
- [NixOS](#nixos)
- [Flatpak](#flatpak)
- [Snap](#snap)
- [AppImage](#appimage)
- [macOS](#macos)
- [Homebrew Core](#homebrew-core)
- [MacPorts](#macports)
- [Windows](#windows)
- [Winget](#winget)
- [Scoop](#scoop)
- [Chocolatey](#chocolatey)
- [MSYS2](#msys2)
- [Building from source](#building-from-source)
- [Configuring Helix's runtime files](#configuring-helixs-runtime-files)
- [Linux and macOS](#linux-and-macos)
- [Windows](#windows)
- [Multiple runtime directories](#multiple-runtime-directories)
- [Validating the installation](#validating-the-installation)
- [Configure the desktop shortcut](#configure-the-desktop-shortcut)
<!--toc:end-->
To install Helix, follow the instructions specific to your operating system.
Note that:
- To get the latest nightly version of Helix, you need to
[build from source](./building-from-source.md).
[build from source](#building-from-source).
- To take full advantage of Helix, install the language servers for your
preferred programming languages. See the
[wiki](https://github.com/helix-editor/helix/wiki/Language-Server-Configurations)
[wiki](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers)
for instructions.
## Pre-built binaries
Download pre-built binaries from the [GitHub Releases page](https://github.com/helix-editor/helix/releases).
The tarball contents include an `hx` binary and a `runtime` directory.
To set up Helix:
Download pre-built binaries from the
[GitHub Releases page](https://github.com/helix-editor/helix/releases). Add the binary to your system's `$PATH` to use it from the command
line.
1. Add the `hx` binary to your system's `$PATH` to allow it to be used from the command line.
2. Copy the `runtime` directory to a location that `hx` searches for runtime files. A typical location on Linux/macOS is `~/.config/helix/runtime`.
## Linux, macOS, Windows and OpenBSD packaging status
To see the runtime directories that `hx` searches, run `hx --health`. If necessary, you can override the default runtime location by setting the `HELIX_RUNTIME` environment variable.
[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions)
## Linux
The following third party repositories are available:
### Ubuntu
Add the `PPA` for Helix:
```sh
sudo add-apt-repository ppa:maveonair/helix-editor
sudo apt update
sudo apt install helix
```
### Fedora/RHEL
```sh
sudo dnf install helix
```
### Arch Linux extra
Releases are available in the `extra` repository:
```sh
sudo pacman -S helix
```
Additionally, a [helix-git](https://aur.archlinux.org/packages/helix-git/) package is available
in the AUR, which builds the master branch.
### NixOS
Helix is available in [nixpkgs](https://github.com/nixos/nixpkgs) through the `helix` attribute,
the unstable channel usually carries the latest release.
Helix is also available as a [flake](https://nixos.wiki/wiki/Flakes) in the project
root. Use `nix develop` to spin up a reproducible development shell. Outputs are
cached for each push to master using [Cachix](https://www.cachix.org/). The
flake is configured to automatically make use of this cache assuming the user
accepts the new settings on first use.
If you are using a version of Nix without flakes enabled,
[install Cachix CLI](https://docs.cachix.org/installation) and use
`cachix use helix` to configure Nix to use cached outputs when possible.
### Flatpak
Helix is available on [Flathub](https://flathub.org/en-GB/apps/com.helix_editor.Helix):
```sh
flatpak install flathub com.helix_editor.Helix
flatpak run com.helix_editor.Helix
```
### Snap
Helix is available on [Snapcraft](https://snapcraft.io/helix) and can be installed with:
```sh
snap install --classic helix
```
This will install Helix as both `/snap/bin/helix` and `/snap/bin/hx`, so make sure `/snap/bin` is in your `PATH`.
### AppImage
Install Helix using the Linux [AppImage](https://appimage.org/) format.
Download the official Helix AppImage from the [latest releases](https://github.com/helix-editor/helix/releases/latest) page.
```sh
chmod +x helix-*.AppImage # change permission for executable mode
./helix-*.AppImage # run helix
```
## macOS
### Homebrew Core
```sh
brew install helix
```
### MacPorts
```sh
port install helix
```
## Windows
Install on Windows using [Winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/), [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/)
or [MSYS2](https://msys2.org/).
### Winget
Windows Package Manager winget command-line tool is by default available on Windows 11 and modern versions of Windows 10 as a part of the App Installer.
You can get [App Installer from the Microsoft Store](https://www.microsoft.com/p/app-installer/9nblggh4nns1#activetab=pivot:overviewtab). If it's already installed, make sure it is updated with the latest version.
```sh
winget install Helix.Helix
```
### Scoop
```sh
scoop install helix
```
### Chocolatey
```sh
choco install helix
```
### MSYS2
For 64-bit Windows 8.1 or above:
```sh
pacman -S mingw-w64-ucrt-x86_64-helix
```
## Building from source
Requirements:
Clone the Helix GitHub repository into a directory of your choice. The
examples in this documentation assume installation into either `~/src/` on
Linux and macOS, or `%userprofile%\src\` on Windows.
- The [Rust toolchain](https://www.rust-lang.org/tools/install)
- The [Git version control system](https://git-scm.com/)
- A C++14 compatible compiler to build the tree-sitter grammars, for example GCC or Clang
If you are using the `musl-libc` standard library instead of `glibc` the following environment variable must be set during the build to ensure tree-sitter grammars can be loaded correctly:
```sh
RUSTFLAGS="-C target-feature=-crt-static"
```
1. Clone the repository:
```sh
git clone https://github.com/helix-editor/helix
cd helix
```
2. Compile from source:
```sh
cargo install --path helix-term --locked
```
This command will create the `hx` executable and construct the tree-sitter
grammars in the local `runtime` folder.
> 💡 If you do not want to fetch or build grammars, set an environment variable `HELIX_DISABLE_AUTO_GRAMMAR_BUILD`
> 💡 Tree-sitter grammars can be fetched and compiled if not pre-packaged. Fetch
> grammars with `hx --grammar fetch` and compile them with
> `hx --grammar build`. This will install them in
> the `runtime` directory within the user's helix config directory (more
> [details below](#multiple-runtime-directories)).
### Configuring Helix's runtime files
#### Linux and macOS
The **runtime** directory is one below the Helix source, so either set a
`HELIX_RUNTIME` environment variable to point to that directory and add it to
your `~/.bashrc` or equivalent:
```sh
HELIX_RUNTIME=~/src/helix/runtime
```
Or, create a symbolic link:
```sh
ln -Ts $PWD/runtime ~/.config/helix/runtime
```
If the above command fails to create a symbolic link because the file exists either move `~/.config/helix/runtime` to a new location or delete it, then run the symlink command above again.
#### Windows
Either set the `HELIX_RUNTIME` environment variable to point to the runtime files using the Windows setting (search for
`Edit environment variables for your account`) or use the `setx` command in
Cmd:
```sh
setx HELIX_RUNTIME "%userprofile%\source\repos\helix\runtime"
```
> 💡 `%userprofile%` resolves to your user directory like
> `C:\Users\Your-Name\` for example.
Or, create a symlink in `%appdata%\helix\` that links to the source code directory:
| Method | Command |
| ---------- | -------------------------------------------------------------------------------------- |
| PowerShell | `New-Item -ItemType Junction -Target "runtime" -Path "$Env:AppData\helix\runtime"` |
| Cmd | `cd %appdata%\helix` <br/> `mklink /D runtime "%userprofile%\src\helix\runtime"` |
> 💡 On Windows, creating a symbolic link may require running PowerShell or
> Cmd as an administrator.
#### Multiple runtime directories
When Helix finds multiple runtime directories it will search through them for files in the
following order:
1. `runtime/` sibling directory to `$CARGO_MANIFEST_DIR` directory (this is intended for
developing and testing helix only).
2. `runtime/` subdirectory of OS-dependent helix user config directory.
3. `$HELIX_RUNTIME`
4. Distribution-specific fallback directory (set at compile time—not run time—
with the `HELIX_DEFAULT_RUNTIME` environment variable)
5. `runtime/` subdirectory of path to Helix executable.
This order also sets the priority for selecting which file will be used if multiple runtime
directories have files with the same name.
#### Note to packagers
If you are making a package of Helix for end users, to provide a good out of
the box experience, you should set the `HELIX_DEFAULT_RUNTIME` environment
variable at build time (before invoking `cargo build`) to a directory which
will store the final runtime files after installation. For example, say you want
to package the runtime into `/usr/lib/helix/runtime`. The rough steps a build
script could follow are:
1. `export HELIX_DEFAULT_RUNTIME=/usr/lib/helix/runtime`
1. `cargo build --profile opt --locked --path helix-term`
1. `cp -r runtime $BUILD_DIR/usr/lib/helix/`
1. `cp target/opt/hx $BUILD_DIR/usr/bin/hx`
This way the resulting `hx` binary will always look for its runtime directory in
`/usr/lib/helix/runtime` if the user has no custom runtime in `~/.config/helix`
or `HELIX_RUNTIME`.
### Validating the installation
To make sure everything is set up as expected you should run the Helix health
check:
```sh
hx --health
```
For more information on the health check results refer to
[Health check](https://github.com/helix-editor/helix/wiki/Healthcheck).
### Configure the desktop shortcut
If your desktop environment supports the
[XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html)
you can configure Helix to show up in the application menu by copying the
provided `.desktop` and icon files to their correct folders:
```sh
cp contrib/Helix.desktop ~/.local/share/applications
cp contrib/helix.png ~/.icons # or ~/.local/share/icons
```
To use another terminal than the system default, you can modify the `.desktop`
file. For example, to use `kitty`:
```sh
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
```

View File

@@ -1,4 +1,4 @@
## Keymap
# Keymap
- [Normal mode](#normal-mode)
- [Movement](#movement)
@@ -13,8 +13,6 @@
- [Window mode](#window-mode)
- [Space mode](#space-mode)
- [Popup](#popup)
- [Completion Menu](#completion-menu)
- [Signature-help Popup](#signature-help-popup)
- [Unimpaired](#unimpaired)
- [Insert mode](#insert-mode)
- [Select / extend mode](#select--extend-mode)
@@ -25,8 +23,6 @@
> 💡 Mappings marked (**TS**) require a tree-sitter grammar for the file type.
> ⚠️ Some terminals' default key mappings conflict with Helix's. If any of the mappings described on this page do not work as expected, check your terminal's mappings to ensure they do not conflict. See the [wiki](https://github.com/helix-editor/helix/wiki/Terminal-Support) for known conflicts.
## Normal mode
Normal mode is the default mode when you launch helix. You can return to it from other modes by pressing the `Escape` key.
@@ -47,18 +43,18 @@ Normal mode is the default mode when you launch helix. You can return to it from
| `W` | Move next WORD start | `move_next_long_word_start` |
| `B` | Move previous WORD start | `move_prev_long_word_start` |
| `E` | Move next WORD end | `move_next_long_word_end` |
| `t` | Find till next char | `find_till_char` |
| `t` | Find 'till next char | `find_till_char` |
| `f` | Find next char | `find_next_char` |
| `T` | Find till previous char | `till_prev_char` |
| `T` | Find 'till previous char | `till_prev_char` |
| `F` | Find previous char | `find_prev_char` |
| `G` | Go to line number `<n>` | `goto_line` |
| `Alt-.` | Repeat last motion (`f`, `t`, `m`, `[` or `]`) | `repeat_last_motion` |
| `Alt-.` | Repeat last motion (`f`, `t` or `m`) | `repeat_last_motion` |
| `Home` | Move to the start of the line | `goto_line_start` |
| `End` | Move to the end of the line | `goto_line_end` |
| `Ctrl-b`, `PageUp` | Move page up | `page_up` |
| `Ctrl-f`, `PageDown` | Move page down | `page_down` |
| `Ctrl-u` | Move cursor and page half page up | `page_cursor_half_up` |
| `Ctrl-d` | Move cursor and page half page down | `page_cursor_half_down` |
| `Ctrl-u` | Move half page up | `half_page_up` |
| `Ctrl-d` | Move half page down | `half_page_down` |
| `Ctrl-i` | Jump forward on the jumplist | `jump_forward` |
| `Ctrl-o` | Jump backward on the jumplist | `jump_backward` |
| `Ctrl-s` | Save the current selection to the jumplist | `save_selection` |
@@ -89,7 +85,7 @@ Normal mode is the default mode when you launch helix. You can return to it from
| `"` `<reg>` | Select a register to yank to or paste from | `select_register` |
| `>` | Indent selection | `indent` |
| `<` | Unindent selection | `unindent` |
| `=` | Format selection (**LSP**) | `format_selections` |
| `=` | Format selection (currently nonfunctional/disabled) (**LSP**) | `format_selections` |
| `d` | Delete selection | `delete_selection` |
| `Alt-d` | Delete selection, without yanking | `delete_selection_noyank` |
| `c` | Change selection (delete and enter insert mode) | `change_selection` |
@@ -112,43 +108,39 @@ Normal mode is the default mode when you launch helix. You can return to it from
### Selection manipulation
| Key | Description | Command |
| ----- | ----------- | ------- |
| `s` | Select all regex matches inside selections | `select_regex` |
| `S` | Split selection into sub selections on regex matches | `split_selection` |
| `Alt-s` | Split selection on newlines | `split_selection_on_newline` |
| `Alt-minus` | Merge selections | `merge_selections` |
| `Alt-_` | Merge consecutive selections | `merge_consecutive_selections` |
| `&` | Align selection in columns | `align_selections` |
| `_` | Trim whitespace from the selection | `trim_selections` |
| `;` | Collapse selection onto a single cursor | `collapse_selection` |
| `Alt-;` | Flip selection cursor and anchor | `flip_selections` |
| `Alt-:` | Ensures the selection is in forward direction | `ensure_selections_forward` |
| `,` | Keep only the primary selection | `keep_primary_selection` |
| `Alt-,` | Remove the primary selection | `remove_primary_selection` |
| `C` | Copy selection onto the next line (Add cursor below) | `copy_selection_on_next_line` |
| `Alt-C` | Copy selection onto the previous line (Add cursor above) | `copy_selection_on_prev_line` |
| `(` | Rotate main selection backward | `rotate_selections_backward` |
| `)` | Rotate main selection forward | `rotate_selections_forward` |
| `Alt-(` | Rotate selection contents backward | `rotate_selection_contents_backward` |
| `Alt-)` | Rotate selection contents forward | `rotate_selection_contents_forward` |
| `%` | Select entire file | `select_all` |
| `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` |
| `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` |
| `J` | Join lines inside selection | `join_selections` |
| `Alt-J` | Join lines inside selection and select the inserted space | `join_selections_space` |
| `K` | Keep selections matching the regex | `keep_selections` |
| `Alt-K` | Remove selections matching the regex | `remove_selections` |
| `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` |
| `Alt-o`, `Alt-up` | Expand selection to parent syntax node (**TS**) | `expand_selection` |
| `Alt-i`, `Alt-down` | Shrink syntax tree object selection (**TS**) | `shrink_selection` |
| `Alt-p`, `Alt-left` | Select previous sibling node in syntax tree (**TS**) | `select_prev_sibling` |
| `Alt-n`, `Alt-right` | Select next sibling node in syntax tree (**TS**) | `select_next_sibling` |
| `Alt-a` | Select all sibling nodes in syntax tree (**TS**) | `select_all_siblings` |
| `Alt-I`, `Alt-Shift-down`| Select all children nodes in syntax tree (**TS**) | `select_all_children` |
| `Alt-e` | Move to end of parent node in syntax tree (**TS**) | `move_parent_node_end` |
| `Alt-b` | Move to start of parent node in syntax tree (**TS**) | `move_parent_node_start` |
| Key | Description | Command |
| ----- | ----------- | ------- |
| `s` | Select all regex matches inside selections | `select_regex` |
| `S` | Split selection into sub selections on regex matches | `split_selection` |
| `Alt-s` | Split selection on newlines | `split_selection_on_newline` |
| `Alt-minus` | Merge selections | `merge_selections` |
| `Alt-_` | Merge consecutive selections | `merge_consecutive_selections` |
| `&` | Align selection in columns | `align_selections` |
| `_` | Trim whitespace from the selection | `trim_selections` |
| `;` | Collapse selection onto a single cursor | `collapse_selection` |
| `Alt-;` | Flip selection cursor and anchor | `flip_selections` |
| `Alt-:` | Ensures the selection is in forward direction | `ensure_selections_forward` |
| `,` | Keep only the primary selection | `keep_primary_selection` |
| `Alt-,` | Remove the primary selection | `remove_primary_selection` |
| `C` | Copy selection onto the next line (Add cursor below) | `copy_selection_on_next_line` |
| `Alt-C` | Copy selection onto the previous line (Add cursor above) | `copy_selection_on_prev_line` |
| `(` | Rotate main selection backward | `rotate_selections_backward` |
| `)` | Rotate main selection forward | `rotate_selections_forward` |
| `Alt-(` | Rotate selection contents backward | `rotate_selection_contents_backward` |
| `Alt-)` | Rotate selection contents forward | `rotate_selection_contents_forward` |
| `%` | Select entire file | `select_all` |
| `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` |
| `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` |
| `J` | Join lines inside selection | `join_selections` |
| `Alt-J` | Join lines inside selection and select the inserted space | `join_selections_space` |
| `K` | Keep selections matching the regex | `keep_selections` |
| `Alt-K` | Remove selections matching the regex | `remove_selections` |
| `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` |
| `Alt-o`, `Alt-up` | Expand selection to parent syntax node (**TS**) | `expand_selection` |
| `Alt-i`, `Alt-down` | Shrink syntax tree object selection (**TS**) | `shrink_selection` |
| `Alt-p`, `Alt-left` | Select previous sibling node in syntax tree (**TS**) | `select_prev_sibling` |
| `Alt-n`, `Alt-right` | Select next sibling node in syntax tree (**TS**) | `select_next_sibling` |
### Search
@@ -160,8 +152,7 @@ Search commands all operate on the `/` register by default. To use a different r
| `?` | Search for previous pattern | `rsearch` |
| `n` | Select next search match | `search_next` |
| `N` | Select previous search match | `search_prev` |
| `*` | Use current selection as the search pattern, automatically wrapping with `\b` on word boundaries | `search_selection_detect_word_boundaries` |
| `Alt-*` | Use current selection as the search pattern | `search_selection` |
| `*` | Use current selection as the search pattern | `search_selection` |
### Minor modes
@@ -191,18 +182,18 @@ normal mode) is persistent and can be exited using the escape key. This is
useful when you're simply looking over text and not actively editing it.
| Key | Description | Command |
| ----- | ----------- | ------- |
| `z`, `c` | Vertically center the line | `align_view_center` |
| `t` | Align the line to the top of the screen | `align_view_top` |
| `b` | Align the line to the bottom of the screen | `align_view_bottom` |
| `m` | Align the line to the middle of the screen (horizontally) | `align_view_middle` |
| `j`, `down` | Scroll the view downwards | `scroll_down` |
| `k`, `up` | Scroll the view upwards | `scroll_up` |
| `Ctrl-f`, `PageDown` | Move page down | `page_down` |
| `Ctrl-b`, `PageUp` | Move page up | `page_up` |
| `Ctrl-u` | Move cursor and page half page up | `page_cursor_half_up` |
| `Ctrl-d` | Move cursor and page half page down | `page_cursor_half_down` |
| Key | Description | Command |
| ----- | ----------- | ------- |
| `z`, `c` | Vertically center the line | `align_view_center` |
| `t` | Align the line to the top of the screen | `align_view_top` |
| `b` | Align the line to the bottom of the screen | `align_view_bottom` |
| `m` | Align the line to the middle of the screen (horizontally) | `align_view_middle` |
| `j`, `down` | Scroll the view downwards | `scroll_down` |
| `k`, `up` | Scroll the view upwards | `scroll_up` |
| `Ctrl-f`, `PageDown` | Move page down | `page_down` |
| `Ctrl-b`, `PageUp` | Move page up | `page_up` |
| `Ctrl-d` | Move half page down | `half_page_down` |
| `Ctrl-u` | Move half page up | `half_page_up` |
#### Goto mode
@@ -213,7 +204,6 @@ Jumps to various locations.
| Key | Description | Command |
| ----- | ----------- | ------- |
| `g` | Go to line number `<n>` else start of file | `goto_file_start` |
| <code>&#124;</code> | Go to column number `<n>` else start of line | `goto_column` |
| `e` | Go to the end of the file | `goto_last_line` |
| `f` | Go to files in the selections | `goto_file` |
| `h` | Go to the start of the line | `goto_line_start` |
@@ -233,13 +223,13 @@ Jumps to various locations.
| `.` | Go to last modification in current file | `goto_last_modification` |
| `j` | Move down textual (instead of visual) line | `move_line_down` |
| `k` | Move up textual (instead of visual) line | `move_line_up` |
| `w` | Show labels at each word and select the word that belongs to the entered labels | `goto_word` |
#### Match mode
Accessed by typing `m` in [normal mode](#normal-mode).
Please refer to the relevant sections for detailed explanations about [surround](./surround.md) and [textobjects](./textobjects.md).
See the relevant section in [Usage](./usage.md) for an explanation about
[surround](./usage.md#surround) and [textobject](./usage.md#navigating-using-tree-sitter-textobjects) usage.
| Key | Description | Command |
| ----- | ----------- | ------- |
@@ -284,12 +274,11 @@ This layer is a kludge of mappings, mostly pickers.
| Key | Description | Command |
| ----- | ----------- | ------- |
| `f` | Open file picker at LSP workspace root | `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` |
| `j` | Open jumplist picker | `jumplist_picker` |
| `g` | Open changed file picker | `changed_file_picker` |
| `G` | Debug (experimental) | N/A |
| `g` | Debug (experimental) | N/A |
| `k` | Show documentation for item under cursor in a [popup](#popup) (**LSP**) | `hover` |
| `s` | Open document symbol picker (**LSP**) | `symbol_picker` |
| `S` | Open workspace symbol picker (**LSP**) | `workspace_symbol_picker` |
@@ -300,9 +289,6 @@ This layer is a kludge of mappings, mostly pickers.
| `h` | Select symbol references (**LSP**) | `select_references_to_symbol_under_cursor` |
| `'` | Open last fuzzy picker | `last_picker` |
| `w` | Enter [window mode](#window-mode) | N/A |
| `c` | Comment/uncomment selections | `toggle_comments` |
| `C` | Block comment/uncomment selections | `toggle_block_comments` |
| `Alt-c` | Line comment/uncomment selections | `toggle_line_comments` |
| `p` | Paste system clipboard after selections | `paste_clipboard_after` |
| `P` | Paste system clipboard before selections | `paste_clipboard_before` |
| `y` | Yank selections to clipboard | `yank_to_clipboard` |
@@ -315,35 +301,13 @@ This layer is a kludge of mappings, mostly pickers.
##### Popup
Displays documentation for item under cursor. Remapping currently not supported.
Displays documentation for item under cursor.
| Key | Description |
| ---- | ----------- |
| `Ctrl-u` | Scroll up |
| `Ctrl-d` | Scroll down |
##### Completion Menu
Displays documentation for the selected completion item. Remapping currently not supported.
| Key | Description |
| ---- | ----------- |
| `Shift-Tab`, `Ctrl-p`, `Up` | Previous entry |
| `Tab`, `Ctrl-n`, `Down` | Next entry |
| `Enter` | Close menu and accept completion |
| `Ctrl-c` | Close menu and reject completion |
Any other keypresses result in the completion being accepted.
##### Signature-help Popup
Displays the signature of the selected completion item. Remapping currently not supported.
| Key | Description |
| ---- | ----------- |
| `Alt-p` | Previous signature |
| `Alt-n` | Next signature |
#### Unimpaired
These mappings are in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired).
@@ -446,8 +410,6 @@ you to selectively add search terms to your selections.
## Picker
Keys to use within picker. Remapping currently not supported.
See the documentation page on [pickers](./pickers.md) for more info.
[Prompt](#prompt) keybinds also work in pickers, except where they conflict with picker keybinds.
| Key | Description |
| ----- | ------------- |

View File

@@ -1,7 +1,7 @@
## Language Support
# Language Support
The following languages and Language Servers are supported. To use
Language Server features, you must first [configure][lsp-config-wiki] the
Language Server features, you must first [install][lsp-install-wiki] the
appropriate Language Server.
You can check the language support in your installed helix version with `hx --health`.
@@ -11,6 +11,6 @@ Languages][adding-languages] guide for more language configuration information.
{{#include ./generated/lang-support.md}}
[lsp-config-wiki]: https://github.com/helix-editor/helix/wiki/Language-Server-Configurations
[lsp-install-wiki]: https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers
[lang-config]: ./languages.md
[adding-languages]: ./guides/adding_languages.md

View File

@@ -1,4 +1,4 @@
## Languages
# Languages
Language-specific settings and settings for language servers are configured
in `languages.toml` files.
@@ -13,7 +13,7 @@ There are three possible locations for a `languages.toml` file:
2. In your [configuration directory](./configuration.md). This overrides values
from the built-in language configuration. For example, to disable
auto-formatting for Rust:
auto-LSP-formatting in Rust:
```toml
# in <config_dir>/helix/languages.toml
@@ -42,7 +42,7 @@ name = "mylang"
scope = "source.mylang"
injection-regex = "mylang"
file-types = ["mylang", "myl"]
comment-tokens = "#"
comment-token = "#"
indent = { tab-width = 2, unit = " " }
formatter = { command = "mylang-formatter" , args = ["--stdin"] }
language-servers = [ "mylang-lsp" ]
@@ -60,17 +60,14 @@ These configuration keys are available:
| `shebangs` | The interpreters from the shebang line, for example `["sh", "bash"]` |
| `roots` | A set of marker files to look for when trying to find the workspace root. For example `Cargo.lock`, `yarn.lock` |
| `auto-format` | Whether to autoformat this language when saving |
| `diagnostic-severity` | Minimal severity of diagnostic for it to be displayed. (Allowed values: `error`, `warning`, `info`, `hint`) |
| `comment-tokens` | The tokens to use as a comment token, either a single token `"//"` or an array `["//", "///", "//!"]` (the first token will be used for commenting). Also configurable as `comment-token` for backwards compatibility|
| `block-comment-tokens`| The start and end tokens for a multiline comment either an array or single table of `{ start = "/*", end = "*/"}`. The first set of tokens will be used for commenting, any pairs in the array can be uncommented |
| `diagnostic-severity` | Minimal severity of diagnostic for it to be displayed. (Allowed values: `Error`, `Warning`, `Info`, `Hint`) |
| `comment-token` | The token to use as a comment-token |
| `indent` | The indent to use. Has sub keys `unit` (the text inserted into the document when indenting; usually set to N spaces or `"\t"` for tabs) and `tab-width` (the number of spaces rendered for a tab) |
| `language-servers` | The Language Servers used for this language. See below for more information in the section [Configuring Language Servers for a language](#configuring-language-servers-for-a-language) |
| `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. The filename of the current buffer can be passed as argument by using the `%{buffer_name}` expansion variable. See below for more information in the [Configuring the formatter command](#configuring-the-formatter-command) |
| `soft-wrap` | [editor.softwrap](./editor.md#editorsoft-wrap-section)
| `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 |
| `soft-wrap` | [editor.softwrap](./configuration.md#editorsoft-wrap-section)
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` |
| `rulers` | Overrides the `editor.rulers` config key for the language. |
| `path-completion` | Overrides the `editor.path-completion` config key for the language. |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. |
| `persistent-diagnostic-sources` | An array of LSP diagnostic sources assumed unchanged when the language server resends the same set of diagnostics. Helix can track the position for these diagnostics internally instead. Useful for diagnostics that are recomputed on save.
@@ -81,36 +78,24 @@ from the above section. `file-types` is a list of strings or tables, for
example:
```toml
file-types = ["toml", { glob = "Makefile" }, { glob = ".git/config" }, { glob = ".github/workflows/*.yaml" } ]
file-types = ["Makefile", "toml", { suffix = ".git/config" }]
```
When determining a language configuration to use, Helix searches the file-types
with the following priorities:
1. Glob: values in `glob` tables are checked against the full path of the given
file. Globs are standard Unix-style path globs (e.g. the kind you use in Shell)
and can be used to match paths for a specific prefix, suffix, directory, etc.
In the above example, the `{ glob = "Makefile" }` config would match files
with the name `Makefile`, the `{ glob = ".git/config" }` config would match
`config` files in `.git` directories, and the `{ glob = ".github/workflows/*.yaml" }`
config would match any `yaml` files in `.github/workflow` directories. Note
that globs should always use the Unix path separator `/` even on Windows systems;
the matcher will automatically take the machine-specific separators into account.
If the glob isn't an absolute path or doesn't already start with a glob prefix,
`*/` will automatically be added to ensure it matches for any subdirectory.
2. Extension: if there are no glob matches, any `file-types` string that matches
the file extension of a given file wins. In the example above, the `"toml"`
config matches files like `Cargo.toml` or `languages.toml`.
### Configuring the formatter command
[Command line expansions](./command-line.md#expansions) are supported in the arguments
of the formatter command. In particular, the `%{buffer_name}` variable can be passed as
argument to the formatter:
```toml
formatter = { command = "mylang-formatter" , args = ["--stdin", "--stdin-filename %{buffer_name}"] }
```
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
@@ -135,14 +120,13 @@ languages = { typescript = [ { formatCommand ="prettier --stdin-filepath ${INPUT
These are the available options for a language server.
| Key | Description |
| ---- | ----------- |
| `command` | The name or path of the language server binary to execute. Binaries must be in `$PATH` |
| `args` | A list of arguments to pass to the language server binary |
| `config` | Language server initialization options |
| `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` |
| `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` |
| `required-root-patterns` | A list of `glob` patterns to look for in the working directory. The language server is started if at least one of them is found. |
| Key | Description |
| ---- | ----------- |
| `command` | The name or path of the language server binary to execute. Binaries must be in `$PATH` |
| `args` | A list of arguments to pass to the language server binary |
| `config` | LSP initialization options |
| `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` |
| `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` |
A `format` sub-table within `config` can be used to pass extra formatting options to
[Document Formatting Requests](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_formatting).
@@ -162,8 +146,6 @@ They have to be defined in the `[language-server]` table as described in the pre
Different languages can use the same language server instance, e.g. `typescript-language-server` is used for javascript, jsx, tsx and typescript by default.
The definition order of language servers affects the order in the results list of code action menu.
In case multiple language servers are specified in the `language-servers` attribute of a `language`,
it's often useful to only enable/disable certain language-server features for these language servers.
@@ -252,4 +234,4 @@ use-grammars = { except = [ "yaml", "json" ] }
When omitted, all grammars are fetched and built.
[treesitter-language-injection]: https://tree-sitter.github.io/tree-sitter/3-syntax-highlighting.html#language-injection
[treesitter-language-injection]: https://tree-sitter.github.io/tree-sitter/syntax-highlighting#language-injection

View File

@@ -1,33 +0,0 @@
# Helix mode in other software
Helix' keymap and interaction model ([Using Helix](#usage.md)) is easier to adopt if it can be used consistently in many editing contexts. Yet, certain use cases cannot easily be addressed directly in Helix. Similar to vim, this leads to the creation of "Helix mode" in various other software products, allowing Helix-style editing for a greater variety of use cases.
"Helix mode" is frequently still in early stages or missing entirely. For such cases, we also link to relevant bugs or discussions.
## Other editors
| Editor | Plugin or feature providing Helix editing | Comments
| --- | --- | --- |
| [Vim](https://www.vim.org/) | [helix.vim](https://github.com/chtenb/helix.vim) config |
| [IntelliJ IDEA](https://www.jetbrains.com/idea/) / [Android Studio](https://developer.android.com/studio)| [IdeaVim](https://plugins.jetbrains.com/plugin/164-ideavim) plugin + [helix.idea.vim](https://github.com/chtenb/helix.vim) config | Minimum recommended version is IdeaVim 2.19.0.
| [Visual Studio](https://visualstudio.microsoft.com/) | [VsVim](https://marketplace.visualstudio.com/items?itemName=JaredParMSFT.VsVim) plugin + [helix.vs.vim](https://github.com/chtenb/helix.vim) config |
| [Visual Studio Code](https://code.visualstudio.com/) | [Dance](https://marketplace.visualstudio.com/items?itemName=gregoire.dance) extension, or its [Helix fork](https://marketplace.visualstudio.com/items?itemName=kend.dancehelixkey) | The Helix fork has diverged. You can also use the original Dance and tweak its keybindings directly (try [this config](https://github.com/71/dance/issues/299#issuecomment-1655509531)).
| [Visual Studio Code](https://code.visualstudio.com/) | [Helix for VS Code](https://marketplace.visualstudio.com/items?itemName=jasew.vscode-helix-emulation) extension|
| [Zed](https://zed.dev/) | native via keybindings ([Bug](https://github.com/zed-industries/zed/issues/4642)) |
| [CodeMirror](https://codemirror.net/) | [codemirror-helix](https://gitlab.com/_rvidal/codemirror-helix) |
## Shells
| Shell | Plugin or feature providing Helix editing
| --- | ---
| Fish | [Feature Request](https://github.com/fish-shell/fish-shell/issues/7748)
| Fish | [fish-helix](https://github.com/sshilovsky/fish-helix/tree/main)
| Zsh | [helix-zsh](https://github.com/john-h-k/helix-zsh) or [zsh-helix-mode](https://github.com/Multirious/zsh-helix-mode)
| Nushell | [Feature Request](https://github.com/nushell/reedline/issues/639)
## Other software
| Software | Plugin or feature providing Helix editing. | Comments
| --- | --- | --- |
| [Obsidian](https://obsidian.md/) | [Obsidian-Helix](https://github.com/Sinono3/obsidian-helix) | Uses `codemirror-helix` listed above.

View File

@@ -1,166 +0,0 @@
## Package managers
- [Linux](#linux)
- [Ubuntu/Debian](#ubuntudebian)
- [Ubuntu (PPA)](#ubuntu-ppa)
- [Fedora/RHEL](#fedorarhel)
- [Arch Linux extra](#arch-linux-extra)
- [NixOS](#nixos)
- [Flatpak](#flatpak)
- [Snap](#snap)
- [AppImage](#appimage)
- [macOS](#macos)
- [Homebrew Core](#homebrew-core)
- [MacPorts](#macports)
- [Windows](#windows)
- [Winget](#winget)
- [Scoop](#scoop)
- [Chocolatey](#chocolatey)
- [MSYS2](#msys2)
[![Packaging status](https://repology.org/badge/vertical-allrepos/helix-editor.svg)](https://repology.org/project/helix-editor/versions)
## Linux
The following third party repositories are available:
### Ubuntu/Debian
Install the Debian package from the release page.
If you are running a system older than Ubuntu 22.04, Mint 21, or Debian 12, you can build the `.deb` file locally
[from source](./building-from-source.md#building-the-debian-package).
### Ubuntu (PPA)
Add the `PPA` for Helix:
```sh
sudo add-apt-repository ppa:maveonair/helix-editor
sudo apt update
sudo apt install helix
```
### Fedora/RHEL
```sh
sudo dnf install helix
```
### Arch Linux extra
Releases are available in the `extra` repository:
```sh
sudo pacman -S helix
```
> 💡 When installed from the `extra` repository, run Helix with `helix` instead of `hx`.
>
> For example:
> ```sh
> helix --health
> ```
> to check health
Additionally, a [helix-git](https://aur.archlinux.org/packages/helix-git/) package is available
in the AUR, which builds the master branch.
### NixOS
Helix is available in [nixpkgs](https://github.com/nixos/nixpkgs) through the `helix` attribute,
the unstable channel usually carries the latest release.
Helix is also available as a [flake](https://wiki.nixos.org/wiki/Flakes) in the project
root. Use `nix develop` to spin up a reproducible development shell. Outputs are
cached for each push to master using [Cachix](https://www.cachix.org/). The
flake is configured to automatically make use of this cache assuming the user
accepts the new settings on first use.
If you are using a version of Nix without flakes enabled,
[install Cachix CLI](https://docs.cachix.org/installation) and use
`cachix use helix` to configure Nix to use cached outputs when possible.
### Flatpak
Helix is available on [Flathub](https://flathub.org/en-GB/apps/com.helix_editor.Helix):
```sh
flatpak install flathub com.helix_editor.Helix
flatpak run com.helix_editor.Helix
```
### Snap
Helix is available on [Snapcraft](https://snapcraft.io/helix) and can be installed with:
```sh
snap install --classic helix
```
This will install Helix as both `/snap/bin/helix` and `/snap/bin/hx`, so make sure `/snap/bin` is in your `PATH`.
### AppImage
Install Helix using the Linux [AppImage](https://appimage.org/) format.
Download the official Helix AppImage from the [latest releases](https://github.com/helix-editor/helix/releases/latest) page.
```sh
chmod +x helix-*.AppImage # change permission for executable mode
./helix-*.AppImage # run helix
```
You can optionally [add the `.desktop` file](./building-from-source.md#configure-the-desktop-shortcut). Helix must be installed in `PATH` with the name `hx`. For example:
```sh
mkdir -p "$HOME/.local/bin"
mv helix-*.AppImage "$HOME/.local/bin/hx"
```
and make sure `~/.local/bin` is in your `PATH`.
## macOS
### Homebrew Core
```sh
brew install helix
```
### MacPorts
```sh
sudo port install helix
```
## Windows
Install on Windows using [Winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/), [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/)
or [MSYS2](https://msys2.org/).
### Winget
Windows Package Manager winget command-line tool is by default available on Windows 11 and modern versions of Windows 10 as a part of the App Installer.
You can get [App Installer from the Microsoft Store](https://www.microsoft.com/p/app-installer/9nblggh4nns1#activetab=pivot:overviewtab). If it's already installed, make sure it is updated with the latest version.
```sh
winget install Helix.Helix
```
### Scoop
```sh
scoop install helix
```
### Chocolatey
```sh
choco install helix
```
### MSYS2
For 64-bit Windows 8.1 or above:
```sh
pacman -S mingw-w64-ucrt-x86_64-helix
```

View File

@@ -1,11 +0,0 @@
## Using pickers
Helix has a variety of pickers, which are interactive windows used to select various kinds of items. These include a file picker, global search picker, and more. Most pickers are accessed via keybindings in [space mode](./keymap.md#space-mode). Pickers have their own [keymap](./keymap.md#picker) for navigation.
### Filtering Picker Results
Most pickers perform fuzzy matching using [fzf syntax](https://github.com/junegunn/fzf?tab=readme-ov-file#search-syntax). Two exceptions are the global search picker, which uses regex, and the workspace symbol picker, which passes search terms to the language server. Note that OR operations (`|`) are not currently supported.
If a picker shows multiple columns, you may apply the filter to a specific column by prefixing the column name with `%`. Column names can be shortened to any prefix, so `%p`, `%pa` or `%pat` all mean the same as `%path`. For example, a query of `helix %p .toml !lang` in the global search picker searches for the term "helix" within files with paths ending in ".toml" but not including "lang".
You can insert the contents of a [register](./registers.md) using `Ctrl-r` followed by a register name. For example, one could insert the currently selected text using `Ctrl-r`-`.`, or the directory of the current file using `Ctrl-r`-`%` followed by `Ctrl-w` to remove the last path section. The global search picker will use the contents of the [search register](./registers.md#default-registers) if you press `Enter` without typing a filter. For example, pressing `*`-`Space-/`-`Enter` will start a global search for the currently selected text.

View File

@@ -1,54 +0,0 @@
## Registers
- [User-defined registers](#user-defined-registers)
- [Default registers](#default-registers)
- [Special registers](#special-registers)
In Helix, registers are storage locations for text and other data, such as the
result of a search. Registers can be used to cut, copy, and paste text, similar
to the clipboard in other text editors. Usage is similar to Vim, with `"` being
used to select a register.
### User-defined registers
Helix allows you to create your own named registers for storing text, for
example:
- `"ay` - Yank the current selection to register `a`.
- `"op` - Paste the text in register `o` after the selection.
If a register is selected before invoking a change or delete command, the selection will be stored in the register and the action will be carried out:
- `"hc` - Store the selection in register `h` and then change it (delete and enter insert mode).
- `"md` - Store the selection in register `m` and delete it.
### Default registers
Commands that use registers, like yank (`y`), use a default register if none is specified.
These registers are used as defaults:
| Register character | Contains |
| --- | --- |
| `/` | Last search |
| `:` | Last executed command |
| `"` | Last yanked text |
| `@` | Last recorded macro |
### Special registers
Some registers have special behavior when read from and written to.
| Register character | When read | When written |
| --- | --- | --- |
| `_` | No values are returned | All values are discarded |
| `#` | Selection indices (first selection is `1`, second is `2`, etc.) | This register is not writable |
| `.` | Contents of the current selections | This register is not writable |
| `%` | Name of the current file | This register is not writable |
| `+` | Reads from the system clipboard | Joins and yanks to the system clipboard |
| `*` | Reads from the primary clipboard | Joins and yanks to the primary clipboard |
When yanking multiple selections to the clipboard registers, the selections
are joined with newlines. Pasting from these registers will paste multiple
selections if the clipboard was last yanked to by the Helix session. Otherwise
the clipboard contents are pasted as one selection.

View File

@@ -1,34 +1,13 @@
## Key remapping
# Key remapping
Helix currently supports one-way key remapping through a simple TOML configuration
file. (More powerful solutions such as rebinding via commands will be
available in the future).
There are three kinds of commands that can be used in keymaps:
* Static commands: commands like `move_char_right` which are usually bound to
keys and used for movement and editing. A list of static commands is
available in the [Keymap](./keymap.html) documentation and in the source code
in [`helix-term/src/commands.rs`](https://github.com/helix-editor/helix/blob/master/helix-term/src/commands.rs)
at the invocation of `static_commands!` macro.
* Typable commands: commands that can be executed from command mode (`:`), for
example `:write!`. See the [Commands](./commands.md) documentation for a
list of available typeable commands or the `TypableCommandList` declaration in
the source code at [`helix-term/src/commands/typed.rs`](https://github.com/helix-editor/helix/blob/master/helix-term/src/commands/typed.rs).
* Macros: sequences of keys that are executed in order. These keybindings
start with `@` and then list any number of keys to be executed. For example
`@miw` can be used to select the surrounding word. For now, macro keybindings
are not allowed in keybinding sequences due to limitations in the way that
command sequences are executed. Modifier keys (e.g. Alt+o) can be used
like `"<A-o>"`, e.g. `"@miw<A-o>"`
To remap keys, create a `config.toml` file in your `helix` configuration
directory (default `~/.config/helix` on Linux systems) with a structure like
this:
> 💡 To set a modifier + key as a keymap, type `A-X = ...` or `C-X = ...` for Alt + X or Ctrl + X. Combine with Shift using a dash, e.g. `C-S-esc`.
> Within macros, wrap them in `<>`, e.g. `<A-X>` and `<C-X>` to distinguish from the `A` or `C` keys.
```toml
# At most one section each of 'keys.normal', 'keys.insert' and 'keys.select'
[keys.normal]
@@ -39,7 +18,6 @@ w = "move_line_up" # Maps the 'w' key move_line_up
"C-S-esc" = "extend_line" # Maps Ctrl-Shift-Escape to extend_line
g = { a = "code_action" } # Maps `ga` to show possible code actions
"ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode
"A-x" = "@x<A-d>" # Maps Alt-x to a macro selecting the whole line and deleting it without yanking it
[keys.insert]
"A-x" = "normal_mode" # Maps Alt-X to enter normal mode
@@ -72,28 +50,15 @@ 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-`.
The [Super key](https://en.wikipedia.org/wiki/Super_key_(keyboard_button)) - the Windows/Linux
key or the Command key on Mac keyboards - is also supported when using a terminal emulator that
supports the [enhanced keyboard protocol](https://github.com/helix-editor/helix/wiki/Terminal-Support#enhanced-keyboard-protocol).
The super key is encoded with prefixes `Meta-`, `Cmd-` or `Win-`. These are all synonyms for the
super modifier - binding a key with a `Win-` modifier will mean it can be used with the
Windows/Linux key or the Command key.
```toml
[keys.normal]
C-s = ":write" # Ctrl and 's' to write
Cmd-s = ":write" # Cmd or Win or Meta and 's' to write
```
Special keys are encoded as follows:
Ctrl, Shift and Alt modifiers are encoded respectively with the prefixes
`C-`, `S-` and `A-`. Special keys are encoded as follows:
| Key name | Representation |
| --- | --- |
| Backspace | `"backspace"` |
| Space | `"space"` |
| Return/Enter | `"ret"` |
| \- | `"minus"` |
| Left | `"left"` |
| Right | `"right"` |
| Up | `"up"` |
@@ -110,13 +75,5 @@ Special keys are encoded as follows:
Keys can be disabled by binding them to the `no_op` command.
All other keys such as `?`, `!`, `-` etc. can be used literally:
```toml
[keys.normal]
"?" = ":write"
"!" = ":write"
"-" = ":write"
```
Note: `-` can't be used when combined with a modifier, for example `Alt` + `-` should be written as `A-minus`. `A--` is not accepted.
A list of commands is available in the [Keymap](https://docs.helix-editor.com/keymap.html) documentation
and in the source code at [`helix-term/src/commands.rs`](https://github.com/helix-editor/helix/blob/master/helix-term/src/commands.rs) at the invocation of `static_commands!` macro and the `TypableCommandList`.

View File

@@ -1,24 +0,0 @@
## Surround
Helix includes built-in functionality similar to [vim-surround](https://github.com/tpope/vim-surround).
The keymappings have been inspired from [vim-sandwich](https://github.com/machakann/vim-sandwich):
![Surround demo](https://user-images.githubusercontent.com/23398472/122865801-97073180-d344-11eb-8142-8f43809982c6.gif)
| Key Sequence | Action |
| --------------------------------- | --------------------------------------- |
| `ms<char>` (after selecting text) | Add surround characters to selection |
| `mr<char_to_replace><new_char>` | Replace the closest surround characters |
| `md<char_to_delete>` | Delete the closest surround characters |
You can use counts to act on outer pairs.
Surround can also act on multiple selections. For example, to change every occurrence of `(use)` to `[use]`:
1. `%` to select the whole file
2. `s` to split the selections on a search term
3. Input `use` and hit Enter
4. `mr([` to replace the parentheses with square brackets
Multiple characters are currently not supported, but planned for future release.

View File

@@ -1,66 +0,0 @@
## Moving the selection with syntax-aware motions
`Alt-p`, `Alt-o`, `Alt-i`, and `Alt-n` (or `Alt` and arrow keys) allow you to move the
selection according to its location in the syntax tree. For example, many languages have the
following syntax for function calls:
```js
func(arg1, arg2, arg3);
```
A function call might be parsed by tree-sitter into a tree like the following.
```tsq
(call
function: (identifier) ; func
arguments:
(arguments ; (arg1, arg2, arg3)
(identifier) ; arg1
(identifier) ; arg2
(identifier))) ; arg3
```
Use `:tree-sitter-subtree` to view the syntax tree of the primary selection. In
a more intuitive tree format:
```
┌────┐
│call│
┌─────┴────┴─────┐
│ │
┌─────▼────┐ ┌────▼────┐
│identifier│ │arguments│
│ "func" │ ┌────┴───┬─────┴───┐
└──────────┘ │ │ │
│ │ │
┌─────────▼┐ ┌────▼─────┐ ┌▼─────────┐
│identifier│ │identifier│ │identifier│
│ "arg1" │ │ "arg2" │ │ "arg3" │
└──────────┘ └──────────┘ └──────────┘
```
If you have a selection that wraps `arg1` (see the tree above), and you use
`Alt-n`, it will select the next sibling in the syntax tree: `arg2`.
```js
// before
func([arg1], arg2, arg3)
// after
func(arg1, [arg2], arg3);
```
Similarly, `Alt-o` will expand the selection to the parent node, in this case, the
arguments node.
```js
func[(arg1, arg2, arg3)];
```
There is also some nuanced behavior that prevents you from getting stuck on a
node with no sibling. When using `Alt-p` with a selection on `arg1`, the previous
child node will be selected. In the event that `arg1` does not have a previous
sibling, the selection will move up the syntax tree and select the previous
element. As a result, using `Alt-p` with a selection on `arg1` will move the
selection to the "func" `identifier`.
[lang-support]: ./lang-support.md

View File

@@ -1,47 +0,0 @@
## Selecting and manipulating text with textobjects
In Helix, textobjects are a way to select, manipulate and operate on a piece of
text in a structured way. They allow you to refer to blocks of text based on
their structure or purpose, such as a word, sentence, paragraph, or even a
function or block of code.
![Textobject demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif)
![Textobject tree-sitter 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)
- `mi` - Select inside the object (`vi` in Vim, `<alt-i>` in Kakoune)
| Key after `mi` or `ma` | Textobject selected |
| --- | --- |
| `w` | Word |
| `W` | WORD |
| `p` | Paragraph |
| `(`, `[`, `'`, etc. | Specified surround pairs |
| `m` | The closest surround pair |
| `f` | Function |
| `t` | Type (or Class) |
| `a` | Argument/parameter |
| `c` | Comment |
| `T` | Test |
| `g` | Change |
> 💡 `f`, `t`, etc. need a tree-sitter grammar active for the current
document and a special tree-sitter query file to work properly. [Only
some grammars](./lang-support.md) currently have the query file implemented.
Contributions are welcome!
## Navigating using tree-sitter textobjects
Navigating between functions, classes, parameters, and other elements is
possible using tree-sitter and textobject queries. For
example to move to the next function use `]f`, to move to previous
type use `[t`, and so on.
![Tree-sitter-nav-demo](https://user-images.githubusercontent.com/23398472/152332550-7dfff043-36a2-4aec-b8f2-77c13eb56d6f.gif)
For the full reference see the [unimpaired](./keymap.html#unimpaired) section of the key bind
documentation.
> 💡 This feature relies on tree-sitter textobjects
> and requires the corresponding query file to work properly.

View File

@@ -1,4 +1,4 @@
## Themes
# Themes
To use a theme add `theme = "<name>"` to the top of your [`config.toml`](./configuration.md) file, or select it during runtime using `:theme <name>`.
@@ -36,6 +36,13 @@ For inspiration, you can find the default `theme.toml`
user-submitted themes
[here](https://github.com/helix-editor/helix/blob/master/runtime/themes).
### Using the linter
Use the supplied linting tool to check for errors and missing scopes:
```sh
cargo xtask themelint onedark # replace onedark with <name>
```
## The details of theme creation
@@ -136,7 +143,7 @@ The following is a list of scopes available to use for styling:
#### Syntax highlighting
These keys match [tree-sitter scopes](https://tree-sitter.github.io/tree-sitter/3-syntax-highlighting.html#highlights).
These keys match [tree-sitter scopes](https://tree-sitter.github.io/tree-sitter/syntax-highlighting#theme).
When determining styling for a highlight, the longest matching theme key will be used. For example, if the highlight is `function.builtin.static`, the key `function.builtin` will be used instead of `function`.
@@ -171,19 +178,16 @@ We use a similar set of scopes as
- `comment` - Code comments
- `line` - Single line comments (`//`)
- `documentation` - Line documentation comments (e.g. `///` in Rust)
- `block` - Block comments (e.g. (`/* */`)
- `documentation` - Block documentation comments (e.g. `/** */` in Rust)
- `unused` - Unused variables and patterns, e.g. `_` and `_foo`
- `documentation` - Documentation comments (e.g. `///` in Rust)
- `variable` - Variables
- `builtin` - Reserved language variables (`self`, `this`, `super`, etc.)
- `parameter` - Function parameters
- `other`
- `member` - Fields of composite data types (e.g. structs, unions)
- `private` - Private fields that use a unique syntax (currently just ECMAScript-based languages)
- `label` - `.class`, `#id` in CSS, etc.
- `label`
- `punctuation`
- `delimiter` - Commas, colons
@@ -209,7 +213,6 @@ We use a similar set of scopes as
- `function`
- `builtin`
- `method`
- `private` - Private methods that use a unique syntax (currently just ECMAScript-based languages)
- `macro`
- `special` (preprocessor in C)
@@ -218,7 +221,7 @@ We use a similar set of scopes as
- `namespace`
- `special` - `derive` in Rust, etc.
- `special`
- `markup`
- `heading`
@@ -248,7 +251,6 @@ We use a similar set of scopes as
- `gutter` - gutter indicator
- `delta` - modifications
- `moved` - renamed or moved files/changes
- `conflict` - merge conflicts
- `gutter` - gutter indicator
#### Interface
@@ -285,6 +287,7 @@ These scopes are used for theming the editor interface:
| `ui.debug.active` | Indicator for the line at which debugging execution is paused at, found in the gutter |
| `ui.gutter` | Gutter |
| `ui.gutter.selected` | Gutter for the line the cursor is on |
| `ui.highlight.frameline` | Line at which debugging execution is paused at |
| `ui.linenr` | Line numbers |
| `ui.linenr.selected` | Line number for the line the cursor is on |
| `ui.statusline` | Statusline |
@@ -294,35 +297,29 @@ These scopes are used for theming the editor interface:
| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) |
| `ui.statusline.separator` | Separator character in statusline |
| `ui.bufferline` | Style for the buffer line |
| `ui.bufferline.active` | Style for the active buffer in buffer line |
| `ui.bufferline.active` | Style for the active buffer in buffer line |
| `ui.bufferline.background` | Style for bufferline background |
| `ui.popup` | Documentation popups (e.g. Space + k) |
| `ui.popup.info` | Prompt for multiple key options |
| `ui.picker.header` | Header row area in pickers with multiple columns |
| `ui.picker.header.column` | Column names in pickers with multiple columns |
| `ui.picker.header.column.active` | The column name in pickers with multiple columns where the cursor is entering into. |
| `ui.window` | Borderlines separating splits |
| `ui.help` | Description box for commands |
| `ui.text` | Default text style, command prompts, popup text, etc. |
| `ui.text.focus` | The currently selected line in the picker |
| `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) |
| `ui.text.info` | The key: command text in `ui.popup.info` boxes |
| `ui.text.directory` | Directory names in prompt completion |
| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) |
| `ui.virtual.whitespace` | Visible whitespace characters |
| `ui.virtual.indent-guide` | Vertical indent width guides |
| `ui.virtual.inlay-hint` | Default style for inlay hints of all kinds |
| `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (language servers are not required to set a kind) |
| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (language servers are not required to set a kind) |
| `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (LSPs are not required to set a kind) |
| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (LSPs are not required to set a kind) |
| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) |
| `ui.virtual.jump-label` | Style for virtual jump labels |
| `ui.menu` | Code and command completion menus |
| `ui.menu.selected` | Selected autocomplete item |
| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar |
| `ui.selection` | For selections in the editing area |
| `ui.selection.primary` | |
| `ui.highlight` | Highlighted lines in the picker preview |
| `ui.highlight.frameline` | Line at which debugging execution is paused at |
| `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) |
| `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) |
| `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) |
@@ -336,8 +333,5 @@ These scopes are used for theming the editor interface:
| `diagnostic.info` | Diagnostics info (editing area) |
| `diagnostic.warning` | Diagnostics warning (editing area) |
| `diagnostic.error` | Diagnostics error (editing area) |
| `diagnostic.unnecessary` | Diagnostics with unnecessary tag (editing area) |
| `diagnostic.deprecated` | Diagnostics with deprecated tag (editing area) |
| `tabstop` | Snippet placeholder |
[editor-section]: ./configuration.md#editor-section

View File

@@ -1,5 +1,15 @@
# Using Helix
<!--toc:start-->
- [Registers](#registers)
- [User-defined registers](#user-defined-registers)
- [Special registers](#special-registers)
- [Surround](#surround)
- [Selecting and manipulating text with textobjects](#selecting-and-manipulating-text-with-textobjects)
- [Navigating using tree-sitter textobjects](#navigating-using-tree-sitter-textobjects)
- [Moving the selection with syntax-aware motions](#moving-the-selection-with-syntax-aware-motions)
<!--toc:end-->
For a full interactive introduction to Helix, refer to the
[tutor](https://github.com/helix-editor/helix/blob/master/runtime/tutor) which
can be accessed via the command `hx --tutor` or `:tutor`.
@@ -7,27 +17,192 @@ can be accessed via the command `hx --tutor` or `:tutor`.
> 💡 Currently, not all functionality is fully documented, please refer to the
> [key mappings](./keymap.md) list.
## Modes
## Registers
Helix is a modal editor, meaning it has different modes for different tasks. The main modes are:
In Helix, registers are storage locations for text and other data, such as the
result of a search. Registers can be used to cut, copy, and paste text, similar
to the clipboard in other text editors. Usage is similar to Vim, with `"` being
used to select a register.
* [Normal mode](./keymap.md#normal-mode): For navigation and editing commands. This is the default mode.
* [Insert mode](./keymap.md#insert-mode): For typing text directly into the document. Access by typing `i` in normal mode.
* [Select/extend mode](./keymap.md#select--extend-mode): For making selections and performing operations on them. Access by typing `v` in normal mode.
### User-defined registers
## Buffers
Helix allows you to create your own named registers for storing text, for
example:
Buffers are in-memory representations of files. You can have multiple buffers open at once. Use [pickers](./pickers.md) or commands like `:buffer-next` and `:buffer-previous` to open buffers or switch between them.
- `"ay` - Yank the current selection to register `a`.
- `"op` - Paste the text in register `o` after the selection.
## Selection-first editing
If a register is selected before invoking a change or delete command, the selection will be stored in the register and the action will be carried out:
Inspired by [Kakoune](http://kakoune.org/), Helix follows the `selection → action` model. This means that whatever you are 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 single width selection.
- `"hc` - Store the selection in register `h` and then change it (delete and enter insert mode).
- `"md` - Store the selection in register `m` and delete it.
## Multiple selections
### Default registers
Also inspired by Kakoune, multiple selections are a core mode of interaction in Helix. For example, the standard way of replacing multiple instances of a word is to first select all instances (so there is one selection per instance) and then use the change action (`c`) to edit them all at the same time.
Commands that use registers, like yank (`y`), use a default register if none is specified.
These registers are used as defaults:
## Motions
| Register character | Contains |
| --- | --- |
| `/` | Last search |
| `:` | Last executed command |
| `"` | Last yanked text |
| `@` | Last recorded macro |
Motions are commands that move the cursor or modify selections. They're used for navigation and text manipulation. Examples include `w` to move to the next word, or `f` to find a character. See the [Movement](./keymap.md#movement) section of the keymap for more motions.
### Special registers
Some registers have special behavior when read from and written to.
| Register character | When read | When written |
| --- | --- | --- |
| `_` | No values are returned | All values are discarded |
| `#` | Selection indices (first selection is `1`, second is `2`, etc.) | This register is not writable |
| `.` | Contents of the current selections | This register is not writable |
| `%` | Name of the current file | This register is not writable |
| `+` | Reads from the system clipboard | Joins and yanks to the system clipboard |
| `*` | Reads from the primary clipboard | Joins and yanks to the primary clipboard |
When yanking multiple selections to the clipboard registers, the selections
are joined with newlines. Pasting from these registers will paste multiple
selections if the clipboard was last yanked to by the Helix session. Otherwise
the clipboard contents are pasted as one selection.
## Surround
Helix includes built-in functionality similar to [vim-surround](https://github.com/tpope/vim-surround).
The keymappings have been inspired from [vim-sandwich](https://github.com/machakann/vim-sandwich):
![Surround demo](https://user-images.githubusercontent.com/23398472/122865801-97073180-d344-11eb-8142-8f43809982c6.gif)
| Key Sequence | Action |
| --------------------------------- | --------------------------------------- |
| `ms<char>` (after selecting text) | Add surround characters to selection |
| `mr<char_to_replace><new_char>` | Replace the closest surround characters |
| `md<char_to_delete>` | Delete the closest surround characters |
You can use counts to act on outer pairs.
Surround can also act on multiple selections. For example, to change every occurrence of `(use)` to `[use]`:
1. `%` to select the whole file
2. `s` to split the selections on a search term
3. Input `use` and hit Enter
4. `mr([` to replace the parentheses with square brackets
Multiple characters are currently not supported, but planned for future release.
## Selecting and manipulating text with textobjects
In Helix, textobjects are a way to select, manipulate and operate on a piece of
text in a structured way. They allow you to refer to blocks of text based on
their structure or purpose, such as a word, sentence, paragraph, or even a
function or block of code.
![Textobject demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif)
![Textobject tree-sitter 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)
- `mi` - Select inside the object (`vi` in Vim, `<alt-i>` in Kakoune)
| Key after `mi` or `ma` | Textobject selected |
| --- | --- |
| `w` | Word |
| `W` | WORD |
| `p` | Paragraph |
| `(`, `[`, `'`, etc. | Specified surround pairs |
| `m` | The closest surround pair |
| `f` | Function |
| `t` | Type (or Class) |
| `a` | Argument/parameter |
| `c` | Comment |
| `T` | Test |
| `g` | Change |
> 💡 `f`, `t`, etc. need a tree-sitter grammar active for the current
document and a special tree-sitter query file to work properly. [Only
some grammars][lang-support] currently have the query file implemented.
Contributions are welcome!
## Navigating using tree-sitter textobjects
Navigating between functions, classes, parameters, and other elements is
possible using tree-sitter and textobject queries. For
example to move to the next function use `]f`, to move to previous
type use `[t`, and so on.
![Tree-sitter-nav-demo][tree-sitter-nav-demo]
For the full reference see the [unimpaired][unimpaired-keybinds] section of the key bind
documentation.
> 💡 This feature relies on tree-sitter textobjects
> and requires the corresponding query file to work properly.
## Moving the selection with syntax-aware motions
`Alt-p`, `Alt-o`, `Alt-i`, and `Alt-n` (or `Alt` and arrow keys) allow you to move the
selection according to its location in the syntax tree. For example, many languages have the
following syntax for function calls:
```js
func(arg1, arg2, arg3);
```
A function call might be parsed by tree-sitter into a tree like the following.
```tsq
(call
function: (identifier) ; func
arguments:
(arguments ; (arg1, arg2, arg3)
(identifier) ; arg1
(identifier) ; arg2
(identifier))) ; arg3
```
Use `:tree-sitter-subtree` to view the syntax tree of the primary selection. In
a more intuitive tree format:
```
┌────┐
│call│
┌─────┴────┴─────┐
│ │
┌─────▼────┐ ┌────▼────┐
│identifier│ │arguments│
│ "func" │ ┌────┴───┬─────┴───┐
└──────────┘ │ │ │
│ │ │
┌─────────▼┐ ┌────▼─────┐ ┌▼─────────┐
│identifier│ │identifier│ │identifier│
│ "arg1" │ │ "arg2" │ │ "arg3" │
└──────────┘ └──────────┘ └──────────┘
```
If you have a selection that wraps `arg1` (see the tree above), and you use
`Alt-n`, it will select the next sibling in the syntax tree: `arg2`.
```js
// before
func([arg1], arg2, arg3)
// after
func(arg1, [arg2], arg3);
```
Similarly, `Alt-o` will expand the selection to the parent node, in this case, the
arguments node.
```js
func[(arg1, arg2, arg3)];
```
There is also some nuanced behavior that prevents you from getting stuck on a
node with no sibling. When using `Alt-p` with a selection on `arg1`, the previous
child node will be selected. In the event that `arg1` does not have a previous
sibling, the selection will move up the syntax tree and select the previous
element. As a result, using `Alt-p` with a selection on `arg1` will move the
selection to the "func" `identifier`.
[lang-support]: ./lang-support.md
[unimpaired-keybinds]: ./keymap.md#unimpaired
[tree-sitter-nav-demo]: https://user-images.githubusercontent.com/23398472/152332550-7dfff043-36a2-4aec-b8f2-77c13eb56d6f.gif

View File

@@ -1,5 +1,5 @@
<!DOCTYPE HTML>
<html lang="{{ language }}" class="{{ default_theme }} sidebar-visible" dir="{{ text_direction }}">
<html lang="{{ language }}" class="{{ default_theme }}" dir="{{ text_direction }}">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
@@ -52,17 +52,15 @@
<!-- MathJax -->
<script async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
{{/if}}
</head>
<body class="sidebar-visible no-js">
<div id="body-container">
<!-- Provide site root to javascript -->
<script>
var path_to_root = "{{ path_to_root }}";
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "{{ preferred_dark_theme }}" : "{{ default_theme }}";
</script>
<!-- Start loading toc.js asap -->
<script src="{{ path_to_root }}toc.js"></script>
</head>
<body>
<div id="body-container">
<!-- Work around some values being stored in localStorage wrapped in quotes -->
<script>
try {
@@ -84,16 +82,19 @@
var theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
const html = document.documentElement;
var html = document.querySelector('html');
html.classList.remove('{{ default_theme }}')
html.classList.add(theme);
html.classList.add("js");
var body = document.querySelector('body');
body.classList.remove('no-js')
body.classList.add('js');
</script>
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
<!-- Hide / unhide sidebar before it is displayed -->
<script>
var body = document.querySelector('body');
var sidebar = null;
var sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
if (document.body.clientWidth >= 1080) {
@@ -103,21 +104,39 @@
sidebar = 'hidden';
}
sidebar_toggle.checked = sidebar === 'visible';
html.classList.remove('sidebar-visible');
html.classList.add("sidebar-" + sidebar);
body.classList.remove('sidebar-visible');
body.classList.add("sidebar-" + sidebar);
</script>
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
<!-- populated by js -->
<mdbook-sidebar-scrollbox class="sidebar-scrollbox"></mdbook-sidebar-scrollbox>
<noscript>
<iframe class="sidebar-iframe-outer" src="{{ path_to_root }}toc.html"></iframe>
</noscript>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
<div class="sidebar-scrollbox">
{{#toc}}{{/toc}}
</div>
<div id="sidebar-resize-handle" class="sidebar-resize-handle"></div>
</nav>
<!-- Track and set sidebar scroll position -->
<script>
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
sidebarScrollbox.addEventListener('click', function(e) {
if (e.target.tagName === 'A') {
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
}
}, { passive: true });
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
sessionStorage.removeItem('sidebar-scroll');
if (sidebarScrollTop) {
// preserve sidebar scroll position when navigating via links within sidebar
sidebarScrollbox.scrollTop = sidebarScrollTop;
} else {
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
var activeSection = document.querySelector('#sidebar .active');
if (activeSection) {
activeSection.scrollIntoView({ block: 'center' });
}
}
</script>
<div id="page-wrapper" class="page-wrapper">
<div class="page">

View File

@@ -6,26 +6,27 @@
<name>Helix</name>
<summary>A post-modern text editor</summary>
<summary xml:lang="ar">مُحَرِّرُ نُصُوصٍ سَابِقٌ لِعَهدِه</summary>
<developer id="com.helix_editor">
<name>Blaž Hrastnik</name>
</developer>
<description>
<p>
Helix is a terminal-based text editor inspired by Kakoune / Neovim and written in Rust.
</p>
<p xml:lang="ar">
<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>
<description xml:lang="ar">
<p>
مُحَرِّرُ نُصُوصٍ يَعمَلُ فِي الطَّرَفِيَّة، مُستَلهَمٌ مِن Kakoune وَ Neovim وَمَكتُوبٌ بِلُغَةِ رَست البَرمَجِيَّة.
</p>
<ul>
<li>Vim-like modal editing</li>
<li xml:lang="ar">تَحرِيرٌ وَضعِيٌّ شَبيهٌ بِـVim</li>
<li>Multiple selections</li>
<li xml:lang="ar">تَحدِيدَاتٌ لِلنَّصِ مُتَعَدِّدَة</li>
<li>Built-in language server support</li>
<li xml:lang="ar">دَعْمٌ مُدمَجٌ لِخَوادِمِ اللُّغَات</li>
<li>Smart, incremental syntax highlighting and code editing via tree-sitter</li>
<li xml:lang="ar">تَحرِيرُ التَّعلِيمَاتِ البَّرمَجِيَّةِ مَعَ تَمييزٍ لِلتَّركِيبِ النَّحُويِّ بِواسِطَةِ tree-sitter</li>
<li>تَحرِيرٌ وَضعِيٌّ شَبيهٌ بِـVim</li>
<li>تَحدِيدَاتٌ لِلنَّصِ مُتَعَدِّدَة</li>
<li>دَعْمٌ مُدمَجٌ لِخَوادِمِ اللُّغَات</li>
<li>تَحرِيرُ التَّعلِيمَاتِ البَّرمَجِيَّةِ مَعَ تَمييزٍ لِلتَّركِيبِ النَّحُويِّ بِواسِطَةِ tree-sitter</li>
</ul>
</description>
@@ -47,21 +48,6 @@
<content_rating type="oars-1.1" />
<releases>
<release version="25.07" date="2025-07-15">
<url>https://helix-editor.com/news/release-25-07-highlights/</url>
</release>
<release version="25.01.1" date="2025-01-19">
<url>https://github.com/helix-editor/helix/releases/tag/25.01.1</url>
</release>
<release version="25.01" date="2025-01-03">
<url>https://helix-editor.com/news/release-25-01-highlights/</url>
</release>
<release version="24.07" date="2024-07-14">
<url>https://github.com/helix-editor/helix/releases/tag/24.07</url>
</release>
<release version="24.03" date="2024-03-30">
<url>https://helix-editor.com/news/release-24-03-highlights/</url>
</release>
<release version="23.10" date="2023-10-24">
<url>https://helix-editor.com/news/release-23-10-highlights/</url>
</release>
@@ -85,9 +71,9 @@
</release>
</releases>
<recommends>
<requires>
<control>keyboard</control>
</recommends>
</requires>
<categories>
<category>Utility</category>

View File

@@ -86,6 +86,6 @@ Keywords[ru]=текст;текстовый редактор;
Keywords[sr]=Текст;едитор;
Keywords[tr]=Metin;düzenleyici;
Icon=helix
Categories=Utility;TextEditor;ConsoleOnly
Categories=Utility;TextEditor;
StartupNotify=false
MimeType=text/english;text/plain;text/x-makefile;text/x-c++hdr;text/x-c++src;text/x-chdr;text/x-csrc;text/x-java;text/x-moc;text/x-pascal;text/x-tcl;text/x-tex;application/x-shellscript;text/x-c;text/x-c++;

View File

@@ -2,31 +2,22 @@
# Bash completion script for Helix editor
_hx() {
local cur prev languages
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD - 1]}"
# $1 command name
# $2 word being completed
# $3 word preceding
COMPREPLY=()
case "$prev" in
-g | --grammar)
mapfile -t COMPREPLY < <(compgen -W 'fetch build' -- "$cur")
return 0
;;
--health)
languages=$(hx --health | tail -n '+7' | awk '{print $1}' | sed 's/\x1b\[[0-9;]*m//g')
mapfile -t COMPREPLY < <(compgen -W """$languages""" -- "$cur")
return 0
;;
esac
case "$2" in
-*)
mapfile -t COMPREPLY < <(compgen -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config --log" -- """$2""")
return 0
;;
*)
mapfile -t COMPREPLY < <(compgen -fd -- """$2""")
return 0
;;
esac
case "$3" in
-g | --grammar)
COMPREPLY=($(compgen -W "fetch build" -- $2))
;;
--health)
local languages=$(hx --health |tail -n '+7' |awk '{print $1}' |sed 's/\x1b\[[0-9;]*m//g')
COMPREPLY=($(compgen -W "$languages" -- $2))
;;
*)
COMPREPLY=($(compgen -fd -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config --log" -- $2))
;;
esac
} && complete -o filenames -F _hx hx

View File

@@ -20,7 +20,7 @@ var config = [ "--config" "-c" ]
set edit:completion:arg-completer[hx] = {|@args|
var n = (count $args)
if (>= $n 3) {
# Stop completions if passed arg will take precedence
# Stop completions if passed arg will take presedence
# and invalidate further input
if (has-value $skips $args[-2]) {
return

View File

@@ -1,18 +1,15 @@
#!/usr/bin/env fish
# Fish completion script for Helix editor
set -l langs (hx --health |tail -n '+7' |awk '{print $1}' |sed 's/\x1b\[[0-9;]*m//g')
complete -c hx -s h -l help -d "Prints help information"
complete -c hx -l tutor -d "Loads the tutorial"
complete -c hx -l health -xa "(__hx_langs_ops)" -d "Checks for errors"
complete -c hx -s g -l grammar -x -a "fetch build" -d "Fetch or build tree-sitter grammars"
complete -c hx -l health -x -a "$langs" -d "Checks for errors in editor setup"
complete -c hx -s g -l grammar -x -a "fetch build" -d "Fetches or builds tree-sitter grammars"
complete -c hx -s v -o vv -o vvv -d "Increases logging verbosity"
complete -c hx -s V -l version -d "Prints version information"
complete -c hx -l vsplit -d "Splits all given files vertically"
complete -c hx -l hsplit -d "Splits all given files horizontally"
complete -c hx -s c -l config -r -d "Specifies a file to use for config"
complete -c hx -l log -r -d "Specifies a file to use for logging"
complete -c hx -s w -l working-dir -d "Specify initial working directory" -xa "(__fish_complete_directories)"
function __hx_langs_ops
hx --health languages | tail -n '+2' | string replace -fr '^(\S+) .*' '$1'
end
complete -c hx -l vsplit -d "Splits all given files vertically into different windows"
complete -c hx -l hsplit -d "Splits all given files horizontally into different windows"
complete -c hx -s c -l config -r -d "Specifies a file to use for completion"
complete -c hx -l log -r -d "Specifies a file to write log data into"

View File

@@ -1,29 +0,0 @@
# Completions for Helix: <https://github.com/helix-editor/helix>
#
# NOTE: the `+N` syntax is not supported in Nushell (https://github.com/nushell/nushell/issues/13418)
# so it has not been specified here and will not be proposed in the autocompletion of Nushell.
# The help message won't be overridden though, so it will still be present here
def health_categories [] {
let languages = ^hx --health languages | detect columns | get Language | filter { $in != null }
let completions = [ "all", "clipboard", "languages" ] | append $languages
return $completions
}
def grammar_categories [] { ["fetch", "build"] }
# A post-modern text editor.
export extern hx [
--help(-h), # Prints help information
--tutor, # Loads the tutorial
--health: string@health_categories, # Checks for potential errors in editor setup
--grammar(-g): string@grammar_categories, # Fetches or builds tree-sitter grammars listed in `languages.toml`
--config(-c): glob, # Specifies a file to use for configuration
-v, # Increases logging verbosity each use for up to 3 times
--log: glob, # Specifies a file to use for logging
--version(-V), # Prints version information
--vsplit, # Splits all given files vertically into different windows
--hsplit, # Splits all given files horizontally into different windows
--working-dir(-w): glob, # Specify an initial working directory
...files: glob, # Sets the input file to use, position can also be specified via file[:row[:col]]
]

View File

@@ -14,18 +14,16 @@ _hx() {
"--health[Checks for errors in editor setup]:language:->health" \
"-g[Fetches or builds tree-sitter grammars]:action:->grammar" \
"--grammar[Fetches or builds tree-sitter grammars]:action:->grammar" \
"--vsplit[Splits all given files vertically]" \
"--hsplit[Splits all given files horizontally]" \
"--vsplit[Splits all given files vertically into different windows]" \
"--hsplit[Splits all given files horizontally into different windows]" \
"-c[Specifies a file to use for configuration]" \
"--config[Specifies a file to use for configuration]" \
"-w[Specify initial working directory]" \
"--working-dir[Specify initial working directory]" \
"--log[Specifies a file to use for logging]" \
"--log[Specifies a file to write log data into]" \
"*:file:_files"
case "$state" in
health)
local languages=($(hx --health | tail -n '+11' | awk '{print $1}' | sed 's/\x1b\[[0-9;]*m//g;s/[✘✓]//g'))
local languages=($(hx --health |tail -n '+7' |awk '{print $1}' |sed 's/\x1b\[[0-9;]*m//g'))
_values 'language' $languages
;;
grammar)
@@ -33,3 +31,4 @@ _hx() {
;;
esac
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -1,3 +0,0 @@
#!/usr/bin/env sh
HELIX_RUNTIME=/usr/lib/helix/runtime exec /usr/lib/helix/hx "$@"

View File

@@ -1,85 +1,8 @@
{
lib,
rustPlatform,
callPackage,
runCommand,
installShellFiles,
git,
gitRev ? null,
grammarOverlays ? [],
includeGrammarIf ? _: true,
}: let
fs = lib.fileset;
src = fs.difference (fs.gitTracked ./.) (fs.unions [
./.envrc
./rustfmt.toml
./screenshot.png
./book
./docs
./runtime
./flake.lock
(fs.fileFilter (file: lib.strings.hasInfix ".git" file.name) ./.)
(fs.fileFilter (file: file.hasExt "svg") ./.)
(fs.fileFilter (file: file.hasExt "md") ./.)
(fs.fileFilter (file: file.hasExt "nix") ./.)
]);
# Next we actually need to build the grammars and the runtime directory
# that they reside in. It is built by calling the derivation in the
# grammars.nix file, then taking the runtime directory in the git repo
# and hooking symlinks up to it.
grammars = callPackage ./grammars.nix {inherit grammarOverlays includeGrammarIf;};
runtimeDir = runCommand "helix-runtime" {} ''
mkdir -p $out
ln -s ${./runtime}/* $out
rm -r $out/grammars
ln -s ${grammars} $out/grammars
'';
# Flake's default package for non-flake-enabled nix instances
let
compat = builtins.fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/b4a34015c698c7793d592d66adbab377907a2be8.tar.gz";
sha256 = "sha256:1qc703yg0babixi6wshn5wm2kgl5y1drcswgszh4xxzbrwkk9sv7";
};
in
rustPlatform.buildRustPackage (self: {
cargoLock = {
lockFile = ./Cargo.lock;
# This is not allowed in nixpkgs but is very convenient here: it allows us to
# avoid specifying `outputHashes` here for any git dependencies we might take
# on temporarily.
allowBuiltinFetchGit = true;
};
nativeBuildInputs = [
installShellFiles
git
];
buildType = "release";
name = with builtins; (fromTOML (readFile ./helix-term/Cargo.toml)).package.name;
src = fs.toSource {
root = ./.;
fileset = src;
};
# Helix attempts to reach out to the network and get the grammars. Nix doesn't allow this.
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";
# So Helix knows what rev it is.
HELIX_NIX_BUILD_REV = gitRev;
doCheck = false;
strictDeps = true;
# Sets the Helix runtime dir to the grammars
env.HELIX_DEFAULT_RUNTIME = "${runtimeDir}";
# Get all the application stuff in the output directory.
postInstall = ''
mkdir -p $out/lib
installShellCompletion ${./contrib/completion}/hx.{bash,fish,zsh}
mkdir -p $out/share/{applications,icons/hicolor/{256x256,scalable}/apps}
cp ${./contrib/Helix.desktop} $out/share/applications/Helix.desktop
cp ${./logo.svg} $out/share/icons/hicolor/scalable/apps/helix.svg
cp ${./contrib/helix.png} $out/share/icons/hicolor/256x256/apps/helix.png
'';
meta.mainProgram = "hx";
})
(import compat {src = ./.;}).defaultNix.default

View File

@@ -13,10 +13,8 @@ Some suggestions to get started:
- Instead of running a release version of Helix, while developing you may want to run in debug mode with `cargo run` which is way faster to compile
- Looking for even faster compile times? Give a try to [mold](https://github.com/rui314/mold)
- If your preferred language is missing, integrating a tree-sitter grammar for
it and defining syntax highlight queries for it is straightforward and
it and defining syntax highlight queries for it is straight forward and
doesn't require much knowledge of the internals.
- If you don't use the Nix development shell and are getting your rust-analyzer binary from rustup, you may need to run `rustup component add rust-analyzer`.
This is because `rust-toolchain.toml` selects our MSRV for the development toolchain but doesn't download the matching rust-analyzer automatically.
We provide an [architecture.md][architecture.md] that should give you
a good overview of the internals.
@@ -55,22 +53,12 @@ Existing tests can be used as examples. Helpers can be found in
[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`.
Contributors using MacOS might encounter `Too many open files (os error 24)`
failures while running integration tests. This can be resolved by increasing
the default value (e.g. to `10240` from `256`) by running `ulimit -n 10240`.
## Minimum Stable Rust Version (MSRV) Policy
Helix keeps an intentionally low MSRV for the sake of easy building and packaging
downstream. We follow [Firefox's MSRV policy]. Once Firefox's MSRV increases we
may bump ours as well, but be sure to check that popular distributions like Ubuntu
package the new MSRV version. When increasing the MSRV, update these three places:
Helix follows the MSRV of Firefox.
The current MSRV and future changes to the MSRV are listed in the [Firefox documentation].
* the `workspace.package.rust-version` key in `Cargo.toml` in the repository root
* the `env.MSRV` key at the top of `.github/workflows/build.yml`
* the `toolchain.channel` key in `rust-toolchain.toml`
[Firefox's MSRV policy]: https://firefox-source-docs.mozilla.org/writing-rust-code/update-policy.html
[Firefox documentation]: https://firefox-source-docs.mozilla.org/writing-rust-code/update-policy.html
[good-first-issue]: https://github.com/helix-editor/helix/labels/E-easy
[log-file]: https://github.com/helix-editor/helix/wiki/FAQ#access-the-log-file
[architecture.md]: ./architecture.md

View File

@@ -1,14 +1,13 @@
| Crate | Description |
| ----------- | ----------- |
| helix-core | Core editing primitives, functional. |
| helix-lsp | Language server client |
| helix-lsp-types | Language Server Protocol type definitions |
| helix-dap | Debug Adapter Protocol (DAP) client |
| helix-loader | Functions for building, fetching, and loading external resources |
| helix-view | UI abstractions for use in backends, imperative shell. |
| helix-term | Terminal UI |
| helix-tui | TUI primitives, forked from tui-rs, inspired by Cursive |
| Crate | Description |
| ----------- | ----------- |
| helix-core | Core editing primitives, functional. |
| helix-lsp | Language server client |
| helix-dap | Debug Adapter Protocol (DAP) client |
| helix-loader | Functions for building, fetching, and loading external resources |
| helix-view | UI abstractions for use in backends, imperative shell. |
| helix-term | Terminal UI |
| helix-tui | TUI primitives, forked from tui-rs, inspired by Cursive |
This document contains a high-level overview of Helix internals.

View File

@@ -5,18 +5,19 @@ Helix releases are versioned in the Calendar Versioning scheme:
`22.05.1`. In these instructions we'll use `<tag>` as a placeholder for the tag
being published.
* Merge the PR with the release updates. That branch should:
* Update the version:
* Update the `workspace.package.version` key in `Cargo.toml`. Cargo only accepts
SemVer versions so a CalVer version of `22.07` for example must be formatted
as `22.7.0`. Patch/bugfix releases should increment the SemVer patch number. A
patch release for 22.07 would be `22.7.1`.
* Run `cargo check` and commit the resulting change to `Cargo.lock`
* Add changelog notes to `CHANGELOG.md`
* 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)
* 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
* Switch to master and pull
* `git tag -s -m "<tag>" -a <tag> && git push origin <tag>` (note the `-s` which signs the tag)
* `git tag -s -m "<tag>" -a <tag> && git push`
* Make sure to switch to master and pull first
* Edit the `Cargo.toml` file and change the date in the `version` field to the next planned release
* Due to Cargo having a strict requirement on SemVer with 3 or more version
numbers, a `0` is required in the micro version; however, unless we are
publishing a patch release after a major release, the `.0` is dropped in
the user facing version.
* Releases are planned to happen every two months, so `22.05.0` would change to `22.07.0`
* If we are pushing a patch/bugfix release in the same month as the previous
release, bump the micro version, e.g. `22.07.0` to `22.07.1`
* Wait for the Release CI to finish
* It will automatically turn the git tag into a GitHub release when it uploads artifacts
* Edit the new release
@@ -30,7 +31,7 @@ being published.
* Post to reddit
* [Example post](https://www.reddit.com/r/rust/comments/uzp5ze/helix_editor_2205_released/)
[homebrew formula]: https://github.com/Homebrew/homebrew-core/blob/master/Formula/h/helix.rb
[homebrew formula]: https://github.com/Homebrew/homebrew-core/blob/master/Formula/helix.rb
## Changelog Curation

70
flake.lock generated
View File

@@ -1,12 +1,50 @@
{
"nodes": {
"crane": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1701025348,
"narHash": "sha256-42GHmYH+GF7VjwGSt+fVT1CQuNpGanJbNgVHTAZppUM=",
"owner": "ipetkov",
"repo": "crane",
"rev": "42afaeb1a0325194a7cdb526332d2cb92fddd07b",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1740560979,
"narHash": "sha256-Vr3Qi346M+8CjedtbyUevIGDZW8LcA1fTG0ugPY/Hic=",
"lastModified": 1700794826,
"narHash": "sha256-RyJTnTNKhO0yqRpDISk03I/4A67/dp96YRxc86YOPgU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5135c59491985879812717f4c9fea69604e7f26f",
"rev": "5a09cb4b393d58f9ed0d9ca1555016a8543c2ac8",
"type": "github"
},
"original": {
@@ -18,22 +56,27 @@
},
"root": {
"inputs": {
"crane": "crane",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1740623427,
"narHash": "sha256-3SdPQrZoa4odlScFDUHd4CUPQ/R1gtH4Mq9u8CBiK8M=",
"lastModified": 1701137803,
"narHash": "sha256-0LcPAdql5IhQSUXJx3Zna0dYTgdIoYO7zUrsKgiBd04=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "d342e8b5fd88421ff982f383c853f0fc78a847ab",
"rev": "9dd940c967502f844eacea52a61e9596268d4f70",
"type": "github"
},
"original": {
@@ -41,6 +84,21 @@
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

248
flake.nix
View File

@@ -3,8 +3,16 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-utils.follows = "flake-utils";
};
};
crane = {
url = "github:ipetkov/crane";
inputs.nixpkgs.follows = "nixpkgs";
};
};
@@ -12,89 +20,179 @@
outputs = {
self,
nixpkgs,
crane,
flake-utils,
rust-overlay,
...
}: let
inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
eachSystem = lib.genAttrs systems;
pkgsFor = eachSystem (system:
import nixpkgs {
localSystem.system = system;
overlays = [(import rust-overlay) self.overlays.helix];
});
gitRev = self.rev or self.dirtyRev or null;
in {
packages = eachSystem (system: {
inherit (pkgsFor.${system}) helix;
/*
The default Helix build. Uses the latest stable Rust toolchain, and unstable
nixpkgs.
The build inputs can be overridden with the following:
packages.${system}.default.override { rustPlatform = newPlatform; };
Overriding a derivation attribute can be done as well:
packages.${system}.default.overrideAttrs { buildType = "debug"; };
*/
default = self.packages.${system}.helix;
});
checks =
lib.mapAttrs (system: pkgs: let
# Get Helix's MSRV toolchain to build with by default.
msrvToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
msrvPlatform = pkgs.makeRustPlatform {
cargo = msrvToolchain;
rustc = msrvToolchain;
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
overlays = [(import rust-overlay)];
};
mkRootPath = rel:
builtins.path {
path = "${toString ./.}/${rel}";
name = rel;
};
in {
helix = self.packages.${system}.helix.override {
rustPlatform = msrvPlatform;
};
})
pkgsFor;
# Devshell behavior is preserved.
devShells =
lib.mapAttrs (system: pkgs: {
default = let
commonRustFlagsEnv = "-C link-arg=-fuse-ld=lld -C target-cpu=native --cfg tokio_unstable";
platformRustFlagsEnv = lib.optionalString pkgs.stdenv.isLinux "-Clink-arg=-Wl,--no-rosegment";
filteredSource = let
pathsToIgnore = [
".envrc"
".ignore"
".github"
".gitignore"
"logo_dark.svg"
"logo_light.svg"
"rust-toolchain.toml"
"rustfmt.toml"
"runtime"
"screenshot.png"
"book"
"docs"
"README.md"
"CHANGELOG.md"
"shell.nix"
"default.nix"
"grammars.nix"
"flake.nix"
"flake.lock"
];
ignorePaths = path: type: let
inherit (nixpkgs) lib;
# 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
pkgs.mkShell {
inputsFrom = [self.checks.${system}.helix];
nativeBuildInputs = with pkgs;
[
lld
cargo-flamegraph
rust-bin.nightly.latest.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);
shellHook = ''
export RUST_BACKTRACE="1"
export RUSTFLAGS="''${RUSTFLAGS:-""} ${commonRustFlagsEnv} ${platformRustFlagsEnv}"
lib.all (p: ! (lib.hasPrefix p relPath)) pathsToIgnore;
in
builtins.path {
name = "helix-source";
path = toString ./.;
# filter out unnecessary paths
filter = ignorePaths;
};
makeOverridableHelix = old: config: let
grammars = pkgs.callPackage ./grammars.nix config;
runtimeDir = pkgs.runCommand "helix-runtime" {} ''
mkdir -p $out
ln -s ${mkRootPath "runtime"}/* $out
rm -r $out/grammars
ln -s ${grammars} $out/grammars
'';
helix-wrapped =
pkgs.runCommand
old.name
{
inherit (old) pname version;
meta = old.meta or {};
passthru =
(old.passthru or {})
// {
unwrapped = old;
};
nativeBuildInputs = [pkgs.makeWrapper];
makeWrapperArgs = config.makeWrapperArgs or [];
}
''
cp -rs --no-preserve=mode,ownership ${old} $out
wrapProgram "$out/bin/hx" ''${makeWrapperArgs[@]} --set HELIX_RUNTIME "${runtimeDir}"
'';
in
helix-wrapped
// {
override = makeOverridableHelix old;
passthru =
helix-wrapped.passthru
// {
wrapper = old: makeOverridableHelix old config;
};
};
stdenv =
if pkgs.stdenv.isLinux
then pkgs.stdenv
else pkgs.clangStdenv;
rustFlagsEnv =
if stdenv.isLinux
then ''$RUSTFLAGS -C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment''
else "$RUSTFLAGS";
rustToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
craneLibMSRV = (crane.mkLib pkgs).overrideToolchain rustToolchain;
craneLibStable = (crane.mkLib pkgs).overrideToolchain pkgs.pkgsBuildHost.rust-bin.stable.latest.default;
commonArgs = {
inherit stdenv;
inherit (craneLibMSRV.crateNameFromCargoToml {cargoToml = ./helix-term/Cargo.toml;}) pname;
inherit (craneLibMSRV.crateNameFromCargoToml {cargoToml = ./Cargo.toml;}) version;
src = filteredSource;
# disable fetching and building of tree-sitter grammars in the helix-term build.rs
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";
buildInputs = [stdenv.cc.cc.lib];
# disable tests
doCheck = false;
meta.mainProgram = "hx";
};
cargoArtifacts = craneLibMSRV.buildDepsOnly commonArgs;
in {
packages = {
helix-unwrapped = craneLibStable.buildPackage (commonArgs
// {
cargoArtifacts = craneLibStable.buildDepsOnly commonArgs;
postInstall = ''
mkdir -p $out/share/applications $out/share/icons/hicolor/scalable/apps $out/share/icons/hicolor/256x256/apps
cp contrib/Helix.desktop $out/share/applications
cp logo.svg $out/share/icons/hicolor/scalable/apps/helix.svg
cp contrib/helix.png $out/share/icons/hicolor/256x256/apps
'';
};
})
pkgsFor;
overlays = {
helix = final: prev: {
helix = final.callPackage ./default.nix {inherit gitRev;};
});
helix = makeOverridableHelix self.packages.${system}.helix-unwrapped {};
default = self.packages.${system}.helix;
};
default = self.overlays.helix;
checks = {
# Build the crate itself
inherit (self.packages.${system}) helix;
clippy = craneLibMSRV.cargoClippy (commonArgs
// {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
});
fmt = craneLibMSRV.cargoFmt commonArgs;
doc = craneLibMSRV.cargoDoc (commonArgs
// {
inherit cargoArtifacts;
});
test = craneLibMSRV.cargoTest (commonArgs
// {
inherit cargoArtifacts;
});
};
devShells.default = pkgs.mkShell {
inputsFrom = builtins.attrValues self.checks.${system};
nativeBuildInputs = with pkgs;
[lld_13 cargo-flamegraph rust-analyzer]
++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) pkgs.cargo-tarpaulin)
++ (lib.optional stdenv.isLinux pkgs.lldb)
++ (lib.optional stdenv.isDarwin pkgs.darwin.apple_sdk.frameworks.CoreFoundation);
shellHook = ''
export HELIX_RUNTIME="$PWD/runtime"
export RUST_BACKTRACE="1"
export RUSTFLAGS="${rustFlagsEnv}"
'';
};
})
// {
overlays.default = final: prev: {
inherit (self.packages.${final.system}) helix;
};
};
};
nixConfig = {
extra-substituters = ["https://helix.cachix.org"];
extra-trusted-public-keys = ["helix.cachix.org-1:ejp9KQpR1FBI2onstMQ34yogDm4OgU2ru6lIwPvuCVs="];

View File

@@ -1,13 +1,22 @@
{
stdenv,
lib,
runCommandLocal,
runCommand,
yj,
includeGrammarIf ? _: true,
grammarOverlays ? [],
...
}: let
# HACK: nix < 2.6 has a bug in the toml parser, so we convert to JSON
# before parsing
languages-json = runCommandLocal "languages-toml-to-json" {} ''
${yj}/bin/yj -t < ${./languages.toml} > $out
'';
languagesConfig =
builtins.fromTOML (builtins.readFile ./languages.toml);
if lib.versionAtLeast builtins.nixVersion "2.6.0"
then builtins.fromTOML (builtins.readFile ./languages.toml)
else builtins.fromJSON (builtins.readFile (builtins.toPath languages-json));
isGitGrammar = grammar:
builtins.hasAttr "source" grammar
&& builtins.hasAttr "git" grammar.source
@@ -19,17 +28,7 @@
owner = builtins.elemAt match 0;
repo = builtins.elemAt match 1;
};
# If `use-grammars.only` is set, use only those grammars.
# If `use-grammars.except` is set, use all other grammars.
# Otherwise use all grammars.
useGrammar = grammar:
if languagesConfig ? use-grammars.only
then builtins.elem grammar.name languagesConfig.use-grammars.only
else if languagesConfig ? use-grammars.except
then !(builtins.elem grammar.name languagesConfig.use-grammars.except)
else true;
grammarsToUse = builtins.filter useGrammar languagesConfig.grammar;
gitGrammars = builtins.filter isGitGrammar grammarsToUse;
gitGrammars = builtins.filter isGitGrammar languagesConfig.grammar;
buildGrammar = grammar: let
gh = toGitHubFetcher grammar.source.git;
sourceGit = builtins.fetchTree {
@@ -57,10 +56,10 @@
version = grammar.source.rev;
src = source;
sourceRoot =
if builtins.hasAttr "subpath" grammar.source
then "source/${grammar.source.subpath}"
else "source";
sourceRoot = if builtins.hasAttr "subpath" grammar.source then
"source/${grammar.source.subpath}"
else
"source";
dontConfigure = true;
@@ -107,21 +106,17 @@
'';
};
grammarsToBuild = builtins.filter includeGrammarIf gitGrammars;
builtGrammars =
builtins.map (grammar: {
inherit (grammar) name;
value = buildGrammar grammar;
})
grammarsToBuild;
builtGrammars = builtins.map (grammar: {
inherit (grammar) name;
value = buildGrammar grammar;
}) grammarsToBuild;
extensibleGrammars =
lib.makeExtensible (self: builtins.listToAttrs builtGrammars);
overlaidGrammars =
lib.pipe extensibleGrammars
overlayedGrammars = lib.pipe extensibleGrammars
(builtins.map (overlay: grammar: grammar.extend overlay) grammarOverlays);
grammarLinks =
lib.mapAttrsToList
grammarLinks = lib.mapAttrsToList
(name: artifact: "ln -s ${artifact}/${name}.so $out/${name}.so")
(lib.filterAttrs (n: v: lib.isDerivation v) overlaidGrammars);
(lib.filterAttrs (n: v: lib.isDerivation v) overlayedGrammars);
in
runCommand "consolidated-helix-grammars" {} ''
mkdir -p $out

View File

@@ -16,48 +16,42 @@ unicode-lines = ["ropey/unicode_lines"]
integration = []
[dependencies]
helix-stdx = { path = "../helix-stdx" }
helix-loader = { path = "../helix-loader" }
helix-parsec = { path = "../helix-parsec" }
ropey.workspace = true
smallvec = "1.15"
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
smallvec = "1.11"
smartstring = "1.0.1"
unicode-segmentation.workspace = true
# unicode-width is changing width definitions
# that both break our logic and disagree with common
# width definitions in terminals, we need to replace it.
# For now lets lock the version to avoid rendering glitches
# when installing without `--locked`
unicode-width = "=0.1.12"
unicode-general-category = "1.0"
slotmap.workspace = true
tree-house.workspace = true
once_cell = "1.21"
unicode-segmentation = "1.10"
unicode-width = "0.1"
unicode-general-category = "0.6"
# slab = "0.4.2"
slotmap = "1.0"
tree-sitter.workspace = true
once_cell = "1.19"
arc-swap = "1"
regex = "1"
bitflags.workspace = true
foldhash.workspace = true
url = "2.5.4"
bitflags = "2.4"
ahash = "0.8.6"
hashbrown = { version = "0.14.3", features = ["raw"] }
dunce = "1.0"
log = "0.4"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml.workspace = true
toml = "0.7"
imara-diff = "0.1.0"
imara-diff = "0.2.0"
encoding_rs = "0.8"
chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] }
textwrap = "0.16.2"
etcetera = "0.8"
textwrap = "0.16.0"
nucleo.workspace = true
parking_lot.workspace = true
globset = "0.4.16"
regex-cursor = "0.1.5"
parking_lot = "0.12"
[dev-dependencies]
quickcheck = { version = "1", default-features = false }
indoc = "2.0.6"
indoc = "2.0.4"

View File

@@ -75,9 +75,9 @@ impl From<(&char, &char)> for Pair {
impl AutoPairs {
/// Make a new AutoPairs set with the given pairs and default conditions.
pub fn new<'a, V, A>(pairs: V) -> Self
pub fn new<'a, V: 'a, A>(pairs: V) -> Self
where
V: IntoIterator<Item = A> + 'a,
V: IntoIterator<Item = A>,
A: Into<Pair>,
{
let mut auto_pairs = HashMap::new();

View File

@@ -1,69 +0,0 @@
use crate::Tendril;
// todo: should this be grapheme aware?
pub fn to_pascal_case(text: impl Iterator<Item = char>) -> Tendril {
let mut res = Tendril::new();
to_pascal_case_with(text, &mut res);
res
}
pub fn to_pascal_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
let mut at_word_start = true;
for c in text {
// we don't count _ as a word char here so case conversions work well
if !c.is_alphanumeric() {
at_word_start = true;
continue;
}
if at_word_start {
at_word_start = false;
buf.extend(c.to_uppercase());
} else {
buf.push(c)
}
}
}
pub fn to_upper_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
for c in text {
for c in c.to_uppercase() {
buf.push(c)
}
}
}
pub fn to_lower_case_with(text: impl Iterator<Item = char>, buf: &mut Tendril) {
for c in text {
for c in c.to_lowercase() {
buf.push(c)
}
}
}
pub fn to_camel_case(text: impl Iterator<Item = char>) -> Tendril {
let mut res = Tendril::new();
to_camel_case_with(text, &mut res);
res
}
pub fn to_camel_case_with(mut text: impl Iterator<Item = char>, buf: &mut Tendril) {
for c in &mut text {
if c.is_alphanumeric() {
buf.extend(c.to_lowercase())
}
}
let mut at_word_start = false;
for c in text {
// we don't count _ as a word char here so case conversions work well
if !c.is_alphanumeric() {
at_word_start = true;
continue;
}
if at_word_start {
at_word_start = false;
buf.extend(c.to_uppercase());
} else {
buf.push(c)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,11 @@
//! This module contains the functionality toggle comments on lines over the selection
//! using the comment character defined in the user's `languages.toml`
use smallvec::SmallVec;
use crate::{
syntax::config::BlockCommentToken, Change, Range, Rope, RopeSlice, Selection, Tendril,
Transaction,
find_first_non_whitespace_char, Change, Rope, RopeSlice, Selection, Tendril, Transaction,
};
use helix_stdx::rope::RopeSliceExt;
use std::borrow::Cow;
pub const DEFAULT_COMMENT_TOKEN: &str = "#";
/// Returns the longest matching comment token of the given line (if it exists).
pub fn get_comment_token<'a, S: AsRef<str>>(
text: RopeSlice,
tokens: &'a [S],
line_num: usize,
) -> Option<&'a str> {
let line = text.line(line_num);
let start = line.first_non_whitespace_char()?;
tokens
.iter()
.map(AsRef::as_ref)
.filter(|token| line.slice(start..).starts_with(token))
.max_by_key(|token| token.len())
}
/// Given text, a comment token, and a set of line indices, returns the following:
/// - Whether the given lines should be considered commented
/// - If any of the lines are uncommented, all lines are considered as such.
@@ -44,23 +22,24 @@ fn find_line_comment(
) -> (bool, Vec<usize>, usize, usize) {
let mut commented = true;
let mut to_change = Vec::new();
let mut min = usize::MAX; // minimum col for first_non_whitespace_char
let mut min = usize::MAX; // minimum col for find_first_non_whitespace_char
let mut margin = 1;
let token_len = token.chars().count();
for line in lines {
let line_slice = text.line(line);
if let Some(pos) = line_slice.first_non_whitespace_char() {
if let Some(pos) = find_first_non_whitespace_char(line_slice) {
let len = line_slice.len_chars();
min = std::cmp::min(min, pos);
if pos < min {
min = pos;
}
// line can be shorter than pos + token len
let fragment = Cow::from(line_slice.slice(pos..std::cmp::min(pos + token.len(), len)));
// as soon as one of the non-blank lines doesn't have a comment, the whole block is
// considered uncommented.
if fragment != token {
// as soon as one of the non-blank lines doesn't have a comment, the whole block is
// considered uncommented.
commented = false;
}
@@ -74,7 +53,6 @@ fn find_line_comment(
to_change.push(line);
}
}
(commented, to_change, min, margin)
}
@@ -82,7 +60,7 @@ fn find_line_comment(
pub fn toggle_line_comments(doc: &Rope, selection: &Selection, token: Option<&str>) -> Transaction {
let text = doc.slice(..);
let token = token.unwrap_or(DEFAULT_COMMENT_TOKEN);
let token = token.unwrap_or("//");
let comment = Tendril::from(format!("{} ", token));
let mut lines: Vec<usize> = Vec::with_capacity(selection.len());
@@ -116,372 +94,59 @@ pub fn toggle_line_comments(doc: &Rope, selection: &Selection, token: Option<&st
Transaction::change(doc, changes.into_iter())
}
#[derive(Debug, PartialEq, Eq)]
pub enum CommentChange {
Commented {
range: Range,
start_pos: usize,
end_pos: usize,
start_margin: bool,
end_margin: bool,
start_token: String,
end_token: String,
},
Uncommented {
range: Range,
start_pos: usize,
end_pos: usize,
start_token: String,
end_token: String,
},
Whitespace {
range: Range,
},
}
pub fn find_block_comments(
tokens: &[BlockCommentToken],
text: RopeSlice,
selection: &Selection,
) -> (bool, Vec<CommentChange>) {
let mut commented = true;
let mut only_whitespace = true;
let mut comment_changes = Vec::with_capacity(selection.len());
let default_tokens = tokens.first().cloned().unwrap_or_default();
let mut start_token = default_tokens.start.clone();
let mut end_token = default_tokens.end.clone();
let mut tokens = tokens.to_vec();
// sort the tokens by length, so longer tokens will match first
tokens.sort_by(|a, b| {
if a.start.len() == b.start.len() {
b.end.len().cmp(&a.end.len())
} else {
b.start.len().cmp(&a.start.len())
}
});
for range in selection {
let selection_slice = range.slice(text);
if let (Some(start_pos), Some(end_pos)) = (
selection_slice.first_non_whitespace_char(),
selection_slice.last_non_whitespace_char(),
) {
let mut line_commented = false;
let mut after_start = 0;
let mut before_end = 0;
let len = (end_pos + 1) - start_pos;
for BlockCommentToken { start, end } in &tokens {
let start_len = start.chars().count();
let end_len = end.chars().count();
after_start = start_pos + start_len;
before_end = end_pos.saturating_sub(end_len);
if len >= start_len + end_len {
let start_fragment = selection_slice.slice(start_pos..after_start);
let end_fragment = selection_slice.slice(before_end + 1..end_pos + 1);
// block commented with these tokens
if start_fragment == start.as_str() && end_fragment == end.as_str() {
start_token = start.to_string();
end_token = end.to_string();
line_commented = true;
break;
}
}
}
if !line_commented {
comment_changes.push(CommentChange::Uncommented {
range: *range,
start_pos,
end_pos,
start_token: default_tokens.start.clone(),
end_token: default_tokens.end.clone(),
});
commented = false;
} else {
comment_changes.push(CommentChange::Commented {
range: *range,
start_pos,
end_pos,
start_margin: selection_slice.get_char(after_start) == Some(' '),
end_margin: after_start != before_end
&& (selection_slice.get_char(before_end) == Some(' ')),
start_token: start_token.to_string(),
end_token: end_token.to_string(),
});
}
only_whitespace = false;
} else {
comment_changes.push(CommentChange::Whitespace { range: *range });
}
}
if only_whitespace {
commented = false;
}
(commented, comment_changes)
}
#[must_use]
pub fn create_block_comment_transaction(
doc: &Rope,
selection: &Selection,
commented: bool,
comment_changes: Vec<CommentChange>,
) -> (Transaction, SmallVec<[Range; 1]>) {
let mut changes: Vec<Change> = Vec::with_capacity(selection.len() * 2);
let mut ranges: SmallVec<[Range; 1]> = SmallVec::with_capacity(selection.len());
let mut offs = 0;
for change in comment_changes {
if commented {
if let CommentChange::Commented {
range,
start_pos,
end_pos,
start_token,
end_token,
start_margin,
end_margin,
} = change
{
let from = range.from();
changes.push((
from + start_pos,
from + start_pos + start_token.len() + start_margin as usize,
None,
));
changes.push((
from + end_pos - end_token.len() - end_margin as usize + 1,
from + end_pos + 1,
None,
));
}
} else {
// uncommented so manually map ranges through changes
match change {
CommentChange::Uncommented {
range,
start_pos,
end_pos,
start_token,
end_token,
} => {
let from = range.from();
changes.push((
from + start_pos,
from + start_pos,
Some(Tendril::from(format!("{} ", start_token))),
));
changes.push((
from + end_pos + 1,
from + end_pos + 1,
Some(Tendril::from(format!(" {}", end_token))),
));
let offset = start_token.chars().count() + end_token.chars().count() + 2;
ranges.push(
Range::new(from + offs, from + offs + end_pos + 1 + offset)
.with_direction(range.direction()),
);
offs += offset;
}
CommentChange::Commented { range, .. } | CommentChange::Whitespace { range } => {
ranges.push(Range::new(range.from() + offs, range.to() + offs));
}
}
}
}
(Transaction::change(doc, changes.into_iter()), ranges)
}
#[must_use]
pub fn toggle_block_comments(
doc: &Rope,
selection: &Selection,
tokens: &[BlockCommentToken],
) -> Transaction {
let text = doc.slice(..);
let (commented, comment_changes) = find_block_comments(tokens, text, selection);
let (mut transaction, ranges) =
create_block_comment_transaction(doc, selection, commented, comment_changes);
if !commented {
transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index()));
}
transaction
}
pub fn split_lines_of_selection(text: RopeSlice, selection: &Selection) -> Selection {
let mut ranges = SmallVec::new();
for range in selection.ranges() {
let (line_start, line_end) = range.line_range(text.slice(..));
let mut pos = text.line_to_char(line_start);
for line in text.slice(pos..text.line_to_char(line_end + 1)).lines() {
let start = pos;
pos += line.len_chars();
ranges.push(Range::new(start, pos));
}
}
Selection::new(ranges, 0)
}
#[cfg(test)]
mod test {
use super::*;
mod find_line_comment {
use super::*;
#[test]
fn not_commented() {
// four lines, two space indented, except for line 1 which is blank.
let doc = Rope::from(" 1\n\n 2\n 3");
let text = doc.slice(..);
let res = find_line_comment("//", text, 0..3);
// (commented = false, to_change = [line 0, line 2], min = col 2, margin = 0)
assert_eq!(res, (false, vec![0, 2], 2, 0));
}
#[test]
fn is_commented() {
// three lines where the second line is empty.
let doc = Rope::from("// hello\n\n// there");
let res = find_line_comment("//", doc.slice(..), 0..3);
// (commented = true, to_change = [line 0, line 2], min = col 0, margin = 1)
assert_eq!(res, (true, vec![0, 2], 0, 1));
}
}
// TODO: account for uncommenting with uneven comment indentation
mod toggle_line_comment {
use super::*;
#[test]
fn comment() {
// four lines, two space indented, except for line 1 which is blank.
let mut doc = Rope::from(" 1\n\n 2\n 3");
// select whole document
let selection = Selection::single(0, doc.len_chars() - 1);
let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut doc);
assert_eq!(doc, " # 1\n\n # 2\n # 3");
}
#[test]
fn uncomment() {
let mut doc = Rope::from(" # 1\n\n # 2\n # 3");
let mut 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
}
#[test]
fn uncomment_0_margin_comments() {
let mut doc = Rope::from(" #1\n\n #2\n #3");
let mut 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
}
#[test]
fn uncomment_0_margin_comments_with_no_space() {
let mut doc = Rope::from("#");
let mut 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, "");
assert!(selection.len() == 1); // to ignore the selection unused warning
}
}
#[test]
fn test_find_block_comments() {
// three lines 5 characters.
let mut doc = Rope::from("1\n2\n3");
fn test_find_line_comment() {
// four lines, two space indented, except for line 1 which is blank.
let mut doc = Rope::from(" 1\n\n 2\n 3");
// select whole document
let selection = Selection::single(0, doc.len_chars());
let mut selection = Selection::single(0, doc.len_chars() - 1);
let text = doc.slice(..);
let res = find_block_comments(&[BlockCommentToken::default()], text, &selection);
assert_eq!(
res,
(
false,
vec![CommentChange::Uncommented {
range: Range::new(0, 5),
start_pos: 0,
end_pos: 4,
start_token: "/*".to_string(),
end_token: "*/".to_string(),
}]
)
);
let res = find_line_comment("//", text, 0..3);
// (commented = true, to_change = [line 0, line 2], min = col 2, margin = 0)
assert_eq!(res, (false, vec![0, 2], 2, 0));
// comment
let transaction = toggle_block_comments(&doc, &selection, &[BlockCommentToken::default()]);
let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut doc);
selection = selection.map(transaction.changes());
assert_eq!(doc, "/* 1\n2\n3 */");
assert_eq!(doc, " // 1\n\n // 2\n // 3");
// uncomment
let selection = Selection::single(0, doc.len_chars());
let transaction = toggle_block_comments(&doc, &selection, &[BlockCommentToken::default()]);
let transaction = toggle_line_comments(&doc, &selection, None);
transaction.apply(&mut doc);
assert_eq!(doc, "1\n2\n3");
selection = selection.map(transaction.changes());
assert_eq!(doc, " 1\n\n 2\n 3");
assert!(selection.len() == 1); // to ignore the selection unused warning
// don't panic when there is just a space in comment
doc = Rope::from("/* */");
let selection = Selection::single(0, doc.len_chars());
let transaction = toggle_block_comments(&doc, &selection, &[BlockCommentToken::default()]);
// 0 margin comments
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.
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, "");
}
assert!(selection.len() == 1); // to ignore the selection unused warning
/// Test, if `get_comment_tokens` works, even if the content of the file includes chars, whose
/// byte size unequal the amount of chars
#[test]
fn test_get_comment_with_char_boundaries() {
let rope = Rope::from("··");
let tokens = ["//", "///"];
assert_eq!(
super::get_comment_token(rope.slice(..), tokens.as_slice(), 0),
None
);
}
/// Test for `get_comment_token`.
///
/// Assuming the comment tokens are stored as `["///", "//"]`, `get_comment_token` should still
/// return `///` instead of `//` if the user is in a doc-comment section.
#[test]
fn test_use_longest_comment() {
let text = Rope::from(" /// amogus");
let tokens = ["///", "//"];
assert_eq!(
super::get_comment_token(text.slice(..), tokens.as_slice(), 0),
Some("///")
);
// TODO: account for uncommenting with uneven comment indentation
}
}

View File

@@ -1,25 +0,0 @@
use std::borrow::Cow;
use crate::{diagnostic::LanguageServerId, Transaction};
#[derive(Debug, PartialEq, Clone)]
pub struct CompletionItem {
pub transaction: Transaction,
pub label: Cow<'static, str>,
pub kind: Cow<'static, str>,
/// Containing Markdown
pub documentation: Option<String>,
pub provider: CompletionProvider,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum CompletionProvider {
Lsp(LanguageServerId),
Path,
}
impl From<LanguageServerId> for CompletionProvider {
fn from(id: LanguageServerId) -> Self {
CompletionProvider::Lsp(id)
}
}

View File

@@ -1,45 +1,10 @@
use crate::syntax::{config::Configuration, Loader, LoaderError};
/// Language configuration based on built-in languages.toml.
pub fn default_lang_config() -> Configuration {
/// Syntax configuration loader based on built-in languages.toml.
pub fn default_syntax_loader() -> crate::syntax::Configuration {
helix_loader::config::default_lang_config()
.try_into()
.expect("Could not deserialize built-in languages.toml")
.expect("Could not serialize built-in languages.toml")
}
/// Language configuration loader based on built-in languages.toml.
pub fn default_lang_loader() -> Loader {
Loader::new(default_lang_config()).expect("Could not compile loader for default config")
}
#[derive(Debug)]
pub enum LanguageLoaderError {
DeserializeError(toml::de::Error),
LoaderError(LoaderError),
}
impl std::fmt::Display for LanguageLoaderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DeserializeError(err) => write!(f, "Failed to parse language config: {err}"),
Self::LoaderError(err) => write!(f, "Failed to compile language config: {err}"),
}
}
}
impl std::error::Error for LanguageLoaderError {}
/// Language configuration based on user configured languages.toml.
pub fn user_lang_config() -> Result<Configuration, toml::de::Error> {
/// Syntax configuration loader based on user configured languages.toml.
pub fn user_syntax_loader() -> Result<crate::syntax::Configuration, toml::de::Error> {
helix_loader::config::user_lang_config()?.try_into()
}
/// Language configuration loader based on user configured languages.toml.
pub fn user_lang_loader() -> Result<Loader, LanguageLoaderError> {
let config: Configuration = helix_loader::config::user_lang_config()
.map_err(LanguageLoaderError::DeserializeError)?
.try_into()
.map_err(LanguageLoaderError::DeserializeError)?;
Loader::new(config).map_err(LanguageLoaderError::LoaderError)
}

View File

@@ -1,12 +1,8 @@
//! LSP diagnostic utility types.
use std::{fmt, sync::Arc};
pub use helix_stdx::range::Range;
use serde::{Deserialize, Serialize};
/// Describes the severity level of a [`Diagnostic`].
#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Deserialize, Serialize)]
pub enum Severity {
Hint,
Info,
@@ -20,6 +16,13 @@ impl Default for Severity {
}
}
/// A range of `char`s within the text.
#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq)]
pub struct Range {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Eq, Hash, PartialEq, Clone, Deserialize, Serialize)]
pub enum NumberOrString {
Number(i32),
@@ -44,59 +47,8 @@ pub struct Diagnostic {
pub message: String,
pub severity: Option<Severity>,
pub code: Option<NumberOrString>,
pub provider: DiagnosticProvider,
pub language_server_id: usize,
pub tags: Vec<DiagnosticTag>,
pub source: Option<String>,
pub data: Option<serde_json::Value>,
}
/// The source of a diagnostic.
///
/// This type is cheap to clone: all data is either `Copy` or wrapped in an `Arc`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum DiagnosticProvider {
Lsp {
/// The ID of the language server which sent the diagnostic.
server_id: LanguageServerId,
/// An optional identifier under which diagnostics are managed by the client.
///
/// `identifier` is a field from the LSP "Pull Diagnostics" feature meant to provide an
/// optional "namespace" for diagnostics: a language server can respond to a diagnostics
/// pull request with an identifier and these diagnostics should be treated as separate
/// from push diagnostics. Rust-analyzer uses this feature for example to provide Cargo
/// diagnostics with push and internal diagnostics with pull. The push diagnostics should
/// not clear the pull diagnostics and vice-versa.
identifier: Option<Arc<str>>,
},
// Future internal features can go here...
}
impl DiagnosticProvider {
pub fn language_server_id(&self) -> Option<LanguageServerId> {
match self {
Self::Lsp { server_id, .. } => Some(*server_id),
// _ => None,
}
}
}
// while I would prefer having this in helix-lsp that necessitates a bunch of
// conversions I would rather not add. I think its fine since this just a very
// trivial newtype wrapper and we would need something similar once we define
// completions in core
slotmap::new_key_type! {
pub struct LanguageServerId;
}
impl fmt::Display for LanguageServerId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self.0)
}
}
impl Diagnostic {
#[inline]
pub fn severity(&self) -> Severity {
self.severity.unwrap_or(Severity::Warning)
}
}

View File

@@ -1,22 +1,51 @@
use std::ops::Range;
use std::time::Instant;
use imara_diff::{Algorithm, Diff, Hunk, IndentHeuristic, IndentLevel, InternedInput};
use imara_diff::intern::InternedInput;
use imara_diff::Algorithm;
use ropey::RopeSlice;
use crate::{ChangeSet, Rope, Tendril, Transaction};
struct ChangeSetBuilder<'a> {
/// A `imara_diff::Sink` that builds a `ChangeSet` for a character diff of a hunk
struct CharChangeSetBuilder<'a> {
res: &'a mut ChangeSet,
hunk: &'a InternedInput<char>,
pos: u32,
}
impl imara_diff::Sink for CharChangeSetBuilder<'_> {
type Out = ();
fn process_change(&mut self, before: Range<u32>, after: Range<u32>) {
self.res.retain((before.start - self.pos) as usize);
self.res.delete(before.len());
self.pos = before.end;
let res = self.hunk.after[after.start as usize..after.end as usize]
.iter()
.map(|&token| self.hunk.interner[token])
.collect();
self.res.insert(res);
}
fn finish(self) -> Self::Out {
self.res.retain(self.hunk.before.len() - self.pos as usize);
}
}
struct LineChangeSetBuilder<'a> {
res: ChangeSet,
after: RopeSlice<'a>,
file: &'a InternedInput<RopeSlice<'a>>,
current_hunk: InternedInput<char>,
char_diff: Diff,
pos: u32,
}
impl ChangeSetBuilder<'_> {
fn process_hunk(&mut self, before: Range<u32>, after: Range<u32>) {
impl imara_diff::Sink for LineChangeSetBuilder<'_> {
type Out = ChangeSet;
fn process_change(&mut self, before: Range<u32>, after: Range<u32>) {
let len = self.file.before[self.pos as usize..before.start as usize]
.iter()
.map(|&it| self.file.interner[it].len_chars())
@@ -80,36 +109,25 @@ impl ChangeSetBuilder<'_> {
.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
self.char_diff.compute_with(
imara_diff::diff(
Algorithm::Myers,
&self.current_hunk.before,
&self.current_hunk.after,
self.current_hunk.interner.num_tokens(),
&self.current_hunk,
CharChangeSetBuilder {
res: &mut self.res,
hunk: &self.current_hunk,
pos: 0,
},
);
let mut pos = 0;
for Hunk { before, after } in self.char_diff.hunks() {
self.res.retain((before.start - pos) as usize);
self.res.delete(before.len());
pos = before.end;
let res = self.current_hunk.after[after.start as usize..after.end as usize]
.iter()
.map(|&token| self.current_hunk.interner[token])
.collect();
self.res.insert(res);
}
self.res
.retain(self.current_hunk.before.len() - pos as usize);
// reuse allocations
self.current_hunk.clear();
}
}
fn finish(mut self) -> ChangeSet {
fn finish(mut self) -> Self::Out {
let len = self.file.before[self.pos as usize..]
.iter()
.map(|&it| self.file.interner[it].len_chars())
@@ -122,7 +140,7 @@ impl ChangeSetBuilder<'_> {
struct RopeLines<'a>(RopeSlice<'a>);
impl<'a> imara_diff::TokenSource for RopeLines<'a> {
impl<'a> imara_diff::intern::TokenSource for RopeLines<'a> {
type Token = RopeSlice<'a>;
type Tokenizer = ropey::iter::Lines<'a>;
@@ -143,23 +161,15 @@ pub fn compare_ropes(before: &Rope, after: &Rope) -> Transaction {
let res = ChangeSet::with_capacity(32);
let after = after.slice(..);
let file = InternedInput::new(RopeLines(before.slice(..)), RopeLines(after));
let mut builder = ChangeSetBuilder {
let builder = LineChangeSetBuilder {
res,
file: &file,
after,
pos: 0,
current_hunk: InternedInput::default(),
char_diff: Diff::default(),
};
let mut diff = Diff::compute(Algorithm::Histogram, &file);
diff.postprocess_with_heuristic(
&file,
IndentHeuristic::new(|token| IndentLevel::for_ascii_line(file.interner[token].bytes(), 4)),
);
for hunk in diff.hunks() {
builder.process_hunk(hunk.before, hunk.after)
}
let res = builder.finish().into();
let res = imara_diff::diff(Algorithm::Histogram, &file, builder).into();
log::debug!(
"rope diff took {}s",

View File

@@ -10,21 +10,18 @@
//! called a "block" and the caller must advance it as needed.
use std::borrow::Cow;
use std::cmp::Ordering;
use std::fmt::Debug;
use std::mem::replace;
use std::mem::{replace, take};
#[cfg(test)]
mod test;
use unicode_segmentation::{Graphemes, UnicodeSegmentation};
use helix_stdx::rope::{RopeGraphemes, RopeSliceExt};
use crate::graphemes::{Grapheme, GraphemeStr};
use crate::syntax::Highlight;
use crate::text_annotations::TextAnnotations;
use crate::{Position, RopeSlice};
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)]
@@ -40,104 +37,52 @@ pub enum GraphemeSource {
},
}
impl GraphemeSource {
/// Returns whether this grapheme is virtual inline text
pub fn is_virtual(self) -> bool {
matches!(self, GraphemeSource::VirtualText { .. })
}
pub fn is_eof(self) -> bool {
// all doc chars except the EOF char have non-zero codepoints
matches!(self, GraphemeSource::Document { codepoints: 0 })
}
pub fn doc_chars(self) -> usize {
match self {
GraphemeSource::Document { codepoints } => codepoints as usize,
GraphemeSource::VirtualText { .. } => 0,
}
}
}
#[derive(Debug, Clone)]
pub struct FormattedGrapheme<'a> {
pub raw: Grapheme<'a>,
pub grapheme: Grapheme<'a>,
pub source: GraphemeSource,
pub visual_pos: Position,
/// Document line at the start of the grapheme
pub line_idx: usize,
/// Document char position at the start of the grapheme
pub char_idx: usize,
}
impl FormattedGrapheme<'_> {
pub fn is_virtual(&self) -> bool {
self.source.is_virtual()
}
pub fn doc_chars(&self) -> usize {
self.source.doc_chars()
}
pub fn is_whitespace(&self) -> bool {
self.raw.is_whitespace()
}
pub fn width(&self) -> usize {
self.raw.width()
}
pub fn is_word_boundary(&self) -> bool {
self.raw.is_word_boundary()
}
}
#[derive(Debug, Clone)]
struct GraphemeWithSource<'a> {
grapheme: Grapheme<'a>,
source: GraphemeSource,
}
impl<'a> GraphemeWithSource<'a> {
fn new(
impl<'a> FormattedGrapheme<'a> {
pub fn new(
g: GraphemeStr<'a>,
visual_x: usize,
tab_width: u16,
source: GraphemeSource,
) -> GraphemeWithSource<'a> {
GraphemeWithSource {
) -> FormattedGrapheme<'a> {
FormattedGrapheme {
grapheme: Grapheme::new(g, visual_x, tab_width),
source,
}
}
fn placeholder() -> Self {
GraphemeWithSource {
/// 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 },
}
}
fn doc_chars(&self) -> usize {
self.source.doc_chars()
pub fn doc_chars(&self) -> usize {
match self.source {
GraphemeSource::Document { codepoints } => codepoints as usize,
GraphemeSource::VirtualText { .. } => 0,
}
}
fn is_whitespace(&self) -> bool {
pub fn is_whitespace(&self) -> bool {
self.grapheme.is_whitespace()
}
fn is_newline(&self) -> bool {
matches!(self.grapheme, Grapheme::Newline)
}
fn is_eof(&self) -> bool {
self.source.is_eof()
}
fn width(&self) -> usize {
pub fn width(&self) -> usize {
self.grapheme.width()
}
fn is_word_boundary(&self) -> bool {
pub fn is_word_boundary(&self) -> bool {
self.grapheme.is_word_boundary()
}
}
@@ -151,7 +96,6 @@ pub struct TextFormat {
pub wrap_indicator: Box<str>,
pub wrap_indicator_highlight: Option<Highlight>,
pub viewport_width: u16,
pub soft_wrap_at_text_width: bool,
}
// test implementation is basically only used for testing or when softwrap is always disabled
@@ -165,7 +109,6 @@ impl Default for TextFormat {
wrap_indicator: Box::from(" "),
viewport_width: 17,
wrap_indicator_highlight: None,
soft_wrap_at_text_width: false,
}
}
}
@@ -173,7 +116,7 @@ impl Default for TextFormat {
#[derive(Debug)]
pub struct DocumentFormatter<'t> {
text_fmt: &'t TextFormat,
annotations: &'t TextAnnotations<'t>,
annotations: &'t TextAnnotations,
/// The visual position at the end of the last yielded word boundary
visual_pos: Position,
@@ -184,7 +127,10 @@ pub struct DocumentFormatter<'t> {
line_pos: usize,
exhausted: bool,
inline_annotation_graphemes: Option<(Graphemes<'t>, Option<Highlight>)>,
/// 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
@@ -193,9 +139,9 @@ pub struct DocumentFormatter<'t> {
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<GraphemeWithSource<'t>>,
peeked_grapheme: Option<(FormattedGrapheme<'t>, usize)>,
/// A first-in first-out (fifo) buffer for the Graphemes of any given word
word_buf: Vec<GraphemeWithSource<'t>>,
word_buf: Vec<FormattedGrapheme<'t>>,
/// The index of the next grapheme that will be yielded from the `word_buf`
word_i: usize,
}
@@ -211,35 +157,35 @@ impl<'t> DocumentFormatter<'t> {
text_fmt: &'t TextFormat,
annotations: &'t TextAnnotations,
char_idx: usize,
) -> Self {
) -> (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: text.slice(block_char_idx..).graphemes(),
char_pos: block_char_idx,
exhausted: false,
indent_level: None,
peeked_grapheme: None,
word_buf: Vec::with_capacity(64),
word_i: 0,
line_pos: block_line_idx,
inline_annotation_graphemes: None,
}
(
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,
char_pos: usize,
) -> Option<(&'t str, Option<Highlight>)> {
fn next_inline_annotation_grapheme(&mut self) -> Option<(&'t str, Option<Highlight>)> {
loop {
if let Some(&mut (ref mut annotation, highlight)) =
self.inline_annotation_graphemes.as_mut()
self.inline_anntoation_graphemes.as_mut()
{
if let Some(grapheme) = annotation.next() {
return Some((grapheme, highlight));
@@ -247,9 +193,9 @@ impl<'t> DocumentFormatter<'t> {
}
if let Some((annotation, highlight)) =
self.annotations.next_inline_annotation_at(char_pos)
self.annotations.next_inline_annotation_at(self.char_pos)
{
self.inline_annotation_graphemes = Some((
self.inline_anntoation_graphemes = Some((
UnicodeSegmentation::graphemes(&*annotation.text, true),
highlight,
))
@@ -259,19 +205,21 @@ impl<'t> DocumentFormatter<'t> {
}
}
fn advance_grapheme(&mut self, col: usize, char_pos: usize) -> Option<GraphemeWithSource<'t>> {
fn advance_grapheme(&mut self, col: usize) -> Option<FormattedGrapheme<'t>> {
let (grapheme, source) =
if let Some((grapheme, highlight)) = self.next_inline_annotation_grapheme(char_pos) {
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(char_pos);
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 {
@@ -280,19 +228,19 @@ impl<'t> DocumentFormatter<'t> {
self.exhausted = true;
// EOF grapheme is required for rendering
// and correct position computations
return Some(GraphemeWithSource {
return Some(FormattedGrapheme {
grapheme: Grapheme::Other { g: " ".into() },
source: GraphemeSource::Document { codepoints: 0 },
});
};
let grapheme = GraphemeWithSource::new(grapheme, col, self.text_fmt.tab_width, source);
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) -> usize {
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 {
@@ -306,17 +254,15 @@ impl<'t> DocumentFormatter<'t> {
0
};
let virtual_lines =
self.annotations
.virtual_lines_at(self.char_pos, self.visual_pos, self.line_pos);
self.visual_pos.col = indent_carry_over as usize;
self.visual_pos.row += 1 + virtual_lines;
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 = GraphemeWithSource::new(
let grapheme = FormattedGrapheme::new(
g.into(),
self.visual_pos.col + word_width,
self.text_fmt.tab_width,
@@ -336,72 +282,46 @@ impl<'t> DocumentFormatter<'t> {
.change_position(visual_x, self.text_fmt.tab_width);
word_width += grapheme.width();
}
if let Some(grapheme) = &mut self.peeked_grapheme {
let visual_x = self.visual_pos.col + word_width;
grapheme
.grapheme
.change_position(visual_x, self.text_fmt.tab_width);
}
word_width
}
fn peek_grapheme(&mut self, col: usize, char_pos: usize) -> Option<&GraphemeWithSource<'t>> {
if self.peeked_grapheme.is_none() {
self.peeked_grapheme = self.advance_grapheme(col, char_pos);
}
self.peeked_grapheme.as_ref()
}
fn next_grapheme(&mut self, col: usize, char_pos: usize) -> Option<GraphemeWithSource<'t>> {
self.peek_grapheme(col, char_pos);
self.peeked_grapheme.take()
}
fn advance_to_next_word(&mut self) {
self.word_buf.clear();
let mut word_width = 0;
let mut word_chars = 0;
if self.exhausted {
return;
}
let virtual_lines_before_word = self.virtual_lines;
let mut virtual_lines_before_grapheme = self.virtual_lines;
loop {
let mut col = self.visual_pos.col + word_width;
let char_pos = self.char_pos + word_chars;
match col.cmp(&(self.text_fmt.viewport_width as usize)) {
// The EOF char and newline chars are always selectable in helix. That means
// that wrapping happens "too-early" if a word fits a line perfectly. This
// is intentional so that all selectable graphemes are always visible (and
// therefore the cursor never disappears). However if the user manually set a
// lower softwrap width then this is undesirable. Just increasing the viewport-
// width by one doesn't work because if a line is wrapped multiple times then
// some words may extend past the specified width.
//
// So we special case a word that ends exactly at line bounds and is followed
// by a newline/eof character here.
Ordering::Equal
if self.text_fmt.soft_wrap_at_text_width
&& self
.peek_grapheme(col, char_pos)
.is_some_and(|grapheme| grapheme.is_newline() || grapheme.is_eof()) => {
}
Ordering::Equal if word_width > self.text_fmt.max_wrap as usize => return,
Ordering::Greater if word_width > self.text_fmt.max_wrap as usize => {
self.peeked_grapheme = self.word_buf.pop();
// 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;
}
Ordering::Equal | Ordering::Greater => {
word_width = self.wrap_word();
col = self.visual_pos.col + word_width;
}
Ordering::Less => (),
word_width = self.wrap_word(virtual_lines_before_word);
}
let Some(grapheme) = self.next_grapheme(col, char_pos) else {
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;
};
word_chars += grapheme.doc_chars();
// Track indentation
if !grapheme.is_whitespace() && self.indent_level.is_none() {
@@ -420,18 +340,19 @@ impl<'t> DocumentFormatter<'t> {
}
}
/// returns the char index at the end of the last yielded grapheme
pub fn next_char_pos(&self) -> usize {
self.char_pos
/// 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 position at the end of the last yielded grapheme
pub fn next_visual_pos(&self) -> Position {
/// 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>;
type Item = (FormattedGrapheme<'t>, Position);
fn next(&mut self) -> Option<Self::Item> {
let grapheme = if self.text_fmt.soft_wrap {
@@ -441,40 +362,23 @@ impl<'t> Iterator for DocumentFormatter<'t> {
}
let grapheme = replace(
self.word_buf.get_mut(self.word_i)?,
GraphemeWithSource::placeholder(),
FormattedGrapheme::placeholder(),
);
self.word_i += 1;
grapheme
} else {
self.advance_grapheme(self.visual_pos.col, self.char_pos)?
self.advance_grapheme(self.visual_pos.col)?
};
let grapheme = FormattedGrapheme {
raw: grapheme.grapheme,
source: grapheme.source,
visual_pos: self.visual_pos,
line_idx: self.line_pos,
char_idx: self.char_pos,
};
self.char_pos += grapheme.doc_chars();
if !grapheme.is_virtual() {
self.annotations.process_virtual_text_anchors(&grapheme);
}
if grapheme.raw == Grapheme::Newline {
// move to end of newline char
self.visual_pos.col += 1;
let virtual_lines =
self.annotations
.virtual_lines_at(self.char_pos, self.visual_pos, self.line_pos);
self.visual_pos.row += 1 + virtual_lines;
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;
if !grapheme.is_virtual() {
self.line_pos += 1;
}
self.line_pos += 1;
} else {
self.visual_pos.col += grapheme.width();
}
Some(grapheme)
Some((grapheme, pos))
}
}

View File

@@ -1,3 +1,5 @@
use std::rc::Rc;
use crate::doc_formatter::{DocumentFormatter, TextFormat};
use crate::text_annotations::{InlineAnnotation, Overlay, TextAnnotations};
@@ -12,7 +14,6 @@ impl TextFormat {
wrap_indicator_highlight: None,
// use a prime number to allow lining up too often with repeat
viewport_width: 17,
soft_wrap_at_text_width: false,
}
}
}
@@ -22,23 +23,20 @@ impl<'t> DocumentFormatter<'t> {
use std::fmt::Write;
let mut res = String::new();
let viewport_width = self.text_fmt.viewport_width;
let soft_wrap_at_text_width = self.text_fmt.soft_wrap_at_text_width;
let mut line = 0;
for grapheme in self {
if grapheme.visual_pos.row != line {
for (grapheme, pos) in self {
if pos.row != line {
line += 1;
assert_eq!(grapheme.visual_pos.row, line);
write!(res, "\n{}", ".".repeat(grapheme.visual_pos.col)).unwrap();
}
if !soft_wrap_at_text_width {
assert_eq!(pos.row, line);
write!(res, "\n{}", ".".repeat(pos.col)).unwrap();
assert!(
grapheme.visual_pos.col <= viewport_width as usize,
pos.col <= viewport_width as usize,
"softwrapped failed {}<={viewport_width}",
grapheme.visual_pos.col
pos.col
);
}
write!(res, "{}", grapheme.raw).unwrap();
write!(res, "{}", grapheme.grapheme).unwrap();
}
res
@@ -52,6 +50,7 @@ fn softwrap_text(text: &str) -> String {
&TextAnnotations::default(),
0,
)
.0
.collect_to_str()
}
@@ -102,37 +101,14 @@ fn long_word_softwrap() {
);
}
#[test]
fn softwrap_multichar_grapheme() {
assert_eq!(
softwrap_text("xxxx xxxx xxx a\u{0301}bc\n"),
"xxxx xxxx xxx \n.ábc \n "
)
}
fn softwrap_text_at_text_width(text: &str) -> String {
let mut text_fmt = TextFormat::new_test(true);
text_fmt.soft_wrap_at_text_width = true;
let annotations = TextAnnotations::default();
let mut formatter =
DocumentFormatter::new_at_prev_checkpoint(text.into(), &text_fmt, &annotations, 0);
formatter.collect_to_str()
}
#[test]
fn long_word_softwrap_text_width() {
assert_eq!(
softwrap_text_at_text_width("xxxxxxxx1xxxx2xxx\nxxxxxxxx1xxxx2xxx"),
"xxxxxxxx1xxxx2xxx \nxxxxxxxx1xxxx2xxx "
);
}
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, None),
TextAnnotations::default().add_overlay(overlays.into(), None),
char_pos,
)
.0
.collect_to_str()
}
@@ -166,9 +142,10 @@ fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) -
DocumentFormatter::new_at_prev_checkpoint(
text.into(),
&TextFormat::new_test(softwrap),
TextAnnotations::default().add_inline_annotations(annotations, None),
TextAnnotations::default().add_inline_annotations(annotations.into(), None),
0,
)
.0
.collect_to_str()
}
@@ -187,26 +164,18 @@ fn annotation() {
"foo foo foo foo \n.foo foo foo foo \n.foo foo foo "
);
}
#[test]
fn annotation_and_overlay() {
let annotations = [InlineAnnotation {
char_idx: 0,
text: "fooo".into(),
}];
let overlay = [Overlay {
char_idx: 0,
grapheme: "\t".into(),
}];
assert_eq!(
DocumentFormatter::new_at_prev_checkpoint(
"bbar".into(),
&TextFormat::new_test(false),
TextAnnotations::default()
.add_inline_annotations(annotations.as_slice(), None)
.add_overlay(overlay.as_slice(), None),
.add_inline_annotations(Rc::new([InlineAnnotation::new(0, "fooo")]), None)
.add_overlay(Rc::new([Overlay::new(0, "\t")]), None),
0,
)
.0
.collect_to_str(),
"fooo bar "
);

View File

@@ -1,333 +0,0 @@
//! Support for [EditorConfig](https://EditorConfig.org) configuration loading.
//!
//! EditorConfig is an editor-agnostic format for specifying configuration in an INI-like, human
//! friendly syntax in `.editorconfig` files (which are intended to be checked into VCS). This
//! module provides functions to search for all `.editorconfig` files that apply to a given path
//! and returns an `EditorConfig` type containing any specified configuration options.
//!
//! At time of writing, this module follows the [spec](https://spec.editorconfig.org/) at
//! version 0.17.2.
use std::{
collections::HashMap,
fs,
num::{NonZeroU16, NonZeroU8},
path::Path,
str::FromStr,
};
use encoding_rs::Encoding;
use globset::{GlobBuilder, GlobMatcher};
use crate::{
indent::{IndentStyle, MAX_INDENT},
LineEnding,
};
/// Configuration declared for a path in `.editorconfig` files.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct EditorConfig {
pub indent_style: Option<IndentStyle>,
pub tab_width: Option<NonZeroU8>,
pub line_ending: Option<LineEnding>,
pub encoding: Option<&'static Encoding>,
// pub spelling_language: Option<SpellingLanguage>,
pub trim_trailing_whitespace: Option<bool>,
pub insert_final_newline: Option<bool>,
pub max_line_length: Option<NonZeroU16>,
}
impl EditorConfig {
/// Finds any configuration in `.editorconfig` files which applies to the given path.
///
/// If no configuration applies then `EditorConfig::default()` is returned.
pub fn find(path: &Path) -> Self {
let mut configs = Vec::new();
// <https://spec.editorconfig.org/#file-processing>
for ancestor in path.ancestors() {
let editor_config_file = ancestor.join(".editorconfig");
let Ok(contents) = fs::read_to_string(&editor_config_file) else {
continue;
};
let ini = match contents.parse::<Ini>() {
Ok(ini) => ini,
Err(err) => {
log::warn!("Ignoring EditorConfig file at '{editor_config_file:?}' because a glob failed to compile: {err}");
continue;
}
};
let is_root = ini.pairs.get("root").map(AsRef::as_ref) == Some("true");
configs.push((ini, ancestor));
// > The search shall stop if an EditorConfig file is found with the `root` key set to
// > `true` in the preamble or when reaching the root filesystem directory.
if is_root {
break;
}
}
let mut pairs = Pairs::new();
// Reverse the configuration stack so that the `.editorconfig` files closest to `path`
// are applied last and overwrite settings in files closer to the search ceiling.
//
// > If multiple EditorConfig files have matching sections, the pairs from the closer
// > EditorConfig file are read last, so pairs in closer files take precedence.
for (config, dir) in configs.into_iter().rev() {
let relative_path = path.strip_prefix(dir).expect("dir is an ancestor of path");
for section in config.sections {
if section.glob.is_match(relative_path) {
log::info!(
"applying EditorConfig from section '{}' in file {:?}",
section.glob.glob(),
dir.join(".editorconfig")
);
pairs.extend(section.pairs);
}
}
}
Self::from_pairs(pairs)
}
fn from_pairs(pairs: Pairs) -> Self {
enum IndentSize {
Tab,
Spaces(NonZeroU8),
}
// <https://spec.editorconfig.org/#supported-pairs>
let indent_size = pairs.get("indent_size").and_then(|value| {
if value.as_ref() == "tab" {
Some(IndentSize::Tab)
} else if let Ok(spaces) = value.parse::<NonZeroU8>() {
Some(IndentSize::Spaces(spaces))
} else {
None
}
});
let tab_width = pairs
.get("tab_width")
.and_then(|value| value.parse::<NonZeroU8>().ok())
.or(match indent_size {
Some(IndentSize::Spaces(spaces)) => Some(spaces),
_ => None,
});
let indent_style = pairs
.get("indent_style")
.and_then(|value| match value.as_ref() {
"tab" => Some(IndentStyle::Tabs),
"space" => {
let spaces = match indent_size {
Some(IndentSize::Spaces(spaces)) => spaces.get(),
Some(IndentSize::Tab) => tab_width.map(|n| n.get()).unwrap_or(4),
None => 4,
};
Some(IndentStyle::Spaces(spaces.clamp(1, MAX_INDENT)))
}
_ => None,
});
let line_ending = pairs
.get("end_of_line")
.and_then(|value| match value.as_ref() {
"lf" => Some(LineEnding::LF),
"crlf" => Some(LineEnding::Crlf),
#[cfg(feature = "unicode-lines")]
"cr" => Some(LineEnding::CR),
_ => None,
});
let encoding = pairs.get("charset").and_then(|value| match value.as_ref() {
"latin1" => Some(encoding_rs::WINDOWS_1252),
"utf-8" => Some(encoding_rs::UTF_8),
// `utf-8-bom` is intentionally ignored.
// > `utf-8-bom` is discouraged.
"utf-16le" => Some(encoding_rs::UTF_16LE),
"utf-16be" => Some(encoding_rs::UTF_16BE),
_ => None,
});
let trim_trailing_whitespace =
pairs
.get("trim_trailing_whitespace")
.and_then(|value| match value.as_ref() {
"true" => Some(true),
"false" => Some(false),
_ => None,
});
let insert_final_newline = pairs
.get("insert_final_newline")
.and_then(|value| match value.as_ref() {
"true" => Some(true),
"false" => Some(false),
_ => None,
});
// This option is not in the spec but is supported by some editors.
// <https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#max_line_length>
let max_line_length = pairs
.get("max_line_length")
.and_then(|value| value.parse::<NonZeroU16>().ok());
Self {
indent_style,
tab_width,
line_ending,
encoding,
trim_trailing_whitespace,
insert_final_newline,
max_line_length,
}
}
}
type Pairs = HashMap<Box<str>, Box<str>>;
#[derive(Debug)]
struct Section {
glob: GlobMatcher,
pairs: Pairs,
}
#[derive(Debug, Default)]
struct Ini {
pairs: Pairs,
sections: Vec<Section>,
}
impl FromStr for Ini {
type Err = globset::Error;
fn from_str(source: &str) -> Result<Self, Self::Err> {
// <https://spec.editorconfig.org/#file-format>
let mut ini = Ini::default();
// > EditorConfig files are in an INI-like file format. To read an EditorConfig file, take
// > one line at a time, from beginning to end. For each line:
for full_line in source.lines() {
// > 1. Remove all leading and trailing whitespace.
let line = full_line.trim();
// > 2. Process the remaining text as specified for its type below.
// > The types of lines are:
// > * Blank: contains nothing. Blank lines are ignored.
if line.is_empty() {
continue;
}
// > * Comment: starts with a ';' or '#'. Comment lines are ignored.
if line.starts_with([';', '#']) {
continue;
}
if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
// > * Section Header: starts with a `[` and ends with a `]`. These lines define
// > globs...
// <https://spec.editorconfig.org/#glob-expressions>
// We need to modify the glob string slightly since EditorConfig's glob flavor
// doesn't match `globset`'s exactly. `globset` only allows '**' at the beginning
// or end of a glob or between two '/'s. (This replacement is not very fancy but
// should cover most practical cases.)
let mut glob_str = section.replace("**.", "**/*.");
if !is_glob_relative(section) {
glob_str.insert_str(0, "**/");
}
let glob = GlobBuilder::new(&glob_str)
.literal_separator(true)
.backslash_escape(true)
.build()?;
ini.sections.push(Section {
glob: glob.compile_matcher(),
pairs: Pairs::new(),
});
} else if let Some((key, value)) = line.split_once('=') {
// > * Key-Value Pair (or Pair): contains a key and a value, separated by an `=`.
// > * Key: The part before the first `=` on the line.
// > * Value: The part, if any, after the first `=` on the line.
// > * Keys and values are trimmed of leading and trailing whitespace, but
// > include any whitespace that is between non-whitespace characters.
// > * If a value is not provided, then the value is an empty string.
let key = key.trim().to_lowercase().into_boxed_str();
let value = value.trim().to_lowercase().into_boxed_str();
if let Some(section) = ini.sections.last_mut() {
section.pairs.insert(key, value);
} else {
ini.pairs.insert(key, value);
}
}
}
Ok(ini)
}
}
/// Determines whether a glob is relative to the directory of the config file.
fn is_glob_relative(source: &str) -> bool {
// > If the glob contains a path separator (a `/` not inside square brackets), then the
// > glob is relative to the directory level of the particular `.editorconfig` file itself.
let mut idx = 0;
while let Some(open) = source[idx..].find('[').map(|open| idx + open) {
if source[..open].contains('/') {
return true;
}
idx = source[open..]
.find(']')
.map_or(source.len(), |close| idx + close);
}
source[idx..].contains('/')
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn is_glob_relative_test() {
assert!(is_glob_relative("subdir/*.c"));
assert!(!is_glob_relative("*.txt"));
assert!(!is_glob_relative("[a/b].c"));
}
fn editor_config(path: impl AsRef<Path>, source: &str) -> EditorConfig {
let path = path.as_ref();
let ini = source.parse::<Ini>().unwrap();
let pairs = ini
.sections
.into_iter()
.filter(|section| section.glob.is_match(path))
.fold(Pairs::new(), |mut acc, section| {
acc.extend(section.pairs);
acc
});
EditorConfig::from_pairs(pairs)
}
#[test]
fn parse_test() {
let source = r#"
[*]
indent_style = space
[Makefile]
indent_style = tab
[docs/**.txt]
insert_final_newline = true
"#;
assert_eq!(
editor_config("a.txt", source),
EditorConfig {
indent_style: Some(IndentStyle::Spaces(4)),
..Default::default()
}
);
assert_eq!(
editor_config("pkg/Makefile", source),
EditorConfig {
indent_style: Some(IndentStyle::Tabs),
..Default::default()
}
);
assert_eq!(
editor_config("docs/config/editor.txt", source),
EditorConfig {
indent_style: Some(IndentStyle::Spaces(4)),
insert_final_newline: Some(true),
..Default::default()
}
);
}
}

View File

@@ -1,6 +1,6 @@
use std::ops::DerefMut;
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
use nucleo::pattern::{Atom, AtomKind, CaseMatching};
use nucleo::Config;
use parking_lot::Mutex;
@@ -38,12 +38,6 @@ pub fn fuzzy_match<T: AsRef<str>>(
if path {
matcher.config.set_match_paths();
}
let pattern = Atom::new(
pattern,
CaseMatching::Smart,
Normalization::Smart,
AtomKind::Fuzzy,
false,
);
let pattern = Atom::new(pattern, CaseMatching::Smart, AtomKind::Fuzzy, false);
pattern.match_list(items, &mut matcher)
}

View File

@@ -1,7 +1,7 @@
//! Utility functions to traverse the unicode graphemes of a `Rope`'s text contents.
//!
//! Based on <https://github.com/cessen/led/blob/c4fa72405f510b7fd16052f90a598c429b3104a6/src/graphemes.rs>
use ropey::{str_utils::byte_to_char_idx, RopeSlice};
use ropey::{iter::Chunks, str_utils::byte_to_char_idx, RopeSlice};
use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete};
use unicode_width::UnicodeWidthStr;
@@ -28,11 +28,6 @@ pub enum Grapheme<'a> {
}
impl<'a> Grapheme<'a> {
pub fn new_decoration(g: &'static str) -> Grapheme<'a> {
assert_ne!(g, "\t");
Grapheme::new(g.into(), 0, 0)
}
pub fn new(g: GraphemeStr<'a>, visual_x: usize, tab_width: u16) -> Grapheme<'a> {
match g {
g if g == "\t" => Grapheme::Tab {
@@ -64,7 +59,7 @@ impl<'a> Grapheme<'a> {
}
pub fn is_whitespace(&self) -> bool {
!matches!(&self, Grapheme::Other { g } if !g.chars().next().is_some_and(char_is_whitespace))
!matches!(&self, Grapheme::Other { g } if !g.chars().all(char_is_whitespace))
}
// TODO currently word boundaries are used for softwrapping.
@@ -72,7 +67,7 @@ impl<'a> Grapheme<'a> {
// 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().next().is_some_and(char_is_word))
!matches!(&self, Grapheme::Other { g,.. } if g.chars().all(char_is_word))
}
}
@@ -119,9 +114,6 @@ pub fn grapheme_width(g: &str) -> usize {
}
}
// NOTE: for byte indexing versions of these functions see `RopeSliceExt`'s
// `floor_grapheme_boundary` and `ceil_grapheme_boundary` and the rope grapheme iterators.
#[must_use]
pub fn nth_prev_grapheme_boundary(slice: RopeSlice, char_idx: usize, n: usize) -> usize {
// Bounds check
@@ -211,6 +203,43 @@ pub fn nth_next_grapheme_boundary(slice: RopeSlice, char_idx: usize, n: usize) -
chunk_char_idx + tmp
}
#[must_use]
pub fn nth_next_grapheme_boundary_byte(slice: RopeSlice, mut byte_idx: usize, n: usize) -> usize {
// Bounds check
debug_assert!(byte_idx <= slice.len_bytes());
// Get the chunk with our byte index in it.
let (mut chunk, mut chunk_byte_idx, mut _chunk_char_idx, _) = slice.chunk_at_byte(byte_idx);
// Set up the grapheme cursor.
let mut gc = GraphemeCursor::new(byte_idx, slice.len_bytes(), true);
// Find the nth next grapheme cluster boundary.
for _ in 0..n {
loop {
match gc.next_boundary(chunk, chunk_byte_idx) {
Ok(None) => return slice.len_bytes(),
Ok(Some(n)) => {
byte_idx = n;
break;
}
Err(GraphemeIncomplete::NextChunk) => {
chunk_byte_idx += chunk.len();
let (a, _, _c, _) = slice.chunk_at_byte(chunk_byte_idx);
chunk = a;
// chunk_char_idx = c;
}
Err(GraphemeIncomplete::PreContext(n)) => {
let ctx_chunk = slice.chunk_at_byte(n - 1).0;
gc.provide_context(ctx_chunk, n - ctx_chunk.len());
}
_ => unreachable!(),
}
}
}
byte_idx
}
/// Finds the next grapheme boundary after the given char position.
#[must_use]
#[inline(always)]
@@ -218,6 +247,13 @@ pub fn next_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> usize {
nth_next_grapheme_boundary(slice, char_idx, 1)
}
/// Finds the next grapheme boundary after the given byte position.
#[must_use]
#[inline(always)]
pub fn next_grapheme_boundary_byte(slice: RopeSlice, byte_idx: usize) -> usize {
nth_next_grapheme_boundary_byte(slice, byte_idx, 1)
}
/// Returns the passed char index if it's already a grapheme boundary,
/// or the next grapheme boundary char index if not.
#[must_use]
@@ -242,6 +278,153 @@ pub fn ensure_grapheme_boundary_prev(slice: RopeSlice, char_idx: usize) -> usize
}
}
/// Returns the passed byte index if it's already a grapheme boundary,
/// or the next grapheme boundary byte index if not.
#[must_use]
#[inline]
pub fn ensure_grapheme_boundary_next_byte(slice: RopeSlice, byte_idx: usize) -> usize {
if byte_idx == 0 {
byte_idx
} else {
// TODO: optimize so we're not constructing grapheme cursor twice
if is_grapheme_boundary_byte(slice, byte_idx) {
byte_idx
} else {
next_grapheme_boundary_byte(slice, byte_idx)
}
}
}
/// Returns whether the given char position is a grapheme boundary.
#[must_use]
pub fn is_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> bool {
// Bounds check
debug_assert!(char_idx <= slice.len_chars());
// We work with bytes for this, so convert.
let byte_idx = slice.char_to_byte(char_idx);
// Get the chunk with our byte index in it.
let (chunk, chunk_byte_idx, _, _) = slice.chunk_at_byte(byte_idx);
// Set up the grapheme cursor.
let mut gc = GraphemeCursor::new(byte_idx, slice.len_bytes(), true);
// Determine if the given position is a grapheme cluster boundary.
loop {
match gc.is_boundary(chunk, chunk_byte_idx) {
Ok(n) => return n,
Err(GraphemeIncomplete::PreContext(n)) => {
let (ctx_chunk, ctx_byte_start, _, _) = slice.chunk_at_byte(n - 1);
gc.provide_context(ctx_chunk, ctx_byte_start);
}
Err(_) => unreachable!(),
}
}
}
/// Returns whether the given byte position is a grapheme boundary.
#[must_use]
pub fn is_grapheme_boundary_byte(slice: RopeSlice, byte_idx: usize) -> bool {
// Bounds check
debug_assert!(byte_idx <= slice.len_bytes());
// Get the chunk with our byte index in it.
let (chunk, chunk_byte_idx, _, _) = slice.chunk_at_byte(byte_idx);
// Set up the grapheme cursor.
let mut gc = GraphemeCursor::new(byte_idx, slice.len_bytes(), true);
// Determine if the given position is a grapheme cluster boundary.
loop {
match gc.is_boundary(chunk, chunk_byte_idx) {
Ok(n) => return n,
Err(GraphemeIncomplete::PreContext(n)) => {
let (ctx_chunk, ctx_byte_start, _, _) = slice.chunk_at_byte(n - 1);
gc.provide_context(ctx_chunk, ctx_byte_start);
}
Err(_) => unreachable!(),
}
}
}
/// An iterator over the graphemes of a `RopeSlice`.
#[derive(Clone)]
pub struct RopeGraphemes<'a> {
text: RopeSlice<'a>,
chunks: Chunks<'a>,
cur_chunk: &'a str,
cur_chunk_start: usize,
cursor: GraphemeCursor,
}
impl<'a> fmt::Debug for RopeGraphemes<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RopeGraphemes")
.field("text", &self.text)
.field("chunks", &self.chunks)
.field("cur_chunk", &self.cur_chunk)
.field("cur_chunk_start", &self.cur_chunk_start)
// .field("cursor", &self.cursor)
.finish()
}
}
impl<'a> RopeGraphemes<'a> {
#[must_use]
pub fn new(slice: RopeSlice) -> RopeGraphemes {
let mut chunks = slice.chunks();
let first_chunk = chunks.next().unwrap_or("");
RopeGraphemes {
text: slice,
chunks,
cur_chunk: first_chunk,
cur_chunk_start: 0,
cursor: GraphemeCursor::new(0, slice.len_bytes(), true),
}
}
}
impl<'a> Iterator for RopeGraphemes<'a> {
type Item = RopeSlice<'a>;
fn next(&mut self) -> Option<RopeSlice<'a>> {
let a = self.cursor.cur_cursor();
let b;
loop {
match self
.cursor
.next_boundary(self.cur_chunk, self.cur_chunk_start)
{
Ok(None) => {
return None;
}
Ok(Some(n)) => {
b = n;
break;
}
Err(GraphemeIncomplete::NextChunk) => {
self.cur_chunk_start += self.cur_chunk.len();
self.cur_chunk = self.chunks.next().unwrap_or("");
}
Err(GraphemeIncomplete::PreContext(idx)) => {
let (chunk, byte_idx, _, _) = self.text.chunk_at_byte(idx.saturating_sub(1));
self.cursor.provide_context(chunk, byte_idx);
}
_ => unreachable!(),
}
}
if a < self.cur_chunk_start {
Some(self.text.byte_slice(a..b))
} else {
let a2 = a - self.cur_chunk_start;
let b2 = b - self.cur_chunk_start;
Some((&self.cur_chunk[a2..b2]).into())
}
}
}
/// A highly compressed Cow<'a, str> that holds
/// atmost u31::MAX bytes and is readonly
pub struct GraphemeStr<'a> {
@@ -292,7 +475,7 @@ impl<'a> From<&'a str> for GraphemeStr<'a> {
}
}
impl From<String> for GraphemeStr<'_> {
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;

View File

@@ -27,7 +27,7 @@ pub fn increment(selected_text: &str, amount: i64) -> Option<String> {
let date_time = NaiveDateTime::parse_from_str(date_time, format.fmt).ok()?;
Some(
date_time
.checked_add_signed(Duration::try_minutes(amount)?)?
.checked_add_signed(Duration::minutes(amount))?
.format(format.fmt)
.to_string(),
)
@@ -35,15 +35,14 @@ pub fn increment(selected_text: &str, amount: i64) -> Option<String> {
(true, false) => {
let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?;
Some(
date.checked_add_signed(Duration::try_days(amount)?)?
date.checked_add_signed(Duration::days(amount))?
.format(format.fmt)
.to_string(),
)
}
(false, true) => {
let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?;
let (adjusted_time, _) =
time.overflowing_add_signed(Duration::try_minutes(amount)?);
let (adjusted_time, _) = time.overflowing_add_signed(Duration::minutes(amount));
Some(adjusted_time.format(format.fmt).to_string())
}
(false, false) => None,

View File

@@ -1,18 +1,14 @@
use std::{borrow::Cow, collections::HashMap, iter};
use std::{borrow::Cow, collections::HashMap};
use helix_stdx::rope::RopeSliceExt;
use tree_house::TREE_SITTER_MATCH_LIMIT;
use tree_sitter::{Query, QueryCursor, QueryPredicateArg};
use crate::{
chars::{char_is_line_ending, char_is_whitespace},
find_first_non_whitespace_char,
graphemes::{grapheme_width, tab_width_at},
syntax::{self, config::IndentationHeuristic},
tree_sitter::{
self,
query::{InvalidPredicateError, UserPredicate},
Capture, Grammar, InactiveQueryCursor, Node, Pattern, Query, QueryMatch, RopeInput,
},
Position, Rope, RopeSlice, Syntax, Tendril,
syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax},
tree_sitter::Node,
Position, Rope, RopeGraphemes, RopeSlice,
};
/// Enum representing indentation style.
@@ -204,7 +200,7 @@ pub fn indent_level_for_line(line: RopeSlice, tab_width: usize, indent_width: us
/// Create a string of tabs & spaces that has the same visual width as the given RopeSlice (independent of the tab width).
fn whitespace_with_same_width(text: RopeSlice) -> String {
let mut s = String::new();
for grapheme in text.graphemes() {
for grapheme in RopeGraphemes::new(text) {
if grapheme == "\t" {
s.push('\t');
} else {
@@ -214,36 +210,6 @@ fn whitespace_with_same_width(text: RopeSlice) -> String {
s
}
/// normalizes indentation to tabs/spaces based on user configuration
/// This function does not change the actual indentation width, just the character
/// composition.
pub fn normalize_indentation(
prefix: RopeSlice<'_>,
line: RopeSlice<'_>,
dst: &mut Tendril,
indent_style: IndentStyle,
tab_width: usize,
) -> usize {
#[allow(deprecated)]
let off = crate::visual_coords_at_pos(prefix, prefix.len_chars(), tab_width).col;
let mut len = 0;
let mut original_len = 0;
for ch in line.chars() {
match ch {
'\t' => len += tab_width_at(len + off, tab_width as u16),
' ' => len += 1,
_ => break,
}
original_len += 1;
}
if indent_style == IndentStyle::Tabs {
dst.extend(iter::repeat('\t').take(len / tab_width));
len %= tab_width;
}
dst.extend(iter::repeat(' ').take(len));
original_len
}
fn add_indent_level(
mut base_indent: String,
added_indent_level: isize,
@@ -281,171 +247,48 @@ fn add_indent_level(
}
}
/// Return true if only whitespace comes before the node on its line.
/// If given, new_line_byte_pos is treated the same way as any existing newline.
fn is_first_in_line(node: &Node, text: RopeSlice, new_line_byte_pos: Option<u32>) -> bool {
let line = text.byte_to_line(node.start_byte() as usize);
let mut line_start_byte_pos = text.line_to_byte(line) as u32;
if let Some(pos) = new_line_byte_pos {
if line_start_byte_pos < pos && pos <= node.start_byte() {
line_start_byte_pos = pos;
/// Computes for node and all ancestors whether they are the first node on their line.
/// The first entry in the return value represents the root node, the last one the node itself
fn get_first_in_line(mut node: Node, new_line_byte_pos: Option<usize>) -> Vec<bool> {
let mut first_in_line = Vec::new();
loop {
if let Some(prev) = node.prev_sibling() {
// If we insert a new line, the first node at/after the cursor is considered to be the first in its line
let first = prev.end_position().row != node.start_position().row
|| new_line_byte_pos.map_or(false, |byte_pos| {
node.start_byte() >= byte_pos && prev.start_byte() < byte_pos
});
first_in_line.push(Some(first));
} else {
// Nodes that have no previous siblings are first in their line if and only if their parent is
// (which we don't know yet)
first_in_line.push(None);
}
if let Some(parent) = node.parent() {
node = parent;
} else {
break;
}
}
text.byte_slice(line_start_byte_pos as usize..node.start_byte() as usize)
.chars()
.all(|c| c.is_whitespace())
}
#[derive(Debug, Default)]
pub struct IndentQueryPredicates {
not_kind_eq: Vec<(Capture, Box<str>)>,
same_line: Option<(Capture, Capture, bool)>,
one_line: Option<(Capture, bool)>,
}
impl IndentQueryPredicates {
fn are_satisfied(
&self,
match_: &QueryMatch,
text: RopeSlice,
new_line_byte_pos: Option<u32>,
) -> bool {
for (capture, not_expected_kind) in self.not_kind_eq.iter() {
let node = match_.nodes_for_capture(*capture).next();
if node.is_some_and(|n| n.kind() == not_expected_kind.as_ref()) {
return false;
}
let mut result = Vec::with_capacity(first_in_line.len());
let mut parent_is_first = true; // The root node is by definition the first node in its line
for first in first_in_line.into_iter().rev() {
if let Some(first) = first {
result.push(first);
parent_is_first = first;
} else {
result.push(parent_is_first);
}
if let Some((capture1, capture2, negated)) = self.same_line {
let n1 = match_.nodes_for_capture(capture1).next();
let n2 = match_.nodes_for_capture(capture2).next();
let satisfied = n1.zip(n2).is_some_and(|(n1, n2)| {
let n1_line = get_node_start_line(text, n1, new_line_byte_pos);
let n2_line = get_node_start_line(text, n2, new_line_byte_pos);
let same_line = n1_line == n2_line;
same_line != negated
});
if !satisfied {
return false;
}
}
if let Some((capture, negated)) = self.one_line {
let node = match_.nodes_for_capture(capture).next();
let satisfied = node.is_some_and(|node| {
let start_line = get_node_start_line(text, node, new_line_byte_pos);
let end_line = get_node_end_line(text, node, new_line_byte_pos);
let one_line = end_line == start_line;
one_line != negated
});
if !satisfied {
return false;
}
}
true
}
}
#[derive(Debug)]
pub struct IndentQuery {
query: Query,
properties: HashMap<Pattern, IndentScope>,
predicates: HashMap<Pattern, IndentQueryPredicates>,
indent_capture: Option<Capture>,
indent_always_capture: Option<Capture>,
outdent_capture: Option<Capture>,
outdent_always_capture: Option<Capture>,
align_capture: Option<Capture>,
anchor_capture: Option<Capture>,
extend_capture: Option<Capture>,
extend_prevent_once_capture: Option<Capture>,
}
impl IndentQuery {
pub fn new(grammar: Grammar, source: &str) -> Result<Self, tree_sitter::query::ParseError> {
let mut properties = HashMap::new();
let mut predicates: HashMap<Pattern, IndentQueryPredicates> = HashMap::new();
let query = Query::new(grammar, source, |pattern, predicate| match predicate {
UserPredicate::SetProperty { key: "scope", val } => {
let scope = match val {
Some("all") => IndentScope::All,
Some("tail") => IndentScope::Tail,
Some(other) => {
return Err(format!("unknown scope (#set! scope \"{other}\")").into())
}
None => return Err("missing scope value (#set! scope ...)".into()),
};
properties.insert(pattern, scope);
Ok(())
}
UserPredicate::Other(predicate) => {
let name = predicate.name();
match name {
"not-kind-eq?" => {
predicate.check_arg_count(2)?;
let capture = predicate.capture_arg(0)?;
let not_expected_kind = predicate.str_arg(1)?;
predicates
.entry(pattern)
.or_default()
.not_kind_eq
.push((capture, not_expected_kind.into()));
Ok(())
}
"same-line?" | "not-same-line?" => {
predicate.check_arg_count(2)?;
let capture1 = predicate.capture_arg(0)?;
let capture2 = predicate.capture_arg(1)?;
let negated = name == "not-same-line?";
predicates.entry(pattern).or_default().same_line =
Some((capture1, capture2, negated));
Ok(())
}
"one-line?" | "not-one-line?" => {
predicate.check_arg_count(1)?;
let capture = predicate.capture_arg(0)?;
let negated = name == "not-one-line?";
predicates.entry(pattern).or_default().one_line = Some((capture, negated));
Ok(())
}
_ => Err(InvalidPredicateError::unknown(UserPredicate::Other(
predicate,
))),
}
}
_ => Err(InvalidPredicateError::unknown(predicate)),
})?;
Ok(Self {
properties,
predicates,
indent_capture: query.get_capture("indent"),
indent_always_capture: query.get_capture("indent.always"),
outdent_capture: query.get_capture("outdent"),
outdent_always_capture: query.get_capture("outdent.always"),
align_capture: query.get_capture("align"),
anchor_capture: query.get_capture("anchor"),
extend_capture: query.get_capture("extend"),
extend_prevent_once_capture: query.get_capture("extend.prevent-once"),
query,
})
}
result
}
/// The total indent for some line of code.
/// This is usually constructed in one of 2 ways:
/// - Successively add indent captures to get the (added) indent from a single line
/// - Successively add the indent results for each line
/// The string that this indentation defines starts with the string contained in the align field (unless it is None), followed by:
/// The string that this indentation defines starts with the string contained in the align field (unless it is None), followed by:
/// - max(0, indent - outdent) tabs, if tabs are used for indentation
/// - max(0, indent - outdent)*indent_width spaces, if spaces are used for indentation
#[derive(Default, Debug, PartialEq, Eq, Clone)]
@@ -566,7 +409,7 @@ enum IndentCaptureType<'a> {
Align(RopeSlice<'a>),
}
impl IndentCaptureType<'_> {
impl<'a> IndentCaptureType<'a> {
fn default_scope(&self) -> IndentScope {
match self {
IndentCaptureType::Indent | IndentCaptureType::IndentAlways => IndentScope::Tail,
@@ -603,114 +446,193 @@ struct IndentQueryResult<'a> {
extend_captures: HashMap<usize, Vec<ExtendCapture>>,
}
fn get_node_start_line(text: RopeSlice, node: &Node, new_line_byte_pos: Option<u32>) -> usize {
let mut node_line = text.byte_to_line(node.start_byte() as usize);
fn get_node_start_line(node: Node, new_line_byte_pos: Option<usize>) -> usize {
let mut node_line = node.start_position().row;
// Adjust for the new line that will be inserted
if new_line_byte_pos.is_some_and(|pos| node.start_byte() >= pos) {
if new_line_byte_pos.map_or(false, |pos| node.start_byte() >= pos) {
node_line += 1;
}
node_line
}
fn get_node_end_line(text: RopeSlice, node: &Node, new_line_byte_pos: Option<u32>) -> usize {
let mut node_line = text.byte_to_line(node.end_byte() as usize);
fn get_node_end_line(node: Node, new_line_byte_pos: Option<usize>) -> usize {
let mut node_line = node.end_position().row;
// Adjust for the new line that will be inserted (with a strict inequality since end_byte is exclusive)
if new_line_byte_pos.is_some_and(|pos| node.end_byte() > pos) {
if new_line_byte_pos.map_or(false, |pos| node.end_byte() > pos) {
node_line += 1;
}
node_line
}
fn query_indents<'a>(
query: &IndentQuery,
query: &Query,
syntax: &Syntax,
cursor: &mut QueryCursor,
text: RopeSlice<'a>,
range: std::ops::Range<u32>,
new_line_byte_pos: Option<u32>,
range: std::ops::Range<usize>,
new_line_byte_pos: Option<usize>,
) -> IndentQueryResult<'a> {
let mut indent_captures: HashMap<usize, Vec<IndentCapture>> = HashMap::new();
let mut extend_captures: HashMap<usize, Vec<ExtendCapture>> = HashMap::new();
let mut cursor = InactiveQueryCursor::new(range, TREE_SITTER_MATCH_LIMIT).execute_query(
&query.query,
&syntax.tree().root_node(),
RopeInput::new(text),
);
cursor.set_byte_range(range);
// Iterate over all captures from the query
while let Some(m) = cursor.next_match() {
for m in cursor.matches(query, syntax.tree().root_node(), RopeProvider(text)) {
// Skip matches where not all custom predicates are fulfilled
if query
.predicates
.get(&m.pattern())
.is_some_and(|preds| !preds.are_satisfied(&m, text, new_line_byte_pos))
{
if !query.general_predicates(m.pattern_index).iter().all(|pred| {
match pred.operator.as_ref() {
"not-kind-eq?" => match (pred.args.get(0), pred.args.get(1)) {
(
Some(QueryPredicateArg::Capture(capture_idx)),
Some(QueryPredicateArg::String(kind)),
) => {
let node = m.nodes_for_capture_index(*capture_idx).next();
match node {
Some(node) => node.kind()!=kind.as_ref(),
_ => true,
}
}
_ => {
panic!("Invalid indent query: Arguments to \"not-kind-eq?\" must be a capture and a string");
}
},
"same-line?" | "not-same-line?" => {
match (pred.args.get(0), pred.args.get(1)) {
(
Some(QueryPredicateArg::Capture(capt1)),
Some(QueryPredicateArg::Capture(capt2))
) => {
let n1 = m.nodes_for_capture_index(*capt1).next();
let n2 = m.nodes_for_capture_index(*capt2).next();
match (n1, n2) {
(Some(n1), Some(n2)) => {
let n1_line = get_node_start_line(n1, new_line_byte_pos);
let n2_line = get_node_start_line(n2, new_line_byte_pos);
let same_line = n1_line == n2_line;
same_line==(pred.operator.as_ref()=="same-line?")
}
_ => true,
}
}
_ => {
panic!("Invalid indent query: Arguments to \"{}\" must be 2 captures", pred.operator);
}
}
}
"one-line?" | "not-one-line?" => match pred.args.get(0) {
Some(QueryPredicateArg::Capture(capture_idx)) => {
let node = m.nodes_for_capture_index(*capture_idx).next();
match node {
Some(node) => {
let (start_line, end_line) = (get_node_start_line(node,new_line_byte_pos), get_node_end_line(node, new_line_byte_pos));
let one_line = end_line == start_line;
one_line != (pred.operator.as_ref() == "not-one-line?")
},
_ => true,
}
}
_ => {
panic!("Invalid indent query: Arguments to \"not-kind-eq?\" must be a capture and a string");
}
},
_ => {
panic!(
"Invalid indent query: Unknown predicate (\"{}\")",
pred.operator
);
}
}
}) {
continue;
}
// A list of pairs (node_id, indent_capture) that are added by this match.
// They cannot be added to indent_captures immediately since they may depend on other captures (such as an @anchor).
let mut added_indent_captures: Vec<(usize, IndentCapture)> = Vec::new();
// The row/column position of the optional anchor in this query
let mut anchor: Option<&Node> = None;
for matched_node in m.matched_nodes() {
let node_id = matched_node.node.id();
let capture = Some(matched_node.capture);
let capture_type = if capture == query.indent_capture {
IndentCaptureType::Indent
} else if capture == query.indent_always_capture {
IndentCaptureType::IndentAlways
} else if capture == query.outdent_capture {
IndentCaptureType::Outdent
} else if capture == query.outdent_always_capture {
IndentCaptureType::OutdentAlways
} else if capture == query.align_capture {
IndentCaptureType::Align(RopeSlice::from(""))
} else if capture == query.anchor_capture {
if anchor.is_some() {
log::error!("Invalid indent query: Encountered more than one @anchor in the same match.")
} else {
anchor = Some(&matched_node.node);
let mut anchor: Option<tree_sitter::Node> = None;
for capture in m.captures {
let capture_name = query.capture_names()[capture.index as usize].as_str();
let capture_type = match capture_name {
"indent" => IndentCaptureType::Indent,
"indent.always" => IndentCaptureType::IndentAlways,
"outdent" => IndentCaptureType::Outdent,
"outdent.always" => IndentCaptureType::OutdentAlways,
// The alignment will be updated to the correct value at the end, when the anchor is known.
"align" => IndentCaptureType::Align(RopeSlice::from("")),
"anchor" => {
if anchor.is_some() {
log::error!("Invalid indent query: Encountered more than one @anchor in the same match.")
} else {
anchor = Some(capture.node);
}
continue;
}
"extend" => {
extend_captures
.entry(capture.node.id())
.or_insert_with(|| Vec::with_capacity(1))
.push(ExtendCapture::Extend);
continue;
}
"extend.prevent-once" => {
extend_captures
.entry(capture.node.id())
.or_insert_with(|| Vec::with_capacity(1))
.push(ExtendCapture::PreventOnce);
continue;
}
_ => {
// Ignore any unknown captures (these may be needed for predicates such as #match?)
continue;
}
continue;
} else if capture == query.extend_capture {
extend_captures
.entry(node_id)
.or_insert_with(|| Vec::with_capacity(1))
.push(ExtendCapture::Extend);
continue;
} else if capture == query.extend_prevent_once_capture {
extend_captures
.entry(node_id)
.or_insert_with(|| Vec::with_capacity(1))
.push(ExtendCapture::PreventOnce);
continue;
} else {
// Ignore any unknown captures (these may be needed for predicates such as #match?)
continue;
};
// Apply additional settings for this capture
let scope = query
.properties
.get(&m.pattern())
.copied()
.unwrap_or_else(|| capture_type.default_scope());
let indent_capture = IndentCapture {
let scope = capture_type.default_scope();
let mut indent_capture = IndentCapture {
capture_type,
scope,
};
added_indent_captures.push((node_id, indent_capture))
// Apply additional settings for this capture
for property in query.property_settings(m.pattern_index) {
match property.key.as_ref() {
"scope" => {
indent_capture.scope = match property.value.as_deref() {
Some("all") => IndentScope::All,
Some("tail") => IndentScope::Tail,
Some(s) => {
panic!("Invalid indent query: Unknown value for \"scope\" property (\"{}\")", s);
}
None => {
panic!(
"Invalid indent query: Missing value for \"scope\" property"
);
}
}
}
_ => {
panic!(
"Invalid indent query: Unknown property \"{}\"",
property.key
);
}
}
}
added_indent_captures.push((capture.node.id(), indent_capture))
}
for (node_id, mut capture) in added_indent_captures {
// Set the anchor for all align queries.
if let IndentCaptureType::Align(_) = capture.capture_type {
let Some(anchor) = anchor else {
log::error!("Invalid indent query: @align requires an accompanying @anchor.");
continue;
let anchor = match anchor {
None => {
log::error!(
"Invalid indent query: @align requires an accompanying @anchor."
);
continue;
}
Some(anchor) => anchor,
};
let line = text.byte_to_line(anchor.start_byte() as usize);
let line_start = text.line_to_byte(line);
capture.capture_type = IndentCaptureType::Align(
text.byte_slice(line_start..anchor.start_byte() as usize),
text.line(anchor.start_position().row)
.byte_slice(0..anchor.start_position().column),
);
}
indent_captures
@@ -762,15 +684,13 @@ fn extend_nodes<'a>(
// - 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 text.byte_to_line(deepest_preceding.end_byte() as usize) == line {
if deepest_preceding.end_position().row == line {
extend_node = true;
} else {
let cursor_indent =
indent_level_for_line(text.line(line), tab_width, indent_width);
let node_indent = indent_level_for_line(
text.line(
text.byte_to_line(deepest_preceding.start_byte() as usize),
),
text.line(deepest_preceding.start_position().row),
tab_width,
indent_width,
);
@@ -787,7 +707,7 @@ fn extend_nodes<'a>(
if node_captured && stop_extend {
stop_extend = false;
} else if extend_node && !stop_extend {
*node = deepest_preceding.clone();
*node = deepest_preceding;
break;
}
// If the tree contains a syntax error, `deepest_preceding` may not
@@ -804,17 +724,17 @@ fn extend_nodes<'a>(
/// - The indent captures for all relevant nodes.
#[allow(clippy::too_many_arguments)]
fn init_indent_query<'a, 'b>(
query: &IndentQuery,
query: &Query,
syntax: &'a Syntax,
text: RopeSlice<'b>,
tab_width: usize,
indent_width: usize,
line: usize,
byte_pos: u32,
new_line_byte_pos: Option<u32>,
byte_pos: usize,
new_line_byte_pos: Option<usize>,
) -> Option<(Node<'a>, HashMap<usize, Vec<IndentCapture<'b>>>)> {
// The innermost tree-sitter node which is considered for the indent
// computation. It may change if some preceding node is extended
// computation. It may change if some predeceding node is extended
let mut node = syntax
.tree()
.root_node()
@@ -824,25 +744,37 @@ fn init_indent_query<'a, 'b>(
// The query range should intersect with all nodes directly preceding
// the position of the indent query in case one of them is extended.
let mut deepest_preceding = None; // The deepest node preceding the indent query position
for child in node.children() {
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.clone());
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().clone();
prec = prec.child(prec.child_count() - 1).unwrap();
}
prec
});
let query_range = deepest_preceding
.as_ref()
.map(|prec| prec.byte_range().end - 1..byte_pos + 1)
.unwrap_or(byte_pos..byte_pos + 1);
let query_result = query_indents(query, syntax, text, query_range, new_line_byte_pos);
(query_result, deepest_preceding)
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_byte_pos,
);
ts_parser.cursors.push(cursor);
(query_result, deepest_preceding)
})
};
let extend_captures = query_result.extend_captures;
@@ -877,7 +809,6 @@ fn init_indent_query<'a, 'b>(
/// - The line after the node. This is defined by:
/// - The scope `tail`.
/// - 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.
/// The indents from different nodes for the same line are then combined.
/// The result [Indentation] is simply the sum of the [Indentation] for all lines.
@@ -900,7 +831,7 @@ fn init_indent_query<'a, 'b>(
/// ```
#[allow(clippy::too_many_arguments)]
pub fn treesitter_indent_for_pos<'a>(
query: &IndentQuery,
query: &Query,
syntax: &Syntax,
tab_width: usize,
indent_width: usize,
@@ -909,7 +840,7 @@ pub fn treesitter_indent_for_pos<'a>(
pos: usize,
new_line: bool,
) -> Option<Indentation<'a>> {
let byte_pos = text.char_to_byte(pos) as u32;
let byte_pos = text.char_to_byte(pos);
let new_line_byte_pos = new_line.then_some(byte_pos);
let (mut node, mut indent_captures) = init_indent_query(
query,
@@ -921,6 +852,7 @@ pub fn treesitter_indent_for_pos<'a>(
byte_pos,
new_line_byte_pos,
)?;
let mut first_in_line = get_first_in_line(node, new_line.then_some(byte_pos));
let mut result = Indentation::default();
// We always keep track of all the indent changes on one line, in order to only indent once
@@ -929,7 +861,9 @@ pub fn treesitter_indent_for_pos<'a>(
let mut indent_for_line_below = Indentation::default();
loop {
let is_first = is_first_in_line(&node, text, new_line_byte_pos);
// This can safely be unwrapped because `first_in_line` contains
// one entry for each ancestor of the node (which is what we iterate over)
let is_first = *first_in_line.last().unwrap();
// Apply all indent definitions for this node.
// Since we only iterate over each node once, we can remove the
@@ -952,8 +886,8 @@ pub fn treesitter_indent_for_pos<'a>(
}
if let Some(parent) = node.parent() {
let node_line = get_node_start_line(text, &node, new_line_byte_pos);
let parent_line = get_node_start_line(text, &parent, new_line_byte_pos);
let node_line = get_node_start_line(node, new_line_byte_pos);
let parent_line = get_node_start_line(parent, new_line_byte_pos);
if node_line != parent_line {
// Don't add indent for the line below the line of the query
@@ -972,12 +906,12 @@ pub fn treesitter_indent_for_pos<'a>(
}
node = parent;
first_in_line.pop();
} else {
// Only add the indentation for the line below if that line
// is not after the line that the indentation is calculated for.
let node_start_line = text.byte_to_line(node.start_byte() as usize);
if node_start_line < line
|| (new_line && node_start_line == line && node.start_byte() < byte_pos)
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);
}
@@ -992,7 +926,7 @@ pub fn treesitter_indent_for_pos<'a>(
/// This is done either using treesitter, or if that's not available by copying the indentation from the current line
#[allow(clippy::too_many_arguments)]
pub fn indent_for_newline(
loader: &syntax::Loader,
language_config: Option<&LanguageConfiguration>,
syntax: Option<&Syntax>,
indent_heuristic: &IndentationHeuristic,
indent_style: &IndentStyle,
@@ -1009,7 +943,7 @@ pub fn indent_for_newline(
Some(syntax),
) = (
indent_heuristic,
syntax.and_then(|syntax| loader.indent_query(syntax.root_language())),
language_config.and_then(|config| config.indent_query()),
syntax,
) {
if let Some(indent) = treesitter_indent_for_pos(
@@ -1036,7 +970,7 @@ pub fn indent_for_newline(
let mut num_attempts = 0;
for line_idx in (0..=line_before).rev() {
let line = text.line(line_idx);
let first_non_whitespace_char = match line.first_non_whitespace_char() {
let first_non_whitespace_char = match find_first_non_whitespace_char(line) {
Some(i) => i,
None => {
continue;
@@ -1077,10 +1011,10 @@ pub fn indent_for_newline(
indent_style.as_str().repeat(indent_level)
}
pub fn get_scopes<'a>(syntax: Option<&'a Syntax>, text: RopeSlice, pos: usize) -> Vec<&'a str> {
pub fn get_scopes(syntax: Option<&Syntax>, text: RopeSlice, pos: usize) -> Vec<&'static str> {
let mut scopes = Vec::new();
if let Some(syntax) = syntax {
let pos = text.char_to_byte(pos) as u32;
let pos = text.char_to_byte(pos);
let mut node = match syntax
.tree()
.root_node()

View File

@@ -1,16 +1,12 @@
pub use encoding_rs as encoding;
pub mod auto_pairs;
pub mod case_conversion;
pub mod chars;
pub mod command_line;
pub mod comment;
pub mod completion;
pub mod config;
pub mod diagnostic;
pub mod diff;
pub mod doc_formatter;
pub mod editor_config;
pub mod fuzzy;
pub mod graphemes;
pub mod history;
@@ -21,17 +17,17 @@ pub mod macros;
pub mod match_brackets;
pub mod movement;
pub mod object;
pub mod path;
mod position;
pub mod search;
pub mod selection;
pub mod snippets;
pub mod shellwords;
pub mod surround;
pub mod syntax;
pub mod test;
pub mod text_annotations;
pub mod textobject;
mod transaction;
pub mod uri;
pub mod wrap;
pub mod unicode {
@@ -42,6 +38,9 @@ pub mod unicode {
pub use helix_loader::find_workspace;
pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option<usize> {
line.chars().position(|ch| !ch.is_whitespace())
}
mod rope_reader;
pub use rope_reader::RopeReader;
@@ -53,11 +52,12 @@ pub use smartstring::SmartString;
pub type Tendril = SmartString<smartstring::LazyCompact>;
#[doc(inline)]
pub use {regex, tree_house::tree_sitter};
pub use {regex, tree_sitter};
pub use graphemes::RopeGraphemes;
pub use position::{
char_idx_at_visual_offset, coords_at_pos, pos_at_coords, softwrapped_dimensions,
visual_offset_from_anchor, visual_offset_from_block, Position, VisualOffsetError,
char_idx_at_visual_offset, coords_at_pos, pos_at_coords, visual_offset_from_anchor,
visual_offset_from_block, Position, VisualOffsetError,
};
#[allow(deprecated)]
pub use position::{pos_at_visual_coords, visual_coords_at_pos};
@@ -66,12 +66,7 @@ pub use selection::{Range, Selection};
pub use smallvec::{smallvec, SmallVec};
pub use syntax::Syntax;
pub use completion::CompletionItem;
pub use diagnostic::Diagnostic;
pub use line_ending::{LineEnding, NATIVE_LINE_ENDING};
pub use transaction::{Assoc, Change, ChangeSet, Deletion, Operation, Transaction};
pub use uri::Uri;
pub use tree_house::Language;

View File

@@ -1,7 +1,7 @@
use std::iter;
use crate::tree_sitter::Node;
use ropey::RopeSlice;
use tree_sitter::Node;
use crate::movement::Direction::{self, Backward, Forward};
use crate::Syntax;
@@ -9,34 +9,16 @@ use crate::Syntax;
const MAX_PLAINTEXT_SCAN: usize = 10000;
const MATCH_LIMIT: usize = 16;
pub const BRACKETS: [(char, char); 9] = [
// Limit matching pairs to only ( ) { } [ ] < > ' ' " "
const PAIRS: &[(char, char)] = &[
('(', ')'),
('{', '}'),
('[', ']'),
('<', '>'),
('', ''),
('', ''),
('«', '»'),
('「', '」'),
('', ''),
('\'', '\''),
('\"', '\"'),
];
// The difference between BRACKETS and PAIRS is that we can find matching
// BRACKETS in a plain text file, but we can't do the same for PAIRs.
// PAIRS also contains all BRACKETS.
pub const PAIRS: [(char, char); BRACKETS.len() + 3] = {
let mut pairs = [(' ', ' '); BRACKETS.len() + 3];
let mut idx = 0;
while idx < BRACKETS.len() {
pairs[idx] = BRACKETS[idx];
idx += 1;
}
pairs[idx] = ('"', '"');
pairs[idx + 1] = ('\'', '\'');
pairs[idx + 2] = ('`', '`');
pairs
};
/// Returns the position of the matching bracket under cursor.
///
/// If the cursor is on the opening bracket, the position of
@@ -48,7 +30,7 @@ pub const PAIRS: [(char, char); BRACKETS.len() + 3] = {
/// If no matching bracket is found, `None` is returned.
#[must_use]
pub fn find_matching_bracket(syntax: &Syntax, doc: RopeSlice, pos: usize) -> Option<usize> {
if pos >= doc.len_chars() || !is_valid_pair(doc.char(pos)) {
if pos >= doc.len_chars() || !is_valid_bracket(doc.char(pos)) {
return None;
}
find_pair(syntax, doc, pos, false)
@@ -75,78 +57,74 @@ fn find_pair(
pos_: usize,
traverse_parents: bool,
) -> Option<usize> {
let pos = doc.char_to_byte(pos_) as u32;
let tree = syntax.tree();
let pos = doc.char_to_byte(pos_);
let root = syntax.tree_for_byte_range(pos, pos).root_node();
let mut node = root.descendant_for_byte_range(pos, pos)?;
let mut node = tree.root_node().descendant_for_byte_range(pos, pos)?;
loop {
if node.is_named() && node.child_count() >= 2 {
let open = node.child(0).unwrap();
let close = node.child(node.child_count() - 1).unwrap();
if node.is_named() {
let (start_byte, end_byte) = surrounding_bytes(doc, &node)?;
let (start_char, end_char) = (doc.byte_to_char(start_byte), doc.byte_to_char(end_byte));
if let (Some((start_pos, open)), Some((end_pos, close))) =
(as_char(doc, &open), as_char(doc, &close))
{
if PAIRS.contains(&(open, close)) {
if end_pos == pos_ {
return Some(start_pos);
}
if is_valid_pair(doc, start_char, end_char) {
if end_byte == pos {
return Some(start_char);
}
// We return the end char if the cursor is either on the start char
// or at some arbitrary position between start and end char.
if traverse_parents || start_pos == pos_ {
return Some(end_pos);
}
// We return the end char if the cursor is either on the start char
// or at some arbitrary position between start and end char.
if traverse_parents || start_byte == pos {
return Some(end_char);
}
}
}
// this node itselt wasn't a pair but maybe its siblings are
if let Some((start_char, end_char)) = as_close_pair(doc, &node) {
if let Some(pair_start) =
find_pair_end(doc, node.prev_sibling(), start_char, end_char, Backward)
{
// check if we are *on* the pair (special cased so we don't look
// at the current node twice and to jump to the start on that case)
if let Some(open) = as_close_pair(doc, &node) {
if let Some(pair_start) = find_pair_end(doc, node.prev_sibling(), open, Backward) {
return Some(pair_start);
}
}
if let Some((start_char, end_char)) = as_open_pair(doc, &node) {
if let Some(pair_end) =
find_pair_end(doc, node.next_sibling(), start_char, end_char, Forward)
{
return Some(pair_end);
}
}
if traverse_parents {
for sibling in
iter::successors(node.next_sibling(), |node| node.next_sibling()).take(MATCH_LIMIT)
{
let Some((start_char, end_char)) = as_close_pair(doc, &sibling) else {
continue;
};
if find_pair_end(doc, sibling.prev_sibling(), start_char, end_char, Backward)
.is_some()
{
return doc.try_byte_to_char(sibling.start_byte() as usize).ok();
if !traverse_parents {
// check if we are *on* the opening pair (special cased here as
// an opptimization since we only care about bracket on the cursor
// here)
if let Some(close) = as_open_pair(doc, &node) {
if let Some(pair_end) = find_pair_end(doc, node.next_sibling(), close, Forward) {
return Some(pair_end);
}
}
} else if node.is_named() {
break;
if node.is_named() {
break;
}
}
for close in
iter::successors(node.next_sibling(), |node| node.next_sibling()).take(MATCH_LIMIT)
{
let Some(open) = as_close_pair(doc, &close) else {
continue;
};
if find_pair_end(doc, Some(node), open, Backward).is_some() {
return doc.try_byte_to_char(close.start_byte()).ok();
}
}
let Some(parent) = node.parent() else {
break;
};
node = parent;
}
let node = root.named_descendant_for_byte_range(pos, pos + 1)?;
let node = tree.root_node().named_descendant_for_byte_range(pos, pos)?;
if node.child_count() != 0 {
return None;
}
let node_start = doc.byte_to_char(node.start_byte() as usize);
let node_text = doc.byte_slice(node.start_byte() as usize..node.end_byte() as usize);
find_matching_bracket_plaintext(node_text, pos_ - node_start).map(|pos| pos + node_start)
let node_start = doc.byte_to_char(node.start_byte());
find_matching_bracket_plaintext(doc.byte_slice(node.byte_range()), pos_ - node_start)
.map(|pos| pos + node_start)
}
/// Returns the position of the matching bracket under cursor.
@@ -162,22 +140,14 @@ fn find_pair(
/// If no matching bracket is found, `None` is returned.
#[must_use]
pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Option<usize> {
let bracket = doc.get_char(cursor_pos)?;
let matching_bracket = {
let pair = get_pair(bracket);
if pair.0 == bracket {
pair.1
} else {
pair.0
}
};
// Don't do anything when the cursor is not on top of a bracket.
let bracket = doc.char(cursor_pos);
if !is_valid_bracket(bracket) {
return None;
}
// Determine the direction of the matching.
let is_fwd = is_open_bracket(bracket);
let is_fwd = is_forward_bracket(bracket);
let chars_iter = if is_fwd {
doc.chars_at(cursor_pos + 1)
} else {
@@ -189,7 +159,19 @@ pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Opt
for (i, candidate) in chars_iter.take(MAX_PLAINTEXT_SCAN).enumerate() {
if candidate == bracket {
open_cnt += 1;
} else if candidate == matching_bracket {
} else if is_valid_pair(
doc,
if is_fwd {
cursor_pos
} else {
cursor_pos - i - 1
},
if is_fwd {
cursor_pos + i + 1
} else {
cursor_pos
},
) {
// Return when all pending brackets have been closed.
if open_cnt == 1 {
return Some(if is_fwd {
@@ -205,55 +187,37 @@ pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Opt
None
}
/// Returns the open and closing chars pair. If not found in
/// [`BRACKETS`] returns (ch, ch).
///
/// ```
/// use helix_core::match_brackets::get_pair;
///
/// assert_eq!(get_pair('['), ('[', ']'));
/// assert_eq!(get_pair('}'), ('{', '}'));
/// assert_eq!(get_pair('"'), ('"', '"'));
/// ```
pub fn get_pair(ch: char) -> (char, char) {
PAIRS
.iter()
.find(|(open, close)| *open == ch || *close == ch)
.copied()
.unwrap_or((ch, ch))
fn is_valid_bracket(c: char) -> bool {
PAIRS.iter().any(|(l, r)| *l == c || *r == c)
}
pub fn is_open_bracket(ch: char) -> bool {
BRACKETS.iter().any(|(l, _)| *l == ch)
fn is_forward_bracket(c: char) -> bool {
PAIRS.iter().any(|(l, _)| *l == c)
}
pub fn is_close_bracket(ch: char) -> bool {
BRACKETS.iter().any(|(_, r)| *r == ch)
fn is_valid_pair(doc: RopeSlice, start_char: usize, end_char: usize) -> bool {
PAIRS.contains(&(doc.char(start_char), doc.char(end_char)))
}
pub fn is_valid_bracket(ch: char) -> bool {
BRACKETS.iter().any(|(l, r)| *l == ch || *r == ch)
}
fn surrounding_bytes(doc: RopeSlice, node: &Node) -> Option<(usize, usize)> {
let len = doc.len_bytes();
pub fn is_open_pair(ch: char) -> bool {
PAIRS.iter().any(|(l, _)| *l == ch)
}
let start_byte = node.start_byte();
let end_byte = node.end_byte().saturating_sub(1);
pub fn is_close_pair(ch: char) -> bool {
PAIRS.iter().any(|(_, r)| *r == ch)
}
if start_byte >= len || end_byte >= len {
return None;
}
pub fn is_valid_pair(ch: char) -> bool {
PAIRS.iter().any(|(l, r)| *l == ch || *r == ch)
Some((start_byte, end_byte))
}
/// Tests if this node is a pair close char and returns the expected open char
/// and close char contained in this node
fn as_close_pair(doc: RopeSlice, node: &Node) -> Option<(char, char)> {
fn as_close_pair(doc: RopeSlice, node: &Node) -> Option<char> {
let close = as_char(doc, node)?.1;
PAIRS
.iter()
.find_map(|&(open, close_)| (close_ == close).then_some((close, open)))
.find_map(|&(open, close_)| (close_ == close).then_some(open))
}
/// Checks if `node` or its siblings (at most MATCH_LIMIT nodes) is the specified closing char
@@ -264,7 +228,6 @@ fn as_close_pair(doc: RopeSlice, node: &Node) -> Option<(char, char)> {
fn find_pair_end(
doc: RopeSlice,
node: Option<Node>,
start_char: char,
end_char: char,
direction: Direction,
) -> Option<usize> {
@@ -272,30 +235,20 @@ fn find_pair_end(
Forward => Node::next_sibling,
Backward => Node::prev_sibling,
};
let mut depth = 0;
iter::successors(node, advance)
.take(MATCH_LIMIT)
.find_map(|node| {
let (pos, c) = as_char(doc, &node)?;
if c == end_char {
if depth == 0 {
return Some(pos);
}
depth -= 1;
} else if c == start_char {
depth += 1;
}
None
(end_char == c).then_some(pos)
})
}
/// Tests if this node is a pair open char and returns the expected close char
/// and open char contained in this node
fn as_open_pair(doc: RopeSlice, node: &Node) -> Option<(char, char)> {
/// Tests if this node is a pair close char and returns the expected open char
fn as_open_pair(doc: RopeSlice, node: &Node) -> Option<char> {
let open = as_char(doc, node)?.1;
PAIRS
.iter()
.find_map(|&(open_, close)| (open_ == open).then_some((open, close)))
.find_map(|&(open_, close)| (open_ == open).then_some(close))
}
/// If node is a single char return it (and its char position)
@@ -304,7 +257,7 @@ fn as_char(doc: RopeSlice, node: &Node) -> Option<(usize, char)> {
if node.byte_range().len() != 1 {
return None;
}
let pos = doc.try_byte_to_char(node.start_byte() as usize).ok()?;
let pos = doc.try_byte_to_char(node.start_byte()).ok()?;
Some((pos, doc.char(pos)))
}
@@ -312,12 +265,6 @@ fn as_char(doc: RopeSlice, node: &Node) -> Option<(usize, char)> {
mod tests {
use super::*;
#[test]
fn find_matching_bracket_empty_file() {
let actual = find_matching_bracket_plaintext("".into(), 0);
assert_eq!(actual, None);
}
#[test]
fn test_find_matching_bracket_current_line_plaintext() {
let assert = |input: &str, pos, expected| {

View File

@@ -1,6 +1,7 @@
use std::{borrow::Cow, cmp::Reverse, iter};
use std::{cmp::Reverse, iter};
use ropey::iter::Chars;
use tree_sitter::{Node, QueryCursor};
use crate::{
char_idx_at_visual_offset,
@@ -12,10 +13,9 @@ use crate::{
},
line_ending::rope_is_line_ending,
position::char_idx_at_visual_block_offset,
syntax,
syntax::LanguageConfiguration,
text_annotations::TextAnnotations,
textobject::TextObject,
tree_sitter::Node,
visual_offset_from_block, Range, RopeSlice, Selection, Syntax,
};
@@ -79,19 +79,19 @@ pub fn move_vertically_visual(
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 (mut new_pos, virtual_rows) = char_idx_at_visual_offset(
let new_pos = char_idx_at_visual_offset(
slice,
block_off,
row_off,
new_col as usize,
text_fmt,
annotations,
);
if dir == Direction::Forward {
new_pos += (virtual_rows != 0) as usize;
}
)
.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 {
@@ -197,31 +197,13 @@ pub fn move_prev_long_word_end(slice: RopeSlice, range: Range, count: usize) ->
word_move(slice, range, count, WordMotionTarget::PrevLongWordEnd)
}
pub fn move_next_sub_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
word_move(slice, range, count, WordMotionTarget::NextSubWordStart)
}
pub fn move_next_sub_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
word_move(slice, range, count, WordMotionTarget::NextSubWordEnd)
}
pub fn move_prev_sub_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
word_move(slice, range, count, WordMotionTarget::PrevSubWordStart)
}
pub fn move_prev_sub_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
word_move(slice, range, count, WordMotionTarget::PrevSubWordEnd)
}
fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range {
let is_prev = matches!(
target,
WordMotionTarget::PrevWordStart
| WordMotionTarget::PrevLongWordStart
| WordMotionTarget::PrevSubWordStart
| WordMotionTarget::PrevWordEnd
| WordMotionTarget::PrevLongWordEnd
| WordMotionTarget::PrevSubWordEnd
);
// Special-case early-out.
@@ -401,12 +383,6 @@ pub enum WordMotionTarget {
NextLongWordEnd,
PrevLongWordStart,
PrevLongWordEnd,
// A sub word is similar to a regular word, except it is also delimited by
// underscores and transitions from lowercase to uppercase.
NextSubWordStart,
NextSubWordEnd,
PrevSubWordStart,
PrevSubWordEnd,
}
pub trait CharHelpers {
@@ -422,10 +398,8 @@ impl CharHelpers for Chars<'_> {
target,
WordMotionTarget::PrevWordStart
| WordMotionTarget::PrevLongWordStart
| WordMotionTarget::PrevSubWordStart
| WordMotionTarget::PrevWordEnd
| WordMotionTarget::PrevLongWordEnd
| WordMotionTarget::PrevSubWordEnd
);
// Reverse the iterator if needed for the motion direction.
@@ -502,25 +476,6 @@ fn is_long_word_boundary(a: char, b: char) -> bool {
}
}
fn is_sub_word_boundary(a: char, b: char, dir: Direction) -> bool {
match (categorize_char(a), categorize_char(b)) {
(CharCategory::Word, CharCategory::Word) => {
if (a == '_') != (b == '_') {
return true;
}
// Subword boundaries are directional: in 'fooBar', there is a
// boundary between 'o' and 'B', but not between 'B' and 'a'.
match dir {
Direction::Forward => a.is_lowercase() && b.is_uppercase(),
Direction::Backward => a.is_uppercase() && b.is_lowercase(),
}
}
(a, b) if a != b => true,
_ => false,
}
}
fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> bool {
match target {
WordMotionTarget::NextWordStart | WordMotionTarget::PrevWordEnd => {
@@ -539,44 +494,26 @@ fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> boo
is_long_word_boundary(prev_ch, next_ch)
&& (!prev_ch.is_whitespace() || char_is_line_ending(next_ch))
}
WordMotionTarget::NextSubWordStart => {
is_sub_word_boundary(prev_ch, next_ch, Direction::Forward)
&& (char_is_line_ending(next_ch) || !(next_ch.is_whitespace() || next_ch == '_'))
}
WordMotionTarget::PrevSubWordEnd => {
is_sub_word_boundary(prev_ch, next_ch, Direction::Backward)
&& (char_is_line_ending(next_ch) || !(next_ch.is_whitespace() || next_ch == '_'))
}
WordMotionTarget::NextSubWordEnd => {
is_sub_word_boundary(prev_ch, next_ch, Direction::Forward)
&& (!(prev_ch.is_whitespace() || prev_ch == '_') || char_is_line_ending(next_ch))
}
WordMotionTarget::PrevSubWordStart => {
is_sub_word_boundary(prev_ch, next_ch, Direction::Backward)
&& (!(prev_ch.is_whitespace() || prev_ch == '_') || char_is_line_ending(next_ch))
}
}
}
/// Finds the range of the next or previous textobject in the syntax sub-tree of `node`.
/// Returns the range in the forwards direction.
#[allow(clippy::too_many_arguments)]
pub fn goto_treesitter_object(
slice: RopeSlice,
range: Range,
object_name: &str,
dir: Direction,
slice_tree: &Node,
syntax: &Syntax,
loader: &syntax::Loader,
slice_tree: Node,
lang_config: &LanguageConfiguration,
count: usize,
) -> Range {
let textobject_query = loader.textobject_query(syntax.root_language());
let get_range = move |range: Range| -> Option<Range> {
let byte_pos = slice.char_to_byte(range.cursor(slice));
let cap_name = |t: TextObject| format!("{}.{}", object_name, t);
let nodes = textobject_query?.capture_nodes_any(
let mut cursor = QueryCursor::new();
let nodes = lang_config.textobject_query()?.capture_nodes_any(
&[
&cap_name(TextObject::Movement),
&cap_name(TextObject::Around),
@@ -584,6 +521,7 @@ pub fn goto_treesitter_object(
],
slice_tree,
slice,
&mut cursor,
)?;
let node = match dir {
@@ -618,15 +556,14 @@ pub fn goto_treesitter_object(
last_range
}
fn find_parent_start<'tree>(node: &Node<'tree>) -> Option<Node<'tree>> {
fn find_parent_start(mut node: Node) -> Option<Node> {
let start = node.start_byte();
let mut node = Cow::Borrowed(node);
while node.start_byte() >= start || !node.is_named() {
node = Cow::Owned(node.parent()?);
node = node.parent()?;
}
Some(node.into_owned())
Some(node)
}
pub fn move_parent_node_end(
@@ -636,11 +573,16 @@ pub fn move_parent_node_end(
dir: Direction,
movement: Movement,
) -> Selection {
selection.transform(|range| {
let start_from = text.char_to_byte(range.from()) as u32;
let start_to = text.char_to_byte(range.to()) as u32;
let tree = syntax.tree();
let mut node = match syntax.named_descendant_for_byte_range(start_from, start_to) {
selection.transform(|range| {
let start_from = text.char_to_byte(range.from());
let start_to = text.char_to_byte(range.to());
let mut node = match tree
.root_node()
.named_descendant_for_byte_range(start_from, start_to)
{
Some(node) => node,
None => {
log::debug!(
@@ -656,18 +598,18 @@ pub fn move_parent_node_end(
// moving forward, we always want to move one past the end of the
// current node, so use the end byte of the current node, which is an exclusive
// end of the range
Direction::Forward => text.byte_to_char(node.end_byte() as usize),
Direction::Forward => text.byte_to_char(node.end_byte()),
// moving backward, we want the cursor to land on the start char of
// the current node, or if it is already at the start of a node, to traverse up to
// the parent
Direction::Backward => {
let end_head = text.byte_to_char(node.start_byte() as usize);
let end_head = text.byte_to_char(node.start_byte());
// if we're already on the beginning, look up to the parent
if end_head == range.cursor(text) {
node = find_parent_start(&node).unwrap_or(node);
text.byte_to_char(node.start_byte() as usize)
node = find_parent_start(node).unwrap_or(node);
text.byte_to_char(node.start_byte())
} else {
end_head
}
@@ -1075,178 +1017,6 @@ mod test {
}
}
#[test]
fn test_behaviour_when_moving_to_start_of_next_sub_words() {
let tests = [
(
"NextSubwordStart",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 11)),
],
),
(
"next_subword_start",
vec![
(1, Range::new(0, 0), Range::new(0, 5)),
(1, Range::new(4, 4), Range::new(5, 13)),
],
),
(
"Next_Subword_Start",
vec![
(1, Range::new(0, 0), Range::new(0, 5)),
(1, Range::new(4, 4), Range::new(5, 13)),
],
),
(
"NEXT_SUBWORD_START",
vec![
(1, Range::new(0, 0), Range::new(0, 5)),
(1, Range::new(4, 4), Range::new(5, 13)),
],
),
(
"next subword start",
vec![
(1, Range::new(0, 0), Range::new(0, 5)),
(1, Range::new(4, 4), Range::new(5, 13)),
],
),
(
"Next Subword Start",
vec![
(1, Range::new(0, 0), Range::new(0, 5)),
(1, Range::new(4, 4), Range::new(5, 13)),
],
),
(
"NEXT SUBWORD START",
vec![
(1, Range::new(0, 0), Range::new(0, 5)),
(1, Range::new(4, 4), Range::new(5, 13)),
],
),
(
"next__subword__start",
vec![
(1, Range::new(0, 0), Range::new(0, 6)),
(1, Range::new(4, 4), Range::new(4, 6)),
(1, Range::new(5, 5), Range::new(6, 15)),
],
),
(
"Next__Subword__Start",
vec![
(1, Range::new(0, 0), Range::new(0, 6)),
(1, Range::new(4, 4), Range::new(4, 6)),
(1, Range::new(5, 5), Range::new(6, 15)),
],
),
(
"NEXT__SUBWORD__START",
vec![
(1, Range::new(0, 0), Range::new(0, 6)),
(1, Range::new(4, 4), Range::new(4, 6)),
(1, Range::new(5, 5), Range::new(6, 15)),
],
),
];
for (sample, scenario) in tests {
for (count, begin, expected_end) in scenario.into_iter() {
let range = move_next_sub_word_start(Rope::from(sample).slice(..), begin, count);
assert_eq!(range, expected_end, "Case failed: [{}]", sample);
}
}
}
#[test]
fn test_behaviour_when_moving_to_end_of_next_sub_words() {
let tests = [
(
"NextSubwordEnd",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 11)),
],
),
(
"next subword end",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 12)),
],
),
(
"Next Subword End",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 12)),
],
),
(
"NEXT SUBWORD END",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 12)),
],
),
(
"next_subword_end",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 12)),
],
),
(
"Next_Subword_End",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 12)),
],
),
(
"NEXT_SUBWORD_END",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 12)),
],
),
(
"next__subword__end",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 13)),
(1, Range::new(5, 5), Range::new(5, 13)),
],
),
(
"Next__Subword__End",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 13)),
(1, Range::new(5, 5), Range::new(5, 13)),
],
),
(
"NEXT__SUBWORD__END",
vec![
(1, Range::new(0, 0), Range::new(0, 4)),
(1, Range::new(4, 4), Range::new(4, 13)),
(1, Range::new(5, 5), Range::new(5, 13)),
],
),
];
for (sample, scenario) in tests {
for (count, begin, expected_end) in scenario.into_iter() {
let range = move_next_sub_word_end(Rope::from(sample).slice(..), begin, count);
assert_eq!(range, expected_end, "Case failed: [{}]", sample);
}
}
}
#[test]
fn test_behaviour_when_moving_to_start_of_next_long_words() {
let tests = [
@@ -1416,92 +1186,6 @@ mod test {
}
}
#[test]
fn test_behaviour_when_moving_to_start_of_previous_sub_words() {
let tests = [
(
"PrevSubwordEnd",
vec![
(1, Range::new(13, 13), Range::new(14, 11)),
(1, Range::new(11, 11), Range::new(11, 4)),
],
),
(
"prev subword end",
vec![
(1, Range::new(15, 15), Range::new(16, 13)),
(1, Range::new(12, 12), Range::new(13, 5)),
],
),
(
"Prev Subword End",
vec![
(1, Range::new(15, 15), Range::new(16, 13)),
(1, Range::new(12, 12), Range::new(13, 5)),
],
),
(
"PREV SUBWORD END",
vec![
(1, Range::new(15, 15), Range::new(16, 13)),
(1, Range::new(12, 12), Range::new(13, 5)),
],
),
(
"prev_subword_end",
vec![
(1, Range::new(15, 15), Range::new(16, 13)),
(1, Range::new(12, 12), Range::new(13, 5)),
],
),
(
"Prev_Subword_End",
vec![
(1, Range::new(15, 15), Range::new(16, 13)),
(1, Range::new(12, 12), Range::new(13, 5)),
],
),
(
"PREV_SUBWORD_END",
vec![
(1, Range::new(15, 15), Range::new(16, 13)),
(1, Range::new(12, 12), Range::new(13, 5)),
],
),
(
"prev__subword__end",
vec![
(1, Range::new(17, 17), Range::new(18, 15)),
(1, Range::new(13, 13), Range::new(14, 6)),
(1, Range::new(14, 14), Range::new(15, 6)),
],
),
(
"Prev__Subword__End",
vec![
(1, Range::new(17, 17), Range::new(18, 15)),
(1, Range::new(13, 13), Range::new(14, 6)),
(1, Range::new(14, 14), Range::new(15, 6)),
],
),
(
"PREV__SUBWORD__END",
vec![
(1, Range::new(17, 17), Range::new(18, 15)),
(1, Range::new(13, 13), Range::new(14, 6)),
(1, Range::new(14, 14), Range::new(15, 6)),
],
),
];
for (sample, scenario) in tests {
for (count, begin, expected_end) in scenario.into_iter() {
let range = move_prev_sub_word_start(Rope::from(sample).slice(..), begin, count);
assert_eq!(range, expected_end, "Case failed: [{}]", sample);
}
}
}
#[test]
fn test_behaviour_when_moving_to_start_of_previous_long_words() {
let tests = [
@@ -1765,92 +1449,6 @@ mod test {
}
}
#[test]
fn test_behaviour_when_moving_to_end_of_previous_sub_words() {
let tests = [
(
"PrevSubwordEnd",
vec![
(1, Range::new(13, 13), Range::new(14, 11)),
(1, Range::new(11, 11), Range::new(11, 4)),
],
),
(
"prev subword end",
vec![
(1, Range::new(15, 15), Range::new(16, 12)),
(1, Range::new(12, 12), Range::new(12, 4)),
],
),
(
"Prev Subword End",
vec![
(1, Range::new(15, 15), Range::new(16, 12)),
(1, Range::new(12, 12), Range::new(12, 4)),
],
),
(
"PREV SUBWORD END",
vec![
(1, Range::new(15, 15), Range::new(16, 12)),
(1, Range::new(12, 12), Range::new(12, 4)),
],
),
(
"prev_subword_end",
vec![
(1, Range::new(15, 15), Range::new(16, 12)),
(1, Range::new(12, 12), Range::new(12, 4)),
],
),
(
"Prev_Subword_End",
vec![
(1, Range::new(15, 15), Range::new(16, 12)),
(1, Range::new(12, 12), Range::new(12, 4)),
],
),
(
"PREV_SUBWORD_END",
vec![
(1, Range::new(15, 15), Range::new(16, 12)),
(1, Range::new(12, 12), Range::new(12, 4)),
],
),
(
"prev__subword__end",
vec![
(1, Range::new(17, 17), Range::new(18, 13)),
(1, Range::new(13, 13), Range::new(13, 4)),
(1, Range::new(14, 14), Range::new(15, 13)),
],
),
(
"Prev__Subword__End",
vec![
(1, Range::new(17, 17), Range::new(18, 13)),
(1, Range::new(13, 13), Range::new(13, 4)),
(1, Range::new(14, 14), Range::new(15, 13)),
],
),
(
"PREV__SUBWORD__END",
vec![
(1, Range::new(17, 17), Range::new(18, 13)),
(1, Range::new(13, 13), Range::new(13, 4)),
(1, Range::new(14, 14), Range::new(15, 13)),
],
),
];
for (sample, scenario) in tests {
for (count, begin, expected_end) in scenario.into_iter() {
let range = move_prev_sub_word_end(Rope::from(sample).slice(..), begin, count);
assert_eq!(range, expected_end, "Case failed: [{}]", sample);
}
}
}
#[test]
fn test_behaviour_when_moving_to_end_of_next_long_words() {
let tests = [

View File

@@ -1,134 +1,76 @@
use crate::{movement::Direction, syntax::TreeCursor, Range, RopeSlice, Selection, Syntax};
use crate::{Range, RopeSlice, Selection, Syntax};
use tree_sitter::Node;
pub fn expand_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
let cursor = &mut syntax.walk();
selection.transform(|range| {
let from = text.char_to_byte(range.from()) as u32;
let to = text.char_to_byte(range.to()) as u32;
let byte_range = from..to;
cursor.reset_to_byte_range(from, to);
while cursor.node().byte_range() == byte_range {
if !cursor.goto_parent() {
break;
}
select_node_impl(syntax, text, selection, |mut node, from, to| {
while node.start_byte() == from && node.end_byte() == to {
node = node.parent()?;
}
let node = cursor.node();
let from = text.byte_to_char(node.start_byte() as usize);
let to = text.byte_to_char(node.end_byte() as usize);
Range::new(to, from).with_direction(range.direction())
Some(node)
})
}
pub fn shrink_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
select_node_impl(
syntax,
text,
selection,
|cursor| {
cursor.goto_first_child();
},
None,
)
}
pub fn select_next_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
select_node_impl(
syntax,
text,
selection,
|cursor| {
while !cursor.goto_next_sibling() {
if !cursor.goto_parent() {
break;
}
}
},
Some(Direction::Forward),
)
}
pub fn select_all_siblings(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
let mut cursor = syntax.walk();
selection.transform_iter(move |range| {
let (from, to) = range.into_byte_range(text);
cursor.reset_to_byte_range(from as u32, to as u32);
if !cursor.goto_parent_with(|parent| parent.child_count() > 1) {
return vec![range].into_iter();
}
select_children(&mut cursor, text, range).into_iter()
select_node_impl(syntax, text, selection, |descendant, _from, _to| {
descendant.child(0).or(Some(descendant))
})
}
pub fn select_all_children(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
let mut cursor = syntax.walk();
selection.transform_iter(move |range| {
let (from, to) = range.into_byte_range(text);
cursor.reset_to_byte_range(from as u32, to as u32);
select_children(&mut cursor, text, range).into_iter()
pub fn select_sibling<F>(
syntax: &Syntax,
text: RopeSlice,
selection: Selection,
sibling_fn: &F,
) -> Selection
where
F: Fn(Node) -> Option<Node>,
{
select_node_impl(syntax, text, selection, |descendant, _from, _to| {
find_sibling_recursive(descendant, sibling_fn)
})
}
fn select_children(cursor: &mut TreeCursor, text: RopeSlice, range: Range) -> Vec<Range> {
let children = cursor
.children()
.filter(|child| child.is_named())
.map(|child| Range::from_node(child, text, range.direction()))
.collect::<Vec<_>>();
if !children.is_empty() {
children
} else {
vec![range]
}
}
pub fn select_prev_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
select_node_impl(
syntax,
text,
selection,
|cursor| {
while !cursor.goto_previous_sibling() {
if !cursor.goto_parent() {
break;
}
}
},
Some(Direction::Backward),
)
fn find_sibling_recursive<F>(node: Node, sibling_fn: F) -> Option<Node>
where
F: Fn(Node) -> Option<Node>,
{
sibling_fn(node).or_else(|| {
node.parent()
.and_then(|node| find_sibling_recursive(node, sibling_fn))
})
}
fn select_node_impl<F>(
syntax: &Syntax,
text: RopeSlice,
selection: Selection,
motion: F,
direction: Option<Direction>,
select_fn: F,
) -> Selection
where
F: Fn(&mut TreeCursor),
F: Fn(Node, usize, usize) -> Option<Node>,
{
let cursor = &mut syntax.walk();
let tree = syntax.tree();
selection.transform(|range| {
let from = text.char_to_byte(range.from()) as u32;
let to = text.char_to_byte(range.to()) as u32;
let from = text.char_to_byte(range.from());
let to = text.char_to_byte(range.to());
cursor.reset_to_byte_range(from, to);
let node = match tree
.root_node()
.descendant_for_byte_range(from, to)
.and_then(|node| select_fn(node, from, to))
{
Some(node) => node,
None => return range,
};
motion(cursor);
let from = text.byte_to_char(node.start_byte());
let to = text.byte_to_char(node.end_byte());
let node = cursor.node();
let from = text.byte_to_char(node.start_byte() as usize);
let to = text.byte_to_char(node.end_byte() as usize);
Range::new(from, to).with_direction(direction.unwrap_or_else(|| range.direction()))
if range.head < range.anchor {
Range::new(to, from)
} else {
Range::new(from, to)
}
})
}

162
helix-core/src/path.rs Normal file
View File

@@ -0,0 +1,162 @@
use etcetera::home_dir;
use std::path::{Component, Path, PathBuf};
/// Replaces users home directory from `path` with tilde `~` if the directory
/// is available, otherwise returns the path unchanged.
pub fn fold_home_dir(path: &Path) -> PathBuf {
if let Ok(home) = home_dir() {
if let Ok(stripped) = path.strip_prefix(&home) {
return PathBuf::from("~").join(stripped);
}
}
path.to_path_buf()
}
/// Expands tilde `~` into users home directory if available, otherwise returns the path
/// unchanged. The tilde will only be expanded when present as the first component of the path
/// and only slash follows it.
pub fn expand_tilde(path: &Path) -> PathBuf {
let mut components = path.components().peekable();
if let Some(Component::Normal(c)) = components.peek() {
if c == &"~" {
if let Ok(home) = home_dir() {
// it's ok to unwrap, the path starts with `~`
return home.join(path.strip_prefix("~").unwrap());
}
}
}
path.to_path_buf()
}
/// Normalize a path, removing things like `.` and `..`.
///
/// CAUTION: This does not resolve symlinks (unlike
/// [`std::fs::canonicalize`]). This may cause incorrect or surprising
/// behavior at times. This should be used carefully. Unfortunately,
/// [`std::fs::canonicalize`] can be hard to use correctly, since it can often
/// fail, or on Windows returns annoying device paths. This is a problem Cargo
/// needs to improve on.
/// Copied from cargo: <https://github.com/rust-lang/cargo/blob/070e459c2d8b79c5b2ac5218064e7603329c92ae/crates/cargo-util/src/paths.rs#L81>
pub fn get_normalized_path(path: &Path) -> PathBuf {
// normalization strategy is to canonicalize first ancestor path that exists (i.e., canonicalize as much as possible),
// then run handrolled normalization on the non-existent remainder
let (base, path) = path
.ancestors()
.find_map(|base| {
let canonicalized_base = dunce::canonicalize(base).ok()?;
let remainder = path.strip_prefix(base).ok()?.into();
Some((canonicalized_base, remainder))
})
.unwrap_or_else(|| (PathBuf::new(), PathBuf::from(path)));
if path.as_os_str().is_empty() {
return base;
}
let mut components = path.components().peekable();
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
components.next();
PathBuf::from(c.as_os_str())
} else {
PathBuf::new()
};
for component in components {
match component {
Component::Prefix(..) => unreachable!(),
Component::RootDir => {
ret.push(component.as_os_str());
}
Component::CurDir => {}
Component::ParentDir => {
ret.pop();
}
Component::Normal(c) => {
ret.push(c);
}
}
}
base.join(ret)
}
/// Returns the canonical, absolute form of a path with all intermediate components normalized.
///
/// This function is used instead of `std::fs::canonicalize` because we don't want to verify
/// here if the path exists, just normalize it's components.
pub fn get_canonicalized_path(path: &Path) -> PathBuf {
let path = expand_tilde(path);
let path = if path.is_relative() {
helix_loader::current_working_dir().join(path)
} else {
path
};
get_normalized_path(path.as_path())
}
pub fn get_relative_path(path: &Path) -> PathBuf {
let path = PathBuf::from(path);
let path = if path.is_absolute() {
let cwdir = get_normalized_path(&helix_loader::current_working_dir());
get_normalized_path(&path)
.strip_prefix(cwdir)
.map(PathBuf::from)
.unwrap_or(path)
} else {
path
};
fold_home_dir(&path)
}
/// Returns a truncated filepath where the basepart of the path is reduced to the first
/// char of the folder and the whole filename appended.
///
/// Also strip the current working directory from the beginning of the path.
/// Note that this function does not check if the truncated path is unambiguous.
///
/// ```
/// use helix_core::path::get_truncated_path;
/// use std::path::Path;
///
/// assert_eq!(
/// get_truncated_path("/home/cnorris/documents/jokes.txt").as_path(),
/// Path::new("/h/c/d/jokes.txt")
/// );
/// assert_eq!(
/// get_truncated_path("jokes.txt").as_path(),
/// Path::new("jokes.txt")
/// );
/// assert_eq!(
/// get_truncated_path("/jokes.txt").as_path(),
/// Path::new("/jokes.txt")
/// );
/// assert_eq!(
/// get_truncated_path("/h/c/d/jokes.txt").as_path(),
/// Path::new("/h/c/d/jokes.txt")
/// );
/// assert_eq!(get_truncated_path("").as_path(), Path::new(""));
/// ```
///
pub fn get_truncated_path<P: AsRef<Path>>(path: P) -> PathBuf {
let cwd = helix_loader::current_working_dir();
let path = path
.as_ref()
.strip_prefix(cwd)
.unwrap_or_else(|_| path.as_ref());
let file = path.file_name().unwrap_or_default();
let base = path.parent().unwrap_or_else(|| Path::new(""));
let mut ret = PathBuf::new();
for d in base {
ret.push(
d.to_string_lossy()
.chars()
.next()
.unwrap_or_default()
.to_string(),
);
}
ret.push(file);
ret
}

View File

@@ -1,15 +1,9 @@
use std::{
borrow::Cow,
cmp::Ordering,
ops::{Add, AddAssign, Sub, SubAssign},
};
use helix_stdx::rope::RopeSliceExt;
use std::{borrow::Cow, cmp::Ordering};
use crate::{
chars::char_is_line_ending,
doc_formatter::{DocumentFormatter, TextFormat},
graphemes::{ensure_grapheme_boundary_prev, grapheme_width},
graphemes::{ensure_grapheme_boundary_prev, grapheme_width, RopeGraphemes},
line_ending::line_end_char_index,
text_annotations::TextAnnotations,
RopeSlice,
@@ -22,38 +16,6 @@ pub struct Position {
pub col: usize,
}
impl AddAssign for Position {
fn add_assign(&mut self, rhs: Self) {
self.row += rhs.row;
self.col += rhs.col;
}
}
impl SubAssign for Position {
fn sub_assign(&mut self, rhs: Self) {
self.row -= rhs.row;
self.col -= rhs.col;
}
}
impl Sub for Position {
type Output = Position;
fn sub(mut self, rhs: Self) -> Self::Output {
self -= rhs;
self
}
}
impl Add for Position {
type Output = Position;
fn add(mut self, rhs: Self) -> Self::Output {
self += rhs;
self
}
}
impl Position {
pub const fn new(row: usize, col: usize) -> Self {
Self { row, col }
@@ -89,6 +51,11 @@ impl From<(usize, usize)> for Position {
}
}
impl From<Position> for tree_sitter::Point {
fn from(pos: Position) -> Self {
Self::new(pos.row, pos.col)
}
}
/// Convert a character index to (line, column) coordinates.
///
/// column in `char` count which can be used for row:column display in
@@ -98,7 +65,7 @@ pub fn coords_at_pos(text: RopeSlice, pos: usize) -> Position {
let line_start = text.line_to_char(line);
let pos = ensure_grapheme_boundary_prev(text, pos);
let col = text.slice(line_start..pos).graphemes().count();
let col = RopeGraphemes::new(text.slice(line_start..pos)).count();
Position::new(line, col)
}
@@ -123,7 +90,7 @@ pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Po
let mut col = 0;
for grapheme in text.slice(line_start..pos).graphemes() {
for grapheme in RopeGraphemes::new(text.slice(line_start..pos)) {
if grapheme == "\t" {
col += tab_width - (col % tab_width);
} else {
@@ -154,31 +121,22 @@ pub fn visual_offset_from_block(
annotations: &TextAnnotations,
) -> (Position, usize) {
let mut last_pos = Position::default();
let mut formatter =
let (formatter, block_start) =
DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor);
let block_start = formatter.next_char_pos();
let mut char_pos = block_start;
while let Some(grapheme) = formatter.next() {
last_pos = grapheme.visual_pos;
if formatter.next_char_pos() > pos {
return (grapheme.visual_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 height of the given text when softwrapping
pub fn softwrapped_dimensions(text: RopeSlice, text_fmt: &TextFormat) -> (usize, u16) {
let last_pos =
visual_offset_from_block(text, 0, usize::MAX, text_fmt, &TextAnnotations::default()).0;
if last_pos.row == 0 {
(1, last_pos.col as u16)
} else {
(last_pos.row + 1, text_fmt.viewport_width)
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum VisualOffsetError {
PosBeforeAnchorRow,
@@ -195,21 +153,22 @@ pub fn visual_offset_from_anchor(
annotations: &TextAnnotations,
max_rows: usize,
) -> Result<(Position, usize), VisualOffsetError> {
let mut formatter =
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 found_pos = None;
let mut last_pos = Position::default();
let block_start = formatter.next_char_pos();
if pos < block_start {
return Err(VisualOffsetError::PosBeforeAnchorRow);
}
while let Some(grapheme) = formatter.next() {
last_pos = grapheme.visual_pos;
for (grapheme, vpos) in formatter {
last_pos = vpos;
char_pos += grapheme.doc_chars();
if formatter.next_char_pos() > pos {
if char_pos > pos {
if let Some(anchor_line) = anchor_line {
last_pos.row -= anchor_line;
return Ok((last_pos, block_start));
@@ -217,7 +176,7 @@ pub fn visual_offset_from_anchor(
found_pos = Some(last_pos);
}
}
if formatter.next_char_pos() > anchor && anchor_line.is_none() {
if char_pos > anchor && anchor_line.is_none() {
if let Some(mut found_pos) = found_pos {
return if found_pos.row == last_pos.row {
found_pos.row = 0;
@@ -231,7 +190,7 @@ pub fn visual_offset_from_anchor(
}
if let Some(anchor_line) = anchor_line {
if grapheme.visual_pos.row >= anchor_line + max_rows {
if vpos.row >= anchor_line + max_rows {
return Err(VisualOffsetError::PosAfterMaxRow);
}
}
@@ -272,7 +231,7 @@ pub fn pos_at_coords(text: RopeSlice, coords: Position, limit_before_line_ending
};
let mut col_char_offset = 0;
for (i, g) in text.slice(line_start..line_end).graphemes().enumerate() {
for (i, g) in RopeGraphemes::new(text.slice(line_start..line_end)).enumerate() {
if i == col {
break;
}
@@ -303,7 +262,7 @@ pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize)
let mut col_char_offset = 0;
let mut cols_remaining = col;
for grapheme in text.slice(line_start..line_end).graphemes() {
for grapheme in RopeGraphemes::new(text.slice(line_start..line_end)) {
let grapheme_width = if grapheme == "\t" {
tab_width - ((col - cols_remaining) % tab_width)
} else {
@@ -409,43 +368,39 @@ pub fn char_idx_at_visual_block_offset(
text_fmt: &TextFormat,
annotations: &TextAnnotations,
) -> (usize, usize) {
let mut formatter =
let (formatter, mut char_idx) =
DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor);
let mut last_char_idx = formatter.next_char_pos();
let mut found_non_virtual_on_row = false;
let mut last_char_idx = char_idx;
let mut last_char_idx_on_line = None;
let mut last_row = 0;
for grapheme in &mut formatter {
match grapheme.visual_pos.row.cmp(&row) {
for (grapheme, grapheme_pos) in formatter {
match grapheme_pos.row.cmp(&row) {
Ordering::Equal => {
if grapheme.visual_pos.col + grapheme.width() > column {
if grapheme_pos.col + grapheme.width() > column {
if !grapheme.is_virtual() {
return (grapheme.char_idx, 0);
} else if found_non_virtual_on_row {
return (last_char_idx, 0);
return (char_idx, 0);
} else if let Some(char_idx) = last_char_idx_on_line {
return (char_idx, 0);
}
} else if !grapheme.is_virtual() {
found_non_virtual_on_row = true;
last_char_idx = grapheme.char_idx;
last_char_idx_on_line = Some(char_idx)
}
}
Ordering::Greater if found_non_virtual_on_row => return (last_char_idx, 0),
Ordering::Greater => return (last_char_idx, row - last_row),
Ordering::Less => {
if !grapheme.is_virtual() {
last_row = grapheme.visual_pos.row;
last_char_idx = grapheme.char_idx;
}
}
_ => (),
}
last_char_idx = char_idx;
last_row = grapheme_pos.row;
char_idx += grapheme.doc_chars();
}
(formatter.next_char_pos(), 0)
(char_idx, 0)
}
#[cfg(test)]
mod test {
use super::*;
use crate::text_annotations::InlineAnnotation;
use crate::Rope;
#[test]
@@ -806,30 +761,6 @@ mod test {
assert_eq!(pos_at_visual_coords(slice, (10, 10).into(), 4), 0);
}
#[test]
fn test_char_idx_at_visual_row_offset_inline_annotation() {
let text = Rope::from("foo\nbar");
let slice = text.slice(..);
let mut text_fmt = TextFormat::default();
let annotations = [InlineAnnotation {
text: "x".repeat(100).into(),
char_idx: 3,
}];
text_fmt.soft_wrap = true;
assert_eq!(
char_idx_at_visual_offset(
slice,
0,
1,
0,
&text_fmt,
TextAnnotations::default().add_inline_annotations(&annotations, None)
),
(2, 1)
);
}
#[test]
fn test_char_idx_at_visual_row_offset() {
let text = Rope::from("ḧëḷḷö\nẅöṛḷḋ\nfoo");

View File

@@ -7,15 +7,11 @@ use crate::{
ensure_grapheme_boundary_next, ensure_grapheme_boundary_prev, next_grapheme_boundary,
prev_grapheme_boundary,
},
line_ending::get_line_ending,
movement::Direction,
tree_sitter::Node,
Assoc, ChangeSet, RopeSlice,
Assoc, ChangeSet, RopeGraphemes, RopeSlice,
};
use helix_stdx::range::is_subset;
use helix_stdx::rope::{self, RopeSliceExt};
use smallvec::{smallvec, SmallVec};
use std::{borrow::Cow, iter, slice};
use std::borrow::Cow;
/// A single selection range.
///
@@ -75,12 +71,6 @@ impl Range {
Self::new(head, head)
}
pub fn from_node(node: Node, text: RopeSlice, direction: Direction) -> Self {
let from = text.byte_to_char(node.start_byte() as usize);
let to = text.byte_to_char(node.end_byte() as usize);
Range::new(from, to).with_direction(direction)
}
/// Start of the range.
#[inline]
#[must_use]
@@ -123,7 +113,7 @@ impl Range {
}
/// `Direction::Backward` when head < anchor.
/// `Direction::Forward` otherwise.
/// `Direction::Backward` otherwise.
#[inline]
#[must_use]
pub fn direction(&self) -> Direction {
@@ -176,7 +166,7 @@ impl Range {
/// function runs in O(N) (N is number of changes) and can therefore
/// cause performance problems if run for a large number of ranges as the
/// complexity is then O(MN) (for multicuror M=N usually). Instead use
/// [Selection::map] or [ChangeSet::update_positions].
/// [Selection::map] or [ChangeSet::update_positions] instead
pub fn map(mut self, changes: &ChangeSet) -> Self {
use std::cmp::Ordering;
if changes.is_empty() {
@@ -185,16 +175,16 @@ impl Range {
let positions_to_map = match self.anchor.cmp(&self.head) {
Ordering::Equal => [
(&mut self.anchor, Assoc::AfterSticky),
(&mut self.head, Assoc::AfterSticky),
(&mut self.anchor, Assoc::After),
(&mut self.head, Assoc::After),
],
Ordering::Less => [
(&mut self.anchor, Assoc::AfterSticky),
(&mut self.head, Assoc::BeforeSticky),
(&mut self.anchor, Assoc::After),
(&mut self.head, Assoc::Before),
],
Ordering::Greater => [
(&mut self.head, Assoc::AfterSticky),
(&mut self.anchor, Assoc::BeforeSticky),
(&mut self.head, Assoc::After),
(&mut self.anchor, Assoc::Before),
],
};
changes.update_positions(positions_to_map.into_iter());
@@ -379,17 +369,11 @@ impl Range {
/// Returns true if this Range covers a single grapheme in the given text
pub fn is_single_grapheme(&self, doc: RopeSlice) -> bool {
let mut graphemes = doc.slice(self.from()..self.to()).graphemes();
let mut graphemes = RopeGraphemes::new(doc.slice(self.from()..self.to()));
let first = graphemes.next();
let second = graphemes.next();
first.is_some() && second.is_none()
}
/// Converts this char range into an in order byte range, discarding
/// direction.
pub fn into_byte_range(&self, text: RopeSlice) -> (usize, usize) {
(text.char_to_byte(self.from()), text.char_to_byte(self.to()))
}
}
impl From<(usize, usize)> for Range {
@@ -402,15 +386,6 @@ impl From<(usize, usize)> for Range {
}
}
impl From<Range> for helix_stdx::Range {
fn from(range: Range) -> Self {
Self {
start: range.from(),
end: range.to(),
}
}
}
/// A selection consists of one or more selection ranges.
/// invariant: A selection can never be empty (always contains at least primary range).
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -492,16 +467,16 @@ impl Selection {
range.old_visual_position = None;
match range.anchor.cmp(&range.head) {
Ordering::Equal => [
(&mut range.anchor, Assoc::AfterSticky),
(&mut range.head, Assoc::AfterSticky),
(&mut range.anchor, Assoc::After),
(&mut range.head, Assoc::After),
],
Ordering::Less => [
(&mut range.anchor, Assoc::AfterSticky),
(&mut range.head, Assoc::BeforeSticky),
(&mut range.anchor, Assoc::After),
(&mut range.head, Assoc::Before),
],
Ordering::Greater => [
(&mut range.head, Assoc::AfterSticky),
(&mut range.anchor, Assoc::BeforeSticky),
(&mut range.head, Assoc::After),
(&mut range.anchor, Assoc::Before),
],
}
});
@@ -513,20 +488,6 @@ impl Selection {
&self.ranges
}
/// Returns an iterator over the line ranges of each range in the selection.
///
/// Adjacent and overlapping line ranges of the [Range]s in the selection are merged.
pub fn line_ranges<'a>(&'a self, text: RopeSlice<'a>) -> LineRangeIter<'a> {
LineRangeIter {
ranges: self.ranges.iter().peekable(),
text,
}
}
pub fn range_bounds(&self) -> impl Iterator<Item = helix_stdx::Range> + '_ {
self.ranges.iter().map(|&range| range.into())
}
pub fn primary_index(&self) -> usize {
self.primary_index
}
@@ -555,8 +516,6 @@ impl Selection {
}
/// Normalizes a `Selection`.
///
/// Ranges are sorted by [Range::from], with overlapping ranges merged.
fn normalize(mut self) -> Self {
if self.len() < 2 {
return self;
@@ -619,6 +578,7 @@ impl Selection {
self
}
// TODO: consume an iterator or a vec to reduce allocations?
#[must_use]
pub fn new(ranges: SmallVec<[Range; 1]>, primary_index: usize) -> Self {
assert!(!ranges.is_empty());
@@ -673,7 +633,7 @@ impl Selection {
pub fn fragments<'a>(
&'a self,
text: RopeSlice<'a>,
) -> impl DoubleEndedIterator<Item = Cow<'a, str>> + ExactSizeIterator<Item = Cow<'a, str>>
) -> impl DoubleEndedIterator<Item = Cow<'a, str>> + ExactSizeIterator<Item = Cow<str>> + 'a
{
self.ranges.iter().map(move |range| range.fragment(text))
}
@@ -696,9 +656,32 @@ impl Selection {
self.ranges.len()
}
/// returns true if self ⊇ other
// returns true if self ⊇ other
pub fn contains(&self, other: &Selection) -> bool {
is_subset::<true>(self.range_bounds(), other.range_bounds())
let (mut iter_self, mut iter_other) = (self.iter(), other.iter());
let (mut ele_self, mut ele_other) = (iter_self.next(), iter_other.next());
loop {
match (ele_self, ele_other) {
(Some(ra), Some(rb)) => {
if !ra.contains_range(rb) {
// `self` doesn't contain next element from `other`, advance `self`, we need to match all from `other`
ele_self = iter_self.next();
} else {
// matched element from `other`, advance `other`
ele_other = iter_other.next();
};
}
(None, Some(_)) => {
// exhausted `self`, we can't match the reminder of `other`
return false;
}
(_, None) => {
// no elements from `other` left to match, `self` contains `other`
return true;
}
}
}
}
}
@@ -720,59 +703,17 @@ impl IntoIterator for Selection {
}
}
impl FromIterator<Range> for Selection {
fn from_iter<T: IntoIterator<Item = Range>>(ranges: T) -> Self {
Self::new(ranges.into_iter().collect(), 0)
}
}
impl From<Range> for Selection {
fn from(range: Range) -> Self {
Self {
ranges: smallvec![range],
primary_index: 0,
}
}
}
pub struct LineRangeIter<'a> {
ranges: iter::Peekable<slice::Iter<'a, Range>>,
text: RopeSlice<'a>,
}
impl Iterator for LineRangeIter<'_> {
type Item = (usize, usize);
fn next(&mut self) -> Option<Self::Item> {
let (start, mut end) = self.ranges.next()?.line_range(self.text);
while let Some((next_start, next_end)) =
self.ranges.peek().map(|range| range.line_range(self.text))
{
// Merge overlapping and adjacent ranges.
// This subtraction cannot underflow because the ranges are sorted.
if next_start - end <= 1 {
end = next_end;
self.ranges.next();
} else {
break;
}
}
Some((start, end))
}
}
// TODO: checkSelection -> check if valid for doc length && sorted
pub fn keep_or_remove_matches(
text: RopeSlice,
selection: &Selection,
regex: &rope::Regex,
regex: &crate::regex::Regex,
remove: bool,
) -> Option<Selection> {
let result: SmallVec<_> = selection
.iter()
.filter(|range| regex.is_match(text.regex_input_at(range.from()..range.to())) ^ remove)
.filter(|range| regex.is_match(&range.fragment(text)) ^ remove)
.copied()
.collect();
@@ -783,20 +724,25 @@ pub fn keep_or_remove_matches(
None
}
// TODO: support to split on capture #N instead of whole match
pub fn select_on_matches(
text: RopeSlice,
selection: &Selection,
regex: &rope::Regex,
regex: &crate::regex::Regex,
) -> Option<Selection> {
let mut result = SmallVec::with_capacity(selection.len());
for sel in selection {
for mat in regex.find_iter(text.regex_input_at(sel.from()..sel.to())) {
// TODO: can't avoid occasional allocations since Regex can't operate on chunks yet
let fragment = sel.fragment(text);
let sel_start = sel.from();
let start_byte = text.char_to_byte(sel_start);
for mat in regex.find_iter(&fragment) {
// TODO: retain range direction
let start = text.byte_to_char(mat.start());
let end = text.byte_to_char(mat.end());
let start = text.byte_to_char(start_byte + mat.start());
let end = text.byte_to_char(start_byte + mat.end());
let range = Range::new(start, end);
// Make sure the match is not right outside of the selection.
@@ -815,7 +761,12 @@ pub fn select_on_matches(
None
}
pub fn split_on_newline(text: RopeSlice, selection: &Selection) -> Selection {
// TODO: support to split on capture #N instead of whole match
pub fn split_on_matches(
text: RopeSlice,
selection: &Selection,
regex: &crate::regex::Regex,
) -> Selection {
let mut result = SmallVec::with_capacity(selection.len());
for sel in selection {
@@ -825,49 +776,21 @@ pub fn split_on_newline(text: RopeSlice, selection: &Selection) -> Selection {
continue;
}
let sel_start = sel.from();
let sel_end = sel.to();
let mut start = sel_start;
for line in sel.slice(text).lines() {
let Some(line_ending) = get_line_ending(&line) else {
break;
};
let line_end = start + line.len_chars();
// TODO: retain range direction
result.push(Range::new(start, line_end - line_ending.len_chars()));
start = line_end;
}
if start < sel_end {
result.push(Range::new(start, sel_end));
}
}
// TODO: figure out a new primary index
Selection::new(result, 0)
}
pub fn split_on_matches(text: RopeSlice, selection: &Selection, regex: &rope::Regex) -> Selection {
let mut result = SmallVec::with_capacity(selection.len());
for sel in selection {
// Special case: zero-width selection.
if sel.from() == sel.to() {
result.push(*sel);
continue;
}
// TODO: can't avoid occasional allocations since Regex can't operate on chunks yet
let fragment = sel.fragment(text);
let sel_start = sel.from();
let sel_end = sel.to();
let start_byte = text.char_to_byte(sel_start);
let mut start = sel_start;
for mat in regex.find_iter(text.regex_input_at(sel_start..sel_end)) {
for mat in regex.find_iter(&fragment) {
// TODO: retain range direction
let end = text.byte_to_char(mat.start());
let end = text.byte_to_char(start_byte + mat.start());
result.push(Range::new(start, end));
start = text.byte_to_char(mat.end());
start = text.byte_to_char(start_byte + mat.end());
}
if start < sel_end {
@@ -1098,12 +1021,14 @@ mod test {
#[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, &rope::Regex::new(r"[A-Z][a-z]*").unwrap()),
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
@@ -1113,14 +1038,8 @@ mod test {
let r = Rope::from_str("This\nString\n\ncontains multiple\nlines");
let s = r.slice(..);
let start_of_line = rope::RegexBuilder::new()
.syntax(rope::Config::new().multi_line(true))
.build(r"^")
.unwrap();
let end_of_line = rope::RegexBuilder::new()
.syntax(rope::Config::new().multi_line(true))
.build(r"$")
.unwrap();
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!(
@@ -1158,9 +1077,9 @@ mod test {
select_on_matches(
s,
&Selection::single(0, s.len_chars()),
&rope::RegexBuilder::new()
.syntax(rope::Config::new().multi_line(true))
.build(r"^[a-z ]*$")
&RegexBuilder::new(r"^[a-z ]*$")
.multi_line(true)
.build()
.unwrap()
),
Some(Selection::new(
@@ -1200,32 +1119,6 @@ mod test {
assert_eq!(Range::new(12, 0).line_range(s), (0, 2));
}
#[test]
fn selection_line_ranges() {
let (text, selection) = crate::test::print(
r#" L0
#[|these]# line #(|ranges)# are #(|merged)# L1
L2
single one-line #(|range)# L3
L4
single #(|multiline L5
range)# L6
L7
these #(|multiline L8
ranges)# are #(|also L9
merged)# L10
L11
adjacent #(|ranges)# L12
are merged #(|the same way)# L13
"#,
);
let rope = Rope::from_str(&text);
assert_eq!(
vec![(1, 1), (3, 3), (5, 6), (8, 10), (12, 13)],
selection.line_ranges(rope.slice(..)).collect::<Vec<_>>(),
);
}
#[test]
fn test_cursor() {
let r = Rope::from_str("\r\nHi\r\nthere!");
@@ -1278,15 +1171,13 @@ mod test {
#[test]
fn test_split_on_matches() {
use crate::regex::Regex;
let text = Rope::from(" abcd efg wrs xyz 123 456");
let selection = Selection::new(smallvec![Range::new(0, 9), Range::new(11, 20),], 0);
let result = split_on_matches(
text.slice(..),
&selection,
&rope::Regex::new(r"\s+").unwrap(),
);
let result = split_on_matches(text.slice(..), &selection, &Regex::new(r"\s+").unwrap());
assert_eq!(
result.ranges(),

View File

@@ -0,0 +1,350 @@
use std::borrow::Cow;
/// Auto escape for shellwords usage.
pub fn escape(input: Cow<str>) -> Cow<str> {
if !input.chars().any(|x| x.is_ascii_whitespace()) {
input
} else if cfg!(unix) {
Cow::Owned(input.chars().fold(String::new(), |mut buf, c| {
if c.is_ascii_whitespace() {
buf.push('\\');
}
buf.push(c);
buf
}))
} else {
Cow::Owned(format!("\"{}\"", input))
}
}
enum State {
OnWhitespace,
Unquoted,
UnquotedEscaped,
Quoted,
QuoteEscaped,
Dquoted,
DquoteEscaped,
}
pub struct Shellwords<'a> {
state: State,
/// Shellwords where whitespace and escapes has been resolved.
words: Vec<Cow<'a, str>>,
/// The parts of the input that are divided into shellwords. This can be
/// used to retrieve the original text for a given word by looking up the
/// same index in the Vec as the word in `words`.
parts: Vec<&'a str>,
}
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;
Quoted
}
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
UnquotedEscaped
} else {
OnWhitespace
}
}
c if c.is_ascii_whitespace() => {
end = i;
OnWhitespace
}
_ => Unquoted,
},
Unquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
UnquotedEscaped
} else {
Unquoted
}
}
c if c.is_ascii_whitespace() => {
end = i;
OnWhitespace
}
_ => Unquoted,
},
UnquotedEscaped => Unquoted,
Quoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
QuoteEscaped
} else {
Quoted
}
}
'\'' => {
end = i;
OnWhitespace
}
_ => Quoted,
},
QuoteEscaped => Quoted,
Dquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
DquoteEscaped
} else {
Dquoted
}
}
'"' => {
end = i;
OnWhitespace
}
_ => Dquoted,
},
DquoteEscaped => Dquoted,
};
let c_len = c.len_utf8();
if i == input.len() - c_len && end == 0 {
end = i + c_len;
}
if end > 0 {
let esc_trim = escaped.trim();
let inp = &input[unescaped_start..end];
if !(esc_trim.is_empty() && inp.trim().is_empty()) {
if esc_trim.is_empty() {
words.push(inp.into());
parts.push(inp);
} else {
words.push([escaped, inp.into()].concat().into());
parts.push(&input[part_start..end]);
escaped = "".to_string();
}
}
unescaped_start = i + 1;
part_start = i + 1;
end = 0;
}
}
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
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
#[cfg(windows)]
fn test_normal() {
let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó"),
Cow::from("wörds"),
Cow::from("\\three\\"),
Cow::from("\\"),
Cow::from("with\\ escaping\\\\"),
];
// TODO test is_owned and is_borrowed, once they get stabilized.
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_normal() {
let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó"),
Cow::from("wörds"),
Cow::from(r#"three "with escaping\"#),
];
// TODO test is_owned and is_borrowed, once they get stabilized.
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_quoted() {
let quoted =
r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#;
let shellwords = Shellwords::from(quoted);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó wörds"),
Cow::from(r#"three' "with escaping\"#),
Cow::from("quote incomplete"),
];
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_dquoted() {
let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#;
let shellwords = Shellwords::from(dquoted);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó wörds"),
Cow::from(r#"three' "with escaping\"#),
Cow::from("dquote incomplete"),
];
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_mixed() {
let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#;
let shellwords = Shellwords::from(dquoted);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó wörds"),
Cow::from("three' \"with escaping\\"),
Cow::from("no space before"),
Cow::from("and after"),
Cow::from("$#%^@"),
Cow::from("%^&(%^"),
Cow::from(")(*&^%"),
Cow::from(r#"a\\b"#),
//last ' just changes to quoted but since we dont have anything after it, it should be ignored
];
assert_eq!(expected, result);
}
#[test]
fn test_lists() {
let input =
r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "quotes"]'"#;
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", "quotes"]"#),
];
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𒀀"]
);
}
}

View File

@@ -1,13 +0,0 @@
mod active;
mod elaborate;
mod parser;
mod render;
#[derive(PartialEq, Eq, Hash, Debug, PartialOrd, Ord, Clone, Copy)]
pub struct TabstopIdx(usize);
pub const LAST_TABSTOP_IDX: TabstopIdx = TabstopIdx(usize::MAX);
pub use active::ActiveSnippet;
pub use elaborate::{Snippet, SnippetElement, Transform};
pub use render::RenderedSnippet;
pub use render::SnippetRenderCtx;

View File

@@ -1,272 +0,0 @@
use std::ops::{Index, IndexMut};
use foldhash::HashSet;
use helix_stdx::range::{is_exact_subset, is_subset};
use helix_stdx::Range;
use ropey::Rope;
use crate::movement::Direction;
use crate::snippets::render::{RenderedSnippet, Tabstop};
use crate::snippets::TabstopIdx;
use crate::{Assoc, ChangeSet, Selection, Transaction};
pub struct ActiveSnippet {
ranges: Vec<Range>,
active_tabstops: HashSet<TabstopIdx>,
current_tabstop: TabstopIdx,
tabstops: Vec<Tabstop>,
}
impl Index<TabstopIdx> for ActiveSnippet {
type Output = Tabstop;
fn index(&self, index: TabstopIdx) -> &Tabstop {
&self.tabstops[index.0]
}
}
impl IndexMut<TabstopIdx> for ActiveSnippet {
fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop {
&mut self.tabstops[index.0]
}
}
impl ActiveSnippet {
pub fn new(snippet: RenderedSnippet) -> Option<Self> {
let snippet = Self {
ranges: snippet.ranges,
tabstops: snippet.tabstops,
active_tabstops: HashSet::default(),
current_tabstop: TabstopIdx(0),
};
(snippet.tabstops.len() != 1).then_some(snippet)
}
pub fn is_valid(&self, new_selection: &Selection) -> bool {
is_subset::<false>(self.ranges.iter().copied(), new_selection.range_bounds())
}
pub fn tabstops(&self) -> impl Iterator<Item = &Tabstop> {
self.tabstops.iter()
}
pub fn delete_placeholder(&self, doc: &Rope) -> Transaction {
Transaction::delete(
doc,
self[self.current_tabstop]
.ranges
.iter()
.map(|range| (range.start, range.end)),
)
}
/// maps the active snippets through a `ChangeSet` updating all tabstop ranges
pub fn map(&mut self, changes: &ChangeSet) -> bool {
let positions_to_map = self.ranges.iter_mut().flat_map(|range| {
[
(&mut range.start, Assoc::After),
(&mut range.end, Assoc::Before),
]
});
changes.update_positions(positions_to_map);
for (i, tabstop) in self.tabstops.iter_mut().enumerate() {
if self.active_tabstops.contains(&TabstopIdx(i)) {
let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| {
let end_assoc = if range.start == range.end {
Assoc::Before
} else {
Assoc::After
};
[
(&mut range.start, Assoc::Before),
(&mut range.end, end_assoc),
]
});
changes.update_positions(positions_to_map);
} else {
let positions_to_map = tabstop.ranges.iter_mut().flat_map(|range| {
let end_assoc = if range.start == range.end {
Assoc::After
} else {
Assoc::Before
};
[
(&mut range.start, Assoc::After),
(&mut range.end, end_assoc),
]
});
changes.update_positions(positions_to_map);
}
let mut snippet_ranges = self.ranges.iter();
let mut snippet_range = snippet_ranges.next().unwrap();
let mut tabstop_i = 0;
let mut prev = Range { start: 0, end: 0 };
let num_ranges = tabstop.ranges.len() / self.ranges.len();
tabstop.ranges.retain_mut(|range| {
if tabstop_i == num_ranges {
snippet_range = snippet_ranges.next().unwrap();
tabstop_i = 0;
}
tabstop_i += 1;
let retain = snippet_range.start <= snippet_range.end;
if retain {
range.start = range.start.max(snippet_range.start);
range.end = range.end.max(range.start).min(snippet_range.end);
// guaranteed by assoc
debug_assert!(prev.start <= range.start);
debug_assert!(range.start <= range.end);
if prev.end > range.start {
// not really sure what to do in this case. It shouldn't
// really occur in practice, the below just ensures
// our invariants hold
range.start = prev.end;
range.end = range.end.max(range.start)
}
prev = *range;
}
retain
});
}
self.ranges.iter().all(|range| range.end <= range.start)
}
pub fn next_tabstop(&mut self, current_selection: &Selection) -> (Selection, bool) {
let primary_idx = self.primary_idx(current_selection);
while self.current_tabstop.0 + 1 < self.tabstops.len() {
self.current_tabstop.0 += 1;
if self.activate_tabstop() {
let selection = self.tabstop_selection(primary_idx, Direction::Forward);
return (selection, self.current_tabstop.0 + 1 == self.tabstops.len());
}
}
(
self.tabstop_selection(primary_idx, Direction::Forward),
true,
)
}
pub fn prev_tabstop(&mut self, current_selection: &Selection) -> Option<Selection> {
let primary_idx = self.primary_idx(current_selection);
while self.current_tabstop.0 != 0 {
self.current_tabstop.0 -= 1;
if self.activate_tabstop() {
return Some(self.tabstop_selection(primary_idx, Direction::Forward));
}
}
None
}
// computes the primary idx adjusted for the number of cursors in the current tabstop
fn primary_idx(&self, current_selection: &Selection) -> usize {
let primary: Range = current_selection.primary().into();
let res = self
.ranges
.iter()
.position(|&range| range.contains(primary));
res.unwrap_or_else(|| {
unreachable!(
"active snippet must be valid {current_selection:?} {:?}",
self.ranges
)
})
}
fn activate_tabstop(&mut self) -> bool {
let tabstop = &self[self.current_tabstop];
if tabstop.has_placeholder() && tabstop.ranges.iter().all(|range| range.is_empty()) {
return false;
}
self.active_tabstops.clear();
self.active_tabstops.insert(self.current_tabstop);
let mut parent = self[self.current_tabstop].parent;
while let Some(tabstop) = parent {
self.active_tabstops.insert(tabstop);
parent = self[tabstop].parent;
}
true
// TODO: if the user removes the selection(s) in one snippet (but
// there are still other cursors in other snippets) and jumps to the
// next tabstop the selection in that tabstop is restored (at the
// next tabstop). This could be annoying since its not possible to
// remove a snippet cursor until the snippet is complete. On the other
// hand it may be useful since the user may just have meant to edit
// a subselection (like with s) of the tabstops and so the selection
// removal was just temporary. Potentially this could have some sort of
// separate keymap
}
pub fn tabstop_selection(&self, primary_idx: usize, direction: Direction) -> Selection {
let tabstop = &self[self.current_tabstop];
tabstop.selection(direction, primary_idx, self.ranges.len())
}
pub fn insert_subsnippet(mut self, snippet: RenderedSnippet) -> Option<Self> {
if snippet.ranges.len() % self.ranges.len() != 0
|| !is_exact_subset(self.ranges.iter().copied(), snippet.ranges.iter().copied())
{
log::warn!("number of subsnippets did not match, discarding outer snippet");
return ActiveSnippet::new(snippet);
}
let mut cnt = 0;
let parent = self[self.current_tabstop].parent;
let tabstops = snippet.tabstops.into_iter().map(|mut tabstop| {
cnt += 1;
if let Some(parent) = &mut tabstop.parent {
parent.0 += self.current_tabstop.0;
} else {
tabstop.parent = parent;
}
tabstop
});
self.tabstops
.splice(self.current_tabstop.0..=self.current_tabstop.0, tabstops);
self.activate_tabstop();
Some(self)
}
}
#[cfg(test)]
mod tests {
use std::iter::{self};
use ropey::Rope;
use crate::snippets::{ActiveSnippet, Snippet, SnippetRenderCtx};
use crate::{Selection, Transaction};
#[test]
fn fully_remove() {
let snippet = Snippet::parse("foo(${1:bar})$0").unwrap();
let mut doc = Rope::from("bar.\n");
let (transaction, _, snippet) = snippet.render(
&doc,
&Selection::point(4),
|_| (4, 4),
&mut SnippetRenderCtx::test_ctx(),
);
assert!(transaction.apply(&mut doc));
assert_eq!(doc, "bar.foo(bar)\n");
let mut snippet = ActiveSnippet::new(snippet).unwrap();
let edit = Transaction::change(&doc, iter::once((4, 12, None)));
assert!(edit.apply(&mut doc));
snippet.map(edit.changes());
assert!(!snippet.is_valid(&Selection::point(4)))
}
#[test]
fn tabstop_zero_with_placeholder() {
// The `$0` tabstop should not have placeholder text. When we receive a snippet like this
// (from older versions of clangd for example) we should discard the placeholder text.
let snippet = Snippet::parse("sizeof(${0:expression-or-type})").unwrap();
let mut doc = Rope::from("\n");
let (transaction, _, snippet) = snippet.render(
&doc,
&Selection::point(0),
|_| (0, 0),
&mut SnippetRenderCtx::test_ctx(),
);
assert!(transaction.apply(&mut doc));
assert_eq!(doc, "sizeof()\n");
assert!(ActiveSnippet::new(snippet).is_none());
}
}

View File

@@ -1,385 +0,0 @@
use std::mem::swap;
use std::ops::Index;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use helix_stdx::rope::RopeSliceExt;
use helix_stdx::Range;
use regex_cursor::engines::meta::Builder as RegexBuilder;
use regex_cursor::engines::meta::Regex;
use regex_cursor::regex_automata::util::syntax::Config as RegexConfig;
use ropey::RopeSlice;
use crate::case_conversion::to_lower_case_with;
use crate::case_conversion::to_upper_case_with;
use crate::case_conversion::{to_camel_case_with, to_pascal_case_with};
use crate::snippets::parser::{self, CaseChange, FormatItem};
use crate::snippets::{TabstopIdx, LAST_TABSTOP_IDX};
use crate::Tendril;
#[derive(Debug)]
pub struct Snippet {
elements: Vec<SnippetElement>,
tabstops: Vec<Tabstop>,
}
impl Snippet {
pub fn parse(snippet: &str) -> Result<Self> {
let parsed_snippet = parser::parse(snippet)
.map_err(|rest| anyhow!("Failed to parse snippet. Remaining input: {}", rest))?;
Ok(Snippet::new(parsed_snippet))
}
pub fn new(elements: Vec<parser::SnippetElement>) -> Snippet {
let mut res = Snippet {
elements: Vec::new(),
tabstops: Vec::new(),
};
res.elements = res.elaborate(elements, None).into();
res.fixup_tabstops();
res.ensure_last_tabstop();
res.renumber_tabstops();
res
}
pub fn elements(&self) -> &[SnippetElement] {
&self.elements
}
pub fn tabstops(&self) -> impl Iterator<Item = &Tabstop> {
self.tabstops.iter()
}
fn renumber_tabstops(&mut self) {
Self::renumber_tabstops_in(&self.tabstops, &mut self.elements);
for i in 0..self.tabstops.len() {
if let Some(parent) = self.tabstops[i].parent {
let parent = self
.tabstops
.binary_search_by_key(&parent, |tabstop| tabstop.idx)
.expect("all tabstops have been resolved");
self.tabstops[i].parent = Some(TabstopIdx(parent));
}
let tabstop = &mut self.tabstops[i];
if let TabstopKind::Placeholder { default } = &tabstop.kind {
let mut default = default.clone();
tabstop.kind = TabstopKind::Empty;
Self::renumber_tabstops_in(&self.tabstops, Arc::get_mut(&mut default).unwrap());
self.tabstops[i].kind = TabstopKind::Placeholder { default };
}
}
}
fn renumber_tabstops_in(tabstops: &[Tabstop], elements: &mut [SnippetElement]) {
for elem in elements {
match elem {
SnippetElement::Tabstop { idx } => {
idx.0 = tabstops
.binary_search_by_key(&*idx, |tabstop| tabstop.idx)
.expect("all tabstops have been resolved")
}
SnippetElement::Variable { default, .. } => {
if let Some(default) = default {
Self::renumber_tabstops_in(tabstops, default);
}
}
SnippetElement::Text(_) => (),
}
}
}
fn fixup_tabstops(&mut self) {
self.tabstops.sort_by_key(|tabstop| tabstop.idx);
self.tabstops.dedup_by(|tabstop1, tabstop2| {
if tabstop1.idx != tabstop2.idx {
return false;
}
// use the first non empty tabstop for all multicursor tabstops
if tabstop2.kind.is_empty() {
swap(tabstop2, tabstop1)
}
true
})
}
fn ensure_last_tabstop(&mut self) {
if matches!(self.tabstops.last(), Some(tabstop) if tabstop.idx == LAST_TABSTOP_IDX) {
return;
}
self.tabstops.push(Tabstop {
idx: LAST_TABSTOP_IDX,
parent: None,
kind: TabstopKind::Empty,
});
self.elements.push(SnippetElement::Tabstop {
idx: LAST_TABSTOP_IDX,
})
}
fn elaborate(
&mut self,
default: Vec<parser::SnippetElement>,
parent: Option<TabstopIdx>,
) -> Box<[SnippetElement]> {
default
.into_iter()
.map(|val| match val {
parser::SnippetElement::Tabstop {
tabstop,
transform: None,
} => SnippetElement::Tabstop {
idx: self.elaborate_placeholder(tabstop, parent, Vec::new()),
},
parser::SnippetElement::Tabstop {
tabstop,
transform: Some(transform),
} => SnippetElement::Tabstop {
idx: self.elaborate_transform(tabstop, parent, transform),
},
parser::SnippetElement::Placeholder { tabstop, value } => SnippetElement::Tabstop {
idx: self.elaborate_placeholder(tabstop, parent, value),
},
parser::SnippetElement::Choice { tabstop, choices } => SnippetElement::Tabstop {
idx: self.elaborate_choice(tabstop, parent, choices),
},
parser::SnippetElement::Variable {
name,
default,
transform,
} => SnippetElement::Variable {
name,
default: default.map(|default| self.elaborate(default, parent)),
// TODO: error for invalid transforms
transform: transform.and_then(Transform::new).map(Box::new),
},
parser::SnippetElement::Text(text) => SnippetElement::Text(text),
})
.collect()
}
fn elaborate_choice(
&mut self,
idx: usize,
parent: Option<TabstopIdx>,
choices: Vec<Tendril>,
) -> TabstopIdx {
let idx = TabstopIdx::elaborate(idx);
self.tabstops.push(Tabstop {
idx,
parent,
kind: TabstopKind::Choice {
choices: choices.into(),
},
});
idx
}
fn elaborate_placeholder(
&mut self,
idx: usize,
parent: Option<TabstopIdx>,
mut default: Vec<parser::SnippetElement>,
) -> TabstopIdx {
let idx = TabstopIdx::elaborate(idx);
if idx == LAST_TABSTOP_IDX && !default.is_empty() {
// Older versions of clangd for example may send a snippet like `${0:placeholder}`
// which is considered by VSCode to be a misuse of the `$0` tabstop.
log::warn!("Discarding placeholder text for the `$0` tabstop ({default:?}). \
The `$0` tabstop signifies the final cursor position and should not include placeholder text.");
default.clear();
}
let default = self.elaborate(default, Some(idx));
self.tabstops.push(Tabstop {
idx,
parent,
kind: TabstopKind::Placeholder {
default: default.into(),
},
});
idx
}
fn elaborate_transform(
&mut self,
idx: usize,
parent: Option<TabstopIdx>,
transform: parser::Transform,
) -> TabstopIdx {
let idx = TabstopIdx::elaborate(idx);
if let Some(transform) = Transform::new(transform) {
self.tabstops.push(Tabstop {
idx,
parent,
kind: TabstopKind::Transform(Arc::new(transform)),
})
} else {
// TODO: proper error
self.tabstops.push(Tabstop {
idx,
parent,
kind: TabstopKind::Empty,
})
}
idx
}
}
impl Index<TabstopIdx> for Snippet {
type Output = Tabstop;
fn index(&self, index: TabstopIdx) -> &Tabstop {
&self.tabstops[index.0]
}
}
#[derive(Debug)]
pub enum SnippetElement {
Tabstop {
idx: TabstopIdx,
},
Variable {
name: Tendril,
default: Option<Box<[SnippetElement]>>,
transform: Option<Box<Transform>>,
},
Text(Tendril),
}
#[derive(Debug)]
pub struct Tabstop {
idx: TabstopIdx,
pub parent: Option<TabstopIdx>,
pub kind: TabstopKind,
}
#[derive(Debug)]
pub enum TabstopKind {
Choice { choices: Arc<[Tendril]> },
Placeholder { default: Arc<[SnippetElement]> },
Empty,
Transform(Arc<Transform>),
}
impl TabstopKind {
pub fn is_empty(&self) -> bool {
matches!(self, TabstopKind::Empty)
}
}
#[derive(Debug)]
pub struct Transform {
regex: Regex,
regex_str: Box<str>,
global: bool,
replacement: Box<[FormatItem]>,
}
impl PartialEq for Transform {
fn eq(&self, other: &Self) -> bool {
self.replacement == other.replacement
&& self.global == other.global
// doens't compare m and i setting but close enough
&& self.regex_str == other.regex_str
}
}
impl Transform {
fn new(transform: parser::Transform) -> Option<Transform> {
let mut config = RegexConfig::new();
let mut global = false;
let mut invalid_config = false;
for c in transform.options.chars() {
match c {
'i' => {
config = config.case_insensitive(true);
}
'm' => {
config = config.multi_line(true);
}
'g' => {
global = true;
}
// we ignore 'u' since we always want to
// do unicode aware matching
_ => invalid_config = true,
}
}
if invalid_config {
log::error!("invalid transform configuration characters {transform:?}");
}
let regex = match RegexBuilder::new().syntax(config).build(&transform.regex) {
Ok(regex) => regex,
Err(err) => {
log::error!("invalid transform {err} {transform:?}");
return None;
}
};
Some(Transform {
regex,
regex_str: transform.regex.as_str().into(),
global,
replacement: transform.replacement.into(),
})
}
pub fn apply(&self, mut doc: RopeSlice<'_>, range: Range) -> Tendril {
let mut buf = Tendril::new();
let it = self
.regex
.captures_iter(doc.regex_input_at(range))
.enumerate();
doc = doc.slice(range);
let mut last_match = 0;
for (_, cap) in it {
// unwrap on 0 is OK because captures only reports matches
let m = cap.get_group(0).unwrap();
buf.extend(doc.byte_slice(last_match..m.start).chunks());
last_match = m.end;
for fmt in &*self.replacement {
match *fmt {
FormatItem::Text(ref text) => {
buf.push_str(text);
}
FormatItem::Capture(i) => {
if let Some(cap) = cap.get_group(i) {
buf.extend(doc.byte_slice(cap.range()).chunks());
}
}
FormatItem::CaseChange(i, change) => {
if let Some(cap) = cap.get_group(i).filter(|i| !i.is_empty()) {
let mut chars = doc.byte_slice(cap.range()).chars();
match change {
CaseChange::Upcase => to_upper_case_with(chars, &mut buf),
CaseChange::Downcase => to_lower_case_with(chars, &mut buf),
CaseChange::Capitalize => {
let first_char = chars.next().unwrap();
buf.extend(first_char.to_uppercase());
buf.extend(chars);
}
CaseChange::PascalCase => to_pascal_case_with(chars, &mut buf),
CaseChange::CamelCase => to_camel_case_with(chars, &mut buf),
}
}
}
FormatItem::Conditional(i, ref if_, ref else_) => {
if cap.get_group(i).map_or(true, |mat| mat.is_empty()) {
buf.push_str(else_)
} else {
buf.push_str(if_)
}
}
}
}
if !self.global {
break;
}
}
buf.extend(doc.byte_slice(last_match..).chunks());
buf
}
}
impl TabstopIdx {
fn elaborate(idx: usize) -> Self {
TabstopIdx(idx.wrapping_sub(1))
}
}

View File

@@ -1,935 +0,0 @@
/*!
A parser for LSP/VSCode style snippet syntax
See <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#snippet_syntax>.
``` text
any ::= tabstop | placeholder | choice | variable | text
tabstop ::= '$' int | '${' int '}'
placeholder ::= '${' int ':' any '}'
choice ::= '${' int '|' text (',' text)* '|}'
variable ::= '$' var | '${' var }'
| '${' var ':' any '}'
| '${' var '/' regex '/' (format | text)+ '/' options '}'
format ::= '$' int | '${' int '}'
| '${' int ':' '/upcase' | '/downcase' | '/capitalize' '}'
| '${' int ':+' if '}'
| '${' int ':?' if ':' else '}'
| '${' int ':-' else '}' | '${' int ':' else '}'
regex ::= Regular Expression value (ctor-string)
options ::= Regular Expression option (ctor-options)
var ::= [_a-zA-Z] [_a-zA-Z0-9]*
int ::= [0-9]+
text ::= .*
if ::= text
else ::= text
```
*/
use crate::Tendril;
use helix_parsec::*;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CaseChange {
Upcase,
Downcase,
Capitalize,
PascalCase,
CamelCase,
}
#[derive(Debug, PartialEq, Eq)]
pub enum FormatItem {
Text(Tendril),
Capture(usize),
CaseChange(usize, CaseChange),
Conditional(usize, Tendril, Tendril),
}
#[derive(Debug, PartialEq, Eq)]
pub struct Transform {
pub regex: Tendril,
pub replacement: Vec<FormatItem>,
pub options: Tendril,
}
#[derive(Debug, PartialEq, Eq)]
pub enum SnippetElement {
Tabstop {
tabstop: usize,
transform: Option<Transform>,
},
Placeholder {
tabstop: usize,
value: Vec<SnippetElement>,
},
Choice {
tabstop: usize,
choices: Vec<Tendril>,
},
Variable {
name: Tendril,
default: Option<Vec<SnippetElement>>,
transform: Option<Transform>,
},
Text(Tendril),
}
pub fn parse(s: &str) -> Result<Vec<SnippetElement>, &str> {
snippet().parse(s).and_then(|(remainder, snippet)| {
if remainder.is_empty() {
Ok(snippet)
} else {
Err(remainder)
}
})
}
fn var<'a>() -> impl Parser<'a, Output = &'a str> {
// var = [_a-zA-Z][_a-zA-Z0-9]*
move |input: &'a str| {
input
.char_indices()
.take_while(|(p, c)| {
*c == '_'
|| if *p == 0 {
c.is_ascii_alphabetic()
} else {
c.is_ascii_alphanumeric()
}
})
.last()
.map(|(index, c)| {
let index = index + c.len_utf8();
(&input[index..], &input[0..index])
})
.ok_or(input)
}
}
const TEXT_ESCAPE_CHARS: &[char] = &['\\', '}', '$'];
const CHOICE_TEXT_ESCAPE_CHARS: &[char] = &['\\', '|', ','];
fn text<'a>(
escape_chars: &'static [char],
term_chars: &'static [char],
) -> impl Parser<'a, Output = Tendril> {
move |input: &'a str| {
let mut chars = input.char_indices().peekable();
let mut res = Tendril::new();
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
if let Some(&(_, c)) = chars.peek() {
if escape_chars.contains(&c) {
chars.next();
res.push(c);
continue;
}
}
res.push('\\');
}
c if term_chars.contains(&c) => return Ok((&input[i..], res)),
c => res.push(c),
}
}
Ok(("", res))
}
}
fn digit<'a>() -> impl Parser<'a, Output = usize> {
filter_map(take_while(|c| c.is_ascii_digit()), |s| s.parse().ok())
}
fn case_change<'a>() -> impl Parser<'a, Output = CaseChange> {
use CaseChange::*;
choice!(
map("upcase", |_| Upcase),
map("downcase", |_| Downcase),
map("capitalize", |_| Capitalize),
map("pascalcase", |_| PascalCase),
map("camelcase", |_| CamelCase),
)
}
fn format<'a>() -> impl Parser<'a, Output = FormatItem> {
use FormatItem::*;
choice!(
// '$' int
map(right("$", digit()), Capture),
// '${' int '}'
map(seq!("${", digit(), "}"), |seq| Capture(seq.1)),
// '${' int ':' '/upcase' | '/downcase' | '/capitalize' '}'
map(seq!("${", digit(), ":/", case_change(), "}"), |seq| {
CaseChange(seq.1, seq.3)
}),
// '${' int ':+' if '}'
map(
seq!("${", digit(), ":+", text(TEXT_ESCAPE_CHARS, &['}']), "}"),
|seq| { Conditional(seq.1, seq.3, Tendril::new()) }
),
// '${' int ':?' if ':' else '}'
map(
seq!(
"${",
digit(),
":?",
text(TEXT_ESCAPE_CHARS, &[':']),
":",
text(TEXT_ESCAPE_CHARS, &['}']),
"}"
),
|seq| { Conditional(seq.1, seq.3, seq.5) }
),
// '${' int ':-' else '}' | '${' int ':' else '}'
map(
seq!(
"${",
digit(),
":",
optional("-"),
text(TEXT_ESCAPE_CHARS, &['}']),
"}"
),
|seq| { Conditional(seq.1, Tendril::new(), seq.4) }
),
)
}
fn regex<'a>() -> impl Parser<'a, Output = Transform> {
map(
seq!(
"/",
// TODO parse as ECMAScript and convert to rust regex
text(&['/'], &['/']),
"/",
zero_or_more(choice!(
format(),
// text doesn't parse $, if format fails we just accept the $ as text
map("$", |_| FormatItem::Text("$".into())),
map(text(&['\\', '/'], &['/', '$']), FormatItem::Text),
)),
"/",
// vscode really doesn't allow escaping } here
// so it's impossible to write a regex escape containing a }
// we can consider deviating here and allowing the escape
text(&[], &['}']),
),
|(_, value, _, replacement, _, options)| Transform {
regex: value,
replacement,
options,
},
)
}
fn tabstop<'a>() -> impl Parser<'a, Output = SnippetElement> {
map(
or(
map(right("$", digit()), |i| (i, None)),
map(
seq!("${", digit(), optional(regex()), "}"),
|(_, i, transform, _)| (i, transform),
),
),
|(tabstop, transform)| SnippetElement::Tabstop { tabstop, transform },
)
}
fn placeholder<'a>() -> impl Parser<'a, Output = SnippetElement> {
map(
seq!(
"${",
digit(),
":",
// according to the grammar there is just a single anything here.
// However in the prose it is explained that placeholders can be nested.
// The example there contains both a placeholder text and a nested placeholder
// which indicates a list. Looking at the VSCode sourcecode, the placeholder
// is indeed parsed as zero_or_more so the grammar is simply incorrect here
zero_or_more(anything(TEXT_ESCAPE_CHARS, true)),
"}"
),
|seq| SnippetElement::Placeholder {
tabstop: seq.1,
value: seq.3,
},
)
}
fn choice<'a>() -> impl Parser<'a, Output = SnippetElement> {
map(
seq!(
"${",
digit(),
"|",
sep(text(CHOICE_TEXT_ESCAPE_CHARS, &['|', ',']), ","),
"|}",
),
|seq| SnippetElement::Choice {
tabstop: seq.1,
choices: seq.3,
},
)
}
fn variable<'a>() -> impl Parser<'a, Output = SnippetElement> {
choice!(
// $var
map(right("$", var()), |name| SnippetElement::Variable {
name: name.into(),
default: None,
transform: None,
}),
// ${var}
map(seq!("${", var(), "}",), |values| SnippetElement::Variable {
name: values.1.into(),
default: None,
transform: None,
}),
// ${var:default}
map(
seq!(
"${",
var(),
":",
zero_or_more(anything(TEXT_ESCAPE_CHARS, true)),
"}",
),
|values| SnippetElement::Variable {
name: values.1.into(),
default: Some(values.3),
transform: None,
}
),
// ${var/value/format/options}
map(seq!("${", var(), regex(), "}"), |values| {
SnippetElement::Variable {
name: values.1.into(),
default: None,
transform: Some(values.2),
}
}),
)
}
fn anything<'a>(
escape_chars: &'static [char],
end_at_brace: bool,
) -> impl Parser<'a, Output = SnippetElement> {
let term_chars: &[_] = if end_at_brace { &['$', '}'] } else { &['$'] };
move |input: &'a str| {
let parser = choice!(
tabstop(),
placeholder(),
choice(),
variable(),
map("$", |_| SnippetElement::Text("$".into())),
map(text(escape_chars, term_chars), SnippetElement::Text),
);
parser.parse(input)
}
}
fn snippet<'a>() -> impl Parser<'a, Output = Vec<SnippetElement>> {
one_or_more(anything(TEXT_ESCAPE_CHARS, false))
}
#[cfg(test)]
mod test {
use crate::snippets::{Snippet, SnippetRenderCtx};
use super::SnippetElement::*;
use super::*;
#[test]
fn empty_string_is_error() {
assert_eq!(Err(""), parse(""));
}
#[test]
fn parse_placeholders_in_function_call() {
assert_eq!(
Ok(vec![
Text("match(".into()),
Placeholder {
tabstop: 1,
value: vec![Text("Arg1".into())],
},
Text(")".into()),
]),
parse("match(${1:Arg1})")
);
// The `$0` tabstop should not have placeholder text. The parser should handle this case
// normally and then the placeholder text should be discarded during elaboration.
assert_eq!(
Ok(vec![
Text("sizeof(".into()),
Placeholder {
tabstop: 0,
value: vec![Text("expression-or-type".into())],
},
Text(")".into()),
]),
parse("sizeof(${0:expression-or-type})")
);
}
#[test]
fn unterminated_placeholder() {
assert_eq!(
Ok(vec![
Text("match(".into()),
Text("$".into()),
Text("{1:)".into())
]),
parse("match(${1:)")
)
}
#[test]
fn parse_empty_placeholder() {
assert_eq!(
Ok(vec![
Text("match(".into()),
Placeholder {
tabstop: 1,
value: vec![],
},
Text(")".into()),
]),
parse("match(${1:})")
)
}
#[test]
fn parse_placeholders_in_statement() {
assert_eq!(
Ok(vec![
Text("local ".into()),
Placeholder {
tabstop: 1,
value: vec![Text("var".into())],
},
Text(" = ".into()),
Placeholder {
tabstop: 1,
value: vec![Text("value".into())],
},
]),
parse("local ${1:var} = ${1:value}")
)
}
#[test]
fn parse_tabstop_nested_in_placeholder() {
assert_eq!(
Ok(vec![Placeholder {
tabstop: 1,
value: vec![
Text("var, ".into()),
Tabstop {
tabstop: 2,
transform: None
}
],
}]),
parse("${1:var, $2}")
)
}
#[test]
fn parse_placeholder_nested_in_placeholder() {
assert_eq!(
Ok({
vec![Placeholder {
tabstop: 1,
value: vec![
Text("foo ".into()),
Placeholder {
tabstop: 2,
value: vec![Text("bar".into())],
},
],
}]
}),
parse("${1:foo ${2:bar}}")
)
}
#[test]
fn parse_all() {
assert_eq!(
Ok(vec![
Text("hello ".into()),
Tabstop {
tabstop: 1,
transform: None
},
Tabstop {
tabstop: 2,
transform: None
},
Text(" ".into()),
Choice {
tabstop: 1,
choices: vec!["one".into(), "two".into(), "three".into()],
},
Text(" ".into()),
Variable {
name: "name".into(),
default: Some(vec![Text("foo".into())]),
transform: None,
},
Text(" ".into()),
Variable {
name: "var".into(),
default: None,
transform: None,
},
Text(" ".into()),
Variable {
name: "TM".into(),
default: None,
transform: None,
},
]),
parse("hello $1${2} ${1|one,two,three|} ${name:foo} $var $TM")
);
}
#[test]
fn regex_capture_replace() {
assert_eq!(
Ok({
vec![Variable {
name: "TM_FILENAME".into(),
default: None,
transform: Some(Transform {
regex: "(.*).+$".into(),
replacement: vec![FormatItem::Capture(1), FormatItem::Text("$".into())],
options: Tendril::new(),
}),
}]
}),
parse("${TM_FILENAME/(.*).+$/$1$/}")
);
}
#[test]
fn rust_macro() {
assert_eq!(
Ok({
vec![
Text("macro_rules! ".into()),
Tabstop {
tabstop: 1,
transform: None,
},
Text(" {\n (".into()),
Tabstop {
tabstop: 2,
transform: None,
},
Text(") => {\n ".into()),
Tabstop {
tabstop: 0,
transform: None,
},
Text("\n };\n}".into()),
]
}),
parse("macro_rules! $1 {\n ($2) => {\n $0\n };\n}")
);
}
fn assert_text(snippet: &str, parsed_text: &str) {
let snippet = Snippet::parse(snippet).unwrap();
let mut rendered_snippet = snippet.prepare_render();
let rendered_text = snippet
.render_at(
&mut rendered_snippet,
"".into(),
false,
&mut SnippetRenderCtx::test_ctx(),
0,
)
.0;
assert_eq!(rendered_text, parsed_text)
}
#[test]
fn robust_parsing() {
assert_text("$", "$");
assert_text("\\\\$", "\\$");
assert_text("{", "{");
assert_text("\\}", "}");
assert_text("\\abc", "\\abc");
assert_text("foo${f:\\}}bar", "foo}bar");
assert_text("\\{", "\\{");
assert_text("I need \\\\\\$", "I need \\$");
assert_text("\\", "\\");
assert_text("\\{{", "\\{{");
assert_text("{{", "{{");
assert_text("{{dd", "{{dd");
assert_text("}}", "}}");
assert_text("ff}}", "ff}}");
assert_text("farboo", "farboo");
assert_text("far{{}}boo", "far{{}}boo");
assert_text("far{{123}}boo", "far{{123}}boo");
assert_text("far\\{{123}}boo", "far\\{{123}}boo");
assert_text("far{{id:bern}}boo", "far{{id:bern}}boo");
assert_text("far{{id:bern {{basel}}}}boo", "far{{id:bern {{basel}}}}boo");
assert_text(
"far{{id:bern {{id:basel}}}}boo",
"far{{id:bern {{id:basel}}}}boo",
);
assert_text(
"far{{id:bern {{id2:basel}}}}boo",
"far{{id:bern {{id2:basel}}}}boo",
);
assert_text("${}$\\a\\$\\}\\\\", "${}$\\a$}\\");
assert_text("farboo", "farboo");
assert_text("far{{}}boo", "far{{}}boo");
assert_text("far{{123}}boo", "far{{123}}boo");
assert_text("far\\{{123}}boo", "far\\{{123}}boo");
assert_text("far`123`boo", "far`123`boo");
assert_text("far\\`123\\`boo", "far\\`123\\`boo");
assert_text("\\$far-boo", "$far-boo");
}
fn assert_snippet(snippet: &str, expect: &[SnippetElement]) {
let elements = parse(snippet).unwrap();
assert_eq!(elements, expect.to_owned())
}
#[test]
fn parse_variable() {
use SnippetElement::*;
assert_snippet(
"$far-boo",
&[
Variable {
name: "far".into(),
default: None,
transform: None,
},
Text("-boo".into()),
],
);
assert_snippet(
"far$farboo",
&[
Text("far".into()),
Variable {
name: "farboo".into(),
transform: None,
default: None,
},
],
);
assert_snippet(
"far${farboo}",
&[
Text("far".into()),
Variable {
name: "farboo".into(),
transform: None,
default: None,
},
],
);
assert_snippet(
"$123",
&[Tabstop {
tabstop: 123,
transform: None,
}],
);
assert_snippet(
"$farboo",
&[Variable {
name: "farboo".into(),
transform: None,
default: None,
}],
);
assert_snippet(
"$far12boo",
&[Variable {
name: "far12boo".into(),
transform: None,
default: None,
}],
);
assert_snippet(
"000_${far}_000",
&[
Text("000_".into()),
Variable {
name: "far".into(),
transform: None,
default: None,
},
Text("_000".into()),
],
);
}
#[test]
fn parse_variable_transform() {
assert_snippet(
"${foo///}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: Tendril::new(),
replacement: Vec::new(),
options: Tendril::new(),
}),
default: None,
}],
);
assert_snippet(
"${foo/regex/format/gmi}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: "regex".into(),
replacement: vec![FormatItem::Text("format".into())],
options: "gmi".into(),
}),
default: None,
}],
);
assert_snippet(
"${foo/([A-Z][a-z])/format/}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: "([A-Z][a-z])".into(),
replacement: vec![FormatItem::Text("format".into())],
options: Tendril::new(),
}),
default: None,
}],
);
// invalid regex TODO: reneable tests once we actually parse this regex flavor
// assert_text(
// "${foo/([A-Z][a-z])/format/GMI}",
// "${foo/([A-Z][a-z])/format/GMI}",
// );
// assert_text(
// "${foo/([A-Z][a-z])/format/funky}",
// "${foo/([A-Z][a-z])/format/funky}",
// );
// assert_text("${foo/([A-Z][a-z]/format/}", "${foo/([A-Z][a-z]/format/}");
assert_text(
"${foo/regex\\/format/options}",
"${foo/regex\\/format/options}",
);
// tricky regex
assert_snippet(
"${foo/m\\/atch/$1/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: "m/atch".into(),
replacement: vec![FormatItem::Capture(1)],
options: "i".into(),
}),
default: None,
}],
);
// incomplete
assert_text("${foo///", "${foo///");
assert_text("${foo/regex/format/options", "${foo/regex/format/options");
// format string
assert_snippet(
"${foo/.*/${0:fooo}/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: ".*".into(),
replacement: vec![FormatItem::Conditional(0, Tendril::new(), "fooo".into())],
options: "i".into(),
}),
default: None,
}],
);
assert_snippet(
"${foo/.*/${1}/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: ".*".into(),
replacement: vec![FormatItem::Capture(1)],
options: "i".into(),
}),
default: None,
}],
);
assert_snippet(
"${foo/.*/$1/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: ".*".into(),
replacement: vec![FormatItem::Capture(1)],
options: "i".into(),
}),
default: None,
}],
);
assert_snippet(
"${foo/.*/This-$1-encloses/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: ".*".into(),
replacement: vec![
FormatItem::Text("This-".into()),
FormatItem::Capture(1),
FormatItem::Text("-encloses".into()),
],
options: "i".into(),
}),
default: None,
}],
);
assert_snippet(
"${foo/.*/complex${1:else}/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: ".*".into(),
replacement: vec![
FormatItem::Text("complex".into()),
FormatItem::Conditional(1, Tendril::new(), "else".into()),
],
options: "i".into(),
}),
default: None,
}],
);
assert_snippet(
"${foo/.*/complex${1:-else}/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: ".*".into(),
replacement: vec![
FormatItem::Text("complex".into()),
FormatItem::Conditional(1, Tendril::new(), "else".into()),
],
options: "i".into(),
}),
default: None,
}],
);
assert_snippet(
"${foo/.*/complex${1:+if}/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: ".*".into(),
replacement: vec![
FormatItem::Text("complex".into()),
FormatItem::Conditional(1, "if".into(), Tendril::new()),
],
options: "i".into(),
}),
default: None,
}],
);
assert_snippet(
"${foo/.*/complex${1:?if:else}/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: ".*".into(),
replacement: vec![
FormatItem::Text("complex".into()),
FormatItem::Conditional(1, "if".into(), "else".into()),
],
options: "i".into(),
}),
default: None,
}],
);
assert_snippet(
"${foo/.*/complex${1:/upcase}/i}",
&[Variable {
name: "foo".into(),
transform: Some(Transform {
regex: ".*".into(),
replacement: vec![
FormatItem::Text("complex".into()),
FormatItem::CaseChange(1, CaseChange::Upcase),
],
options: "i".into(),
}),
default: None,
}],
);
assert_snippet(
"${TM_DIRECTORY/src\\//$1/}",
&[Variable {
name: "TM_DIRECTORY".into(),
transform: Some(Transform {
regex: "src/".into(),
replacement: vec![FormatItem::Capture(1)],
options: Tendril::new(),
}),
default: None,
}],
);
assert_snippet(
"${TM_SELECTED_TEXT/a/\\/$1/g}",
&[Variable {
name: "TM_SELECTED_TEXT".into(),
transform: Some(Transform {
regex: "a".into(),
replacement: vec![FormatItem::Text("/".into()), FormatItem::Capture(1)],
options: "g".into(),
}),
default: None,
}],
);
assert_snippet(
"${TM_SELECTED_TEXT/a/in\\/$1ner/g}",
&[Variable {
name: "TM_SELECTED_TEXT".into(),
transform: Some(Transform {
regex: "a".into(),
replacement: vec![
FormatItem::Text("in/".into()),
FormatItem::Capture(1),
FormatItem::Text("ner".into()),
],
options: "g".into(),
}),
default: None,
}],
);
assert_snippet(
"${TM_SELECTED_TEXT/a/end\\//g}",
&[Variable {
name: "TM_SELECTED_TEXT".into(),
transform: Some(Transform {
regex: "a".into(),
replacement: vec![FormatItem::Text("end/".into())],
options: "g".into(),
}),
default: None,
}],
);
}
// TODO port more tests from https://github.com/microsoft/vscode/blob/dce493cb6e36346ef2714e82c42ce14fc461b15c/src/vs/editor/contrib/snippet/test/browser/snippetParser.test.ts
}

View File

@@ -1,355 +0,0 @@
use std::borrow::Cow;
use std::ops::{Index, IndexMut};
use std::sync::Arc;
use helix_stdx::Range;
use ropey::{Rope, RopeSlice};
use smallvec::SmallVec;
use crate::indent::{normalize_indentation, IndentStyle};
use crate::movement::Direction;
use crate::snippets::elaborate;
use crate::snippets::TabstopIdx;
use crate::snippets::{Snippet, SnippetElement, Transform};
use crate::{selection, Selection, Tendril, Transaction};
#[derive(Debug, Clone, PartialEq)]
pub enum TabstopKind {
Choice { choices: Arc<[Tendril]> },
Placeholder,
Empty,
Transform(Arc<Transform>),
}
#[derive(Debug, PartialEq)]
pub struct Tabstop {
pub ranges: SmallVec<[Range; 1]>,
pub parent: Option<TabstopIdx>,
pub kind: TabstopKind,
}
impl Tabstop {
pub fn has_placeholder(&self) -> bool {
matches!(
self.kind,
TabstopKind::Choice { .. } | TabstopKind::Placeholder
)
}
pub fn selection(
&self,
direction: Direction,
primary_idx: usize,
snippet_ranges: usize,
) -> Selection {
Selection::new(
self.ranges
.iter()
.map(|&range| {
let mut range = selection::Range::new(range.start, range.end);
if direction == Direction::Backward {
range = range.flip()
}
range
})
.collect(),
primary_idx * (self.ranges.len() / snippet_ranges),
)
}
}
#[derive(Debug, Default, PartialEq)]
pub struct RenderedSnippet {
pub tabstops: Vec<Tabstop>,
pub ranges: Vec<Range>,
}
impl RenderedSnippet {
pub fn first_selection(&self, direction: Direction, primary_idx: usize) -> Selection {
self.tabstops[0].selection(direction, primary_idx, self.ranges.len())
}
}
impl Index<TabstopIdx> for RenderedSnippet {
type Output = Tabstop;
fn index(&self, index: TabstopIdx) -> &Tabstop {
&self.tabstops[index.0]
}
}
impl IndexMut<TabstopIdx> for RenderedSnippet {
fn index_mut(&mut self, index: TabstopIdx) -> &mut Tabstop {
&mut self.tabstops[index.0]
}
}
impl Snippet {
pub fn prepare_render(&self) -> RenderedSnippet {
let tabstops =
self.tabstops()
.map(|tabstop| Tabstop {
ranges: SmallVec::new(),
parent: tabstop.parent,
kind: match &tabstop.kind {
elaborate::TabstopKind::Choice { choices } => TabstopKind::Choice {
choices: choices.clone(),
},
// start out as empty: the first non-empty placeholder will change this to
// a placeholder automatically
elaborate::TabstopKind::Empty
| elaborate::TabstopKind::Placeholder { .. } => TabstopKind::Empty,
elaborate::TabstopKind::Transform(transform) => {
TabstopKind::Transform(transform.clone())
}
},
})
.collect();
RenderedSnippet {
tabstops,
ranges: Vec::new(),
}
}
pub fn render_at(
&self,
snippet: &mut RenderedSnippet,
indent: RopeSlice<'_>,
at_newline: bool,
ctx: &mut SnippetRenderCtx,
pos: usize,
) -> (Tendril, usize) {
let mut ctx = SnippetRender {
dst: snippet,
src: self,
indent,
text: Tendril::new(),
off: pos,
ctx,
at_newline,
};
ctx.render_elements(self.elements());
let end = ctx.off;
let text = ctx.text;
snippet.ranges.push(Range { start: pos, end });
(text, end - pos)
}
pub fn render(
&self,
doc: &Rope,
selection: &Selection,
change_range: impl FnMut(&selection::Range) -> (usize, usize),
ctx: &mut SnippetRenderCtx,
) -> (Transaction, Selection, RenderedSnippet) {
let mut snippet = self.prepare_render();
let mut off = 0;
let (transaction, selection) = Transaction::change_by_selection_ignore_overlapping(
doc,
selection,
change_range,
|replacement_start, replacement_end| {
let line_idx = doc.char_to_line(replacement_start);
let line_start = doc.line_to_char(line_idx);
let prefix = doc.slice(line_start..replacement_start);
let indent_len = prefix.chars().take_while(|c| c.is_whitespace()).count();
let indent = prefix.slice(..indent_len);
let at_newline = indent_len == replacement_start - line_start;
let (replacement, replacement_len) = self.render_at(
&mut snippet,
indent,
at_newline,
ctx,
(replacement_start as i128 + off) as usize,
);
off +=
replacement_start as i128 - replacement_end as i128 + replacement_len as i128;
Some(replacement)
},
);
(transaction, selection, snippet)
}
}
pub type VariableResolver = dyn FnMut(&str) -> Option<Cow<str>>;
pub struct SnippetRenderCtx {
pub resolve_var: Box<VariableResolver>,
pub tab_width: usize,
pub indent_style: IndentStyle,
pub line_ending: &'static str,
}
impl SnippetRenderCtx {
#[cfg(test)]
pub(super) fn test_ctx() -> SnippetRenderCtx {
SnippetRenderCtx {
resolve_var: Box::new(|_| None),
tab_width: 4,
indent_style: IndentStyle::Spaces(4),
line_ending: "\n",
}
}
}
struct SnippetRender<'a> {
ctx: &'a mut SnippetRenderCtx,
dst: &'a mut RenderedSnippet,
src: &'a Snippet,
indent: RopeSlice<'a>,
text: Tendril,
off: usize,
at_newline: bool,
}
impl SnippetRender<'_> {
fn render_elements(&mut self, elements: &[SnippetElement]) {
for element in elements {
self.render_element(element)
}
}
fn render_element(&mut self, element: &SnippetElement) {
match *element {
SnippetElement::Tabstop { idx } => self.render_tabstop(idx),
SnippetElement::Variable {
ref name,
ref default,
ref transform,
} => {
// TODO: allow resolve_var access to the doc and make it return rope slice
// so we can access selections and other document content without allocating
if let Some(val) = (self.ctx.resolve_var)(name) {
if let Some(transform) = transform {
self.push_multiline_str(&transform.apply(
(&*val).into(),
Range {
start: 0,
end: val.chars().count(),
},
));
} else {
self.push_multiline_str(&val)
}
} else if let Some(default) = default {
self.render_elements(default)
}
}
SnippetElement::Text(ref text) => self.push_multiline_str(text),
}
}
fn push_multiline_str(&mut self, text: &str) {
let mut lines = text
.split('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line));
let first_line = lines.next().unwrap();
self.push_str(first_line, self.at_newline);
for line in lines {
self.push_newline();
self.push_str(line, true);
}
}
fn push_str(&mut self, mut text: &str, at_newline: bool) {
if at_newline {
let old_len = self.text.len();
let old_indent_len = normalize_indentation(
self.indent,
text.into(),
&mut self.text,
self.ctx.indent_style,
self.ctx.tab_width,
);
// this is ok because indentation can only be ascii chars (' ' and '\t')
self.off += self.text.len() - old_len;
text = &text[old_indent_len..];
if text.is_empty() {
self.at_newline = true;
return;
}
}
self.text.push_str(text);
self.off += text.chars().count();
}
fn push_newline(&mut self) {
self.off += self.ctx.line_ending.chars().count() + self.indent.len_chars();
self.text.push_str(self.ctx.line_ending);
self.text.extend(self.indent.chunks());
}
fn render_tabstop(&mut self, tabstop: TabstopIdx) {
let start = self.off;
let end = match &self.src[tabstop].kind {
elaborate::TabstopKind::Placeholder { default } if !default.is_empty() => {
self.render_elements(default);
self.dst[tabstop].kind = TabstopKind::Placeholder;
self.off
}
_ => start,
};
self.dst[tabstop].ranges.push(Range { start, end });
}
}
#[cfg(test)]
mod tests {
use helix_stdx::Range;
use crate::snippets::render::Tabstop;
use crate::snippets::{Snippet, SnippetRenderCtx};
use super::TabstopKind;
fn assert_snippet(snippet: &str, expect: &str, tabstops: &[Tabstop]) {
let snippet = Snippet::parse(snippet).unwrap();
let mut rendered_snippet = snippet.prepare_render();
let rendered_text = snippet
.render_at(
&mut rendered_snippet,
"\t".into(),
false,
&mut SnippetRenderCtx::test_ctx(),
0,
)
.0;
assert_eq!(rendered_text, expect);
assert_eq!(&rendered_snippet.tabstops, tabstops);
assert_eq!(
rendered_snippet.ranges.last().unwrap().end,
rendered_text.chars().count()
);
assert_eq!(rendered_snippet.ranges.last().unwrap().start, 0)
}
#[test]
fn rust_macro() {
assert_snippet(
"macro_rules! ${1:name} {\n\t($3) => {\n\t\t$2\n\t};\n}",
"macro_rules! name {\n\t () => {\n\t \n\t };\n\t}",
&[
Tabstop {
ranges: vec![Range { start: 13, end: 17 }].into(),
parent: None,
kind: TabstopKind::Placeholder,
},
Tabstop {
ranges: vec![Range { start: 42, end: 42 }].into(),
parent: None,
kind: TabstopKind::Empty,
},
Tabstop {
ranges: vec![Range { start: 26, end: 26 }].into(),
parent: None,
kind: TabstopKind::Empty,
},
Tabstop {
ranges: vec![Range { start: 53, end: 53 }].into(),
parent: None,
kind: TabstopKind::Empty,
},
],
);
}
}

View File

@@ -1,16 +1,18 @@
use std::fmt::Display;
use crate::{
graphemes::next_grapheme_boundary,
match_brackets::{
find_matching_bracket, find_matching_bracket_fuzzy, get_pair, is_close_bracket,
is_open_bracket,
},
movement::Direction,
search, Range, Selection, Syntax,
};
use crate::{movement::Direction, search, Range, Selection};
use ropey::RopeSlice;
pub const PAIRS: &[(char, char)] = &[
('(', ')'),
('[', ']'),
('{', '}'),
('<', '>'),
('«', '»'),
('「', '」'),
('', ''),
];
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
PairNotFound,
@@ -32,68 +34,32 @@ impl Display for Error {
type Result<T> = std::result::Result<T, Error>;
/// Finds the position of surround pairs of any [`crate::match_brackets::PAIRS`]
/// using tree-sitter when possible.
/// Given any char in [PAIRS], return the open and closing chars. If not found in
/// [PAIRS] return (ch, ch).
///
/// # Returns
/// ```
/// use helix_core::surround::get_pair;
///
/// Tuple `(anchor, head)`, meaning it is not always ordered.
/// assert_eq!(get_pair('['), ('[', ']'));
/// assert_eq!(get_pair('}'), ('{', '}'));
/// assert_eq!(get_pair('"'), ('"', '"'));
/// ```
pub fn get_pair(ch: char) -> (char, char) {
PAIRS
.iter()
.find(|(open, close)| *open == ch || *close == ch)
.copied()
.unwrap_or((ch, ch))
}
pub fn find_nth_closest_pairs_pos(
syntax: Option<&Syntax>,
text: RopeSlice,
range: Range,
skip: usize,
) -> Result<(usize, usize)> {
match syntax {
Some(syntax) => find_nth_closest_pairs_ts(syntax, text, range, skip),
None => find_nth_closest_pairs_plain(text, range, skip),
}
}
fn find_nth_closest_pairs_ts(
syntax: &Syntax,
text: RopeSlice,
range: Range,
mut skip: usize,
) -> Result<(usize, usize)> {
let mut opening = range.from();
// We want to expand the selection if we are already on the found pair,
// otherwise we would need to subtract "-1" from "range.to()".
let mut closing = range.to();
let is_open_pair = |ch| PAIRS.iter().any(|(open, _)| *open == ch);
let is_close_pair = |ch| PAIRS.iter().any(|(_, close)| *close == ch);
while skip > 0 {
closing = find_matching_bracket_fuzzy(syntax, text, closing).ok_or(Error::PairNotFound)?;
opening = find_matching_bracket(syntax, text, closing).ok_or(Error::PairNotFound)?;
// If we're already on a closing bracket "find_matching_bracket_fuzzy" will return
// the position of the opening bracket.
if closing < opening {
(opening, closing) = (closing, opening);
}
// In case found brackets are partially inside current selection.
if range.from() < opening || closing < range.to() - 1 {
closing = next_grapheme_boundary(text, closing);
} else {
skip -= 1;
if skip != 0 {
closing = next_grapheme_boundary(text, closing);
}
}
}
// Keep the original direction.
if let Direction::Forward = range.direction() {
Ok((opening, closing))
} else {
Ok((closing, opening))
}
}
fn find_nth_closest_pairs_plain(
text: RopeSlice,
range: Range,
mut skip: usize,
) -> Result<(usize, usize)> {
let mut stack = Vec::with_capacity(2);
let pos = range.from();
let mut close_pos = pos.saturating_sub(1);
@@ -101,7 +67,7 @@ fn find_nth_closest_pairs_plain(
for ch in text.chars_at(pos) {
close_pos += 1;
if is_open_bracket(ch) {
if is_open_pair(ch) {
// Track open pairs encountered so that we can step over
// the corresponding close pairs that will come up further
// down the loop. We want to find a lone close pair whose
@@ -110,7 +76,7 @@ fn find_nth_closest_pairs_plain(
continue;
}
if !is_close_bracket(ch) {
if !is_close_pair(ch) {
// We don't care if this character isn't a brace pair item,
// so short circuit here.
continue;
@@ -191,11 +157,7 @@ pub fn find_nth_pairs_pos(
)
};
// preserve original direction
match range.direction() {
Direction::Forward => Option::zip(open, close).ok_or(Error::PairNotFound),
Direction::Backward => Option::zip(close, open).ok_or(Error::PairNotFound),
}
Option::zip(open, close).ok_or(Error::PairNotFound)
}
fn find_nth_open_pair(
@@ -205,10 +167,6 @@ fn find_nth_open_pair(
mut pos: usize,
n: usize,
) -> Option<usize> {
if pos >= text.len_chars() {
return None;
}
let mut chars = text.chars_at(pos + 1);
// Adjusts pos for the first iteration, and handles the case of the
@@ -287,7 +245,6 @@ fn find_nth_close_pair(
/// are automatically detected around each cursor (note that this may result
/// in them selecting different surround characters for each selection).
pub fn get_surround_pos(
syntax: Option<&Syntax>,
text: RopeSlice,
selection: &Selection,
ch: Option<char>,
@@ -296,19 +253,14 @@ pub fn get_surround_pos(
let mut change_pos = Vec::new();
for &range in selection {
let (open_pos, close_pos) = {
let range_raw = match ch {
Some(ch) => find_nth_pairs_pos(text, ch, range, skip)?,
None => find_nth_closest_pairs_pos(syntax, text, range, skip)?,
};
let range = Range::new(range_raw.0, range_raw.1);
(range.from(), range.to())
let (open_pos, close_pos) = match ch {
Some(ch) => find_nth_pairs_pos(text, ch, range, skip)?,
None => find_nth_closest_pairs_pos(text, range, skip)?,
};
if change_pos.contains(&open_pos) || change_pos.contains(&close_pos) {
return Err(Error::CursorOverlap);
}
// ensure the positions are always paired in the forward direction
change_pos.extend_from_slice(&[open_pos.min(close_pos), close_pos.max(open_pos)]);
change_pos.extend_from_slice(&[open_pos, close_pos]);
}
Ok(change_pos)
}
@@ -331,7 +283,7 @@ mod test {
);
assert_eq!(
get_surround_pos(None, doc.slice(..), &selection, Some('('), 1).unwrap(),
get_surround_pos(doc.slice(..), &selection, Some('('), 1).unwrap(),
expectations
);
}
@@ -346,7 +298,7 @@ mod test {
);
assert_eq!(
get_surround_pos(None, doc.slice(..), &selection, Some('('), 1),
get_surround_pos(doc.slice(..), &selection, Some('('), 1),
Err(Error::PairNotFound)
);
}
@@ -361,7 +313,7 @@ mod test {
);
assert_eq!(
get_surround_pos(None, doc.slice(..), &selection, Some('('), 1),
get_surround_pos(doc.slice(..), &selection, Some('('), 1),
Err(Error::PairNotFound) // overlapping surround chars
);
}
@@ -376,7 +328,7 @@ mod test {
);
assert_eq!(
get_surround_pos(None, doc.slice(..), &selection, Some('['), 1),
get_surround_pos(doc.slice(..), &selection, Some('['), 1),
Err(Error::CursorOverlap)
);
}
@@ -430,21 +382,6 @@ mod test {
)
}
#[test]
fn test_find_nth_closest_pairs_pos_index_range_panic() {
#[rustfmt::skip]
let (doc, selection, _) =
rope_with_selections_and_expectations(
"(a)c)",
"^^^^^"
);
assert_eq!(
find_nth_closest_pairs_pos(None, doc.slice(..), selection.primary(), 1),
Err(Error::PairNotFound)
)
}
// Create a Rope and a matching Selection using a specification language.
// ^ is a single-point selection.
// _ is an expected index. These are returned as a Vec<usize> for use in assertions.

File diff suppressed because it is too large Load Diff

View File

@@ -1,617 +0,0 @@
use crate::{auto_pairs::AutoPairs, diagnostic::Severity, Language};
use globset::GlobSet;
use helix_stdx::rope;
use serde::{ser::SerializeSeq as _, Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
fmt::{self, Display},
path::PathBuf,
str::FromStr,
};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Configuration {
pub language: Vec<LanguageConfiguration>,
#[serde(default)]
pub language_server: HashMap<String, LanguageServerConfiguration>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct LanguageConfiguration {
#[serde(skip)]
pub(super) language: Option<Language>,
#[serde(rename = "name")]
pub language_id: String, // c-sharp, rust, tsx
#[serde(rename = "language-id")]
// see the table under https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem
pub language_server_language_id: Option<String>, // csharp, rust, typescriptreact, for the language-server
pub scope: String, // source.rust
pub file_types: Vec<FileType>, // filename extension or ends_with? <Gemfile, rb, etc>
#[serde(default)]
pub shebangs: Vec<String>, // interpreter(s) associated with language
#[serde(default)]
pub roots: Vec<String>, // these indicate project roots <.git, Cargo.toml>
#[serde(
default,
skip_serializing,
deserialize_with = "from_comment_tokens",
alias = "comment-token"
)]
pub comment_tokens: Option<Vec<String>>,
#[serde(
default,
skip_serializing,
deserialize_with = "from_block_comment_tokens"
)]
pub block_comment_tokens: Option<Vec<BlockCommentToken>>,
pub text_width: Option<usize>,
pub soft_wrap: Option<SoftWrap>,
#[serde(default)]
pub auto_format: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<FormatterConfiguration>,
/// If set, overrides `editor.path-completion`.
pub path_completion: Option<bool>,
#[serde(default)]
pub diagnostic_severity: Severity,
pub grammar: Option<String>, // tree-sitter grammar name, defaults to language_id
// content_regex
#[serde(default, skip_serializing, deserialize_with = "deserialize_regex")]
pub injection_regex: Option<rope::Regex>,
// first_line_regex
//
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
serialize_with = "serialize_lang_features",
deserialize_with = "deserialize_lang_features"
)]
pub language_servers: Vec<LanguageServerFeatures>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indent: Option<IndentationConfiguration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debugger: Option<DebugAdapterConfig>,
/// Automatic insertion of pairs to parentheses, brackets,
/// etc. Defaults to true. Optionally, this can be a list of 2-tuples
/// to specify a list of characters to pair. This overrides the
/// global setting.
#[serde(default, skip_serializing, deserialize_with = "deserialize_auto_pairs")]
pub auto_pairs: Option<AutoPairs>,
pub rulers: Option<Vec<u16>>, // if set, override editor's rulers
/// Hardcoded LSP root directories relative to the workspace root, like `examples` or `tools/fuzz`.
/// Falling back to the current working directory if none are configured.
pub workspace_lsp_roots: Option<Vec<PathBuf>>,
#[serde(default)]
pub persistent_diagnostic_sources: Vec<String>,
}
impl LanguageConfiguration {
pub fn language(&self) -> Language {
// This value must be set by `super::Loader::new`.
self.language.unwrap()
}
}
#[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),
/// A Unix-style path glob. This is compared to the file's absolute path, so
/// it can be used to detect files based on their directories. If the glob
/// is not an absolute path and does not already start with a glob pattern,
/// a glob pattern will be prepended to it.
Glob(globset::Glob),
}
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::Glob(glob) => {
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("glob", glob.glob())?;
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, mut glob)) if key == "glob" => {
// If the glob isn't an absolute path or already starts
// with a glob pattern, add a leading glob so we
// properly match relative paths.
if !glob.starts_with('/') && !glob.starts_with("*/") {
glob.insert_str(0, "*/");
}
globset::Glob::new(glob.as_str())
.map(FileType::Glob)
.map_err(|err| {
serde::de::Error::custom(format!("invalid `glob` pattern: {}", err))
})
}
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)
}
}
fn from_comment_tokens<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum CommentTokens {
Multiple(Vec<String>),
Single(String),
}
Ok(
Option::<CommentTokens>::deserialize(deserializer)?.map(|tokens| match tokens {
CommentTokens::Single(val) => vec![val],
CommentTokens::Multiple(vals) => vals,
}),
)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BlockCommentToken {
pub start: String,
pub end: String,
}
impl Default for BlockCommentToken {
fn default() -> Self {
BlockCommentToken {
start: "/*".to_string(),
end: "*/".to_string(),
}
}
}
fn from_block_comment_tokens<'de, D>(
deserializer: D,
) -> Result<Option<Vec<BlockCommentToken>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum BlockCommentTokens {
Multiple(Vec<BlockCommentToken>),
Single(BlockCommentToken),
}
Ok(
Option::<BlockCommentTokens>::deserialize(deserializer)?.map(|tokens| match tokens {
BlockCommentTokens::Single(val) => vec![val],
BlockCommentTokens::Multiple(vals) => vals,
}),
)
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum LanguageServerFeature {
Format,
GotoDeclaration,
GotoDefinition,
GotoTypeDefinition,
GotoReference,
GotoImplementation,
// Goto, use bitflags, combining previous Goto members?
SignatureHelp,
Hover,
DocumentHighlight,
Completion,
CodeAction,
WorkspaceCommand,
DocumentSymbols,
WorkspaceSymbols,
// Symbols, use bitflags, see above?
Diagnostics,
RenameSymbol,
InlayHints,
DocumentColors,
}
impl Display for LanguageServerFeature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use LanguageServerFeature::*;
let feature = match self {
Format => "format",
GotoDeclaration => "goto-declaration",
GotoDefinition => "goto-definition",
GotoTypeDefinition => "goto-type-definition",
GotoReference => "goto-reference",
GotoImplementation => "goto-implementation",
SignatureHelp => "signature-help",
Hover => "hover",
DocumentHighlight => "document-highlight",
Completion => "completion",
CodeAction => "code-action",
WorkspaceCommand => "workspace-command",
DocumentSymbols => "document-symbols",
WorkspaceSymbols => "workspace-symbols",
Diagnostics => "diagnostics",
RenameSymbol => "rename-symbol",
InlayHints => "inlay-hints",
DocumentColors => "document-colors",
};
write!(f, "{feature}",)
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
enum LanguageServerFeatureConfiguration {
#[serde(rename_all = "kebab-case")]
Features {
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
only_features: HashSet<LanguageServerFeature>,
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
except_features: HashSet<LanguageServerFeature>,
name: String,
},
Simple(String),
}
#[derive(Debug, Default)]
pub struct LanguageServerFeatures {
pub name: String,
pub only: HashSet<LanguageServerFeature>,
pub excluded: HashSet<LanguageServerFeature>,
}
impl LanguageServerFeatures {
pub fn has_feature(&self, feature: LanguageServerFeature) -> bool {
(self.only.is_empty() || self.only.contains(&feature)) && !self.excluded.contains(&feature)
}
}
fn deserialize_lang_features<'de, D>(
deserializer: D,
) -> Result<Vec<LanguageServerFeatures>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Vec<LanguageServerFeatureConfiguration> = Deserialize::deserialize(deserializer)?;
let res = raw
.into_iter()
.map(|config| match config {
LanguageServerFeatureConfiguration::Simple(name) => LanguageServerFeatures {
name,
..Default::default()
},
LanguageServerFeatureConfiguration::Features {
only_features,
except_features,
name,
} => LanguageServerFeatures {
name,
only: only_features,
excluded: except_features,
},
})
.collect();
Ok(res)
}
fn serialize_lang_features<S>(
map: &Vec<LanguageServerFeatures>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut serializer = serializer.serialize_seq(Some(map.len()))?;
for features in map {
let features = if features.only.is_empty() && features.excluded.is_empty() {
LanguageServerFeatureConfiguration::Simple(features.name.to_owned())
} else {
LanguageServerFeatureConfiguration::Features {
only_features: features.only.clone(),
except_features: features.excluded.clone(),
name: features.name.to_owned(),
}
};
serializer.serialize_element(&features)?;
}
serializer.end()
}
fn deserialize_required_root_patterns<'de, D>(deserializer: D) -> Result<Option<GlobSet>, D::Error>
where
D: serde::Deserializer<'de>,
{
let patterns = Vec::<String>::deserialize(deserializer)?;
if patterns.is_empty() {
return Ok(None);
}
let mut builder = globset::GlobSetBuilder::new();
for pattern in patterns {
let glob = globset::Glob::new(&pattern).map_err(serde::de::Error::custom)?;
builder.add(glob);
}
builder.build().map(Some).map_err(serde::de::Error::custom)
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct LanguageServerConfiguration {
pub command: String,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub environment: HashMap<String, String>,
#[serde(default, skip_serializing, deserialize_with = "deserialize_lsp_config")]
pub config: Option<serde_json::Value>,
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(
default,
skip_serializing,
deserialize_with = "deserialize_required_root_patterns"
)]
pub required_root_patterns: Option<GlobSet>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FormatterConfiguration {
pub command: String,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct AdvancedCompletion {
pub name: Option<String>,
pub completion: Option<String>,
pub default: Option<String>,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", untagged)]
pub enum DebugConfigCompletion {
Named(String),
Advanced(AdvancedCompletion),
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum DebugArgumentValue {
String(String),
Array(Vec<String>),
Boolean(bool),
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct DebugTemplate {
pub name: String,
pub request: String,
#[serde(default)]
pub completion: Vec<DebugConfigCompletion>,
pub args: HashMap<String, DebugArgumentValue>,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct DebugAdapterConfig {
pub name: String,
pub transport: String,
#[serde(default)]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
pub port_arg: Option<String>,
pub templates: Vec<DebugTemplate>,
#[serde(default)]
pub quirks: DebuggerQuirks,
}
// Different workarounds for adapters' differences
#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct DebuggerQuirks {
#[serde(default)]
pub absolute_paths: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct IndentationConfiguration {
#[serde(deserialize_with = "deserialize_tab_width")]
pub tab_width: usize,
pub unit: String,
}
/// How the indentation for a newly inserted line should be determined.
/// If the selected heuristic is not available (e.g. because the current
/// language has no tree-sitter indent queries), a simpler one will be used.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IndentationHeuristic {
/// Just copy the indentation of the line that the cursor is currently on.
Simple,
/// Use tree-sitter indent queries to compute the expected absolute indentation level of the new line.
TreeSitter,
/// Use tree-sitter indent queries to compute the expected difference in indentation between the new line
/// and the line before. Add this to the actual indentation level of the line before.
#[default]
Hybrid,
}
/// Configuration for auto pairs
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)]
pub enum AutoPairConfig {
/// Enables or disables auto pairing. False means disabled. True means to use the default pairs.
Enable(bool),
/// The mappings of pairs.
Pairs(HashMap<char, char>),
}
impl Default for AutoPairConfig {
fn default() -> Self {
AutoPairConfig::Enable(true)
}
}
impl From<&AutoPairConfig> for Option<AutoPairs> {
fn from(auto_pair_config: &AutoPairConfig) -> Self {
match auto_pair_config {
AutoPairConfig::Enable(false) => None,
AutoPairConfig::Enable(true) => Some(AutoPairs::default()),
AutoPairConfig::Pairs(pairs) => Some(AutoPairs::new(pairs.iter())),
}
}
}
impl From<AutoPairConfig> for Option<AutoPairs> {
fn from(auto_pairs_config: AutoPairConfig) -> Self {
(&auto_pairs_config).into()
}
}
impl FromStr for AutoPairConfig {
type Err = std::str::ParseBoolError;
// only do bool parsing for runtime setting
fn from_str(s: &str) -> Result<Self, Self::Err> {
let enable: bool = s.parse()?;
Ok(AutoPairConfig::Enable(enable))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct SoftWrap {
/// Soft wrap lines that exceed viewport width. Default to off
// NOTE: Option on purpose because the struct is shared between language config and global config.
// By default the option is None so that the language config falls back to the global config unless explicitly set.
pub enable: Option<bool>,
/// Maximum space left free at the end of the line.
/// This space is used to wrap text at word boundaries. If that is not possible within this limit
/// the word is simply split at the end of the line.
///
/// This is automatically hard-limited to a quarter of the viewport to ensure correct display on small views.
///
/// Default to 20
pub max_wrap: Option<u16>,
/// Maximum number of indentation that can be carried over from the previous line when softwrapping.
/// If a line is indented further then this limit it is rendered at the start of the viewport instead.
///
/// This is automatically hard-limited to a quarter of the viewport to ensure correct display on small views.
///
/// Default to 40
pub max_indent_retain: Option<u16>,
/// Indicator placed at the beginning of softwrapped lines
///
/// Defaults to ↪
pub wrap_indicator: Option<String>,
/// Softwrap at `text_width` instead of viewport width if it is shorter
pub wrap_at_text_width: Option<bool>,
}
fn deserialize_regex<'de, D>(deserializer: D) -> Result<Option<rope::Regex>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer)?
.map(|buf| rope::Regex::new(&buf).map_err(serde::de::Error::custom))
.transpose()
}
fn deserialize_lsp_config<'de, D>(deserializer: D) -> Result<Option<serde_json::Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<toml::Value>::deserialize(deserializer)?
.map(|toml| toml.try_into().map_err(serde::de::Error::custom))
.transpose()
}
fn deserialize_tab_width<'de, D>(deserializer: D) -> Result<usize, D::Error>
where
D: serde::Deserializer<'de>,
{
usize::deserialize(deserializer).and_then(|n| {
if n > 0 && n <= 16 {
Ok(n)
} else {
Err(serde::de::Error::custom(
"tab width must be a value from 1 to 16 inclusive",
))
}
})
}
pub fn deserialize_auto_pairs<'de, D>(deserializer: D) -> Result<Option<AutoPairs>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<AutoPairConfig>::deserialize(deserializer)?.and_then(AutoPairConfig::into))
}
fn default_timeout() -> u64 {
20
}

View File

@@ -65,7 +65,7 @@ pub fn print(s: &str) -> (String, Selection) {
let head_at_beg = iter.next_if_eq(&"|").is_some();
let last_grapheme = |s: &str| {
UnicodeSegmentation::graphemes(s, true)
.next_back()
.last()
.map(String::from)
};

View File

@@ -1,12 +1,9 @@
use std::cell::Cell;
use std::cmp::Ordering;
use std::fmt::Debug;
use std::ops::Range;
use std::ptr::NonNull;
use std::rc::Rc;
use crate::doc_formatter::FormattedGrapheme;
use crate::syntax::{Highlight, OverlayHighlights};
use crate::{Position, Tendril};
use crate::syntax::Highlight;
use crate::Tendril;
/// An inline annotation is continuous text shown
/// on the screen before the grapheme that starts at
@@ -79,118 +76,39 @@ impl Overlay {
}
}
/// Line annotations allow inserting virtual text lines between normal text
/// lines. These lines can be filled with text in the rendering code as their
/// contents have no effect beyond visual appearance.
/// 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`.
///
/// The height of virtual text is usually not known ahead of time as virtual
/// text often requires softwrapping. Furthermore the height of some virtual
/// text like side-by-side diffs depends on the height of the text (again
/// influenced by softwrap) and other virtual text. Therefore line annotations
/// are computed on the fly instead of ahead of time like other annotations.
/// These lines can be filled with text in the rendering code
/// as their contents have no effect beyond visual appearance.
///
/// The core of this trait `insert_virtual_lines` function. It is called at the
/// end of every visual line and allows the `LineAnnotation` to insert empty
/// virtual lines. Apart from that the `LineAnnotation` trait has multiple
/// methods that allow it to track anchors in the document.
///
/// When a new traversal of a document starts `reset_pos` is called. Afterwards
/// the other functions are called with indices that are larger then the
/// one passed to `reset_pos`. This allows performing a binary search (use
/// `partition_point`) in `reset_pos` once and then to only look at the next
/// anchor during each method call.
///
/// The `reset_pos`, `skip_conceal` and `process_anchor` functions all return a
/// `char_idx` anchor. This anchor is stored when transversing the document and
/// when the grapheme at the anchor is traversed the `process_anchor` function
/// is called.
///
/// # Note
///
/// All functions only receive immutable references to `self`.
/// `LineAnnotation`s that want to store an internal position or
/// state of some kind should use `Cell`. Using interior mutability for
/// caches is preferable as otherwise a lot of lifetimes become invariant
/// which complicates APIs a lot.
pub trait LineAnnotation {
/// Resets the internal position to `char_idx`. This function is called
/// when a new traversal of a document starts.
///
/// All `char_idx` passed to `insert_virtual_lines` are strictly monotonically increasing
/// with the first `char_idx` greater or equal to the `char_idx`
/// passed to this function.
///
/// # Returns
///
/// The `char_idx` of the next anchor this `LineAnnotation` is interested in,
/// replaces the currently registered anchor. Return `usize::MAX` to ignore
fn reset_pos(&mut self, _char_idx: usize) -> usize {
usize::MAX
}
/// Called when a text is concealed that contains an anchor registered by this `LineAnnotation`.
/// In this case the line decorations **must** ensure that virtual text anchored within that
/// char range is skipped.
///
/// # Returns
///
/// The `char_idx` of the next anchor this `LineAnnotation` is interested in,
/// **after the end of conceal_end_char_idx**
/// replaces the currently registered anchor. Return `usize::MAX` to ignore
fn skip_concealed_anchors(&mut self, conceal_end_char_idx: usize) -> usize {
self.reset_pos(conceal_end_char_idx)
}
/// Process an anchor (horizontal position is provided) and returns the next anchor.
///
/// # Returns
///
/// The `char_idx` of the next anchor this `LineAnnotation` is interested in,
/// replaces the currently registered anchor. Return `usize::MAX` to ignore
fn process_anchor(&mut self, _grapheme: &FormattedGrapheme) -> usize {
usize::MAX
}
/// This function is called at the end of a visual line to insert virtual text
///
/// # Returns
///
/// The number of additional virtual lines to reserve
///
/// # Note
///
/// The `line_end_visual_pos` parameter indicates the visual vertical distance
/// from the start of block where the traversal starts. This includes the offset
/// from other `LineAnnotations`. This allows inline annotations to consider
/// the height of the text and "align" two different documents (like for side
/// by side diffs). These annotations that want to "align" two documents should
/// therefore be added last so that other virtual text is also considered while aligning
fn insert_virtual_lines(
&mut self,
line_end_char_idx: usize,
line_end_visual_pos: Position,
doc_line: usize,
) -> Position;
/// 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, A, M> {
annotations: &'a [A],
struct Layer<A, M> {
annotations: Rc<[A]>,
current_index: Cell<usize>,
metadata: M,
}
impl<A, M: Clone> Clone for Layer<'_, A, M> {
impl<A, M: Clone> Clone for Layer<A, M> {
fn clone(&self) -> Self {
Layer {
annotations: self.annotations,
annotations: self.annotations.clone(),
current_index: self.current_index.clone(),
metadata: self.metadata.clone(),
}
}
}
impl<A, M> Layer<'_, A, M> {
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
@@ -210,8 +128,8 @@ impl<A, M> Layer<'_, A, M> {
}
}
impl<'a, A, M> From<(&'a [A], M)> for Layer<'a, A, M> {
fn from((annotations, metadata): (&'a [A], M)) -> Layer<'a, A, M> {
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),
@@ -226,81 +144,29 @@ fn reset_pos<A, M>(layers: &[Layer<A, M>], pos: usize, get_pos: impl Fn(&A) -> u
}
}
/// Safety: We store LineAnnotation in a NonNull pointer. This is necessary to work
/// around an unfortunate inconsistency in rusts variance system that unnnecesarily
/// makes the lifetime invariant if implemented with safe code. This makes the
/// DocFormatter API very cumbersome/basically impossible to work with.
///
/// Normally object types `dyn Foo + 'a` are covariant so if we used `Box<dyn LineAnnotation + 'a>` below
/// everything would be alright. However we want to use `Cell<Box<dyn LineAnnotation + 'a>>`
/// to be able to call the mutable function on `LineAnnotation`. The problem is that
/// some types like `Cell` make all their arguments invariant. This is important for soundness
/// normally for the same reasons that `&'a mut T` is invariant over `T`
/// (see <https://doc.rust-lang.org/nomicon/subtyping.html>). However for `&'a mut` (`dyn Foo + 'b`)
/// there is a specical rule in the language to make `'b` covariant (otherwise trait objects would be
/// super annoying to use). See <https://users.rust-lang.org/t/solved-variance-of-dyn-trait-a> for
/// why this is sound. Sadly that rule doesn't apply to `Cell<Box<(dyn Foo + 'a)>`
/// (or other invariant types like `UnsafeCell` or `*mut (dyn Foo + 'a)`).
///
/// We sidestep the problem by using `NonNull` which is covariant. In the
/// special case of trait objects this is sound (easily checked by adding a
/// `PhantomData<&'a mut Foo + 'a)>` field). We don't need an explicit `Cell`
/// type here because we never hand out any refereces to the trait objects. That
/// means any reference to the pointer can create a valid multable reference
/// that is covariant over `'a` (or in other words it's a raw pointer, as long as
/// we don't hand out references we are free to do whatever we want).
struct RawBox<T: ?Sized>(NonNull<T>);
impl<T: ?Sized> RawBox<T> {
/// Safety: Only a single mutable reference
/// created by this function may exist at a given time.
#[allow(clippy::mut_from_ref)]
unsafe fn get(&self) -> &mut T {
&mut *self.0.as_ptr()
}
}
impl<T: ?Sized> From<Box<T>> for RawBox<T> {
fn from(box_: Box<T>) -> Self {
// obviously safe because Box::into_raw never returns null
unsafe { Self(NonNull::new_unchecked(Box::into_raw(box_))) }
}
}
impl<T: ?Sized> Drop for RawBox<T> {
fn drop(&mut self) {
unsafe { drop(Box::from_raw(self.0.as_ptr())) }
}
}
/// Annotations that change that is displayed when the document is render.
/// Also commonly called virtual text.
#[derive(Default)]
pub struct TextAnnotations<'a> {
inline_annotations: Vec<Layer<'a, InlineAnnotation, Option<Highlight>>>,
overlays: Vec<Layer<'a, Overlay, Option<Highlight>>>,
line_annotations: Vec<(Cell<usize>, RawBox<dyn LineAnnotation + 'a>)>,
#[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 Debug for TextAnnotations<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextAnnotations")
.field("inline_annotations", &self.inline_annotations)
.field("overlays", &self.overlays)
.finish_non_exhaustive()
}
}
impl<'a> TextAnnotations<'a> {
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);
for (next_anchor, layer) in &self.line_annotations {
next_anchor.set(unsafe { layer.get().reset_pos(char_idx) });
}
reset_pos(&self.line_annotations, char_idx, |annot| {
annot.anchor_char_idx
});
}
pub fn collect_overlay_highlights(&self, char_range: Range<usize>) -> OverlayHighlights {
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 {
@@ -308,11 +174,11 @@ impl<'a> TextAnnotations<'a> {
// we don't know the number of chars the original grapheme takes
// however it doesn't matter as highlight boundaries are automatically
// aligned to grapheme boundaries in the rendering code
highlights.push((highlight, char_idx..char_idx + 1));
highlights.push((highlight.0, char_idx..char_idx + 1))
}
}
OverlayHighlights::Heterogenous { highlights }
highlights
}
/// Add new inline annotations.
@@ -328,12 +194,10 @@ impl<'a> TextAnnotations<'a> {
/// the annotations that belong to the layers added first will be shown first.
pub fn add_inline_annotations(
&mut self,
layer: &'a [InlineAnnotation],
layer: Rc<[InlineAnnotation]>,
highlight: Option<Highlight>,
) -> &mut Self {
if !layer.is_empty() {
self.inline_annotations.push((layer, highlight).into());
}
self.inline_annotations.push((layer, highlight).into());
self
}
@@ -347,10 +211,8 @@ impl<'a> TextAnnotations<'a> {
///
/// 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: &'a [Overlay], highlight: Option<Highlight>) -> &mut Self {
if !layer.is_empty() {
self.overlays.push((layer, highlight).into());
}
pub fn add_overlay(&mut self, layer: Rc<[Overlay]>, highlight: Option<Highlight>) -> &mut Self {
self.overlays.push((layer, highlight).into());
self
}
@@ -358,9 +220,8 @@ impl<'a> TextAnnotations<'a> {
///
/// 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: Box<dyn LineAnnotation + 'a>) -> &mut Self {
self.line_annotations
.push((Cell::new(usize::MAX), layer.into()));
pub fn add_line_annotation(&mut self, layer: Rc<[LineAnnotation]>) -> &mut Self {
self.line_annotations.push((layer, ()).into());
self
}
@@ -390,35 +251,21 @@ impl<'a> TextAnnotations<'a> {
overlay
}
pub(crate) fn process_virtual_text_anchors(&self, grapheme: &FormattedGrapheme) {
for (next_anchor, layer) in &self.line_annotations {
loop {
match next_anchor.get().cmp(&grapheme.char_idx) {
Ordering::Less => next_anchor
.set(unsafe { layer.get().skip_concealed_anchors(grapheme.char_idx) }),
Ordering::Equal => {
next_anchor.set(unsafe { layer.get().process_anchor(grapheme) })
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;
}
Ordering::Greater => break,
};
}
}
}
pub(crate) fn virtual_lines_at(
&self,
char_idx: usize,
line_end_visual_pos: Position,
doc_line: usize,
) -> usize {
let mut virt_off = Position::new(0, 0);
for (_, layer) in &self.line_annotations {
virt_off += unsafe {
layer
.get()
.insert_virtual_lines(char_idx, line_end_visual_pos + virt_off, doc_line)
};
}
virt_off.row
}
lines
})
.sum()
}
}

View File

@@ -1,14 +1,15 @@
use std::fmt::Display;
use ropey::RopeSlice;
use tree_sitter::{Node, QueryCursor};
use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary};
use crate::line_ending::rope_is_line_ending;
use crate::movement::Direction;
use crate::syntax;
use crate::surround;
use crate::syntax::LanguageConfiguration;
use crate::Range;
use crate::{surround, Syntax};
fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize {
use CharCategory::{Eol, Whitespace};
@@ -198,28 +199,25 @@ pub fn textobject_paragraph(
}
pub fn textobject_pair_surround(
syntax: Option<&Syntax>,
slice: RopeSlice,
range: Range,
textobject: TextObject,
ch: char,
count: usize,
) -> Range {
textobject_pair_surround_impl(syntax, slice, range, textobject, Some(ch), count)
textobject_pair_surround_impl(slice, range, textobject, Some(ch), count)
}
pub fn textobject_pair_surround_closest(
syntax: Option<&Syntax>,
slice: RopeSlice,
range: Range,
textobject: TextObject,
count: usize,
) -> Range {
textobject_pair_surround_impl(syntax, slice, range, textobject, None, count)
textobject_pair_surround_impl(slice, range, textobject, None, count)
}
fn textobject_pair_surround_impl(
syntax: Option<&Syntax>,
slice: RopeSlice,
range: Range,
textobject: TextObject,
@@ -228,7 +226,8 @@ fn textobject_pair_surround_impl(
) -> Range {
let pair_pos = match ch {
Some(ch) => surround::find_nth_pairs_pos(slice, ch, range, count),
None => surround::find_nth_closest_pairs_pos(syntax, slice, range, count),
// Automatically find the closest surround pairs
None => surround::find_nth_closest_pairs_pos(slice, range, count),
};
pair_pos
.map(|(anchor, head)| match textobject {
@@ -259,18 +258,18 @@ pub fn textobject_treesitter(
range: Range,
textobject: TextObject,
object_name: &str,
syntax: &Syntax,
loader: &syntax::Loader,
slice_tree: Node,
lang_config: &LanguageConfiguration,
_count: usize,
) -> Range {
let root = syntax.tree().root_node();
let textobject_query = loader.textobject_query(syntax.root_language());
let get_range = move || -> Option<Range> {
let byte_pos = slice.char_to_byte(range.cursor(slice));
let capture_name = format!("{}.{}", object_name, textobject); // eg. function.inner
let node = textobject_query?
.capture_nodes(&capture_name, &root, slice)?
let mut cursor = QueryCursor::new();
let node = lang_config
.textobject_query()?
.capture_nodes(&capture_name, slice_tree, slice, &mut cursor)?
.filter(|node| node.byte_range().contains(&byte_pos))
.min_by_key(|node| node.byte_range().len())?;
@@ -575,8 +574,7 @@ mod test {
let slice = doc.slice(..);
for &case in scenario {
let (pos, objtype, expected_range, ch, count) = case;
let result =
textobject_pair_surround(None, slice, Range::point(pos), objtype, ch, count);
let result = textobject_pair_surround(slice, Range::point(pos), objtype, ch, count);
assert_eq!(
result,
expected_range.into(),

View File

@@ -29,12 +29,6 @@ pub enum Assoc {
/// Acts like `Before` if a word character is inserted
/// before the position, otherwise acts like `After`
BeforeWord,
/// Acts like `Before` but if the position is within an exact replacement
/// (exact size) the offset to the start of the replacement is kept
BeforeSticky,
/// Acts like `After` but if the position is within an exact replacement
/// (exact size) the offset to the start of the replacement is kept
AfterSticky,
}
impl Assoc {
@@ -46,17 +40,13 @@ impl Assoc {
fn insert_offset(self, s: &str) -> usize {
let chars = s.chars().count();
match self {
Assoc::After | Assoc::AfterSticky => chars,
Assoc::After => chars,
Assoc::AfterWord => s.chars().take_while(|&c| char_is_word(c)).count(),
// return position before inserted text
Assoc::Before | Assoc::BeforeSticky => 0,
Assoc::Before => 0,
Assoc::BeforeWord => chars - s.chars().rev().take_while(|&c| char_is_word(c)).count(),
}
}
pub fn sticky(self) -> bool {
matches!(self, Assoc::BeforeSticky | Assoc::AfterSticky)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
@@ -388,9 +378,7 @@ impl ChangeSet {
macro_rules! map {
($map: expr, $i: expr) => {
loop {
let Some((pos, assoc)) = positions.peek_mut() else {
return;
};
let Some((pos, assoc)) = positions.peek_mut() else { return; };
if **pos < old_pos {
// Positions are not sorted, revert to the last Operation that
// contains this position and continue iterating from there.
@@ -417,10 +405,7 @@ impl ChangeSet {
debug_assert!(old_pos <= **pos, "Reverse Iter across changeset works");
continue 'outer;
}
#[allow(clippy::redundant_closure_call)]
let Some(new_pos) = $map(**pos, *assoc) else {
break;
};
let Some(new_pos) = $map(**pos, *assoc) else { break; };
**pos = new_pos;
positions.next();
}
@@ -466,14 +451,8 @@ impl ChangeSet {
if pos == old_pos && assoc.stay_at_gaps() {
new_pos
} else {
let ins = assoc.insert_offset(s);
// if the deleted and inserted text have the exact same size
// keep the relative offset into the new text
if *len == ins && assoc.sticky() {
new_pos + (pos - old_pos)
} else {
new_pos + assoc.insert_offset(s)
}
// place to end of insert
new_pos + assoc.insert_offset(s)
}
}),
i
@@ -769,7 +748,7 @@ impl<'a> ChangeIterator<'a> {
}
}
impl Iterator for ChangeIterator<'_> {
impl<'a> Iterator for ChangeIterator<'a> {
type Item = Change;
fn next(&mut self) -> Option<Self::Item> {

View File

@@ -1,125 +0,0 @@
use std::{
fmt,
path::{Path, PathBuf},
sync::Arc,
};
/// A generic pointer to a file location.
///
/// Currently this type only supports paths to local files.
///
/// Cloning this type is cheap: the internal representation uses an Arc.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Uri {
File(Arc<Path>),
}
impl Uri {
// This clippy allow mirrors url::Url::from_file_path
#[allow(clippy::result_unit_err)]
pub fn to_url(&self) -> Result<url::Url, ()> {
match self {
Uri::File(path) => url::Url::from_file_path(path),
}
}
pub fn as_path(&self) -> Option<&Path> {
match self {
Self::File(path) => Some(path),
}
}
}
impl From<PathBuf> for Uri {
fn from(path: PathBuf) -> Self {
Self::File(path.into())
}
}
impl fmt::Display for Uri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::File(path) => write!(f, "{}", path.display()),
}
}
}
#[derive(Debug)]
pub struct UrlConversionError {
source: url::Url,
kind: UrlConversionErrorKind,
}
#[derive(Debug)]
pub enum UrlConversionErrorKind {
UnsupportedScheme,
UnableToConvert,
}
impl fmt::Display for UrlConversionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
UrlConversionErrorKind::UnsupportedScheme => {
write!(
f,
"unsupported scheme '{}' in URL {}",
self.source.scheme(),
self.source
)
}
UrlConversionErrorKind::UnableToConvert => {
write!(f, "unable to convert URL to file path: {}", self.source)
}
}
}
}
impl std::error::Error for UrlConversionError {}
fn convert_url_to_uri(url: &url::Url) -> Result<Uri, UrlConversionErrorKind> {
if url.scheme() == "file" {
url.to_file_path()
.map(|path| Uri::File(helix_stdx::path::normalize(path).into()))
.map_err(|_| UrlConversionErrorKind::UnableToConvert)
} else {
Err(UrlConversionErrorKind::UnsupportedScheme)
}
}
impl TryFrom<url::Url> for Uri {
type Error = UrlConversionError;
fn try_from(url: url::Url) -> Result<Self, Self::Error> {
convert_url_to_uri(&url).map_err(|kind| Self::Error { source: url, kind })
}
}
impl TryFrom<&url::Url> for Uri {
type Error = UrlConversionError;
fn try_from(url: &url::Url) -> Result<Self, Self::Error> {
convert_url_to_uri(url).map_err(|kind| Self::Error {
source: url.clone(),
kind,
})
}
}
#[cfg(test)]
mod test {
use super::*;
use url::Url;
#[test]
fn unknown_scheme() {
let url = Url::parse("csharp:/metadata/foo/bar/Baz.cs").unwrap();
assert!(matches!(
Uri::try_from(url),
Err(UrlConversionError {
kind: UrlConversionErrorKind::UnsupportedScheme,
..
})
));
}
}

View File

@@ -4,8 +4,6 @@ use textwrap::{Options, WordSplitter::NoHyphenation};
/// Given a slice of text, return the text re-wrapped to fit it
/// within the given width.
pub fn reflow_hard_wrap(text: &str, text_width: usize) -> SmartString<LazyCompact> {
let options = Options::new(text_width)
.word_splitter(NoHyphenation)
.word_separator(textwrap::WordSeparator::AsciiSpace);
let options = Options::new(text_width).word_splitter(NoHyphenation);
textwrap::refill(text, options).into()
}

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