Merge branch 'master' into resize-windows

pull/8546/head
sander777 7 months ago committed by Sander Cyber
commit 036e5c3195

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

106
Cargo.lock generated

@ -62,9 +62,9 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.81" version = "1.0.82"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
@ -136,9 +136,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.0.90" version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
@ -159,9 +159,9 @@ dependencies = [
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.37" version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [ dependencies = [
"android-tzdata", "android-tzdata",
"iana-time-zone", "iana-time-zone",
@ -171,9 +171,9 @@ dependencies = [
[[package]] [[package]]
name = "clipboard-win" name = "clipboard-win"
version = "5.3.0" version = "5.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d517d4b86184dbb111d3556a10f1c8a04da7428d2987bf1081602bf11c3aa9ee" checksum = "79f4473f5144e20d9aceaf2972478f06ddf687831eafeeb434fbaf0acc4144ad"
dependencies = [ dependencies = [
"error-code", "error-code",
] ]
@ -365,9 +365,9 @@ checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.33" version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
] ]
@ -538,9 +538,9 @@ checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e"
[[package]] [[package]]
name = "gix" name = "gix"
version = "0.61.0" version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e0e59a44bf00de058ee98d6ecf3c9ed8f8842c1da642258ae4120d41ded8f7" checksum = "5631c64fb4cd48eee767bf98a3cbc5c9318ef3bb71074d4c099a2371510282b6"
dependencies = [ dependencies = [
"gix-actor", "gix-actor",
"gix-attributes", "gix-attributes",
@ -663,9 +663,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-config" name = "gix-config"
version = "0.36.0" version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62129c75e4b6229fe15fb9838cdc00c655e87105b651e4edd7c183fc5288b5d1" checksum = "7580e05996e893347ad04e1eaceb92e1c0e6a3ffe517171af99bf6b6df0ca6e5"
dependencies = [ dependencies = [
"bstr", "bstr",
"gix-config-value", "gix-config-value",
@ -709,9 +709,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-diff" name = "gix-diff"
version = "0.42.0" version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78e605593c2ef74980a534ade0909c7dc57cca72baa30cbb67d2dda621f99ac4" checksum = "a5fbc24115b957346cd23fb0f47d830eb799c46c89cdcf2f5acc9bf2938c2d01"
dependencies = [ dependencies = [
"bstr", "bstr",
"gix-command", "gix-command",
@ -729,9 +729,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-dir" name = "gix-dir"
version = "0.3.0" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3413ccd29130900c17574678aee640e4847909acae9febf6424dc77b782c6d32" checksum = "d6943a1f213ad7a060a0548ece229be53f3c2151534b126446ce3533eaf5f14c"
dependencies = [ dependencies = [
"bstr", "bstr",
"gix-discover", "gix-discover",
@ -784,9 +784,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-filter" name = "gix-filter"
version = "0.11.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd71bf3e64d8fb5d5635d4166ca5a36fe56b292ffff06eab1d93ea47fd5beb89" checksum = "5c0d1f01af62bfd2fb3dd291acc2b29d4ab3e96ad52a679174626508ce98ef12"
dependencies = [ dependencies = [
"bstr", "bstr",
"encoding_rs", "encoding_rs",
@ -805,9 +805,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-fs" name = "gix-fs"
version = "0.10.1" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "634b8a743b0aae03c1a74ee0ea24e8c5136895efac64ce52b3ea106e1c6f0613" checksum = "e2184c40e7910529677831c8b481acf788ffd92427ed21fad65b6aa637e631b8"
dependencies = [ dependencies = [
"gix-features", "gix-features",
"gix-utils", "gix-utils",
@ -861,9 +861,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-index" name = "gix-index"
version = "0.31.1" version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "549621f13d9ccf325a7de45506a3266af0d08f915181c5687abb5e8669bfd2e6" checksum = "3383122cf18655ef4c097c0b935bba5eb56983947959aaf3b0ceb1949d4dd371"
dependencies = [ dependencies = [
"bitflags 2.5.0", "bitflags 2.5.0",
"bstr", "bstr",
@ -929,9 +929,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-odb" name = "gix-odb"
version = "0.59.0" version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81b55378c719693380f66d9dd21ce46721eed2981d8789fc698ec1ada6fa176e" checksum = "e8bbb43d2fefdc4701ffdf9224844d05b136ae1b9a73c2f90710c8dd27a93503"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"gix-date", "gix-date",
@ -949,9 +949,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-pack" name = "gix-pack"
version = "0.49.0" version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6391aeaa030ad64aba346a9f5c69bb1c4e5c6fb4411705b03b40b49d8614ec30" checksum = "b58bad27c7677fa6b587aab3a1aca0b6c97373bd371a0a4290677c838c9bcaf1"
dependencies = [ dependencies = [
"clru", "clru",
"gix-chunk", "gix-chunk",
@ -969,9 +969,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-packetline-blocking" name = "gix-packetline-blocking"
version = "0.17.3" version = "0.17.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca8ef6dd3ea50e26f3bf572e90c034d033c804d340cd1eb386392f184a9ba2f7" checksum = "c31d42378a3d284732e4d589979930d0d253360eccf7ec7a80332e5ccb77e14a"
dependencies = [ dependencies = [
"bstr", "bstr",
"faster-hex", "faster-hex",
@ -994,9 +994,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-pathspec" name = "gix-pathspec"
version = "0.7.2" version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a96ed0e71ce9084a471fddfa74e842576a7cbf02fe8bd50388017ac461aed97" checksum = "d479789f3abd10f68a709454ce04cd68b54092ee882c8622ae3aa1bb9bf8496c"
dependencies = [ dependencies = [
"bitflags 2.5.0", "bitflags 2.5.0",
"bstr", "bstr",
@ -1099,9 +1099,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-status" name = "gix-status"
version = "0.8.0" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca216db89947eca709f69ec5851aa76f9628e7c7aab7aa5a927d0c619d046bf2" checksum = "50c413bfd2952e4ee92e48438dac3c696f3555e586a34d184a427f6bedd1e4f9"
dependencies = [ dependencies = [
"bstr", "bstr",
"filetime", "filetime",
@ -1150,16 +1150,17 @@ dependencies = [
[[package]] [[package]]
name = "gix-trace" name = "gix-trace"
version = "0.1.8" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b838b2db8f62c9447d483a4c28d251b67fee32741a82cb4d35e9eb4e9fdc5ab" checksum = "f924267408915fddcd558e3f37295cc7d6a3e50f8bd8b606cee0808c3915157e"
[[package]] [[package]]
name = "gix-traverse" name = "gix-traverse"
version = "0.38.0" version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95aef84bc777025403a09788b1e4815c06a19332e9e5d87a955e1ed7da9bf0cf" checksum = "f4029ec209b0cc480d209da3837a42c63801dd8548f09c1f4502c60accb62aeb"
dependencies = [ dependencies = [
"bitflags 2.5.0",
"gix-commitgraph", "gix-commitgraph",
"gix-date", "gix-date",
"gix-hash", "gix-hash",
@ -1172,9 +1173,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-url" name = "gix-url"
version = "0.27.2" version = "0.27.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f0b24f3ecc79a5a53539de9c2e99425d0ef23feacdcf3faac983aa9a2f26849" checksum = "0db829ebdca6180fbe32be7aed393591df6db4a72dbbc0b8369162390954d1cf"
dependencies = [ dependencies = [
"bstr", "bstr",
"gix-features", "gix-features",
@ -1186,9 +1187,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-utils" name = "gix-utils"
version = "0.1.11" version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0066432d4c277f9877f091279a597ea5331f68ca410efc874f0bdfb1cd348f92" checksum = "35192df7fd0fa112263bad8021e2df7167df4cc2a6e6d15892e1e55621d3d4dc"
dependencies = [ dependencies = [
"bstr", "bstr",
"fastrand", "fastrand",
@ -1207,9 +1208,9 @@ dependencies = [
[[package]] [[package]]
name = "gix-worktree" name = "gix-worktree"
version = "0.32.0" version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe78e03af9eec168eb187e05463a981c57f0a915f64b1788685a776bd2ef969c" checksum = "359a87dfef695b5f91abb9a424c947edca82768f34acfc269659f66174a510b4"
dependencies = [ dependencies = [
"bstr", "bstr",
"gix-attributes", "gix-attributes",
@ -1392,6 +1393,7 @@ dependencies = [
"parking_lot", "parking_lot",
"serde", "serde",
"serde_json", "serde_json",
"slotmap",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@ -2092,18 +2094,18 @@ checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.197" version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.197" version = "1.0.198"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2112,9 +2114,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.115" version = "1.0.116"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813"
dependencies = [ dependencies = [
"itoa", "itoa",
"ryu", "ryu",
@ -2471,9 +2473,9 @@ dependencies = [
[[package]] [[package]]
name = "tree-sitter" name = "tree-sitter"
version = "0.22.2" version = "0.22.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb9c9f15eae91dcd00ee0d86a281d16e6263786991b662b34fa9632c21a046b" checksum = "688200d842c76dd88f9a7719ecb0483f79f5a766fb1c100756d5d8a059abc71b"
dependencies = [ dependencies = [
"cc", "cc",
"regex", "regex",

@ -39,6 +39,7 @@ package.helix-term.opt-level = 2
[workspace.dependencies] [workspace.dependencies]
tree-sitter = { version = "0.22" } tree-sitter = { version = "0.22" }
nucleo = "0.2.0" nucleo = "0.2.0"
slotmap = "1.0.7"
[workspace.package] [workspace.package]
version = "24.3.0" version = "24.3.0"

@ -29,6 +29,7 @@
"namespace" = "magenta" "namespace" = "magenta"
"ui.help" = { fg = "white", bg = "black" } "ui.help" = { fg = "white", bg = "black" }
"ui.virtual.jump-label" = { fg = "blue", modifiers = ["bold", "underlined"] } "ui.virtual.jump-label" = { fg = "blue", modifiers = ["bold", "underlined"] }
"ui.virtual.ruler" = { bg = "black" }
"markup.heading" = "blue" "markup.heading" = "blue"
"markup.list" = "red" "markup.list" = "red"

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

@ -0,0 +1,158 @@
## 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)
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 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 -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
```

@ -33,372 +33,3 @@ signal to the Helix process on Unix operating systems, such as by using the comm
Finally, you can have a `config.toml` local to a project by putting it under a `.helix` directory in your repository. 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. 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. | `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` |
| `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"`
### `[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-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) |
| `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 | 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.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"
```

@ -0,0 +1,386 @@
## Editor
- [`[editor]` Section](#editor-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.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]` 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. | `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` |
| `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"`
### `[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-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) |
| `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 | 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.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"
```

@ -1,6 +1,7 @@
| Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default LSP | | Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default LSP |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| ada | ✓ | ✓ | | `ada_language_server`, `ada_language_server` | | ada | ✓ | ✓ | | `ada_language_server`, `ada_language_server` |
| adl | ✓ | ✓ | ✓ | |
| agda | ✓ | | | | | agda | ✓ | | | |
| astro | ✓ | | | | | astro | ✓ | | | |
| awk | ✓ | ✓ | | `awk-language-server` | | awk | ✓ | ✓ | | `awk-language-server` |
@ -61,6 +62,7 @@
| git-config | ✓ | | | | | git-config | ✓ | | | |
| git-ignore | ✓ | | | | | git-ignore | ✓ | | | |
| git-rebase | ✓ | | | | | git-rebase | ✓ | | | |
| gjs | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` |
| gleam | ✓ | ✓ | | `gleam` | | gleam | ✓ | ✓ | | `gleam` |
| glimmer | ✓ | | | `ember-language-server` | | glimmer | ✓ | | | `ember-language-server` |
| glsl | ✓ | ✓ | ✓ | | | glsl | ✓ | ✓ | ✓ | |
@ -72,6 +74,7 @@
| gowork | ✓ | | | `gopls` | | gowork | ✓ | | | `gopls` |
| graphql | ✓ | ✓ | | `graphql-lsp` | | graphql | ✓ | ✓ | | `graphql-lsp` |
| groovy | ✓ | | | | | groovy | ✓ | | | |
| gts | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` |
| hare | ✓ | | | | | hare | ✓ | | | |
| haskell | ✓ | ✓ | | `haskell-language-server-wrapper` | | haskell | ✓ | ✓ | | `haskell-language-server-wrapper` |
| haskell-persistent | ✓ | | | | | haskell-persistent | ✓ | | | |
@ -82,11 +85,12 @@
| hoon | ✓ | | | | | hoon | ✓ | | | |
| hosts | ✓ | | | | | hosts | ✓ | | | |
| html | ✓ | | | `vscode-html-language-server` | | html | ✓ | | | `vscode-html-language-server` |
| hurl | ✓ | | ✓ | | | hurl | ✓ | | ✓ | |
| hyprlang | ✓ | | ✓ | | | hyprlang | ✓ | | ✓ | |
| idris | | | | `idris2-lsp` | | idris | | | | `idris2-lsp` |
| iex | ✓ | | | | | iex | ✓ | | | |
| ini | ✓ | | | | | ini | ✓ | | | |
| inko | ✓ | ✓ | ✓ | |
| janet | ✓ | | | | | janet | ✓ | | | |
| java | ✓ | ✓ | ✓ | `jdtls` | | java | ✓ | ✓ | ✓ | `jdtls` |
| javascript | ✓ | ✓ | ✓ | `typescript-language-server` | | javascript | ✓ | ✓ | ✓ | `typescript-language-server` |
@ -100,10 +104,11 @@
| julia | ✓ | ✓ | ✓ | `julia` | | julia | ✓ | ✓ | ✓ | `julia` |
| just | ✓ | ✓ | ✓ | | | just | ✓ | ✓ | ✓ | |
| kdl | ✓ | ✓ | ✓ | | | kdl | ✓ | ✓ | ✓ | |
| koka | ✓ | | ✓ | | | koka | ✓ | | ✓ | `koka` |
| kotlin | ✓ | | | `kotlin-language-server` | | kotlin | ✓ | | | `kotlin-language-server` |
| latex | ✓ | ✓ | | `texlab` | | latex | ✓ | ✓ | | `texlab` |
| ld | ✓ | | ✓ | | | ld | ✓ | | ✓ | |
| ldif | ✓ | | | |
| lean | ✓ | | | `lean` | | lean | ✓ | | | `lean` |
| ledger | ✓ | | | | | ledger | ✓ | | | |
| llvm | ✓ | ✓ | ✓ | | | llvm | ✓ | ✓ | ✓ | |
@ -120,6 +125,7 @@
| mermaid | ✓ | | | | | mermaid | ✓ | | | |
| meson | ✓ | | ✓ | | | meson | ✓ | | ✓ | |
| mint | | | | `mint` | | mint | | | | `mint` |
| move | ✓ | | | |
| msbuild | ✓ | | ✓ | | | msbuild | ✓ | | ✓ | |
| nasm | ✓ | ✓ | | | | nasm | ✓ | ✓ | | |
| nickel | ✓ | | ✓ | `nls` | | nickel | ✓ | | ✓ | `nls` |
@ -138,6 +144,7 @@
| passwd | ✓ | | | | | passwd | ✓ | | | |
| pem | ✓ | | | | | pem | ✓ | | | |
| perl | ✓ | ✓ | ✓ | `perlnavigator` | | perl | ✓ | ✓ | ✓ | `perlnavigator` |
| pest | ✓ | ✓ | ✓ | `pest-language-server` |
| php | ✓ | ✓ | ✓ | `intelephense` | | php | ✓ | ✓ | ✓ | `intelephense` |
| php-only | ✓ | | | | | php-only | ✓ | | | |
| pkgbuild | ✓ | ✓ | ✓ | `pkgbuild-language-server`, `bash-language-server` | | pkgbuild | ✓ | ✓ | ✓ | `pkgbuild-language-server`, `bash-language-server` |
@ -172,7 +179,7 @@
| smali | ✓ | | ✓ | | | smali | ✓ | | ✓ | |
| smithy | ✓ | | | `cs` | | smithy | ✓ | | | `cs` |
| sml | ✓ | | | | | sml | ✓ | | | |
| solidity | ✓ | | | `solc` | | solidity | ✓ | | | `solc` |
| spicedb | ✓ | | | | | spicedb | ✓ | | | |
| sql | ✓ | | | | | sql | ✓ | | | |
| sshclientconfig | ✓ | | | | | sshclientconfig | ✓ | | | |
@ -195,7 +202,7 @@
| tsx | ✓ | ✓ | ✓ | `typescript-language-server` | | tsx | ✓ | ✓ | ✓ | `typescript-language-server` |
| twig | ✓ | | | | | twig | ✓ | | | |
| typescript | ✓ | ✓ | ✓ | `typescript-language-server` | | typescript | ✓ | ✓ | ✓ | `typescript-language-server` |
| typst | ✓ | | | `typst-lsp` | | typst | ✓ | | | `tinymist`, `typst-lsp` |
| ungrammar | ✓ | | | | | ungrammar | ✓ | | | |
| unison | ✓ | | ✓ | | | unison | ✓ | | ✓ | |
| uxntal | ✓ | | | | | uxntal | ✓ | | | |
@ -213,6 +220,7 @@
| wren | ✓ | ✓ | ✓ | | | wren | ✓ | ✓ | ✓ | |
| xit | ✓ | | | | | xit | ✓ | | | |
| xml | ✓ | | ✓ | | | xml | ✓ | | ✓ | |
| xtc | ✓ | | | |
| yaml | ✓ | | ✓ | `yaml-language-server`, `ansible-language-server` | | yaml | ✓ | | ✓ | `yaml-language-server`, `ansible-language-server` |
| yuck | ✓ | | | | | yuck | ✓ | | | |
| zig | ✓ | ✓ | ✓ | `zls` | | zig | ✓ | ✓ | ✓ | `zls` |

@ -87,3 +87,4 @@
| `:redraw` | Clear and re-render the whole UI | | `:redraw` | Clear and re-render the whole UI |
| `:move` | Move the current buffer and its corresponding file to a different path | | `:move` | 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 | | `:yank-diagnostic` | Yank diagnostic(s) under primary cursor to register, or clipboard by default |
| `:read`, `:r` | Load a file into buffer |

@ -16,7 +16,7 @@ below.
> 💡 If you are adding a new Language Server configuration, make sure to update > 💡 If you are adding a new Language Server configuration, make sure to update
> the > the
> [Language Server Wiki](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers) > [Language Server Wiki](https://github.com/helix-editor/helix/wiki/Language-Server-Configurations)
> with the installation instructions. > with the installation instructions.
## Grammar configuration ## Grammar configuration

@ -1,38 +1,10 @@
# Installing Helix # Installing Helix
<!--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. To install Helix, follow the instructions specific to your operating system.
Note that: Note that:
- To get the latest nightly version of Helix, you need to - To get the latest nightly version of Helix, you need to
[build from source](#building-from-source). [build from source](./building-from-source.md).
- To take full advantage of Helix, install the language servers for your - To take full advantage of Helix, install the language servers for your
preferred programming languages. See the preferred programming languages. See the
@ -41,291 +13,7 @@ Note that:
## Pre-built binaries ## Pre-built binaries
Download pre-built binaries from the Download pre-built binaries from the [GitHub Releases page](https://github.com/helix-editor/helix/releases).
[GitHub Releases page](https://github.com/helix-editor/helix/releases). Add the binary to your system's `$PATH` to use it from the command Add the `hx` binary to your system's `$PATH` to use it from the command line, and copy the `runtime` directory into the config directory (for example `~/.config/helix/runtime` on Linux/macOS).
line. The runtime location can be overriden via the HELIX_RUNTIME environment variable.
## Linux, macOS, Windows and OpenBSD packaging status
[![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
```
> 💡 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://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 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 -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
```

@ -1,4 +1,4 @@
# Keymap ## Keymap
- [Normal mode](#normal-mode) - [Normal mode](#normal-mode)
- [Movement](#movement) - [Movement](#movement)
@ -12,8 +12,9 @@
- [Match mode](#match-mode) - [Match mode](#match-mode)
- [Window mode](#window-mode) - [Window mode](#window-mode)
- [Space mode](#space-mode) - [Space mode](#space-mode)
- [Comment mode](#comment-mode)
- [Popup](#popup) - [Popup](#popup)
- [Completion Menu](#completion-menu)
- [Signature-help Popup](#signature-help-popup)
- [Unimpaired](#unimpaired) - [Unimpaired](#unimpaired)
- [Insert mode](#insert-mode) - [Insert mode](#insert-mode)
- [Select / extend mode](#select--extend-mode) - [Select / extend mode](#select--extend-mode)
@ -24,6 +25,8 @@
> 💡 Mappings marked (**TS**) require a tree-sitter grammar for the file type. > 💡 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
Normal mode is the default mode when you launch helix. You can return to it from other modes by pressing the `Escape` key. Normal mode is the default mode when you launch helix. You can return to it from other modes by pressing the `Escape` key.
@ -307,13 +310,31 @@ This layer is a kludge of mappings, mostly pickers.
##### Popup ##### Popup
Displays documentation for item under cursor. Displays documentation for item under cursor. Remapping currently not supported.
| Key | Description | | Key | Description |
| ---- | ----------- | | ---- | ----------- |
| `Ctrl-u` | Scroll up | | `Ctrl-u` | Scroll up |
| `Ctrl-d` | Scroll down | | `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 |
##### 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 #### Unimpaired
These mappings are in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired). These mappings are in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired).

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

@ -150,6 +150,8 @@ 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. 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`, 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. it's often useful to only enable/disable certain language-server features for these language servers.

@ -0,0 +1,150 @@
## Package managers
- [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)
[![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
```
> 💡 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://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
```

@ -0,0 +1,54 @@
## 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.

@ -0,0 +1,24 @@
## 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.

@ -0,0 +1,68 @@
## 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

@ -0,0 +1,47 @@
## 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.

@ -36,13 +36,6 @@ For inspiration, you can find the default `theme.toml`
user-submitted themes user-submitted themes
[here](https://github.com/helix-editor/helix/blob/master/runtime/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 ## The details of theme creation
@ -186,6 +179,7 @@ We use a similar set of scopes as
- `parameter` - Function parameters - `parameter` - Function parameters
- `other` - `other`
- `member` - Fields of composite data types (e.g. structs, unions) - `member` - Fields of composite data types (e.g. structs, unions)
- `private` - Private fields that use a unique syntax (currently just ECMAScript-based languages)
- `label` - `label`
@ -213,6 +207,7 @@ We use a similar set of scopes as
- `function` - `function`
- `builtin` - `builtin`
- `method` - `method`
- `private` - Private methods that use a unique syntax (currently just ECMAScript-based languages)
- `macro` - `macro`
- `special` (preprocessor in C) - `special` (preprocessor in C)

@ -1,15 +1,5 @@
# Using Helix # 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 For a full interactive introduction to Helix, refer to the
[tutor](https://github.com/helix-editor/helix/blob/master/runtime/tutor) which [tutor](https://github.com/helix-editor/helix/blob/master/runtime/tutor) which
can be accessed via the command `hx --tutor` or `:tutor`. can be accessed via the command `hx --tutor` or `:tutor`.
@ -17,192 +7,3 @@ can be accessed via the command `hx --tutor` or `:tutor`.
> 💡 Currently, not all functionality is fully documented, please refer to the > 💡 Currently, not all functionality is fully documented, please refer to the
> [key mappings](./keymap.md) list. > [key mappings](./keymap.md) list.
## 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.
## 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

@ -5,4 +5,4 @@ let
sha256 = "sha256:1qc703yg0babixi6wshn5wm2kgl5y1drcswgszh4xxzbrwkk9sv7"; sha256 = "sha256:1qc703yg0babixi6wshn5wm2kgl5y1drcswgszh4xxzbrwkk9sv7";
}; };
in in
(import compat {src = ./.;}).defaultNix.default (import compat {src = ./.;}).defaultNix

@ -25,8 +25,7 @@ smartstring = "1.0.1"
unicode-segmentation = "1.11" unicode-segmentation = "1.11"
unicode-width = "0.1" unicode-width = "0.1"
unicode-general-category = "0.6" unicode-general-category = "0.6"
# slab = "0.4.2" slotmap.workspace = true
slotmap = "1.0"
tree-sitter.workspace = true tree-sitter.workspace = true
once_cell = "1.19" once_cell = "1.19"
arc-swap = "1" arc-swap = "1"

@ -1,4 +1,6 @@
//! LSP diagnostic utility types. //! LSP diagnostic utility types.
use std::fmt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Describes the severity level of a [`Diagnostic`]. /// Describes the severity level of a [`Diagnostic`].
@ -47,8 +49,25 @@ pub struct Diagnostic {
pub message: String, pub message: String,
pub severity: Option<Severity>, pub severity: Option<Severity>,
pub code: Option<NumberOrString>, pub code: Option<NumberOrString>,
pub language_server_id: usize, pub provider: DiagnosticProvider,
pub tags: Vec<DiagnosticTag>, pub tags: Vec<DiagnosticTag>,
pub source: Option<String>, pub source: Option<String>,
pub data: Option<serde_json::Value>, pub data: Option<serde_json::Value>,
} }
// TODO turn this into an enum + feature flag when lsp becomes optional
pub type DiagnosticProvider = LanguageServerId;
// 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)
}
}

@ -278,23 +278,6 @@ 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. /// Returns whether the given char position is a grapheme boundary.
#[must_use] #[must_use]
pub fn is_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> bool { pub fn is_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> bool {

@ -247,41 +247,18 @@ fn add_indent_level(
} }
} }
/// Computes for node and all ancestors whether they are the first node on their line. /// Return true if only whitespace comes before the node on its line.
/// The first entry in the return value represents the root node, the last one the node itself /// If given, new_line_byte_pos is treated the same way as any existing newline.
fn get_first_in_line(mut node: Node, new_line_byte_pos: Option<usize>) -> Vec<bool> { fn is_first_in_line(node: Node, text: RopeSlice, new_line_byte_pos: Option<usize>) -> bool {
let mut first_in_line = Vec::new(); let mut line_start_byte_pos = text.line_to_byte(node.start_position().row);
loop { if let Some(pos) = new_line_byte_pos {
if let Some(prev) = node.prev_sibling() { if line_start_byte_pos < pos && pos <= node.start_byte() {
// If we insert a new line, the first node at/after the cursor is considered to be the first in its line line_start_byte_pos = pos;
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..node.start_byte())
.chars()
let mut result = Vec::with_capacity(first_in_line.len()); .all(|c| c.is_whitespace())
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);
}
}
result
} }
/// The total indent for some line of code. /// The total indent for some line of code.
@ -852,7 +829,6 @@ pub fn treesitter_indent_for_pos<'a>(
byte_pos, byte_pos,
new_line_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(); let mut result = Indentation::default();
// We always keep track of all the indent changes on one line, in order to only indent once // We always keep track of all the indent changes on one line, in order to only indent once
@ -861,9 +837,7 @@ pub fn treesitter_indent_for_pos<'a>(
let mut indent_for_line_below = Indentation::default(); let mut indent_for_line_below = Indentation::default();
loop { loop {
// This can safely be unwrapped because `first_in_line` contains let is_first = is_first_in_line(node, text, new_line_byte_pos);
// one entry for each ancestor of the node (which is what we iterate over)
let is_first = *first_in_line.last().unwrap();
// Apply all indent definitions for this node. // Apply all indent definitions for this node.
// Since we only iterate over each node once, we can remove the // Since we only iterate over each node once, we can remove the
@ -906,7 +880,6 @@ pub fn treesitter_indent_for_pos<'a>(
} }
node = parent; node = parent;
first_in_line.pop();
} else { } else {
// Only add the indentation for the line below if that line // Only add the indentation for the line below if that line
// is not after the line that the indentation is calculated for. // is not after the line that the indentation is calculated for.

@ -9,16 +9,32 @@ use crate::Syntax;
const MAX_PLAINTEXT_SCAN: usize = 10000; const MAX_PLAINTEXT_SCAN: usize = 10000;
const MATCH_LIMIT: usize = 16; const MATCH_LIMIT: usize = 16;
// Limit matching pairs to only ( ) { } [ ] < > ' ' " " pub const BRACKETS: [(char, char); 7] = [
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. /// Returns the position of the matching bracket under cursor.
/// ///
/// If the cursor is on the opening bracket, the position of /// If the cursor is on the opening bracket, the position of
@ -30,7 +46,7 @@ const PAIRS: &[(char, char)] = &[
/// If no matching bracket is found, `None` is returned. /// If no matching bracket is found, `None` is returned.
#[must_use] #[must_use]
pub fn find_matching_bracket(syntax: &Syntax, doc: RopeSlice, pos: usize) -> Option<usize> { pub fn find_matching_bracket(syntax: &Syntax, doc: RopeSlice, pos: usize) -> Option<usize> {
if pos >= doc.len_chars() || !is_valid_bracket(doc.char(pos)) { if pos >= doc.len_chars() || !is_valid_pair(doc.char(pos)) {
return None; return None;
} }
find_pair(syntax, doc, pos, false) find_pair(syntax, doc, pos, false)
@ -59,60 +75,64 @@ fn find_pair(
) -> Option<usize> { ) -> Option<usize> {
let pos = doc.char_to_byte(pos_); let pos = doc.char_to_byte(pos_);
let root = syntax.tree_for_byte_range(pos, pos + 1).root_node(); let root = syntax.tree_for_byte_range(pos, pos).root_node();
let mut node = root.descendant_for_byte_range(pos, pos + 1)?; let mut node = root.descendant_for_byte_range(pos, pos)?;
loop { loop {
if node.is_named() { if node.is_named() && node.child_count() >= 2 {
let (start_byte, end_byte) = surrounding_bytes(doc, &node)?; let open = node.child(0).unwrap();
let (start_char, end_char) = (doc.byte_to_char(start_byte), doc.byte_to_char(end_byte)); let close = node.child(node.child_count() - 1).unwrap();
if is_valid_pair(doc, start_char, end_char) { if let (Some((start_pos, open)), Some((end_pos, close))) =
if end_byte == pos { (as_char(doc, &open), as_char(doc, &close))
return Some(start_char); {
if PAIRS.contains(&(open, close)) {
if start_pos == pos_ {
return Some(start_pos);
} }
// We return the end char if the cursor is either on the 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. // or at some arbitrary position between start and end char.
if traverse_parents || start_byte == pos { if traverse_parents || end_pos == pos_ {
return Some(end_char); return Some(end_pos);
}
} }
} }
} }
// this node itselt wasn't a pair but maybe its siblings are // this node itselt wasn't a pair but maybe its siblings are
// check if we are *on* the pair (special cased so we don't look if let Some((start_char, end_char)) = as_close_pair(doc, &node) {
// at the current node twice and to jump to the start on that case) if let Some(pair_start) =
if let Some(open) = as_close_pair(doc, &node) { find_pair_end(doc, node.prev_sibling(), start_char, end_char, Backward)
if let Some(pair_start) = find_pair_end(doc, node.prev_sibling(), open, Backward) { {
return Some(pair_start); return Some(pair_start);
} }
} }
if let Some((start_char, end_char)) = as_open_pair(doc, &node) {
if !traverse_parents { if let Some(pair_end) =
// check if we are *on* the opening pair (special cased here as find_pair_end(doc, node.next_sibling(), start_char, end_char, Forward)
// 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); return Some(pair_end);
} }
} }
if node.is_named() {
break;
}
}
for close in if traverse_parents {
for sibling in
iter::successors(node.next_sibling(), |node| node.next_sibling()).take(MATCH_LIMIT) iter::successors(node.next_sibling(), |node| node.next_sibling()).take(MATCH_LIMIT)
{ {
let Some(open) = as_close_pair(doc, &close) else { let Some((start_char, end_char)) = as_close_pair(doc, &sibling) else {
continue; continue;
}; };
if find_pair_end(doc, Some(node), open, Backward).is_some() { if find_pair_end(doc, sibling.prev_sibling(), start_char, end_char, Backward)
return doc.try_byte_to_char(close.start_byte()).ok(); .is_some()
{
return doc.try_byte_to_char(sibling.start_byte()).ok();
} }
} }
} else if node.is_named() {
break;
}
let Some(parent) = node.parent() else { let Some(parent) = node.parent() else {
break; break;
}; };
@ -140,14 +160,22 @@ fn find_pair(
/// If no matching bracket is found, `None` is returned. /// If no matching bracket is found, `None` is returned.
#[must_use] #[must_use]
pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Option<usize> { pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Option<usize> {
// Don't do anything when the cursor is not on top of a bracket.
let bracket = doc.get_char(cursor_pos)?; 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.
if !is_valid_bracket(bracket) { if !is_valid_bracket(bracket) {
return None; return None;
} }
// Determine the direction of the matching. // Determine the direction of the matching.
let is_fwd = is_forward_bracket(bracket); let is_fwd = is_open_bracket(bracket);
let chars_iter = if is_fwd { let chars_iter = if is_fwd {
doc.chars_at(cursor_pos + 1) doc.chars_at(cursor_pos + 1)
} else { } else {
@ -159,19 +187,7 @@ pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Opt
for (i, candidate) in chars_iter.take(MAX_PLAINTEXT_SCAN).enumerate() { for (i, candidate) in chars_iter.take(MAX_PLAINTEXT_SCAN).enumerate() {
if candidate == bracket { if candidate == bracket {
open_cnt += 1; open_cnt += 1;
} else if is_valid_pair( } else if candidate == matching_bracket {
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. // Return when all pending brackets have been closed.
if open_cnt == 1 { if open_cnt == 1 {
return Some(if is_fwd { return Some(if is_fwd {
@ -187,37 +203,55 @@ pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Opt
None None
} }
fn is_valid_bracket(c: char) -> bool { /// Returns the open and closing chars pair. If not found in
PAIRS.iter().any(|(l, r)| *l == c || *r == c) /// [`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_forward_bracket(c: char) -> bool { pub fn is_open_bracket(ch: char) -> bool {
PAIRS.iter().any(|(l, _)| *l == c) BRACKETS.iter().any(|(l, _)| *l == ch)
} }
fn is_valid_pair(doc: RopeSlice, start_char: usize, end_char: usize) -> bool { pub fn is_close_bracket(ch: char) -> bool {
PAIRS.contains(&(doc.char(start_char), doc.char(end_char))) BRACKETS.iter().any(|(_, r)| *r == ch)
} }
fn surrounding_bytes(doc: RopeSlice, node: &Node) -> Option<(usize, usize)> { pub fn is_valid_bracket(ch: char) -> bool {
let len = doc.len_bytes(); BRACKETS.iter().any(|(l, r)| *l == ch || *r == ch)
}
let start_byte = node.start_byte(); pub fn is_open_pair(ch: char) -> bool {
let end_byte = node.end_byte().saturating_sub(1); PAIRS.iter().any(|(l, _)| *l == ch)
}
if start_byte >= len || end_byte >= len { pub fn is_close_pair(ch: char) -> bool {
return None; PAIRS.iter().any(|(_, r)| *r == ch)
} }
Some((start_byte, end_byte)) pub fn is_valid_pair(ch: char) -> bool {
PAIRS.iter().any(|(l, r)| *l == ch || *r == ch)
} }
/// Tests if this node is a pair close char and returns the expected open char /// Tests if this node is a pair close char and returns the expected open char
fn as_close_pair(doc: RopeSlice, node: &Node) -> Option<char> { /// and close char contained in this node
fn as_close_pair(doc: RopeSlice, node: &Node) -> Option<(char, char)> {
let close = as_char(doc, node)?.1; let close = as_char(doc, node)?.1;
PAIRS PAIRS
.iter() .iter()
.find_map(|&(open, close_)| (close_ == close).then_some(open)) .find_map(|&(open, close_)| (close_ == close).then_some((close, open)))
} }
/// Checks if `node` or its siblings (at most MATCH_LIMIT nodes) is the specified closing char /// Checks if `node` or its siblings (at most MATCH_LIMIT nodes) is the specified closing char
@ -228,6 +262,7 @@ fn as_close_pair(doc: RopeSlice, node: &Node) -> Option<char> {
fn find_pair_end( fn find_pair_end(
doc: RopeSlice, doc: RopeSlice,
node: Option<Node>, node: Option<Node>,
start_char: char,
end_char: char, end_char: char,
direction: Direction, direction: Direction,
) -> Option<usize> { ) -> Option<usize> {
@ -235,20 +270,30 @@ fn find_pair_end(
Forward => Node::next_sibling, Forward => Node::next_sibling,
Backward => Node::prev_sibling, Backward => Node::prev_sibling,
}; };
let mut depth = 0;
iter::successors(node, advance) iter::successors(node, advance)
.take(MATCH_LIMIT) .take(MATCH_LIMIT)
.find_map(|node| { .find_map(|node| {
let (pos, c) = as_char(doc, &node)?; let (pos, c) = as_char(doc, &node)?;
(end_char == c).then_some(pos) if c == end_char {
if depth == 0 {
return Some(pos);
}
depth -= 1;
} else if c == start_char {
depth += 1;
}
None
}) })
} }
/// Tests if this node is a pair close char and returns the expected open char /// Tests if this node is a pair open char and returns the expected close char
fn as_open_pair(doc: RopeSlice, node: &Node) -> Option<char> { /// and open char contained in this node
fn as_open_pair(doc: RopeSlice, node: &Node) -> Option<(char, char)> {
let open = as_char(doc, node)?.1; let open = as_char(doc, node)?.1;
PAIRS PAIRS
.iter() .iter()
.find_map(|&(open_, close)| (open_ == open).then_some(close)) .find_map(|&(open_, close)| (open_ == open).then_some((open, close)))
} }
/// If node is a single char return it (and its char position) /// If node is a single char return it (and its char position)

@ -1,4 +1,4 @@
use crate::{syntax::TreeCursor, Range, RopeSlice, Selection, Syntax}; use crate::{movement::Direction, syntax::TreeCursor, Range, RopeSlice, Selection, Syntax};
pub fn expand_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { pub fn expand_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
let cursor = &mut syntax.walk(); let cursor = &mut syntax.walk();
@ -25,29 +25,87 @@ pub fn expand_selection(syntax: &Syntax, text: RopeSlice, selection: Selection)
} }
pub fn shrink_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { pub fn shrink_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
select_node_impl(syntax, text, selection, |cursor| { select_node_impl(
syntax,
text,
selection,
|cursor| {
cursor.goto_first_child(); cursor.goto_first_child();
}) },
None,
)
} }
pub fn select_next_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { pub fn select_next_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
select_node_impl(syntax, text, selection, |cursor| { select_node_impl(
syntax,
text,
selection,
|cursor| {
while !cursor.goto_next_sibling() { while !cursor.goto_next_sibling() {
if !cursor.goto_parent() { if !cursor.goto_parent() {
break; break;
} }
} }
},
Some(Direction::Forward),
)
}
pub fn select_all_siblings(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
selection.transform_iter(|range| {
let mut cursor = syntax.walk();
let (from, to) = range.into_byte_range(text);
cursor.reset_to_byte_range(from, to);
if !cursor.goto_parent_with(|parent| parent.child_count() > 1) {
return vec![range].into_iter();
}
select_children(&mut cursor, text, range).into_iter()
}) })
} }
pub fn select_all_children(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
selection.transform_iter(|range| {
let mut cursor = syntax.walk();
let (from, to) = range.into_byte_range(text);
cursor.reset_to_byte_range(from, to);
select_children(&mut cursor, text, range).into_iter()
})
}
fn select_children<'n>(
cursor: &'n mut TreeCursor<'n>,
text: RopeSlice,
range: Range,
) -> Vec<Range> {
let children = cursor
.named_children()
.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 { pub fn select_prev_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
select_node_impl(syntax, text, selection, |cursor| { select_node_impl(
syntax,
text,
selection,
|cursor| {
while !cursor.goto_prev_sibling() { while !cursor.goto_prev_sibling() {
if !cursor.goto_parent() { if !cursor.goto_parent() {
break; break;
} }
} }
}) },
Some(Direction::Backward),
)
} }
fn select_node_impl<F>( fn select_node_impl<F>(
@ -55,6 +113,7 @@ fn select_node_impl<F>(
text: RopeSlice, text: RopeSlice,
selection: Selection, selection: Selection,
motion: F, motion: F,
direction: Option<Direction>,
) -> Selection ) -> Selection
where where
F: Fn(&mut TreeCursor), F: Fn(&mut TreeCursor),
@ -73,6 +132,6 @@ where
let from = text.byte_to_char(node.start_byte()); let from = text.byte_to_char(node.start_byte());
let to = text.byte_to_char(node.end_byte()); let to = text.byte_to_char(node.end_byte());
Range::new(from, to).with_direction(range.direction()) Range::new(from, to).with_direction(direction.unwrap_or_else(|| range.direction()))
}) })
} }

@ -14,6 +14,7 @@ use crate::{
use helix_stdx::rope::{self, RopeSliceExt}; use helix_stdx::rope::{self, RopeSliceExt};
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use std::borrow::Cow; use std::borrow::Cow;
use tree_sitter::Node;
/// A single selection range. /// A single selection range.
/// ///
@ -73,6 +74,12 @@ impl Range {
Self::new(head, head) Self::new(head, head)
} }
pub fn from_node(node: Node, text: RopeSlice, direction: Direction) -> Self {
let from = text.byte_to_char(node.start_byte());
let to = text.byte_to_char(node.end_byte());
Range::new(from, to).with_direction(direction)
}
/// Start of the range. /// Start of the range.
#[inline] #[inline]
#[must_use] #[must_use]
@ -115,7 +122,7 @@ impl Range {
} }
/// `Direction::Backward` when head < anchor. /// `Direction::Backward` when head < anchor.
/// `Direction::Backward` otherwise. /// `Direction::Forward` otherwise.
#[inline] #[inline]
#[must_use] #[must_use]
pub fn direction(&self) -> Direction { pub fn direction(&self) -> Direction {
@ -376,6 +383,12 @@ impl Range {
let second = graphemes.next(); let second = graphemes.next();
first.is_some() && second.is_none() 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 { impl From<(usize, usize)> for Range {
@ -783,7 +796,9 @@ pub fn split_on_newline(text: RopeSlice, selection: &Selection) -> Selection {
let mut start = sel_start; let mut start = sel_start;
for line in sel.slice(text).lines() { for line in sel.slice(text).lines() {
let Some(line_ending) = get_line_ending(&line) else { break }; let Some(line_ending) = get_line_ending(&line) else {
break;
};
let line_end = start + line.len_chars(); let line_end = start + line.len_chars();
// TODO: retain range direction // TODO: retain range direction
result.push(Range::new(start, line_end - line_ending.len_chars())); result.push(Range::new(start, line_end - line_ending.len_chars()));

@ -1,18 +1,16 @@
use std::fmt::Display; use std::fmt::Display;
use crate::{movement::Direction, search, Range, Selection}; 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 ropey::RopeSlice; use ropey::RopeSlice;
pub const PAIRS: &[(char, char)] = &[
('(', ')'),
('[', ']'),
('{', '}'),
('<', '>'),
('«', '»'),
('「', '」'),
('', ''),
];
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum Error { pub enum Error {
PairNotFound, PairNotFound,
@ -34,32 +32,68 @@ impl Display for Error {
type Result<T> = std::result::Result<T, Error>; type Result<T> = std::result::Result<T, Error>;
/// Given any char in [PAIRS], return the open and closing chars. If not found in /// Finds the position of surround pairs of any [`crate::match_brackets::PAIRS`]
/// [PAIRS] return (ch, ch). /// using tree-sitter when possible.
/// ///
/// ``` /// # Returns
/// use helix_core::surround::get_pair;
/// ///
/// assert_eq!(get_pair('['), ('[', ']')); /// Tuple `(anchor, head)`, meaning it is not always ordered.
/// assert_eq!(get_pair('}'), ('{', '}')); pub fn find_nth_closest_pairs_pos(
/// assert_eq!(get_pair('"'), ('"', '"')); syntax: Option<&Syntax>,
/// ``` text: RopeSlice,
pub fn get_pair(ch: char) -> (char, char) { range: Range,
PAIRS skip: usize,
.iter() ) -> Result<(usize, usize)> {
.find(|(open, close)| *open == ch || *close == ch) match syntax {
.copied() Some(syntax) => find_nth_closest_pairs_ts(syntax, text, range, skip),
.unwrap_or((ch, ch)) None => find_nth_closest_pairs_plain(text, range, skip),
}
} }
pub fn find_nth_closest_pairs_pos( fn find_nth_closest_pairs_ts(
syntax: &Syntax,
text: RopeSlice, text: RopeSlice,
range: Range, range: Range,
mut skip: usize, mut skip: usize,
) -> Result<(usize, usize)> { ) -> Result<(usize, usize)> {
let is_open_pair = |ch| PAIRS.iter().any(|(open, _)| *open == ch); let mut opening = range.from();
let is_close_pair = |ch| PAIRS.iter().any(|(_, close)| *close == ch); // 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();
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 mut stack = Vec::with_capacity(2);
let pos = range.from(); let pos = range.from();
let mut close_pos = pos.saturating_sub(1); let mut close_pos = pos.saturating_sub(1);
@ -67,7 +101,7 @@ pub fn find_nth_closest_pairs_pos(
for ch in text.chars_at(pos) { for ch in text.chars_at(pos) {
close_pos += 1; close_pos += 1;
if is_open_pair(ch) { if is_open_bracket(ch) {
// Track open pairs encountered so that we can step over // Track open pairs encountered so that we can step over
// the corresponding close pairs that will come up further // the corresponding close pairs that will come up further
// down the loop. We want to find a lone close pair whose // down the loop. We want to find a lone close pair whose
@ -76,7 +110,7 @@ pub fn find_nth_closest_pairs_pos(
continue; continue;
} }
if !is_close_pair(ch) { if !is_close_bracket(ch) {
// We don't care if this character isn't a brace pair item, // We don't care if this character isn't a brace pair item,
// so short circuit here. // so short circuit here.
continue; continue;
@ -157,7 +191,11 @@ pub fn find_nth_pairs_pos(
) )
}; };
Option::zip(open, close).ok_or(Error::PairNotFound) // 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),
}
} }
fn find_nth_open_pair( fn find_nth_open_pair(
@ -249,6 +287,7 @@ fn find_nth_close_pair(
/// are automatically detected around each cursor (note that this may result /// are automatically detected around each cursor (note that this may result
/// in them selecting different surround characters for each selection). /// in them selecting different surround characters for each selection).
pub fn get_surround_pos( pub fn get_surround_pos(
syntax: Option<&Syntax>,
text: RopeSlice, text: RopeSlice,
selection: &Selection, selection: &Selection,
ch: Option<char>, ch: Option<char>,
@ -257,9 +296,13 @@ pub fn get_surround_pos(
let mut change_pos = Vec::new(); let mut change_pos = Vec::new();
for &range in selection { for &range in selection {
let (open_pos, close_pos) = match ch { let (open_pos, close_pos) = {
let range_raw = match ch {
Some(ch) => find_nth_pairs_pos(text, ch, range, skip)?, Some(ch) => find_nth_pairs_pos(text, ch, range, skip)?,
None => find_nth_closest_pairs_pos(text, 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())
}; };
if change_pos.contains(&open_pos) || change_pos.contains(&close_pos) { if change_pos.contains(&open_pos) || change_pos.contains(&close_pos) {
return Err(Error::CursorOverlap); return Err(Error::CursorOverlap);
@ -288,7 +331,7 @@ mod test {
); );
assert_eq!( assert_eq!(
get_surround_pos(doc.slice(..), &selection, Some('('), 1).unwrap(), get_surround_pos(None, doc.slice(..), &selection, Some('('), 1).unwrap(),
expectations expectations
); );
} }
@ -303,7 +346,7 @@ mod test {
); );
assert_eq!( assert_eq!(
get_surround_pos(doc.slice(..), &selection, Some('('), 1), get_surround_pos(None, doc.slice(..), &selection, Some('('), 1),
Err(Error::PairNotFound) Err(Error::PairNotFound)
); );
} }
@ -318,7 +361,7 @@ mod test {
); );
assert_eq!( assert_eq!(
get_surround_pos(doc.slice(..), &selection, Some('('), 1), get_surround_pos(None, doc.slice(..), &selection, Some('('), 1),
Err(Error::PairNotFound) // overlapping surround chars Err(Error::PairNotFound) // overlapping surround chars
); );
} }
@ -333,7 +376,7 @@ mod test {
); );
assert_eq!( assert_eq!(
get_surround_pos(doc.slice(..), &selection, Some('['), 1), get_surround_pos(None, doc.slice(..), &selection, Some('['), 1),
Err(Error::CursorOverlap) Err(Error::CursorOverlap)
); );
} }
@ -397,7 +440,7 @@ mod test {
); );
assert_eq!( assert_eq!(
find_nth_closest_pairs_pos(doc.slice(..), selection.primary(), 1), find_nth_closest_pairs_pos(None, doc.slice(..), selection.primary(), 1),
Err(Error::PairNotFound) Err(Error::PairNotFound)
) )
} }

@ -510,6 +510,7 @@ pub enum DebugArgumentValue {
pub struct DebugTemplate { pub struct DebugTemplate {
pub name: String, pub name: String,
pub request: String, pub request: String,
#[serde(default)]
pub completion: Vec<DebugConfigCompletion>, pub completion: Vec<DebugConfigCompletion>,
pub args: HashMap<String, DebugArgumentValue>, pub args: HashMap<String, DebugArgumentValue>,
} }
@ -2765,10 +2766,10 @@ mod test {
) )
}; };
test("quantified_nodes", 1..36); test("quantified_nodes", 1..37);
// NOTE: Enable after implementing proper node group capturing // NOTE: Enable after implementing proper node group capturing
// test("quantified_nodes_grouped", 1..36); // test("quantified_nodes_grouped", 1..37);
// test("multiple_nodes_grouped", 1..36); // test("multiple_nodes_grouped", 1..37);
} }
#[test] #[test]
@ -2939,7 +2940,7 @@ mod test {
#[test] #[test]
fn test_pretty_print() { fn test_pretty_print() {
let source = r#"/// Hello"#; let source = r#"// Hello"#;
assert_pretty_print("rust", source, "(line_comment)", 0, source.len()); assert_pretty_print("rust", source, "(line_comment)", 0, source.len());
// A large tree should be indented with fields: // A large tree should be indented with fields:
@ -2958,7 +2959,8 @@ mod test {
" (macro_invocation\n", " (macro_invocation\n",
" macro: (identifier)\n", " macro: (identifier)\n",
" (token_tree\n", " (token_tree\n",
" (string_literal))))))", " (string_literal\n",
" (string_content)))))))",
), ),
0, 0,
source.len(), source.len(),

@ -90,6 +90,19 @@ impl<'a> TreeCursor<'a> {
true true
} }
pub fn goto_parent_with<P>(&mut self, predicate: P) -> bool
where
P: Fn(&Node) -> bool,
{
while self.goto_parent() {
if predicate(&self.node()) {
return true;
}
}
false
}
/// Finds the injection layer that has exactly the same range as the given `range`. /// Finds the injection layer that has exactly the same range as the given `range`.
fn layer_id_of_byte_range(&self, search_range: Range<usize>) -> Option<LayerId> { fn layer_id_of_byte_range(&self, search_range: Range<usize>) -> Option<LayerId> {
let start_idx = self let start_idx = self
@ -102,7 +115,7 @@ impl<'a> TreeCursor<'a> {
.find_map(|range| (range.start == search_range.start).then_some(range.layer_id)) .find_map(|range| (range.start == search_range.start).then_some(range.layer_id))
} }
pub fn goto_first_child(&mut self) -> bool { fn goto_first_child_impl(&mut self, named: bool) -> bool {
// Check if the current node's range is an exact injection layer range. // Check if the current node's range is an exact injection layer range.
if let Some(layer_id) = self if let Some(layer_id) = self
.layer_id_of_byte_range(self.node().byte_range()) .layer_id_of_byte_range(self.node().byte_range())
@ -111,8 +124,16 @@ impl<'a> TreeCursor<'a> {
// Switch to the child layer. // Switch to the child layer.
self.current = layer_id; self.current = layer_id;
self.cursor = self.layers[self.current].tree().root_node(); self.cursor = self.layers[self.current].tree().root_node();
true return true;
} else if let Some(child) = self.cursor.child(0) { }
let child = if named {
self.cursor.named_child(0)
} else {
self.cursor.child(0)
};
if let Some(child) = child {
// Otherwise descend in the current tree. // Otherwise descend in the current tree.
self.cursor = child; self.cursor = child;
true true
@ -121,8 +142,22 @@ impl<'a> TreeCursor<'a> {
} }
} }
pub fn goto_next_sibling(&mut self) -> bool { pub fn goto_first_child(&mut self) -> bool {
if let Some(sibling) = self.cursor.next_sibling() { self.goto_first_child_impl(false)
}
pub fn goto_first_named_child(&mut self) -> bool {
self.goto_first_child_impl(true)
}
fn goto_next_sibling_impl(&mut self, named: bool) -> bool {
let sibling = if named {
self.cursor.next_named_sibling()
} else {
self.cursor.next_sibling()
};
if let Some(sibling) = sibling {
self.cursor = sibling; self.cursor = sibling;
true true
} else { } else {
@ -130,8 +165,22 @@ impl<'a> TreeCursor<'a> {
} }
} }
pub fn goto_prev_sibling(&mut self) -> bool { pub fn goto_next_sibling(&mut self) -> bool {
if let Some(sibling) = self.cursor.prev_sibling() { self.goto_next_sibling_impl(false)
}
pub fn goto_next_named_sibling(&mut self) -> bool {
self.goto_next_sibling_impl(true)
}
fn goto_prev_sibling_impl(&mut self, named: bool) -> bool {
let sibling = if named {
self.cursor.prev_named_sibling()
} else {
self.cursor.prev_sibling()
};
if let Some(sibling) = sibling {
self.cursor = sibling; self.cursor = sibling;
true true
} else { } else {
@ -139,6 +188,14 @@ impl<'a> TreeCursor<'a> {
} }
} }
pub fn goto_prev_sibling(&mut self) -> bool {
self.goto_prev_sibling_impl(false)
}
pub fn goto_prev_named_sibling(&mut self) -> bool {
self.goto_prev_sibling_impl(true)
}
/// Finds the injection layer that contains the given start-end range. /// Finds the injection layer that contains the given start-end range.
fn layer_id_containing_byte_range(&self, start: usize, end: usize) -> LayerId { fn layer_id_containing_byte_range(&self, start: usize, end: usize) -> LayerId {
let start_idx = self let start_idx = self
@ -157,4 +214,51 @@ impl<'a> TreeCursor<'a> {
let root = self.layers[self.current].tree().root_node(); let root = self.layers[self.current].tree().root_node();
self.cursor = root.descendant_for_byte_range(start, end).unwrap_or(root); self.cursor = root.descendant_for_byte_range(start, end).unwrap_or(root);
} }
/// Returns an iterator over the children of the node the TreeCursor is on
/// at the time this is called.
pub fn children(&'a mut self) -> ChildIter {
let parent = self.node();
ChildIter {
cursor: self,
parent,
named: false,
}
}
/// Returns an iterator over the named children of the node the TreeCursor is on
/// at the time this is called.
pub fn named_children(&'a mut self) -> ChildIter {
let parent = self.node();
ChildIter {
cursor: self,
parent,
named: true,
}
}
}
pub struct ChildIter<'n> {
cursor: &'n mut TreeCursor<'n>,
parent: Node<'n>,
named: bool,
}
impl<'n> Iterator for ChildIter<'n> {
type Item = Node<'n>;
fn next(&mut self) -> Option<Self::Item> {
// first iteration, just visit the first child
if self.cursor.node() == self.parent {
self.cursor
.goto_first_child_impl(self.named)
.then(|| self.cursor.node())
} else {
self.cursor
.goto_next_sibling_impl(self.named)
.then(|| self.cursor.node())
}
}
} }

@ -7,9 +7,9 @@ use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary}; use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary};
use crate::line_ending::rope_is_line_ending; use crate::line_ending::rope_is_line_ending;
use crate::movement::Direction; use crate::movement::Direction;
use crate::surround;
use crate::syntax::LanguageConfiguration; use crate::syntax::LanguageConfiguration;
use crate::Range; use crate::Range;
use crate::{surround, Syntax};
fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize { fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize {
use CharCategory::{Eol, Whitespace}; use CharCategory::{Eol, Whitespace};
@ -199,25 +199,28 @@ pub fn textobject_paragraph(
} }
pub fn textobject_pair_surround( pub fn textobject_pair_surround(
syntax: Option<&Syntax>,
slice: RopeSlice, slice: RopeSlice,
range: Range, range: Range,
textobject: TextObject, textobject: TextObject,
ch: char, ch: char,
count: usize, count: usize,
) -> Range { ) -> Range {
textobject_pair_surround_impl(slice, range, textobject, Some(ch), count) textobject_pair_surround_impl(syntax, slice, range, textobject, Some(ch), count)
} }
pub fn textobject_pair_surround_closest( pub fn textobject_pair_surround_closest(
syntax: Option<&Syntax>,
slice: RopeSlice, slice: RopeSlice,
range: Range, range: Range,
textobject: TextObject, textobject: TextObject,
count: usize, count: usize,
) -> Range { ) -> Range {
textobject_pair_surround_impl(slice, range, textobject, None, count) textobject_pair_surround_impl(syntax, slice, range, textobject, None, count)
} }
fn textobject_pair_surround_impl( fn textobject_pair_surround_impl(
syntax: Option<&Syntax>,
slice: RopeSlice, slice: RopeSlice,
range: Range, range: Range,
textobject: TextObject, textobject: TextObject,
@ -226,8 +229,7 @@ fn textobject_pair_surround_impl(
) -> Range { ) -> Range {
let pair_pos = match ch { let pair_pos = match ch {
Some(ch) => surround::find_nth_pairs_pos(slice, ch, range, count), Some(ch) => surround::find_nth_pairs_pos(slice, ch, range, count),
// Automatically find the closest surround pairs None => surround::find_nth_closest_pairs_pos(syntax, slice, range, count),
None => surround::find_nth_closest_pairs_pos(slice, range, count),
}; };
pair_pos pair_pos
.map(|(anchor, head)| match textobject { .map(|(anchor, head)| match textobject {
@ -574,7 +576,8 @@ mod test {
let slice = doc.slice(..); let slice = doc.slice(..);
for &case in scenario { for &case in scenario {
let (pos, objtype, expected_range, ch, count) = case; let (pos, objtype, expected_range, ch, count) = case;
let result = textobject_pair_surround(slice, Range::point(pos), objtype, ch, count); let result =
textobject_pair_surround(None, slice, Range::point(pos), objtype, ch, count);
assert_eq!( assert_eq!(
result, result,
expected_range.into(), expected_range.into(),

@ -31,3 +31,4 @@ tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "io-util", "io-
tokio-stream = "0.1.15" tokio-stream = "0.1.15"
parking_lot = "0.12.1" parking_lot = "0.12.1"
arc-swap = "1" arc-swap = "1"
slotmap.workspace = true

@ -2,7 +2,7 @@ use crate::{
file_operations::FileOperationsInterest, file_operations::FileOperationsInterest,
find_lsp_workspace, jsonrpc, find_lsp_workspace, jsonrpc,
transport::{Payload, Transport}, transport::{Payload, Transport},
Call, Error, OffsetEncoding, Result, Call, Error, LanguageServerId, OffsetEncoding, Result,
}; };
use helix_core::{find_workspace, syntax::LanguageServerFeature, ChangeSet, Rope}; use helix_core::{find_workspace, syntax::LanguageServerFeature, ChangeSet, Rope};
@ -46,7 +46,7 @@ fn workspace_for_uri(uri: lsp::Url) -> WorkspaceFolder {
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client {
id: usize, id: LanguageServerId,
name: String, name: String,
_process: Child, _process: Child,
server_tx: UnboundedSender<Payload>, server_tx: UnboundedSender<Payload>,
@ -179,10 +179,14 @@ impl Client {
server_environment: HashMap<String, String>, server_environment: HashMap<String, String>,
root_path: PathBuf, root_path: PathBuf,
root_uri: Option<lsp::Url>, root_uri: Option<lsp::Url>,
id: usize, id: LanguageServerId,
name: String, name: String,
req_timeout: u64, req_timeout: u64,
) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc<Notify>)> { ) -> Result<(
Self,
UnboundedReceiver<(LanguageServerId, Call)>,
Arc<Notify>,
)> {
// Resolve path to the binary // Resolve path to the binary
let cmd = helix_stdx::env::which(cmd)?; let cmd = helix_stdx::env::which(cmd)?;
@ -234,7 +238,7 @@ impl Client {
&self.name &self.name
} }
pub fn id(&self) -> usize { pub fn id(&self) -> LanguageServerId {
self.id self.id
} }
@ -393,6 +397,16 @@ impl Client {
&self, &self,
params: R::Params, params: R::Params,
) -> impl Future<Output = Result<Value>> ) -> impl Future<Output = Result<Value>>
where
R::Params: serde::Serialize,
{
self.call_with_ref::<R>(&params)
}
fn call_with_ref<R: lsp::request::Request>(
&self,
params: &R::Params,
) -> impl Future<Output = Result<Value>>
where where
R::Params: serde::Serialize, R::Params: serde::Serialize,
{ {
@ -401,7 +415,7 @@ impl Client {
fn call_with_timeout<R: lsp::request::Request>( fn call_with_timeout<R: lsp::request::Request>(
&self, &self,
params: R::Params, params: &R::Params,
timeout_secs: u64, timeout_secs: u64,
) -> impl Future<Output = Result<Value>> ) -> impl Future<Output = Result<Value>>
where where
@ -410,17 +424,16 @@ impl Client {
let server_tx = self.server_tx.clone(); let server_tx = self.server_tx.clone();
let id = self.next_request_id(); let id = self.next_request_id();
let params = serde_json::to_value(params);
async move { async move {
use std::time::Duration; use std::time::Duration;
use tokio::time::timeout; use tokio::time::timeout;
let params = serde_json::to_value(params)?;
let request = jsonrpc::MethodCall { let request = jsonrpc::MethodCall {
jsonrpc: Some(jsonrpc::Version::V2), jsonrpc: Some(jsonrpc::Version::V2),
id: id.clone(), id: id.clone(),
method: R::METHOD.to_string(), method: R::METHOD.to_string(),
params: Self::value_into_params(params), params: Self::value_into_params(params?),
}; };
let (tx, mut rx) = channel::<Result<Value>>(1); let (tx, mut rx) = channel::<Result<Value>>(1);
@ -737,7 +750,7 @@ impl Client {
new_uri: url_from_path(new_path)?, new_uri: url_from_path(new_path)?,
}]; }];
let request = self.call_with_timeout::<lsp::request::WillRenameFiles>( let request = self.call_with_timeout::<lsp::request::WillRenameFiles>(
lsp::RenameFilesParams { files }, &lsp::RenameFilesParams { files },
5, 5,
); );
@ -1022,21 +1035,10 @@ impl Client {
pub fn resolve_completion_item( pub fn resolve_completion_item(
&self, &self,
completion_item: lsp::CompletionItem, completion_item: &lsp::CompletionItem,
) -> Option<impl Future<Output = Result<lsp::CompletionItem>>> { ) -> impl Future<Output = Result<lsp::CompletionItem>> {
let capabilities = self.capabilities.get().unwrap(); let res = self.call_with_ref::<lsp::request::ResolveCompletionItem>(completion_item);
async move { Ok(serde_json::from_value(res.await?)?) }
// Return early if the server does not support resolving completion items.
match capabilities.completion_provider {
Some(lsp::CompletionOptions {
resolve_provider: Some(true),
..
}) => (),
_ => return None,
}
let res = self.call::<lsp::request::ResolveCompletionItem>(completion_item);
Some(async move { Ok(serde_json::from_value(res.await?)?) })
} }
pub fn resolve_code_action( pub fn resolve_code_action(

@ -3,24 +3,24 @@ use std::{collections::HashMap, path::PathBuf, sync::Weak};
use globset::{GlobBuilder, GlobSetBuilder}; use globset::{GlobBuilder, GlobSetBuilder};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::{lsp, Client}; use crate::{lsp, Client, LanguageServerId};
enum Event { enum Event {
FileChanged { FileChanged {
path: PathBuf, path: PathBuf,
}, },
Register { Register {
client_id: usize, client_id: LanguageServerId,
client: Weak<Client>, client: Weak<Client>,
registration_id: String, registration_id: String,
options: lsp::DidChangeWatchedFilesRegistrationOptions, options: lsp::DidChangeWatchedFilesRegistrationOptions,
}, },
Unregister { Unregister {
client_id: usize, client_id: LanguageServerId,
registration_id: String, registration_id: String,
}, },
RemoveClient { RemoveClient {
client_id: usize, client_id: LanguageServerId,
}, },
} }
@ -59,7 +59,7 @@ impl Handler {
pub fn register( pub fn register(
&self, &self,
client_id: usize, client_id: LanguageServerId,
client: Weak<Client>, client: Weak<Client>,
registration_id: String, registration_id: String,
options: lsp::DidChangeWatchedFilesRegistrationOptions, options: lsp::DidChangeWatchedFilesRegistrationOptions,
@ -72,7 +72,7 @@ impl Handler {
}); });
} }
pub fn unregister(&self, client_id: usize, registration_id: String) { pub fn unregister(&self, client_id: LanguageServerId, registration_id: String) {
let _ = self.tx.send(Event::Unregister { let _ = self.tx.send(Event::Unregister {
client_id, client_id,
registration_id, registration_id,
@ -83,12 +83,12 @@ impl Handler {
let _ = self.tx.send(Event::FileChanged { path }); let _ = self.tx.send(Event::FileChanged { path });
} }
pub fn remove_client(&self, client_id: usize) { pub fn remove_client(&self, client_id: LanguageServerId) {
let _ = self.tx.send(Event::RemoveClient { client_id }); let _ = self.tx.send(Event::RemoveClient { client_id });
} }
async fn run(mut rx: mpsc::UnboundedReceiver<Event>) { async fn run(mut rx: mpsc::UnboundedReceiver<Event>) {
let mut state: HashMap<usize, ClientState> = HashMap::new(); let mut state: HashMap<LanguageServerId, ClientState> = HashMap::new();
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
match event { match event {
Event::FileChanged { path } => { Event::FileChanged { path } => {

@ -17,6 +17,7 @@ use helix_core::syntax::{
LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures, LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures,
}; };
use helix_stdx::path; use helix_stdx::path;
use slotmap::SlotMap;
use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::UnboundedReceiver;
use std::{ use std::{
@ -28,8 +29,9 @@ use std::{
use thiserror::Error; use thiserror::Error;
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
pub type Result<T> = core::result::Result<T, Error>; pub type Result<T, E = Error> = core::result::Result<T, E>;
pub type LanguageServerName = String; pub type LanguageServerName = String;
pub use helix_core::diagnostic::LanguageServerId;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum Error { pub enum Error {
@ -284,7 +286,8 @@ pub mod util {
.chars_at(cursor) .chars_at(cursor)
.skip(1) .skip(1)
.take_while(|ch| chars::char_is_word(*ch)) .take_while(|ch| chars::char_is_word(*ch))
.count(); .count()
+ 1;
} }
(start, end) (start, end)
} }
@ -539,6 +542,16 @@ pub mod util {
} else { } else {
return (0, 0, None); return (0, 0, None);
}; };
if start > end {
log::error!(
"Invalid LSP text edit start {:?} > end {:?}, discarding",
start,
end
);
return (0, 0, None);
}
(start, end, replacement) (start, end, replacement)
}), }),
) )
@ -640,38 +653,42 @@ impl Notification {
#[derive(Debug)] #[derive(Debug)]
pub struct Registry { pub struct Registry {
inner: HashMap<LanguageServerName, Vec<Arc<Client>>>, inner: SlotMap<LanguageServerId, Arc<Client>>,
inner_by_name: HashMap<LanguageServerName, Vec<Arc<Client>>>,
syn_loader: Arc<ArcSwap<helix_core::syntax::Loader>>, syn_loader: Arc<ArcSwap<helix_core::syntax::Loader>>,
counter: usize, pub incoming: SelectAll<UnboundedReceiverStream<(LanguageServerId, Call)>>,
pub incoming: SelectAll<UnboundedReceiverStream<(usize, Call)>>,
pub file_event_handler: file_event::Handler, pub file_event_handler: file_event::Handler,
} }
impl Registry { impl Registry {
pub fn new(syn_loader: Arc<ArcSwap<helix_core::syntax::Loader>>) -> Self { pub fn new(syn_loader: Arc<ArcSwap<helix_core::syntax::Loader>>) -> Self {
Self { Self {
inner: HashMap::new(), inner: SlotMap::with_key(),
inner_by_name: HashMap::new(),
syn_loader, syn_loader,
counter: 0,
incoming: SelectAll::new(), incoming: SelectAll::new(),
file_event_handler: file_event::Handler::new(), file_event_handler: file_event::Handler::new(),
} }
} }
pub fn get_by_id(&self, id: usize) -> Option<&Client> { pub fn get_by_id(&self, id: LanguageServerId) -> Option<&Arc<Client>> {
self.inner self.inner.get(id)
.values()
.flatten()
.find(|client| client.id() == id)
.map(|client| &**client)
} }
pub fn remove_by_id(&mut self, id: usize) { pub fn remove_by_id(&mut self, id: LanguageServerId) {
let Some(client) = self.inner.remove(id) else {
log::error!("client was already removed");
return
};
self.file_event_handler.remove_client(id); self.file_event_handler.remove_client(id);
self.inner.retain(|_, language_servers| { let instances = self
language_servers.retain(|ls| id != ls.id()); .inner_by_name
!language_servers.is_empty() .get_mut(client.name())
}); .expect("inner and inner_by_name must be synced");
instances.retain(|ls| id != ls.id());
if instances.is_empty() {
self.inner_by_name.remove(client.name());
}
} }
fn start_client( fn start_client(
@ -681,15 +698,14 @@ impl Registry {
doc_path: Option<&std::path::PathBuf>, doc_path: Option<&std::path::PathBuf>,
root_dirs: &[PathBuf], root_dirs: &[PathBuf],
enable_snippets: bool, enable_snippets: bool,
) -> Result<Option<Arc<Client>>> { ) -> Result<Arc<Client>, StartupError> {
let syn_loader = self.syn_loader.load(); let syn_loader = self.syn_loader.load();
let config = syn_loader let config = syn_loader
.language_server_configs() .language_server_configs()
.get(&name) .get(&name)
.ok_or_else(|| anyhow::anyhow!("Language server '{name}' not defined"))?; .ok_or_else(|| anyhow::anyhow!("Language server '{name}' not defined"))?;
let id = self.counter; let id = self.inner.try_insert_with_key(|id| {
self.counter += 1; start_client(
if let Some(NewClient(client, incoming)) = start_client(
id, id,
name, name,
ls_config, ls_config,
@ -697,12 +713,13 @@ impl Registry {
doc_path, doc_path,
root_dirs, root_dirs,
enable_snippets, enable_snippets,
)? { )
self.incoming.push(UnboundedReceiverStream::new(incoming)); .map(|client| {
Ok(Some(client)) self.incoming.push(UnboundedReceiverStream::new(client.1));
} else { client.0
Ok(None) })
} })?;
Ok(self.inner[id].clone())
} }
/// If this method is called, all documents that have a reference to language servers used by the language config have to refresh their language servers, /// If this method is called, all documents that have a reference to language servers used by the language config have to refresh their language servers,
@ -719,7 +736,15 @@ impl Registry {
.language_servers .language_servers
.iter() .iter()
.filter_map(|LanguageServerFeatures { name, .. }| { .filter_map(|LanguageServerFeatures { name, .. }| {
if self.inner.contains_key(name) { if let Some(old_clients) = self.inner_by_name.remove(name) {
for old_client in old_clients {
self.file_event_handler.remove_client(old_client.id());
self.inner.remove(old_client.id());
tokio::spawn(async move {
let _ = old_client.force_shutdown().await;
});
}
}
let client = match self.start_client( let client = match self.start_client(
name.clone(), name.clone(),
language_config, language_config,
@ -727,33 +752,23 @@ impl Registry {
root_dirs, root_dirs,
enable_snippets, enable_snippets,
) { ) {
Ok(client) => client?, Ok(client) => client,
Err(error) => return Some(Err(error)), Err(StartupError::NoRequiredRootFound) => return None,
Err(StartupError::Error(err)) => return Some(Err(err)),
}; };
let old_clients = self self.inner_by_name
.inner .insert(name.to_owned(), vec![client.clone()]);
.insert(name.clone(), vec![client.clone()])
.unwrap();
for old_client in old_clients {
self.file_event_handler.remove_client(old_client.id());
tokio::spawn(async move {
let _ = old_client.force_shutdown().await;
});
}
Some(Ok(client)) Some(Ok(client))
} else {
None
}
}) })
.collect() .collect()
} }
pub fn stop(&mut self, name: &str) { pub fn stop(&mut self, name: &str) {
if let Some(clients) = self.inner.remove(name) { if let Some(clients) = self.inner_by_name.remove(name) {
for client in clients { for client in clients {
self.file_event_handler.remove_client(client.id()); self.file_event_handler.remove_client(client.id());
self.inner.remove(client.id());
tokio::spawn(async move { tokio::spawn(async move {
let _ = client.force_shutdown().await; let _ = client.force_shutdown().await;
}); });
@ -770,7 +785,7 @@ impl Registry {
) -> impl Iterator<Item = (LanguageServerName, Result<Arc<Client>>)> + 'a { ) -> impl Iterator<Item = (LanguageServerName, Result<Arc<Client>>)> + 'a {
language_config.language_servers.iter().filter_map( language_config.language_servers.iter().filter_map(
move |LanguageServerFeatures { name, .. }| { move |LanguageServerFeatures { name, .. }| {
if let Some(clients) = self.inner.get(name) { if let Some(clients) = self.inner_by_name.get(name) {
if let Some((_, client)) = clients.iter().enumerate().find(|(i, client)| { if let Some((_, client)) = clients.iter().enumerate().find(|(i, client)| {
client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0) client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0)
}) { }) {
@ -785,21 +800,21 @@ impl Registry {
enable_snippets, enable_snippets,
) { ) {
Ok(client) => { Ok(client) => {
let client = client?; self.inner_by_name
self.inner
.entry(name.to_owned()) .entry(name.to_owned())
.or_default() .or_default()
.push(client.clone()); .push(client.clone());
Some((name.clone(), Ok(client))) Some((name.clone(), Ok(client)))
} }
Err(err) => Some((name.to_owned(), Err(err))), Err(StartupError::NoRequiredRootFound) => None,
Err(StartupError::Error(err)) => Some((name.to_owned(), Err(err))),
} }
}, },
) )
} }
pub fn iter_clients(&self) -> impl Iterator<Item = &Arc<Client>> { pub fn iter_clients(&self) -> impl Iterator<Item = &Arc<Client>> {
self.inner.values().flatten() self.inner.values()
} }
} }
@ -822,7 +837,7 @@ impl ProgressStatus {
/// Acts as a container for progress reported by language servers. Each server /// Acts as a container for progress reported by language servers. Each server
/// has a unique id assigned at creation through [`Registry`]. This id is then used /// has a unique id assigned at creation through [`Registry`]. This id is then used
/// to store the progress in this map. /// to store the progress in this map.
pub struct LspProgressMap(HashMap<usize, HashMap<lsp::ProgressToken, ProgressStatus>>); pub struct LspProgressMap(HashMap<LanguageServerId, HashMap<lsp::ProgressToken, ProgressStatus>>);
impl LspProgressMap { impl LspProgressMap {
pub fn new() -> Self { pub fn new() -> Self {
@ -830,28 +845,35 @@ impl LspProgressMap {
} }
/// Returns a map of all tokens corresponding to the language server with `id`. /// Returns a map of all tokens corresponding to the language server with `id`.
pub fn progress_map(&self, id: usize) -> Option<&HashMap<lsp::ProgressToken, ProgressStatus>> { pub fn progress_map(
&self,
id: LanguageServerId,
) -> Option<&HashMap<lsp::ProgressToken, ProgressStatus>> {
self.0.get(&id) self.0.get(&id)
} }
pub fn is_progressing(&self, id: usize) -> bool { pub fn is_progressing(&self, id: LanguageServerId) -> bool {
self.0.get(&id).map(|it| !it.is_empty()).unwrap_or_default() self.0.get(&id).map(|it| !it.is_empty()).unwrap_or_default()
} }
/// Returns last progress status for a given server with `id` and `token`. /// Returns last progress status for a given server with `id` and `token`.
pub fn progress(&self, id: usize, token: &lsp::ProgressToken) -> Option<&ProgressStatus> { pub fn progress(
&self,
id: LanguageServerId,
token: &lsp::ProgressToken,
) -> Option<&ProgressStatus> {
self.0.get(&id).and_then(|values| values.get(token)) self.0.get(&id).and_then(|values| values.get(token))
} }
/// Checks if progress `token` for server with `id` is created. /// Checks if progress `token` for server with `id` is created.
pub fn is_created(&mut self, id: usize, token: &lsp::ProgressToken) -> bool { pub fn is_created(&mut self, id: LanguageServerId, token: &lsp::ProgressToken) -> bool {
self.0 self.0
.get(&id) .get(&id)
.map(|values| values.get(token).is_some()) .map(|values| values.get(token).is_some())
.unwrap_or_default() .unwrap_or_default()
} }
pub fn create(&mut self, id: usize, token: lsp::ProgressToken) { pub fn create(&mut self, id: LanguageServerId, token: lsp::ProgressToken) {
self.0 self.0
.entry(id) .entry(id)
.or_default() .or_default()
@ -861,7 +883,7 @@ impl LspProgressMap {
/// Ends the progress by removing the `token` from server with `id`, if removed returns the value. /// Ends the progress by removing the `token` from server with `id`, if removed returns the value.
pub fn end_progress( pub fn end_progress(
&mut self, &mut self,
id: usize, id: LanguageServerId,
token: &lsp::ProgressToken, token: &lsp::ProgressToken,
) -> Option<ProgressStatus> { ) -> Option<ProgressStatus> {
self.0.get_mut(&id).and_then(|vals| vals.remove(token)) self.0.get_mut(&id).and_then(|vals| vals.remove(token))
@ -870,7 +892,7 @@ impl LspProgressMap {
/// Updates the progress of `token` for server with `id` to `status`, returns the value replaced or `None`. /// Updates the progress of `token` for server with `id` to `status`, returns the value replaced or `None`.
pub fn update( pub fn update(
&mut self, &mut self,
id: usize, id: LanguageServerId,
token: lsp::ProgressToken, token: lsp::ProgressToken,
status: lsp::WorkDoneProgress, status: lsp::WorkDoneProgress,
) -> Option<ProgressStatus> { ) -> Option<ProgressStatus> {
@ -881,19 +903,30 @@ impl LspProgressMap {
} }
} }
struct NewClient(Arc<Client>, UnboundedReceiver<(usize, Call)>); struct NewClient(Arc<Client>, UnboundedReceiver<(LanguageServerId, Call)>);
enum StartupError {
NoRequiredRootFound,
Error(Error),
}
impl<T: Into<Error>> From<T> for StartupError {
fn from(value: T) -> Self {
StartupError::Error(value.into())
}
}
/// start_client takes both a LanguageConfiguration and a LanguageServerConfiguration to ensure that /// start_client takes both a LanguageConfiguration and a LanguageServerConfiguration to ensure that
/// it is only called when it makes sense. /// it is only called when it makes sense.
fn start_client( fn start_client(
id: usize, id: LanguageServerId,
name: String, name: String,
config: &LanguageConfiguration, config: &LanguageConfiguration,
ls_config: &LanguageServerConfiguration, ls_config: &LanguageServerConfiguration,
doc_path: Option<&std::path::PathBuf>, doc_path: Option<&std::path::PathBuf>,
root_dirs: &[PathBuf], root_dirs: &[PathBuf],
enable_snippets: bool, enable_snippets: bool,
) -> Result<Option<NewClient>> { ) -> Result<NewClient, StartupError> {
let (workspace, workspace_is_cwd) = helix_loader::find_workspace(); let (workspace, workspace_is_cwd) = helix_loader::find_workspace();
let workspace = path::normalize(workspace); let workspace = path::normalize(workspace);
let root = find_lsp_workspace( let root = find_lsp_workspace(
@ -918,7 +951,7 @@ fn start_client(
.map(|entry| entry.file_name()) .map(|entry| entry.file_name())
.any(|entry| globset.is_match(entry)) .any(|entry| globset.is_match(entry))
{ {
return Ok(None); return Err(StartupError::NoRequiredRootFound);
} }
} }
@ -970,7 +1003,7 @@ fn start_client(
initialize_notify.notify_one(); initialize_notify.notify_one();
}); });
Ok(Some(NewClient(client, incoming))) Ok(NewClient(client, incoming))
} }
/// Find an LSP workspace of a file using the following mechanism: /// Find an LSP workspace of a file using the following mechanism:

@ -1,4 +1,4 @@
use crate::{jsonrpc, Error, Result}; use crate::{jsonrpc, Error, LanguageServerId, Result};
use anyhow::Context; use anyhow::Context;
use log::{error, info}; use log::{error, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -37,7 +37,7 @@ enum ServerMessage {
#[derive(Debug)] #[derive(Debug)]
pub struct Transport { pub struct Transport {
id: usize, id: LanguageServerId,
name: String, name: String,
pending_requests: Mutex<HashMap<jsonrpc::Id, Sender<Result<Value>>>>, pending_requests: Mutex<HashMap<jsonrpc::Id, Sender<Result<Value>>>>,
} }
@ -47,10 +47,10 @@ impl Transport {
server_stdout: BufReader<ChildStdout>, server_stdout: BufReader<ChildStdout>,
server_stdin: BufWriter<ChildStdin>, server_stdin: BufWriter<ChildStdin>,
server_stderr: BufReader<ChildStderr>, server_stderr: BufReader<ChildStderr>,
id: usize, id: LanguageServerId,
name: String, name: String,
) -> ( ) -> (
UnboundedReceiver<(usize, jsonrpc::Call)>, UnboundedReceiver<(LanguageServerId, jsonrpc::Call)>,
UnboundedSender<Payload>, UnboundedSender<Payload>,
Arc<Notify>, Arc<Notify>,
) { ) {
@ -194,7 +194,7 @@ impl Transport {
async fn process_server_message( async fn process_server_message(
&self, &self,
client_tx: &UnboundedSender<(usize, jsonrpc::Call)>, client_tx: &UnboundedSender<(LanguageServerId, jsonrpc::Call)>,
msg: ServerMessage, msg: ServerMessage,
language_server_name: &str, language_server_name: &str,
) -> Result<()> { ) -> Result<()> {
@ -251,7 +251,7 @@ impl Transport {
async fn recv( async fn recv(
transport: Arc<Self>, transport: Arc<Self>,
mut server_stdout: BufReader<ChildStdout>, mut server_stdout: BufReader<ChildStdout>,
client_tx: UnboundedSender<(usize, jsonrpc::Call)>, client_tx: UnboundedSender<(LanguageServerId, jsonrpc::Call)>,
) { ) {
let mut recv_buffer = String::new(); let mut recv_buffer = String::new();
loop { loop {
@ -329,7 +329,7 @@ impl Transport {
async fn send( async fn send(
transport: Arc<Self>, transport: Arc<Self>,
mut server_stdin: BufWriter<ChildStdin>, mut server_stdin: BufWriter<ChildStdin>,
client_tx: UnboundedSender<(usize, jsonrpc::Call)>, client_tx: UnboundedSender<(LanguageServerId, jsonrpc::Call)>,
mut client_rx: UnboundedReceiver<Payload>, mut client_rx: UnboundedReceiver<Payload>,
initialize_notify: Arc<Notify>, initialize_notify: Arc<Notify>,
) { ) {

@ -3,6 +3,7 @@ use std::ops::{Bound, RangeBounds};
pub use regex_cursor::engines::meta::{Builder as RegexBuilder, Regex}; pub use regex_cursor::engines::meta::{Builder as RegexBuilder, Regex};
pub use regex_cursor::regex_automata::util::syntax::Config; pub use regex_cursor::regex_automata::util::syntax::Config;
use regex_cursor::{Input as RegexInput, RopeyCursor}; use regex_cursor::{Input as RegexInput, RopeyCursor};
use ropey::str_utils::byte_to_char_idx;
use ropey::RopeSlice; use ropey::RopeSlice;
pub trait RopeSliceExt<'a>: Sized { pub trait RopeSliceExt<'a>: Sized {
@ -16,6 +17,23 @@ pub trait RopeSliceExt<'a>: Sized {
fn regex_input_at<R: RangeBounds<usize>>(self, char_range: R) -> RegexInput<RopeyCursor<'a>>; fn regex_input_at<R: RangeBounds<usize>>(self, char_range: R) -> RegexInput<RopeyCursor<'a>>;
fn first_non_whitespace_char(self) -> Option<usize>; fn first_non_whitespace_char(self) -> Option<usize>;
fn last_non_whitespace_char(self) -> Option<usize>; fn last_non_whitespace_char(self) -> Option<usize>;
/// returns the char idx of `byte_idx`, if `byte_idx` is a char boundary
/// this function behaves the same as `byte_to_char` but if `byte_idx` is
/// not a valid char boundary (so within a char) this will return the next
/// char index.
///
/// # Example
///
/// ```
/// # use ropey::RopeSlice;
/// # use helix_stdx::rope::RopeSliceExt;
/// let text = RopeSlice::from("😆");
/// for i in 1..text.len_bytes() {
/// assert_eq!(text.byte_to_char(i), 0);
/// assert_eq!(text.byte_to_next_char(i), 1);
/// }
/// ```
fn byte_to_next_char(self, byte_idx: usize) -> usize;
} }
impl<'a> RopeSliceExt<'a> for RopeSlice<'a> { impl<'a> RopeSliceExt<'a> for RopeSlice<'a> {
@ -75,4 +93,48 @@ impl<'a> RopeSliceExt<'a> for RopeSlice<'a> {
.position(|ch| !ch.is_whitespace()) .position(|ch| !ch.is_whitespace())
.map(|pos| self.len_chars() - pos - 1) .map(|pos| self.len_chars() - pos - 1)
} }
/// returns the char idx of `byte_idx`, if `byte_idx` is
/// a char boundary this function behaves the same as `byte_to_char`
fn byte_to_next_char(self, mut byte_idx: usize) -> usize {
let (chunk, chunk_byte_off, chunk_char_off, _) = self.chunk_at_byte(byte_idx);
byte_idx -= chunk_byte_off;
let is_char_boundary =
is_utf8_char_boundary(chunk.as_bytes().get(byte_idx).copied().unwrap_or(0));
chunk_char_off + byte_to_char_idx(chunk, byte_idx) + !is_char_boundary as usize
}
}
// copied from std
#[inline]
const fn is_utf8_char_boundary(b: u8) -> bool {
// This is bit magic equivalent to: b < 128 || b >= 192
(b as i8) >= -0x40
}
#[cfg(test)]
mod tests {
use ropey::RopeSlice;
use crate::rope::RopeSliceExt;
#[test]
fn next_char_at_byte() {
for i in 0..=6 {
assert_eq!(RopeSlice::from("foobar").byte_to_next_char(i), i);
}
for char_idx in 0..10 {
let len = "😆".len();
assert_eq!(
RopeSlice::from("😆😆😆😆😆😆😆😆😆😆").byte_to_next_char(char_idx * len),
char_idx
);
for i in 1..=len {
assert_eq!(
RopeSlice::from("😆😆😆😆😆😆😆😆😆😆").byte_to_next_char(char_idx * len + i),
char_idx + 1
);
}
}
}
} }

@ -14,7 +14,7 @@ homepage.workspace = true
[features] [features]
default = ["git"] default = ["git"]
unicode-lines = ["helix-core/unicode-lines"] unicode-lines = ["helix-core/unicode-lines", "helix-view/unicode-lines"]
integration = ["helix-event/integration_test"] integration = ["helix-event/integration_test"]
git = ["helix-vcs/git"] git = ["helix-vcs/git"]

@ -4,7 +4,7 @@ use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Selection};
use helix_lsp::{ use helix_lsp::{
lsp::{self, notification::Notification}, lsp::{self, notification::Notification},
util::lsp_range_to_range, util::lsp_range_to_range,
LspProgressMap, LanguageServerId, LspProgressMap,
}; };
use helix_stdx::path::get_relative_path; use helix_stdx::path::get_relative_path;
use helix_view::{ use helix_view::{
@ -655,7 +655,7 @@ impl Application {
pub async fn handle_language_server_message( pub async fn handle_language_server_message(
&mut self, &mut self,
call: helix_lsp::Call, call: helix_lsp::Call,
server_id: usize, server_id: LanguageServerId,
) { ) {
use helix_lsp::{Call, MethodCall, Notification}; use helix_lsp::{Call, MethodCall, Notification};
@ -1030,12 +1030,7 @@ impl Application {
Ok(json!(result)) Ok(json!(result))
} }
Ok(MethodCall::RegisterCapability(params)) => { Ok(MethodCall::RegisterCapability(params)) => {
if let Some(client) = self if let Some(client) = self.editor.language_servers.get_by_id(server_id) {
.editor
.language_servers
.iter_clients()
.find(|client| client.id() == server_id)
{
for reg in params.registrations { for reg in params.registrations {
match reg.method.as_str() { match reg.method.as_str() {
lsp::notification::DidChangeWatchedFiles::METHOD => { lsp::notification::DidChangeWatchedFiles::METHOD => {

@ -4,7 +4,10 @@ pub(crate) mod typed;
pub use dap::*; pub use dap::*;
use helix_event::status; use helix_event::status;
use helix_stdx::rope::{self, RopeSliceExt}; use helix_stdx::{
path::expand_tilde,
rope::{self, RopeSliceExt},
};
use helix_vcs::{FileChange, Hunk}; use helix_vcs::{FileChange, Hunk};
pub use lsp::*; pub use lsp::*;
use tui::{ use tui::{
@ -35,7 +38,7 @@ use helix_core::{
textobject, textobject,
unicode::width::UnicodeWidthChar, unicode::width::UnicodeWidthChar,
visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes, visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes,
RopeReader, RopeSlice, Selection, SmallVec, Tendril, Transaction, RopeReader, RopeSlice, Selection, SmallVec, Syntax, Tendril, Transaction,
}; };
use helix_view::{ use helix_view::{
document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
@ -440,8 +443,10 @@ impl MappableCommand {
reverse_selection_contents, "Reverse selections contents", reverse_selection_contents, "Reverse selections contents",
expand_selection, "Expand selection to parent syntax node", expand_selection, "Expand selection to parent syntax node",
shrink_selection, "Shrink selection to previously expanded syntax node", shrink_selection, "Shrink selection to previously expanded syntax node",
select_next_sibling, "Select next sibling in syntax tree", select_next_sibling, "Select next sibling in the syntax tree",
select_prev_sibling, "Select previous sibling in syntax tree", select_prev_sibling, "Select previous sibling the in syntax tree",
select_all_siblings, "Select all siblings of the current node",
select_all_children, "Select all children of the current node",
jump_forward, "Jump forward on jumplist", jump_forward, "Jump forward on jumplist",
jump_backward, "Jump backward on jumplist", jump_backward, "Jump backward on jumplist",
save_selection, "Save current selection to jumplist", save_selection, "Save current selection to jumplist",
@ -818,28 +823,29 @@ fn toggle_focus_window(cx: &mut Context) {
} }
fn goto_next_buffer(cx: &mut Context) { fn goto_next_buffer(cx: &mut Context) {
goto_buffer(cx.editor, Direction::Forward); goto_buffer(cx.editor, Direction::Forward, cx.count());
} }
fn goto_previous_buffer(cx: &mut Context) { fn goto_previous_buffer(cx: &mut Context) {
goto_buffer(cx.editor, Direction::Backward); goto_buffer(cx.editor, Direction::Backward, cx.count());
} }
fn goto_buffer(editor: &mut Editor, direction: Direction) { fn goto_buffer(editor: &mut Editor, direction: Direction, count: usize) {
let current = view!(editor).doc; let current = view!(editor).doc;
let id = match direction { let id = match direction {
Direction::Forward => { Direction::Forward => {
let iter = editor.documents.keys(); let iter = editor.documents.keys();
let mut iter = iter.skip_while(|id| *id != &current); // skip 'count' times past current buffer
iter.next(); // skip current item iter.cycle().skip_while(|id| *id != &current).nth(count)
iter.next().or_else(|| editor.documents.keys().next())
} }
Direction::Backward => { Direction::Backward => {
let iter = editor.documents.keys(); let iter = editor.documents.keys();
let mut iter = iter.rev().skip_while(|id| *id != &current); // skip 'count' times past current buffer
iter.next(); // skip current item iter.rev()
iter.next().or_else(|| editor.documents.keys().next_back()) .cycle()
.skip_while(|id| *id != &current)
.nth(count)
} }
} }
.unwrap(); .unwrap();
@ -1220,25 +1226,51 @@ fn goto_file_impl(cx: &mut Context, action: Action) {
let primary = selections.primary(); let primary = selections.primary();
// Checks whether there is only one selection with a width of 1 // Checks whether there is only one selection with a width of 1
if selections.len() == 1 && primary.len() == 1 { if selections.len() == 1 && primary.len() == 1 {
let count = cx.count();
let text_slice = text.slice(..);
// In this case it selects the WORD under the cursor
let current_word = textobject::textobject_word(
text_slice,
primary,
textobject::TextObject::Inside,
count,
true,
);
// Trims some surrounding chars so that the actual file is opened.
let surrounding_chars: &[_] = &['\'', '"', '(', ')'];
paths.clear(); paths.clear();
paths.push(
current_word let is_valid_path_char = |c: &char| {
.fragment(text_slice) #[cfg(target_os = "windows")]
.trim_matches(surrounding_chars) let valid_chars = &[
.to_string(), '@', '/', '\\', '.', '-', '_', '+', '#', '$', '%', '{', '}', '[', ']', ':', '!',
); '~', '=',
];
#[cfg(not(target_os = "windows"))]
let valid_chars = &['@', '/', '.', '-', '_', '+', '#', '$', '%', '~', '=', ':'];
valid_chars.contains(c) || c.is_alphabetic() || c.is_numeric()
};
let cursor_pos = primary.cursor(text.slice(..));
let pre_cursor_pos = cursor_pos.saturating_sub(1);
let post_cursor_pos = cursor_pos + 1;
let start_pos = if is_valid_path_char(&text.char(cursor_pos)) {
cursor_pos
} else if is_valid_path_char(&text.char(pre_cursor_pos)) {
pre_cursor_pos
} else {
post_cursor_pos
};
let prefix_len = text
.chars_at(start_pos)
.reversed()
.take_while(is_valid_path_char)
.count();
let postfix_len = text
.chars_at(start_pos)
.take_while(is_valid_path_char)
.count();
let path: Cow<str> = text
.slice((start_pos - prefix_len)..(start_pos + postfix_len))
.into();
log::debug!("Goto file path: {}", path);
match expand_tilde(PathBuf::from(path.as_ref())).to_str() {
Some(path) => paths.push(path.to_string()),
None => cx.editor.set_error("Couldn't get string out of path."),
};
} }
for sel in paths { for sel in paths {
@ -1248,7 +1280,8 @@ fn goto_file_impl(cx: &mut Context, action: Action) {
} }
if let Ok(url) = Url::parse(p) { if let Ok(url) = Url::parse(p) {
return open_url(cx, url, action); open_url(cx, url, action);
continue;
} }
let path = &rel_path.join(p); let path = &rel_path.join(p);
@ -2072,6 +2105,11 @@ fn searcher(cx: &mut Context, direction: Direction) {
let config = cx.editor.config(); let config = cx.editor.config();
let scrolloff = config.scrolloff; let scrolloff = config.scrolloff;
let wrap_around = config.search.wrap_around; let wrap_around = config.search.wrap_around;
let movement = if cx.editor.mode() == Mode::Select {
Movement::Extend
} else {
Movement::Move
};
// TODO: could probably share with select_on_matches? // TODO: could probably share with select_on_matches?
let completions = search_completions(cx, Some(reg)); let completions = search_completions(cx, Some(reg));
@ -2096,7 +2134,7 @@ fn searcher(cx: &mut Context, direction: Direction) {
search_impl( search_impl(
cx.editor, cx.editor,
&regex, &regex,
Movement::Move, movement,
direction, direction,
scrolloff, scrolloff,
wrap_around, wrap_around,
@ -4969,6 +5007,36 @@ pub fn extend_parent_node_start(cx: &mut Context) {
move_node_bound_impl(cx, Direction::Backward, Movement::Extend) move_node_bound_impl(cx, Direction::Backward, Movement::Extend)
} }
fn select_all_impl<F>(editor: &mut Editor, select_fn: F)
where
F: Fn(&Syntax, RopeSlice, Selection) -> Selection,
{
let (view, doc) = current!(editor);
if let Some(syntax) = doc.syntax() {
let text = doc.text().slice(..);
let current_selection = doc.selection(view.id);
let selection = select_fn(syntax, text, current_selection.clone());
doc.set_selection(view.id, selection);
}
}
fn select_all_siblings(cx: &mut Context) {
let motion = |editor: &mut Editor| {
select_all_impl(editor, object::select_all_siblings);
};
cx.editor.apply_motion(motion);
}
fn select_all_children(cx: &mut Context) {
let motion = |editor: &mut Editor| {
select_all_impl(editor, object::select_all_children);
};
cx.editor.apply_motion(motion);
}
fn match_brackets(cx: &mut Context) { fn match_brackets(cx: &mut Context) {
let (view, doc) = current!(cx.editor); let (view, doc) = current!(cx.editor);
let is_select = cx.editor.mode == Mode::Select; let is_select = cx.editor.mode == Mode::Select;
@ -5366,13 +5434,22 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
'e' => textobject_treesitter("entry", range), 'e' => textobject_treesitter("entry", range),
'p' => textobject::textobject_paragraph(text, range, objtype, count), 'p' => textobject::textobject_paragraph(text, range, objtype, count),
'm' => textobject::textobject_pair_surround_closest( 'm' => textobject::textobject_pair_surround_closest(
text, range, objtype, count, doc.syntax(),
text,
range,
objtype,
count,
), ),
'g' => textobject_change(range), 'g' => textobject_change(range),
// TODO: cancel new ranges if inconsistent surround matches across lines // TODO: cancel new ranges if inconsistent surround matches across lines
ch if !ch.is_ascii_alphanumeric() => { ch if !ch.is_ascii_alphanumeric() => textobject::textobject_pair_surround(
textobject::textobject_pair_surround(text, range, objtype, ch, count) doc.syntax(),
} text,
range,
objtype,
ch,
count,
),
_ => range, _ => range,
} }
}); });
@ -5397,7 +5474,8 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
("c", "Comment (tree-sitter)"), ("c", "Comment (tree-sitter)"),
("T", "Test (tree-sitter)"), ("T", "Test (tree-sitter)"),
("e", "Data structure entry (tree-sitter)"), ("e", "Data structure entry (tree-sitter)"),
("m", "Closest surrounding pair"), ("m", "Closest surrounding pair (tree-sitter)"),
("g", "Change"),
(" ", "... or any character acting as a pair"), (" ", "... or any character acting as a pair"),
]; ];
@ -5410,7 +5488,7 @@ fn surround_add(cx: &mut Context) {
// surround_len is the number of new characters being added. // surround_len is the number of new characters being added.
let (open, close, surround_len) = match event.char() { let (open, close, surround_len) = match event.char() {
Some(ch) => { Some(ch) => {
let (o, c) = surround::get_pair(ch); let (o, c) = match_brackets::get_pair(ch);
let mut open = Tendril::new(); let mut open = Tendril::new();
open.push(o); open.push(o);
let mut close = Tendril::new(); let mut close = Tendril::new();
@ -5461,7 +5539,8 @@ fn surround_replace(cx: &mut Context) {
let text = doc.text().slice(..); let text = doc.text().slice(..);
let selection = doc.selection(view.id); let selection = doc.selection(view.id);
let change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) { let change_pos =
match surround::get_surround_pos(doc.syntax(), text, selection, surround_ch, count) {
Ok(c) => c, Ok(c) => c,
Err(err) => { Err(err) => {
cx.editor.set_error(err.to_string()); cx.editor.set_error(err.to_string());
@ -5482,7 +5561,7 @@ fn surround_replace(cx: &mut Context) {
Some(to) => to, Some(to) => to,
None => return doc.set_selection(view.id, selection), None => return doc.set_selection(view.id, selection),
}; };
let (open, close) = surround::get_pair(to); let (open, close) = match_brackets::get_pair(to);
// the changeset has to be sorted to allow nested surrounds // the changeset has to be sorted to allow nested surrounds
let mut sorted_pos: Vec<(usize, char)> = Vec::new(); let mut sorted_pos: Vec<(usize, char)> = Vec::new();
@ -5519,7 +5598,8 @@ fn surround_delete(cx: &mut Context) {
let text = doc.text().slice(..); let text = doc.text().slice(..);
let selection = doc.selection(view.id); let selection = doc.selection(view.id);
let mut change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) { let mut change_pos =
match surround::get_surround_pos(doc.syntax(), text, selection, surround_ch, count) {
Ok(c) => c, Ok(c) => c,
Err(err) => { Err(err) => {
cx.editor.set_error(err.to_string()); cx.editor.set_error(err.to_string());
@ -6000,7 +6080,10 @@ fn jump_to_label(cx: &mut Context, labels: Vec<Range>, behaviour: Movement) {
let doc = doc.id(); let doc = doc.id();
cx.on_next_key(move |cx, event| { cx.on_next_key(move |cx, event| {
let alphabet = &cx.editor.config().jump_label_alphabet; let alphabet = &cx.editor.config().jump_label_alphabet;
let Some(i ) = event.char().and_then(|ch| alphabet.iter().position(|&it| it == ch)) else { let Some(i) = event
.char()
.and_then(|ch| alphabet.iter().position(|&it| it == ch))
else {
doc_mut!(cx.editor, &doc).remove_jump_labels(view); doc_mut!(cx.editor, &doc).remove_jump_labels(view);
return; return;
}; };
@ -6013,7 +6096,10 @@ fn jump_to_label(cx: &mut Context, labels: Vec<Range>, behaviour: Movement) {
cx.on_next_key(move |cx, event| { cx.on_next_key(move |cx, event| {
doc_mut!(cx.editor, &doc).remove_jump_labels(view); doc_mut!(cx.editor, &doc).remove_jump_labels(view);
let alphabet = &cx.editor.config().jump_label_alphabet; let alphabet = &cx.editor.config().jump_label_alphabet;
let Some(inner ) = event.char().and_then(|ch| alphabet.iter().position(|&it| it == ch)) else { let Some(inner) = event
.char()
.and_then(|ch| alphabet.iter().position(|&it| it == ch))
else {
return; return;
}; };
if let Some(mut range) = labels.get(outer + inner).copied() { if let Some(mut range) = labels.get(outer + inner).copied() {

@ -8,7 +8,7 @@ use dap::{StackFrame, Thread, ThreadStates};
use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate}; use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate};
use helix_dap::{self as dap, Client}; use helix_dap::{self as dap, Client};
use helix_lsp::block_on; use helix_lsp::block_on;
use helix_view::{editor::Breakpoint, graphics::Margin}; use helix_view::editor::Breakpoint;
use serde_json::{to_value, Value}; use serde_json::{to_value, Value};
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
@ -172,9 +172,9 @@ pub fn dap_start_impl(
let mut args: HashMap<&str, Value> = HashMap::new(); let mut args: HashMap<&str, Value> = HashMap::new();
if let Some(params) = params {
for (k, t) in &template.args { for (k, t) in &template.args {
let mut value = t.clone(); let mut value = t.clone();
if let Some(ref params) = params {
for (i, x) in params.iter().enumerate() { for (i, x) in params.iter().enumerate() {
let mut param = x.to_string(); let mut param = x.to_string();
if let Some(DebugConfigCompletion::Advanced(cfg)) = template.completion.get(i) { if let Some(DebugConfigCompletion::Advanced(cfg)) = template.completion.get(i) {
@ -198,6 +198,7 @@ pub fn dap_start_impl(
DebugArgumentValue::Boolean(_) => value, DebugArgumentValue::Boolean(_) => value,
}; };
} }
}
match value { match value {
DebugArgumentValue::String(string) => { DebugArgumentValue::String(string) => {
@ -215,7 +216,6 @@ pub fn dap_start_impl(
} }
} }
} }
}
args.insert("cwd", to_value(helix_stdx::env::current_working_dir())?); args.insert("cwd", to_value(helix_stdx::env::current_working_dir())?);
@ -272,6 +272,11 @@ pub fn dap_launch(cx: &mut Context) {
templates, templates,
(), (),
|cx, template, _action| { |cx, template, _action| {
if template.completion.is_empty() {
if let Err(err) = dap_start_impl(cx, Some(&template.name), None, None) {
cx.editor.set_error(err.to_string());
}
} else {
let completions = template.completion.clone(); let completions = template.completion.clone();
let name = template.name.clone(); let name = template.name.clone();
let callback = Box::pin(async move { let callback = Box::pin(async move {
@ -283,6 +288,7 @@ pub fn dap_launch(cx: &mut Context) {
Ok(call) Ok(call)
}); });
cx.jobs.callback(callback); cx.jobs.callback(callback);
}
}, },
)))); ))));
} }
@ -581,12 +587,7 @@ pub fn dap_variables(cx: &mut Context) {
} }
let contents = Text::from(tui::text::Text::from(variables)); let contents = Text::from(tui::text::Text::from(variables));
let margin = if cx.editor.popup_border() { let popup = Popup::new("dap-variables", contents);
Margin::all(1)
} else {
Margin::none()
};
let popup = Popup::new("dap-variables", contents).margin(margin);
cx.replace_or_push_layer("dap-variables", popup); cx.replace_or_push_layer("dap-variables", popup);
} }

@ -1,4 +1,4 @@
use futures_util::{stream::FuturesUnordered, FutureExt}; use futures_util::{stream::FuturesOrdered, FutureExt};
use helix_lsp::{ use helix_lsp::{
block_on, block_on,
lsp::{ lsp::{
@ -6,7 +6,7 @@ use helix_lsp::{
NumberOrString, NumberOrString,
}, },
util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range},
Client, OffsetEncoding, Client, LanguageServerId, OffsetEncoding,
}; };
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tui::{ use tui::{
@ -21,7 +21,6 @@ use helix_stdx::path;
use helix_view::{ use helix_view::{
document::{DocumentInlayHints, DocumentInlayHintsId}, document::{DocumentInlayHints, DocumentInlayHintsId},
editor::Action, editor::Action,
graphics::Margin,
handlers::lsp::SignatureHelpInvoked, handlers::lsp::SignatureHelpInvoked,
theme::Style, theme::Style,
Document, View, Document, View,
@ -266,7 +265,7 @@ enum DiagnosticsFormat {
fn diag_picker( fn diag_picker(
cx: &Context, cx: &Context,
diagnostics: BTreeMap<PathBuf, Vec<(lsp::Diagnostic, usize)>>, diagnostics: BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>,
format: DiagnosticsFormat, format: DiagnosticsFormat,
) -> Picker<PickerDiagnostic> { ) -> Picker<PickerDiagnostic> {
// TODO: drop current_path comparison and instead use workspace: bool flag? // TODO: drop current_path comparison and instead use workspace: bool flag?
@ -341,7 +340,7 @@ pub fn symbol_picker(cx: &mut Context) {
let mut seen_language_servers = HashSet::new(); let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc let mut futures: FuturesOrdered<_> = doc
.language_servers_with_feature(LanguageServerFeature::DocumentSymbols) .language_servers_with_feature(LanguageServerFeature::DocumentSymbols)
.filter(|ls| seen_language_servers.insert(ls.id())) .filter(|ls| seen_language_servers.insert(ls.id()))
.map(|language_server| { .map(|language_server| {
@ -416,7 +415,7 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
let get_symbols = move |pattern: String, editor: &mut Editor| { let get_symbols = move |pattern: String, editor: &mut Editor| {
let doc = doc!(editor); let doc = doc!(editor);
let mut seen_language_servers = HashSet::new(); let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc let mut futures: FuturesOrdered<_> = doc
.language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols)
.filter(|ls| seen_language_servers.insert(ls.id())) .filter(|ls| seen_language_servers.insert(ls.id()))
.map(|language_server| { .map(|language_server| {
@ -497,7 +496,7 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) {
struct CodeActionOrCommandItem { struct CodeActionOrCommandItem {
lsp_item: lsp::CodeActionOrCommand, lsp_item: lsp::CodeActionOrCommand,
language_server_id: usize, language_server_id: LanguageServerId,
} }
impl ui::menu::Item for CodeActionOrCommandItem { impl ui::menu::Item for CodeActionOrCommandItem {
@ -574,7 +573,7 @@ pub fn code_action(cx: &mut Context) {
let mut seen_language_servers = HashSet::new(); let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc let mut futures: FuturesOrdered<_> = doc
.language_servers_with_feature(LanguageServerFeature::CodeAction) .language_servers_with_feature(LanguageServerFeature::CodeAction)
.filter(|ls| seen_language_servers.insert(ls.id())) .filter(|ls| seen_language_servers.insert(ls.id()))
// TODO this should probably already been filtered in something like "language_servers_with_feature" // TODO this should probably already been filtered in something like "language_servers_with_feature"
@ -733,15 +732,7 @@ pub fn code_action(cx: &mut Context) {
}); });
picker.move_down(); // pre-select the first item picker.move_down(); // pre-select the first item
let margin = if editor.menu_border() { let popup = Popup::new("code-action", picker).with_scrollbar(false);
Margin::vertical(1)
} else {
Margin::none()
};
let popup = Popup::new("code-action", picker)
.with_scrollbar(false)
.margin(margin);
compositor.replace_or_push("code-action", popup); compositor.replace_or_push("code-action", popup);
}; };
@ -757,7 +748,11 @@ impl ui::menu::Item for lsp::Command {
} }
} }
pub fn execute_lsp_command(editor: &mut Editor, language_server_id: usize, cmd: lsp::Command) { pub fn execute_lsp_command(
editor: &mut Editor,
language_server_id: LanguageServerId,
cmd: lsp::Command,
) {
// the command is executed on the server and communicated back // the command is executed on the server and communicated back
// to the client asynchronously using workspace edits // to the client asynchronously using workspace edits
let future = match editor let future = match editor
@ -1034,7 +1029,7 @@ pub fn rename_symbol(cx: &mut Context) {
fn create_rename_prompt( fn create_rename_prompt(
editor: &Editor, editor: &Editor,
prefill: String, prefill: String,
language_server_id: Option<usize>, language_server_id: Option<LanguageServerId>,
) -> Box<ui::Prompt> { ) -> Box<ui::Prompt> {
let prompt = ui::Prompt::new( let prompt = ui::Prompt::new(
"rename-to:".into(), "rename-to:".into(),

@ -1,4 +1,5 @@
use std::fmt::Write; use std::fmt::Write;
use std::io::BufReader;
use std::ops::Deref; use std::ops::Deref;
use crate::job::Job; use crate::job::Job;
@ -8,7 +9,7 @@ use super::*;
use helix_core::fuzzy::fuzzy_match; use helix_core::fuzzy::fuzzy_match;
use helix_core::indent::MAX_INDENT; use helix_core::indent::MAX_INDENT;
use helix_core::{line_ending, shellwords::Shellwords}; use helix_core::{line_ending, shellwords::Shellwords};
use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME};
use helix_view::editor::{CloseError, ConfigEvent}; use helix_view::editor::{CloseError, ConfigEvent};
use serde_json::Value; use serde_json::Value;
use ui::completers::{self, Completer}; use ui::completers::{self, Completer};
@ -309,7 +310,7 @@ fn buffer_next(
return Ok(()); return Ok(());
} }
goto_buffer(cx.editor, Direction::Forward); goto_buffer(cx.editor, Direction::Forward, 1);
Ok(()) Ok(())
} }
@ -322,7 +323,7 @@ fn buffer_previous(
return Ok(()); return Ok(());
} }
goto_buffer(cx.editor, Direction::Backward); goto_buffer(cx.editor, Direction::Backward, 1);
Ok(()) Ok(())
} }
@ -2454,6 +2455,39 @@ fn yank_diagnostic(
Ok(()) Ok(())
} }
fn read(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
let scrolloff = cx.editor.config().scrolloff;
let (view, doc) = current!(cx.editor);
ensure!(!args.is_empty(), "file name is expected");
ensure!(args.len() == 1, "only the file name is expected");
let filename = args.get(0).unwrap();
let path = PathBuf::from(filename.to_string());
ensure!(
path.exists() && path.is_file(),
"path is not a file: {:?}",
path
);
let file = std::fs::File::open(path).map_err(|err| anyhow!("error opening file: {}", err))?;
let mut reader = BufReader::new(file);
let (contents, _, _) = read_to_string(&mut reader, Some(doc.encoding()))
.map_err(|err| anyhow!("error reading file: {}", err))?;
let contents = Tendril::from(contents);
let selection = doc.selection(view.id);
let transaction = Transaction::insert(doc.text(), selection, contents);
doc.apply(&transaction, view.id);
doc.append_changes_to_history(view);
view.ensure_cursor_in_view(doc, scrolloff);
Ok(())
}
pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
TypableCommand { TypableCommand {
name: "quit", name: "quit",
@ -3068,6 +3102,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
fun: yank_diagnostic, fun: yank_diagnostic,
signature: CommandSignature::all(completers::register), signature: CommandSignature::all(completers::register),
}, },
TypableCommand {
name: "read",
aliases: &["r"],
doc: "Load a file into buffer",
fun: read,
signature: CommandSignature::positional(&[completers::filename]),
},
]; ];
pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableCommand>> = pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableCommand>> =

@ -11,7 +11,7 @@ use crate::handlers::signature_help::SignatureHelpHandler;
pub use completion::trigger_auto_completion; pub use completion::trigger_auto_completion;
pub use helix_view::handlers::Handlers; pub use helix_view::handlers::Handlers;
mod completion; pub mod completion;
mod signature_help; mod signature_help;
pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers { pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {

@ -30,6 +30,8 @@ use crate::ui::lsp::SignatureHelp;
use crate::ui::{self, CompletionItem, Popup}; use crate::ui::{self, CompletionItem, Popup};
use super::Handlers; use super::Handlers;
pub use resolve::ResolveHandler;
mod resolve;
#[derive(Debug, PartialEq, Eq, Clone, Copy)] #[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum TriggerKind { enum TriggerKind {
@ -251,7 +253,7 @@ fn request_completion(
.into_iter() .into_iter()
.map(|item| CompletionItem { .map(|item| CompletionItem {
item, item,
language_server_id, provider: language_server_id,
resolved: false, resolved: false,
}) })
.collect(); .collect();

@ -0,0 +1,153 @@
use std::sync::Arc;
use helix_lsp::lsp;
use tokio::sync::mpsc::Sender;
use tokio::time::{Duration, Instant};
use helix_event::{send_blocking, AsyncHook, CancelRx};
use helix_view::Editor;
use crate::handlers::completion::CompletionItem;
use crate::job;
/// A hook for resolving incomplete completion items.
///
/// From the [LSP spec](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion):
///
/// > If computing full completion items is expensive, servers can additionally provide a
/// > handler for the completion item resolve request. ...
/// > A typical use case is for example: the `textDocument/completion` request doesn't fill
/// > in the `documentation` property for returned completion items since it is expensive
/// > to compute. When the item is selected in the user interface then a
/// > 'completionItem/resolve' request is sent with the selected completion item as a parameter.
/// > The returned completion item should have the documentation property filled in.
pub struct ResolveHandler {
last_request: Option<Arc<CompletionItem>>,
resolver: Sender<ResolveRequest>,
}
impl ResolveHandler {
pub fn new() -> ResolveHandler {
ResolveHandler {
last_request: None,
resolver: ResolveTimeout {
next_request: None,
in_flight: None,
}
.spawn(),
}
}
pub fn ensure_item_resolved(&mut self, editor: &mut Editor, item: &mut CompletionItem) {
if item.resolved {
return;
}
let needs_resolve = item.item.documentation.is_none()
|| item.item.detail.is_none()
|| item.item.additional_text_edits.is_none();
if !needs_resolve {
item.resolved = true;
return;
}
if self.last_request.as_deref().is_some_and(|it| it == item) {
return;
}
let Some(ls) = editor.language_servers.get_by_id(item.provider).cloned() else {
item.resolved = true;
return;
};
if matches!(
ls.capabilities().completion_provider,
Some(lsp::CompletionOptions {
resolve_provider: Some(true),
..
})
) {
let item = Arc::new(item.clone());
self.last_request = Some(item.clone());
send_blocking(&self.resolver, ResolveRequest { item, ls })
} else {
item.resolved = true;
}
}
}
struct ResolveRequest {
item: Arc<CompletionItem>,
ls: Arc<helix_lsp::Client>,
}
#[derive(Default)]
struct ResolveTimeout {
next_request: Option<ResolveRequest>,
in_flight: Option<(helix_event::CancelTx, Arc<CompletionItem>)>,
}
impl AsyncHook for ResolveTimeout {
type Event = ResolveRequest;
fn handle_event(
&mut self,
request: Self::Event,
timeout: Option<tokio::time::Instant>,
) -> Option<tokio::time::Instant> {
if self
.next_request
.as_ref()
.is_some_and(|old_request| old_request.item == request.item)
{
timeout
} else if self
.in_flight
.as_ref()
.is_some_and(|(_, old_request)| old_request.item == request.item.item)
{
self.next_request = None;
None
} else {
self.next_request = Some(request);
Some(Instant::now() + Duration::from_millis(150))
}
}
fn finish_debounce(&mut self) {
let Some(request) = self.next_request.take() else { return };
let (tx, rx) = helix_event::cancelation();
self.in_flight = Some((tx, request.item.clone()));
tokio::spawn(request.execute(rx));
}
}
impl ResolveRequest {
async fn execute(self, cancel: CancelRx) {
let future = self.ls.resolve_completion_item(&self.item.item);
let Some(resolved_item) = helix_event::cancelable_future(future, cancel).await else {
return;
};
job::dispatch(move |_, compositor| {
if let Some(completion) = &mut compositor
.find::<crate::ui::EditorView>()
.unwrap()
.completion
{
let resolved_item = match resolved_item {
Ok(item) => CompletionItem {
item,
resolved: true,
..*self.item
},
Err(err) => {
log::error!("completion resolve request failed: {err}");
// set item to resolved so we don't request it again
// we could also remove it but that oculd be odd ui
let mut item = (*self.item).clone();
item.resolved = true;
item
}
};
completion.replace_item(&self.item, resolved_item);
};
})
.await
}
}

@ -5,7 +5,7 @@ use helix_core::syntax::LanguageServerFeature;
use helix_event::{ use helix_event::{
cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx, cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
}; };
use helix_lsp::lsp; use helix_lsp::lsp::{self, SignatureInformation};
use helix_stdx::rope::RopeSliceExt; use helix_stdx::rope::RopeSliceExt;
use helix_view::document::Mode; use helix_view::document::Mode;
use helix_view::events::{DocumentDidChange, SelectionDidChange}; use helix_view::events::{DocumentDidChange, SelectionDidChange};
@ -18,7 +18,7 @@ use crate::commands::Open;
use crate::compositor::Compositor; use crate::compositor::Compositor;
use crate::events::{OnModeSwitch, PostInsertChar}; use crate::events::{OnModeSwitch, PostInsertChar};
use crate::handlers::Handlers; use crate::handlers::Handlers;
use crate::ui::lsp::SignatureHelp; use crate::ui::lsp::{Signature, SignatureHelp};
use crate::ui::Popup; use crate::ui::Popup;
use crate::{job, ui}; use crate::{job, ui};
@ -82,6 +82,7 @@ impl helix_event::AsyncHook for SignatureHelpHandler {
} }
} }
self.state = if open { State::Open } else { State::Closed }; self.state = if open { State::Open } else { State::Closed };
return timeout; return timeout;
} }
} }
@ -138,6 +139,31 @@ pub fn request_signature_help(
}); });
} }
fn active_param_range(
signature: &SignatureInformation,
response_active_parameter: Option<u32>,
) -> Option<(usize, usize)> {
let param_idx = signature
.active_parameter
.or(response_active_parameter)
.unwrap_or(0) as usize;
let param = signature.parameters.as_ref()?.get(param_idx)?;
match &param.label {
lsp::ParameterLabel::Simple(string) => {
let start = signature.label.find(string.as_str())?;
Some((start, start + string.len()))
}
lsp::ParameterLabel::LabelOffsets([start, end]) => {
// LS sends offsets based on utf-16 based string representation
// but highlighting in helix is done using byte offset.
use helix_core::str_utils::char_to_byte_idx;
let from = char_to_byte_idx(&signature.label, *start as usize);
let to = char_to_byte_idx(&signature.label, *end as usize);
Some((from, to))
}
}
}
pub fn show_signature_help( pub fn show_signature_help(
editor: &mut Editor, editor: &mut Editor,
compositor: &mut Compositor, compositor: &mut Compositor,
@ -184,54 +210,64 @@ pub fn show_signature_help(
let doc = doc!(editor); let doc = doc!(editor);
let language = doc.language_name().unwrap_or(""); let language = doc.language_name().unwrap_or("");
let signature = match response if response.signatures.is_empty() {
return;
}
let signatures: Vec<Signature> = response
.signatures .signatures
.get(response.active_signature.unwrap_or(0) as usize) .into_iter()
{ .map(|s| {
Some(s) => s, let active_param_range = active_param_range(&s, response.active_parameter);
None => return,
};
let mut contents = SignatureHelp::new(
signature.label.clone(),
language.to_string(),
Arc::clone(&editor.syn_loader),
);
let signature_doc = if config.lsp.display_signature_help_docs { let signature_doc = if config.lsp.display_signature_help_docs {
signature.documentation.as_ref().map(|doc| match doc { s.documentation.map(|doc| match doc {
lsp::Documentation::String(s) => s.clone(), lsp::Documentation::String(s) => s,
lsp::Documentation::MarkupContent(markup) => markup.value.clone(), lsp::Documentation::MarkupContent(markup) => markup.value,
}) })
} else { } else {
None None
}; };
contents.set_signature_doc(signature_doc); Signature {
signature: s.label,
let active_param_range = || -> Option<(usize, usize)> { signature_doc,
let param_idx = signature active_param_range,
.active_parameter
.or(response.active_parameter)
.unwrap_or(0) as usize;
let param = signature.parameters.as_ref()?.get(param_idx)?;
match &param.label {
lsp::ParameterLabel::Simple(string) => {
let start = signature.label.find(string.as_str())?;
Some((start, start + string.len()))
}
lsp::ParameterLabel::LabelOffsets([start, end]) => {
// LS sends offsets based on utf-16 based string representation
// but highlighting in helix is done using byte offset.
use helix_core::str_utils::char_to_byte_idx;
let from = char_to_byte_idx(&signature.label, *start as usize);
let to = char_to_byte_idx(&signature.label, *end as usize);
Some((from, to))
}
} }
}; })
contents.set_active_param_range(active_param_range()); .collect();
let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID); let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
let lsp_signature = response.active_signature.map(|s| s as usize);
// take the new suggested lsp signature if changed
// otherwise take the old signature if possible
// otherwise the last one (in case there is less signatures than before)
let active_signature = old_popup
.as_ref()
.map(|popup| {
let old_lsp_sig = popup.contents().lsp_signature();
let old_sig = popup
.contents()
.active_signature()
.min(signatures.len() - 1);
if old_lsp_sig != lsp_signature {
lsp_signature.unwrap_or(old_sig)
} else {
old_sig
}
})
.unwrap_or(lsp_signature.unwrap_or_default());
let contents = SignatureHelp::new(
language.to_string(),
Arc::clone(&editor.syn_loader),
active_signature,
lsp_signature,
signatures,
);
let mut popup = Popup::new(SignatureHelp::ID, contents) let mut popup = Popup::new(SignatureHelp::ID, contents)
.position(old_popup.and_then(|p| p.get_position())) .position(old_popup.and_then(|p| p.get_position()))
.position_bias(Open::Above) .position_bias(Open::Above)

@ -103,10 +103,12 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"A-;" => flip_selections, "A-;" => flip_selections,
"A-o" | "A-up" => expand_selection, "A-o" | "A-up" => expand_selection,
"A-i" | "A-down" => shrink_selection, "A-i" | "A-down" => shrink_selection,
"A-I" | "A-S-down" => select_all_children,
"A-p" | "A-left" => select_prev_sibling, "A-p" | "A-left" => select_prev_sibling,
"A-n" | "A-right" => select_next_sibling, "A-n" | "A-right" => select_next_sibling,
"A-e" => move_parent_node_end, "A-e" => move_parent_node_end,
"A-b" => move_parent_node_start, "A-b" => move_parent_node_start,
"A-a" => select_all_siblings,
"%" => select_all, "%" => select_all,
"x" => extend_line_below, "x" => extend_line_below,

@ -51,7 +51,7 @@ fn filter_picker_entry(entry: &DirEntry, root: &Path, dedup_symlinks: bool) -> b
// in our picker. // in our picker.
if matches!( if matches!(
entry.file_name().to_str(), entry.file_name().to_str(),
Some(".git" | ".pijul" | ".jj" | ".hg") Some(".git" | ".pijul" | ".jj" | ".hg" | ".svn")
) { ) {
return false; return false;
} }

@ -1,28 +1,24 @@
use crate::{ use crate::{
compositor::{Component, Context, Event, EventResult}, compositor::{Component, Context, Event, EventResult},
handlers::trigger_auto_completion, handlers::{completion::ResolveHandler, trigger_auto_completion},
job,
}; };
use helix_event::AsyncHook;
use helix_view::{ use helix_view::{
document::SavePoint, document::SavePoint,
editor::CompleteAction, editor::CompleteAction,
graphics::Margin,
handlers::lsp::SignatureHelpInvoked, handlers::lsp::SignatureHelpInvoked,
theme::{Modifier, Style}, theme::{Modifier, Style},
ViewId, ViewId,
}; };
use tokio::time::Instant;
use tui::{buffer::Buffer as Surface, text::Span}; use tui::{buffer::Buffer as Surface, text::Span};
use std::{borrow::Cow, sync::Arc, time::Duration}; use std::{borrow::Cow, sync::Arc};
use helix_core::{chars, Change, Transaction}; use helix_core::{chars, Change, Transaction};
use helix_view::{graphics::Rect, Document, Editor}; use helix_view::{graphics::Rect, Document, Editor};
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent}; use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
use helix_lsp::{lsp, util, OffsetEncoding}; use helix_lsp::{lsp, util, LanguageServerId, OffsetEncoding};
impl menu::Item for CompletionItem { impl menu::Item for CompletionItem {
type Data = (); type Data = ();
@ -94,7 +90,7 @@ impl menu::Item for CompletionItem {
#[derive(Debug, PartialEq, Default, Clone)] #[derive(Debug, PartialEq, Default, Clone)]
pub struct CompletionItem { pub struct CompletionItem {
pub item: lsp::CompletionItem, pub item: lsp::CompletionItem,
pub language_server_id: usize, pub provider: LanguageServerId,
pub resolved: bool, pub resolved: bool,
} }
@ -104,7 +100,7 @@ pub struct Completion {
#[allow(dead_code)] #[allow(dead_code)]
trigger_offset: usize, trigger_offset: usize,
filter: String, filter: String,
resolve_handler: tokio::sync::mpsc::Sender<CompletionItem>, resolve_handler: ResolveHandler,
} }
impl Completion { impl Completion {
@ -224,7 +220,7 @@ impl Completion {
($item:expr) => { ($item:expr) => {
match editor match editor
.language_servers .language_servers
.get_by_id($item.language_server_id) .get_by_id($item.provider)
{ {
Some(ls) => ls, Some(ls) => ls,
None => { None => {
@ -285,12 +281,6 @@ impl Completion {
let language_server = language_server!(item); let language_server = language_server!(item);
let offset_encoding = language_server.offset_encoding(); let offset_encoding = language_server.offset_encoding();
let language_server = editor
.language_servers
.get_by_id(item.language_server_id)
.unwrap();
// resolve item if not yet resolved
if !item.resolved { if !item.resolved {
if let Some(resolved) = if let Some(resolved) =
Self::resolve_completion_item(language_server, item.item.clone()) Self::resolve_completion_item(language_server, item.item.clone())
@ -343,16 +333,9 @@ impl Completion {
} }
}); });
let margin = if editor.menu_border() {
Margin::vertical(1)
} else {
Margin::none()
};
let popup = Popup::new(Self::ID, menu) let popup = Popup::new(Self::ID, menu)
.with_scrollbar(false) .with_scrollbar(false)
.ignore_escape_key(true) .ignore_escape_key(true);
.margin(margin);
let (view, doc) = current_ref!(editor); let (view, doc) = current_ref!(editor);
let text = doc.text().slice(..); let text = doc.text().slice(..);
@ -371,7 +354,7 @@ impl Completion {
// TODO: expand nucleo api to allow moving straight to a Utf32String here // TODO: expand nucleo api to allow moving straight to a Utf32String here
// and avoid allocation during matching // and avoid allocation during matching
filter: String::from(fragment), filter: String::from(fragment),
resolve_handler: ResolveHandler::default().spawn(), resolve_handler: ResolveHandler::new(),
}; };
// need to recompute immediately in case start_offset != trigger_offset // need to recompute immediately in case start_offset != trigger_offset
@ -389,7 +372,16 @@ impl Completion {
language_server: &helix_lsp::Client, language_server: &helix_lsp::Client,
completion_item: lsp::CompletionItem, completion_item: lsp::CompletionItem,
) -> Option<lsp::CompletionItem> { ) -> Option<lsp::CompletionItem> {
let future = language_server.resolve_completion_item(completion_item)?; if !matches!(
language_server.capabilities().completion_provider,
Some(lsp::CompletionOptions {
resolve_provider: Some(true),
..
})
) {
return None;
}
let future = language_server.resolve_completion_item(&completion_item);
let response = helix_lsp::block_on(future); let response = helix_lsp::block_on(future);
match response { match response {
Ok(item) => Some(item), Ok(item) => Some(item),
@ -422,7 +414,7 @@ impl Completion {
self.popup.contents().is_empty() self.popup.contents().is_empty()
} }
fn replace_item(&mut self, old_item: CompletionItem, new_item: CompletionItem) { pub fn replace_item(&mut self, old_item: &CompletionItem, new_item: CompletionItem) {
self.popup.contents_mut().replace_option(old_item, new_item); self.popup.contents_mut().replace_option(old_item, new_item);
} }
@ -444,12 +436,12 @@ impl Component for Completion {
self.popup.render(area, surface, cx); self.popup.render(area, surface, cx);
// if we have a selection, render a markdown popup on top/below with info // if we have a selection, render a markdown popup on top/below with info
let option = match self.popup.contents().selection() { let option = match self.popup.contents_mut().selection_mut() {
Some(option) => option, Some(option) => option,
None => return, None => return,
}; };
if !option.resolved { if !option.resolved {
helix_event::send_blocking(&self.resolve_handler, option.clone()); self.resolve_handler.ensure_item_resolved(cx.editor, option);
} }
// need to render: // need to render:
// option.detail // option.detail
@ -498,12 +490,7 @@ impl Component for Completion {
None => return, None => return,
}; };
let popup_area = { let popup_area = self.popup.area(area, cx.editor);
let (popup_x, popup_y) = self.popup.get_rel_position(area, cx.editor);
let (popup_width, popup_height) = self.popup.get_size();
Rect::new(popup_x, popup_y, popup_width, popup_height)
};
let doc_width_available = area.width.saturating_sub(popup_area.right()); let doc_width_available = area.width.saturating_sub(popup_area.right());
let doc_area = if doc_width_available > 30 { let doc_area = if doc_width_available > 30 {
let mut doc_width = doc_width_available; let mut doc_width = doc_width_available;
@ -552,88 +539,3 @@ impl Component for Completion {
markdown_doc.render(doc_area, surface, cx); markdown_doc.render(doc_area, surface, cx);
} }
} }
/// A hook for resolving incomplete completion items.
///
/// From the [LSP spec](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion):
///
/// > If computing full completion items is expensive, servers can additionally provide a
/// > handler for the completion item resolve request. ...
/// > A typical use case is for example: the `textDocument/completion` request doesn't fill
/// > in the `documentation` property for returned completion items since it is expensive
/// > to compute. When the item is selected in the user interface then a
/// > 'completionItem/resolve' request is sent with the selected completion item as a parameter.
/// > The returned completion item should have the documentation property filled in.
#[derive(Debug, Default)]
struct ResolveHandler {
trigger: Option<CompletionItem>,
request: Option<helix_event::CancelTx>,
}
impl AsyncHook for ResolveHandler {
type Event = CompletionItem;
fn handle_event(
&mut self,
item: Self::Event,
timeout: Option<tokio::time::Instant>,
) -> Option<tokio::time::Instant> {
if self
.trigger
.as_ref()
.is_some_and(|trigger| trigger == &item)
{
timeout
} else {
self.trigger = Some(item);
self.request = None;
Some(Instant::now() + Duration::from_millis(150))
}
}
fn finish_debounce(&mut self) {
let Some(item) = self.trigger.take() else { return };
let (tx, rx) = helix_event::cancelation();
self.request = Some(tx);
job::dispatch_blocking(move |editor, _| resolve_completion_item(editor, item, rx))
}
}
fn resolve_completion_item(
editor: &mut Editor,
item: CompletionItem,
cancel: helix_event::CancelRx,
) {
let Some(language_server) = editor.language_server_by_id(item.language_server_id) else {
return;
};
let Some(future) = language_server.resolve_completion_item(item.item.clone()) else {
return;
};
tokio::spawn(async move {
match helix_event::cancelable_future(future, cancel).await {
Some(Ok(resolved_item)) => {
job::dispatch(move |_, compositor| {
if let Some(completion) = &mut compositor
.find::<crate::ui::EditorView>()
.unwrap()
.completion
{
let resolved_item = CompletionItem {
item: resolved_item,
language_server_id: item.language_server_id,
resolved: true,
};
completion.replace_item(item, resolved_item);
};
})
.await
}
Some(Err(err)) => log::error!("completion resolve request failed: {err}"),
None => (),
}
});
}

@ -7,6 +7,7 @@ use helix_core::syntax::Highlight;
use helix_core::syntax::HighlightEvent; use helix_core::syntax::HighlightEvent;
use helix_core::text_annotations::TextAnnotations; use helix_core::text_annotations::TextAnnotations;
use helix_core::{visual_offset_from_block, Position, RopeSlice}; use helix_core::{visual_offset_from_block, Position, RopeSlice};
use helix_stdx::rope::RopeSliceExt;
use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue}; use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue};
use helix_view::graphics::Rect; use helix_view::graphics::Rect;
use helix_view::theme::Style; use helix_view::theme::Style;
@ -32,14 +33,27 @@ impl<F: FnMut(&mut TextRenderer, LinePos)> LineDecoration for F {
} }
} }
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum StyleIterKind {
/// base highlights (usually emitted by TS), byte indices (potentially not codepoint aligned)
BaseHighlights,
/// overlay highlights (emitted by custom code from selections), char indices
Overlay,
}
/// A wrapper around a HighlightIterator /// A wrapper around a HighlightIterator
/// that merges the layered highlights to create the final text style /// that merges the layered highlights to create the final text style
/// and yields the active text style and the char_idx where the active /// and yields the active text style and the char_idx where the active
/// style will have to be recomputed. /// style will have to be recomputed.
///
/// TODO(ropey2): hopefully one day helix and ropey will operate entirely
/// on byte ranges and we can remove this
struct StyleIter<'a, H: Iterator<Item = HighlightEvent>> { struct StyleIter<'a, H: Iterator<Item = HighlightEvent>> {
text_style: Style, text_style: Style,
active_highlights: Vec<Highlight>, active_highlights: Vec<Highlight>,
highlight_iter: H, highlight_iter: H,
kind: StyleIterKind,
text: RopeSlice<'a>,
theme: &'a Theme, theme: &'a Theme,
} }
@ -54,7 +68,7 @@ impl<H: Iterator<Item = HighlightEvent>> Iterator for StyleIter<'_, H> {
HighlightEvent::HighlightEnd => { HighlightEvent::HighlightEnd => {
self.active_highlights.pop(); self.active_highlights.pop();
} }
HighlightEvent::Source { start, end } => { HighlightEvent::Source { start, mut end } => {
if start == end { if start == end {
continue; continue;
} }
@ -64,6 +78,9 @@ impl<H: Iterator<Item = HighlightEvent>> Iterator for StyleIter<'_, H> {
.fold(self.text_style, |acc, span| { .fold(self.text_style, |acc, span| {
acc.patch(self.theme.highlight(span.0)) acc.patch(self.theme.highlight(span.0))
}); });
if self.kind == StyleIterKind::BaseHighlights {
end = self.text.byte_to_next_char(end);
}
return Some((style, end)); return Some((style, end));
} }
} }
@ -185,13 +202,17 @@ pub fn render_text<'t>(
text_style: renderer.text_style, text_style: renderer.text_style,
active_highlights: Vec::with_capacity(64), active_highlights: Vec::with_capacity(64),
highlight_iter: syntax_highlight_iter, highlight_iter: syntax_highlight_iter,
kind: StyleIterKind::BaseHighlights,
theme, theme,
text,
}; };
let mut overlay_styles = StyleIter { let mut overlay_styles = StyleIter {
text_style: Style::default(), text_style: Style::default(),
active_highlights: Vec::with_capacity(64), active_highlights: Vec::with_capacity(64),
highlight_iter: overlay_highlight_iter, highlight_iter: overlay_highlight_iter,
kind: StyleIterKind::Overlay,
theme, theme,
text,
}; };
let mut last_line_pos = LinePos { let mut last_line_pos = LinePos {

@ -12,9 +12,7 @@ use crate::{
use helix_core::{ use helix_core::{
diagnostic::NumberOrString, diagnostic::NumberOrString,
graphemes::{ graphemes::{next_grapheme_boundary, prev_grapheme_boundary},
ensure_grapheme_boundary_next_byte, next_grapheme_boundary, prev_grapheme_boundary,
},
movement::Direction, movement::Direction,
syntax::{self, HighlightEvent}, syntax::{self, HighlightEvent},
text_annotations::TextAnnotations, text_annotations::TextAnnotations,
@ -315,26 +313,14 @@ impl EditorView {
let iter = syntax let iter = syntax
// TODO: range doesn't actually restrict source, just highlight range // TODO: range doesn't actually restrict source, just highlight range
.highlight_iter(text.slice(..), Some(range), None) .highlight_iter(text.slice(..), Some(range), None)
.map(|event| event.unwrap()) .map(|event| event.unwrap());
.map(move |event| match event {
// TODO: use byte slices directly
// convert byte offsets to char offset
HighlightEvent::Source { start, end } => {
let start =
text.byte_to_char(ensure_grapheme_boundary_next_byte(text, start));
let end =
text.byte_to_char(ensure_grapheme_boundary_next_byte(text, end));
HighlightEvent::Source { start, end }
}
event => event,
});
Box::new(iter) Box::new(iter)
} }
None => Box::new( None => Box::new(
[HighlightEvent::Source { [HighlightEvent::Source {
start: text.byte_to_char(range.start), start: range.start,
end: text.byte_to_char(range.end), end: range.end,
}] }]
.into_iter(), .into_iter(),
), ),
@ -350,7 +336,8 @@ impl EditorView {
let text = doc.text().slice(..); let text = doc.text().slice(..);
let row = text.char_to_line(anchor.min(text.len_chars())); let row = text.char_to_line(anchor.min(text.len_chars()));
let range = Self::viewport_byte_range(text, row, height); let mut range = Self::viewport_byte_range(text, row, height);
range = text.byte_to_char(range.start)..text.byte_to_char(range.end);
text_annotations.collect_overlay_highlights(range) text_annotations.collect_overlay_highlights(range)
} }
@ -359,8 +346,8 @@ impl EditorView {
pub fn doc_diagnostics_highlights( pub fn doc_diagnostics_highlights(
doc: &Document, doc: &Document,
theme: &Theme, theme: &Theme,
) -> [Vec<(usize, std::ops::Range<usize>)>; 5] { ) -> [Vec<(usize, std::ops::Range<usize>)>; 7] {
use helix_core::diagnostic::{DiagnosticTag, Severity}; use helix_core::diagnostic::{DiagnosticTag, Range, Severity};
let get_scope_of = |scope| { let get_scope_of = |scope| {
theme theme
.find_scope_index_exact(scope) .find_scope_index_exact(scope)
@ -389,6 +376,25 @@ impl EditorView {
let mut hint_vec = Vec::new(); let mut hint_vec = Vec::new();
let mut warning_vec = Vec::new(); let mut warning_vec = Vec::new();
let mut error_vec = Vec::new(); let mut error_vec = Vec::new();
let mut unnecessary_vec = Vec::new();
let mut deprecated_vec = Vec::new();
let push_diagnostic =
|vec: &mut Vec<(usize, std::ops::Range<usize>)>, scope, range: Range| {
// If any diagnostic overlaps ranges with the prior diagnostic,
// merge the two together. Otherwise push a new span.
match vec.last_mut() {
Some((_, existing_range)) if range.start <= existing_range.end => {
// This branch merges overlapping diagnostics, assuming that the current
// diagnostic starts on range.start or later. If this assertion fails,
// we will discard some part of `diagnostic`. This implies that
// `doc.diagnostics()` is not sorted by `diagnostic.range`.
debug_assert!(existing_range.start <= range.start);
existing_range.end = range.end.max(existing_range.end)
}
_ => vec.push((scope, range.start..range.end)),
}
};
for diagnostic in doc.diagnostics() { for diagnostic in doc.diagnostics() {
// Separate diagnostics into different Vecs by severity. // Separate diagnostics into different Vecs by severity.
@ -400,31 +406,44 @@ impl EditorView {
_ => (&mut default_vec, r#default), _ => (&mut default_vec, r#default),
}; };
let scope = diagnostic // If the diagnostic has tags and a non-warning/error severity, skip rendering
.tags // the diagnostic as info/hint/default and only render it as unnecessary/deprecated
.first() // instead. For warning/error diagnostics, render both the severity highlight and
.and_then(|tag| match tag { // the tag highlight.
DiagnosticTag::Unnecessary => unnecessary, if diagnostic.tags.is_empty()
DiagnosticTag::Deprecated => deprecated, || matches!(
}) diagnostic.severity,
.unwrap_or(scope); Some(Severity::Warning | Severity::Error)
)
{
push_diagnostic(vec, scope, diagnostic.range);
}
// If any diagnostic overlaps ranges with the prior diagnostic, for tag in &diagnostic.tags {
// merge the two together. Otherwise push a new span. match tag {
match vec.last_mut() { DiagnosticTag::Unnecessary => {
Some((_, range)) if diagnostic.range.start <= range.end => { if let Some(scope) = unnecessary {
// This branch merges overlapping diagnostics, assuming that the current push_diagnostic(&mut unnecessary_vec, scope, diagnostic.range)
// diagnostic starts on range.start or later. If this assertion fails, }
// we will discard some part of `diagnostic`. This implies that }
// `doc.diagnostics()` is not sorted by `diagnostic.range`. DiagnosticTag::Deprecated => {
debug_assert!(range.start <= diagnostic.range.start); if let Some(scope) = deprecated {
range.end = diagnostic.range.end.max(range.end) push_diagnostic(&mut deprecated_vec, scope, diagnostic.range)
}
}
} }
_ => vec.push((scope, diagnostic.range.start..diagnostic.range.end)),
} }
} }
[default_vec, info_vec, hint_vec, warning_vec, error_vec] [
default_vec,
unnecessary_vec,
deprecated_vec,
info_vec,
hint_vec,
warning_vec,
error_vec,
]
} }
/// Get highlight spans for selections in a document view. /// Get highlight spans for selections in a document view.
@ -1015,7 +1034,6 @@ impl EditorView {
self.last_insert.1.push(InsertEvent::TriggerCompletion); self.last_insert.1.push(InsertEvent::TriggerCompletion);
// TODO : propagate required size on resize to completion too // TODO : propagate required size on resize to completion too
completion.required_size((size.width, size.height));
self.completion = Some(completion); self.completion = Some(completion);
Some(area) Some(area)
} }
@ -1231,24 +1249,28 @@ impl EditorView {
} }
MouseEventKind::Up(MouseButton::Right) => { MouseEventKind::Up(MouseButton::Right) => {
if let Some((coords, view_id)) = gutter_coords_and_view(cxt.editor, row, column) { if let Some((pos, view_id)) = gutter_coords_and_view(cxt.editor, row, column) {
cxt.editor.focus(view_id); cxt.editor.focus(view_id);
if let Some((pos, _)) = pos_and_view(cxt.editor, row, column, true) {
doc_mut!(cxt.editor).set_selection(view_id, Selection::point(pos));
} else {
let (view, doc) = current!(cxt.editor); let (view, doc) = current!(cxt.editor);
if let Some(pos) =
view.pos_at_visual_coords(doc, coords.row as u16, coords.col as u16, true) if let Some(pos) = view.pos_at_visual_coords(doc, pos.row as u16, 0, true) {
{
doc.set_selection(view_id, Selection::point(pos)); doc.set_selection(view_id, Selection::point(pos));
if modifiers == KeyModifiers::ALT { match modifiers {
commands::MappableCommand::dap_edit_log.execute(cxt); KeyModifiers::ALT => {
} else { commands::MappableCommand::dap_edit_log.execute(cxt)
commands::MappableCommand::dap_edit_condition.execute(cxt);
} }
_ => commands::MappableCommand::dap_edit_condition.execute(cxt),
return EventResult::Consumed(None); };
} }
} }
cxt.editor.ensure_cursor_in_view(view_id);
return EventResult::Consumed(None);
}
EventResult::Ignored(None) EventResult::Ignored(None)
} }

@ -3,60 +3,102 @@ use std::sync::Arc;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use helix_core::syntax; use helix_core::syntax;
use helix_view::graphics::{Margin, Rect, Style}; use helix_view::graphics::{Margin, Rect, Style};
use helix_view::input::Event;
use tui::buffer::Buffer; use tui::buffer::Buffer;
use tui::layout::Alignment;
use tui::text::Text;
use tui::widgets::{BorderType, Paragraph, Widget, Wrap}; use tui::widgets::{BorderType, Paragraph, Widget, Wrap};
use crate::compositor::{Component, Compositor, Context}; use crate::compositor::{Component, Compositor, Context, EventResult};
use crate::alt;
use crate::ui::Markdown; use crate::ui::Markdown;
use super::Popup; use super::Popup;
pub struct SignatureHelp { pub struct Signature {
signature: String, pub signature: String,
signature_doc: Option<String>, pub signature_doc: Option<String>,
/// Part of signature text /// Part of signature text
active_param_range: Option<(usize, usize)>, pub active_param_range: Option<(usize, usize)>,
}
pub struct SignatureHelp {
language: String, language: String,
config_loader: Arc<ArcSwap<syntax::Loader>>, config_loader: Arc<ArcSwap<syntax::Loader>>,
active_signature: usize,
lsp_signature: Option<usize>,
signatures: Vec<Signature>,
} }
impl SignatureHelp { impl SignatureHelp {
pub const ID: &'static str = "signature-help"; pub const ID: &'static str = "signature-help";
pub fn new( pub fn new(
signature: String,
language: String, language: String,
config_loader: Arc<ArcSwap<syntax::Loader>>, config_loader: Arc<ArcSwap<syntax::Loader>>,
active_signature: usize,
lsp_signature: Option<usize>,
signatures: Vec<Signature>,
) -> Self { ) -> Self {
Self { Self {
signature,
signature_doc: None,
active_param_range: None,
language, language,
config_loader, config_loader,
active_signature,
lsp_signature,
signatures,
} }
} }
pub fn set_signature_doc(&mut self, signature_doc: Option<String>) { pub fn active_signature(&self) -> usize {
self.signature_doc = signature_doc; self.active_signature
} }
pub fn set_active_param_range(&mut self, offset: Option<(usize, usize)>) { pub fn lsp_signature(&self) -> Option<usize> {
self.active_param_range = offset; self.lsp_signature
} }
pub fn visible_popup(compositor: &mut Compositor) -> Option<&mut Popup<Self>> { pub fn visible_popup(compositor: &mut Compositor) -> Option<&mut Popup<Self>> {
compositor.find_id::<Popup<Self>>(Self::ID) compositor.find_id::<Popup<Self>>(Self::ID)
} }
fn signature_index(&self) -> String {
format!("({}/{})", self.active_signature + 1, self.signatures.len())
}
} }
impl Component for SignatureHelp { impl Component for SignatureHelp {
fn handle_event(&mut self, event: &Event, _cx: &mut Context) -> EventResult {
let Event::Key(event) = event else {
return EventResult::Ignored(None);
};
if self.signatures.len() <= 1 {
return EventResult::Ignored(None);
}
match event {
alt!('p') => {
self.active_signature = self
.active_signature
.checked_sub(1)
.unwrap_or(self.signatures.len() - 1);
EventResult::Consumed(None)
}
alt!('n') => {
self.active_signature = (self.active_signature + 1) % self.signatures.len();
EventResult::Consumed(None)
}
_ => EventResult::Ignored(None),
}
}
fn render(&mut self, area: Rect, surface: &mut Buffer, cx: &mut Context) { fn render(&mut self, area: Rect, surface: &mut Buffer, cx: &mut Context) {
let margin = Margin::horizontal(1); let margin = Margin::horizontal(1);
let active_param_span = self.active_param_range.map(|(start, end)| { let signature = &self.signatures[self.active_signature];
let active_param_span = signature.active_param_range.map(|(start, end)| {
vec![( vec![(
cx.editor cx.editor
.theme .theme
@ -66,21 +108,29 @@ impl Component for SignatureHelp {
)] )]
}); });
let sig = &self.signatures[self.active_signature];
let sig_text = crate::ui::markdown::highlighted_code_block( let sig_text = crate::ui::markdown::highlighted_code_block(
&self.signature, sig.signature.as_str(),
&self.language, &self.language,
Some(&cx.editor.theme), Some(&cx.editor.theme),
Arc::clone(&self.config_loader), Arc::clone(&self.config_loader),
active_param_span, active_param_span,
); );
if self.signatures.len() > 1 {
let signature_index = self.signature_index();
let text = Text::from(signature_index);
let paragraph = Paragraph::new(&text).alignment(Alignment::Right);
paragraph.render(area.clip_top(1).with_height(1).clip_right(1), surface);
}
let (_, sig_text_height) = crate::ui::text::required_size(&sig_text, area.width); let (_, sig_text_height) = crate::ui::text::required_size(&sig_text, area.width);
let sig_text_area = area.clip_top(1).with_height(sig_text_height); let sig_text_area = area.clip_top(1).with_height(sig_text_height);
let sig_text_area = sig_text_area.inner(&margin).intersection(surface.area); let sig_text_area = sig_text_area.inner(&margin).intersection(surface.area);
let sig_text_para = Paragraph::new(&sig_text).wrap(Wrap { trim: false }); let sig_text_para = Paragraph::new(&sig_text).wrap(Wrap { trim: false });
sig_text_para.render(sig_text_area, surface); sig_text_para.render(sig_text_area, surface);
if self.signature_doc.is_none() { if sig.signature_doc.is_none() {
return; return;
} }
@ -92,7 +142,7 @@ impl Component for SignatureHelp {
} }
} }
let sig_doc = match &self.signature_doc { let sig_doc = match &sig.signature_doc {
None => return, None => return,
Some(doc) => Markdown::new(doc.clone(), Arc::clone(&self.config_loader)), Some(doc) => Markdown::new(doc.clone(), Arc::clone(&self.config_loader)),
}; };
@ -110,13 +160,12 @@ impl Component for SignatureHelp {
const PADDING: u16 = 2; const PADDING: u16 = 2;
const SEPARATOR_HEIGHT: u16 = 1; const SEPARATOR_HEIGHT: u16 = 1;
if PADDING >= viewport.1 || PADDING >= viewport.0 { let sig = &self.signatures[self.active_signature];
return None;
} let max_text_width = viewport.0.saturating_sub(PADDING).clamp(10, 120);
let max_text_width = (viewport.0 - PADDING).min(120);
let signature_text = crate::ui::markdown::highlighted_code_block( let signature_text = crate::ui::markdown::highlighted_code_block(
&self.signature, sig.signature.as_str(),
&self.language, &self.language,
None, None,
Arc::clone(&self.config_loader), Arc::clone(&self.config_loader),
@ -125,7 +174,7 @@ impl Component for SignatureHelp {
let (sig_width, sig_height) = let (sig_width, sig_height) =
crate::ui::text::required_size(&signature_text, max_text_width); crate::ui::text::required_size(&signature_text, max_text_width);
let (width, height) = match self.signature_doc { let (width, height) = match sig.signature_doc {
Some(ref doc) => { Some(ref doc) => {
let doc_md = Markdown::new(doc.clone(), Arc::clone(&self.config_loader)); let doc_md = Markdown::new(doc.clone(), Arc::clone(&self.config_loader));
let doc_text = doc_md.parse(None); let doc_text = doc_md.parse(None);
@ -139,6 +188,12 @@ impl Component for SignatureHelp {
None => (sig_width, sig_height), None => (sig_width, sig_height),
}; };
Some((width + PADDING, height + PADDING)) let sig_index_width = if self.signatures.len() > 1 {
self.signature_index().len() + 1
} else {
0
};
Some((width + PADDING + sig_index_width as u16, height + PADDING))
} }
} }

@ -7,18 +7,11 @@ use crate::{
use helix_core::fuzzy::MATCHER; use helix_core::fuzzy::MATCHER;
use nucleo::pattern::{Atom, AtomKind, CaseMatching}; use nucleo::pattern::{Atom, AtomKind, CaseMatching};
use nucleo::{Config, Utf32Str}; use nucleo::{Config, Utf32Str};
use tui::{ use tui::{buffer::Buffer as Surface, widgets::Table};
buffer::Buffer as Surface,
widgets::{Block, Borders, Table, Widget},
};
pub use tui::widgets::{Cell, Row}; pub use tui::widgets::{Cell, Row};
use helix_view::{ use helix_view::{editor::SmartTabConfig, graphics::Rect, Editor};
editor::SmartTabConfig,
graphics::{Margin, Rect},
Editor,
};
use tui::layout::Constraint; use tui::layout::Constraint;
pub trait Item: Sync + Send + 'static { pub trait Item: Sync + Send + 'static {
@ -241,9 +234,9 @@ impl<T: Item> Menu<T> {
} }
impl<T: Item + PartialEq> Menu<T> { impl<T: Item + PartialEq> Menu<T> {
pub fn replace_option(&mut self, old_option: T, new_option: T) { pub fn replace_option(&mut self, old_option: &T, new_option: T) {
for option in &mut self.options { for option in &mut self.options {
if old_option == *option { if old_option == option {
*option = new_option; *option = new_option;
break; break;
} }
@ -341,16 +334,8 @@ impl<T: Item + 'static> Component for Menu<T> {
.try_get("ui.menu") .try_get("ui.menu")
.unwrap_or_else(|| theme.get("ui.text")); .unwrap_or_else(|| theme.get("ui.text"));
let selected = theme.get("ui.menu.selected"); let selected = theme.get("ui.menu.selected");
surface.clear_with(area, style);
let render_borders = cx.editor.menu_border();
let area = if render_borders { surface.clear_with(area, style);
Widget::render(Block::default().borders(Borders::ALL), area, surface);
area.inner(&Margin::vertical(1))
} else {
area
};
let scroll = self.scroll; let scroll = self.scroll;

@ -13,7 +13,7 @@ mod spinner;
mod statusline; mod statusline;
mod text; mod text;
use crate::compositor::{Component, Compositor}; use crate::compositor::Compositor;
use crate::filter_picker_entry; use crate::filter_picker_entry;
use crate::job::{self, Callback}; use crate::job::{self, Callback};
pub use completion::{Completion, CompletionItem}; pub use completion::{Completion, CompletionItem};
@ -143,14 +143,12 @@ pub fn raw_regex_prompt(
move |_editor: &mut Editor, compositor: &mut Compositor| { move |_editor: &mut Editor, compositor: &mut Compositor| {
let contents = Text::new(format!("{}", err)); let contents = Text::new(format!("{}", err));
let size = compositor.size(); let size = compositor.size();
let mut popup = Popup::new("invalid-regex", contents) let popup = Popup::new("invalid-regex", contents)
.position(Some(helix_core::Position::new( .position(Some(helix_core::Position::new(
size.height as usize - 2, // 2 = statusline + commandline size.height as usize - 2, // 2 = statusline + commandline
0, 0,
))) )))
.auto_close(true); .auto_close(true);
popup.required_size((size.width, size.height));
compositor.replace_or_push("invalid-regex", popup); compositor.replace_or_push("invalid-regex", popup);
}, },
)); ));

@ -15,18 +15,26 @@ use helix_view::{
Editor, Editor,
}; };
const MIN_HEIGHT: u16 = 6;
const MAX_HEIGHT: u16 = 26;
const MAX_WIDTH: u16 = 120;
struct RenderInfo {
area: Rect,
child_height: u16,
render_borders: bool,
is_menu: bool,
}
// TODO: share logic with Menu, it's essentially Popup(render_fn), but render fn needs to return // TODO: share logic with Menu, it's essentially Popup(render_fn), but render fn needs to return
// a width/height hint. maybe Popup(Box<Component>) // a width/height hint. maybe Popup(Box<Component>)
pub struct Popup<T: Component> { pub struct Popup<T: Component> {
contents: T, contents: T,
position: Option<Position>, position: Option<Position>,
margin: Margin,
size: (u16, u16),
child_size: (u16, u16),
area: Rect, area: Rect,
position_bias: Open, position_bias: Open,
scroll: usize, scroll_half_pages: usize,
auto_close: bool, auto_close: bool,
ignore_escape_key: bool, ignore_escape_key: bool,
id: &'static str, id: &'static str,
@ -38,12 +46,9 @@ impl<T: Component> Popup<T> {
Self { Self {
contents, contents,
position: None, position: None,
margin: Margin::none(),
size: (0, 0),
position_bias: Open::Below, position_bias: Open::Below,
child_size: (0, 0),
area: Rect::new(0, 0, 0, 0), area: Rect::new(0, 0, 0, 0),
scroll: 0, scroll_half_pages: 0,
auto_close: false, auto_close: false,
ignore_escape_key: false, ignore_escape_key: false,
id, id,
@ -73,11 +78,6 @@ impl<T: Component> Popup<T> {
self self
} }
pub fn margin(mut self, margin: Margin) -> Self {
self.margin = margin;
self
}
pub fn auto_close(mut self, auto_close: bool) -> Self { pub fn auto_close(mut self, auto_close: bool) -> Self {
self.auto_close = auto_close; self.auto_close = auto_close;
self self
@ -95,28 +95,65 @@ impl<T: Component> Popup<T> {
self self
} }
/// Calculate the position where the popup should be rendered and return the coordinates of the pub fn scroll_half_page_down(&mut self) {
/// top left corner. self.scroll_half_pages += 1;
pub fn get_rel_position(&mut self, viewport: Rect, editor: &Editor) -> (u16, u16) { }
let position = self
pub fn scroll_half_page_up(&mut self) {
self.scroll_half_pages = self.scroll_half_pages.saturating_sub(1);
}
/// Toggles the Popup's scrollbar.
/// Consider disabling the scrollbar in case the child
/// already has its own.
pub fn with_scrollbar(mut self, enable_scrollbar: bool) -> Self {
self.has_scrollbar = enable_scrollbar;
self
}
pub fn contents(&self) -> &T {
&self.contents
}
pub fn contents_mut(&mut self) -> &mut T {
&mut self.contents
}
pub fn area(&mut self, viewport: Rect, editor: &Editor) -> Rect {
self.render_info(viewport, editor).area
}
fn render_info(&mut self, viewport: Rect, editor: &Editor) -> RenderInfo {
let mut position = editor.cursor().0.unwrap_or_default();
if let Some(old_position) = self
.position .position
.get_or_insert_with(|| editor.cursor().0.unwrap_or_default()); .filter(|old_position| old_position.row == position.row)
{
position = old_position;
} else {
self.position = Some(position);
}
let (width, height) = self.size; let is_menu = self
.contents
.type_name()
.starts_with("helix_term::ui::menu::Menu");
// if there's a orientation preference, use that let mut render_borders = if is_menu {
// if we're on the top part of the screen, do below editor.menu_border()
// if we're on the bottom part, do above } else {
editor.popup_border()
};
// -- make sure frame doesn't stick out of bounds // -- make sure frame doesn't stick out of bounds
let mut rel_x = position.col as u16; let mut rel_x = position.col as u16;
let mut rel_y = position.row as u16; let mut rel_y = position.row as u16;
if viewport.width <= rel_x + width {
rel_x = rel_x.saturating_sub((rel_x + width).saturating_sub(viewport.width));
}
let can_put_below = viewport.height > rel_y + height; // if there's a orientation preference, use that
let can_put_above = rel_y.checked_sub(height).is_some(); // if we're on the top part of the screen, do below
// if we're on the bottom part, do above
let can_put_below = viewport.height > rel_y + MIN_HEIGHT;
let can_put_above = rel_y.checked_sub(MIN_HEIGHT).is_some();
let final_pos = match self.position_bias { let final_pos = match self.position_bias {
Open::Below => match can_put_below { Open::Below => match can_put_below {
true => Open::Below, true => Open::Below,
@ -128,59 +165,56 @@ impl<T: Component> Popup<T> {
}, },
}; };
rel_y = match final_pos { // compute maximum space available for child
Open::Above => rel_y.saturating_sub(height), let mut max_height = match final_pos {
Open::Below => rel_y + 1, Open::Above => rel_y,
Open::Below => viewport.height.saturating_sub(1 + rel_y),
}; };
max_height = max_height.min(MAX_HEIGHT);
(rel_x, rel_y) let mut max_width = viewport.width.saturating_sub(2).min(MAX_WIDTH);
render_borders = render_borders && max_height > 3 && max_width > 3;
if render_borders {
max_width -= 2;
max_height -= 2;
} }
pub fn get_size(&self) -> (u16, u16) { // compute required child size and reclamp
(self.size.0, self.size.1) let (mut width, child_height) = self
} .contents
.required_size((max_width, max_height))
.expect("Component needs required_size implemented in order to be embedded in a popup");
pub fn scroll(&mut self, offset: usize, direction: bool) { width = width.min(MAX_WIDTH);
if direction { let height = if render_borders {
let max_offset = self.child_size.1.saturating_sub(self.size.1); (child_height + 2).min(MAX_HEIGHT)
self.scroll = (self.scroll + offset).min(max_offset as usize);
} else { } else {
self.scroll = self.scroll.saturating_sub(offset); child_height.min(MAX_HEIGHT)
} };
if render_borders {
width += 2;
} }
if viewport.width <= rel_x + width + 2 {
pub fn scroll_half_page_down(&mut self) { rel_x = viewport.width.saturating_sub(width + 2);
self.scroll(self.size.1 as usize / 2, true) width = viewport.width.saturating_sub(rel_x + 2)
} }
pub fn scroll_half_page_up(&mut self) { let area = match final_pos {
self.scroll(self.size.1 as usize / 2, false) Open::Above => {
rel_y = rel_y.saturating_sub(height);
Rect::new(rel_x, rel_y, width, position.row as u16 - rel_y)
} }
Open::Below => {
/// Toggles the Popup's scrollbar. rel_y += 1;
/// Consider disabling the scrollbar in case the child let y_max = viewport.bottom().min(height + rel_y);
/// already has its own. Rect::new(rel_x, rel_y, width, y_max - rel_y)
pub fn with_scrollbar(mut self, enable_scrollbar: bool) -> Self {
self.has_scrollbar = enable_scrollbar;
self
} }
};
pub fn contents(&self) -> &T { RenderInfo {
&self.contents area,
child_height,
render_borders,
is_menu,
} }
pub fn contents_mut(&mut self) -> &mut T {
&mut self.contents
}
pub fn area(&mut self, viewport: Rect, editor: &Editor) -> Rect {
// trigger required_size so we recalculate if the child changed
self.required_size((viewport.width, viewport.height));
let (rel_x, rel_y) = self.get_rel_position(viewport, editor);
// clip to viewport
viewport.intersection(Rect::new(rel_x, rel_y, self.size.0, self.size.1))
} }
fn handle_mouse_event( fn handle_mouse_event(
@ -266,64 +300,48 @@ impl<T: Component> Component for Popup<T> {
// tab/enter/ctrl-k or whatever will confirm the selection/ ctrl-n/ctrl-p for scroll. // tab/enter/ctrl-k or whatever will confirm the selection/ ctrl-n/ctrl-p for scroll.
} }
fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
let max_width = 120.min(viewport.0);
let max_height = 26.min(viewport.1.saturating_sub(2)); // add some spacing in the viewport
let inner = Rect::new(0, 0, max_width, max_height).inner(&self.margin);
let (width, height) = self
.contents
.required_size((inner.width, inner.height))
.expect("Component needs required_size implemented in order to be embedded in a popup");
self.child_size = (width, height);
self.size = (
(width + self.margin.width()).min(max_width),
(height + self.margin.height()).min(max_height),
);
// re-clamp scroll offset
let max_offset = self.child_size.1.saturating_sub(self.size.1);
self.scroll = self.scroll.min(max_offset as usize);
Some(self.size)
}
fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) {
let area = self.area(viewport, cx.editor); let RenderInfo {
area,
child_height,
render_borders,
is_menu,
} = self.render_info(viewport, cx.editor);
self.area = area; self.area = area;
cx.scroll = Some(self.scroll);
// clear area // clear area
let background = cx.editor.theme.get("ui.popup"); let background = if is_menu {
surface.clear_with(area, background); // TODO: consistently style menu
cx.editor
let render_borders = cx.editor.popup_border(); .theme
.try_get("ui.menu")
let inner = if self .unwrap_or_else(|| cx.editor.theme.get("ui.text"))
.contents
.type_name()
.starts_with("helix_term::ui::menu::Menu")
{
area
} else { } else {
area.inner(&self.margin) cx.editor.theme.get("ui.popup")
}; };
surface.clear_with(area, background);
let border = usize::from(render_borders); let mut inner = area;
if render_borders { if render_borders {
inner = area.inner(&Margin::all(1));
Widget::render(Block::default().borders(Borders::ALL), area, surface); Widget::render(Block::default().borders(Borders::ALL), area, surface);
} }
let border = usize::from(render_borders);
let max_offset = child_height.saturating_sub(inner.height) as usize;
let half_page_size = (inner.height / 2) as usize;
let scroll = max_offset.min(self.scroll_half_pages * half_page_size);
if half_page_size > 0 {
self.scroll_half_pages = scroll / half_page_size;
}
cx.scroll = Some(scroll);
self.contents.render(inner, surface, cx); self.contents.render(inner, surface, cx);
// render scrollbar if contents do not fit // render scrollbar if contents do not fit
if self.has_scrollbar { if self.has_scrollbar {
let win_height = (inner.height as usize).saturating_sub(2 * border); let win_height = inner.height as usize;
let len = (self.child_size.1 as usize).saturating_sub(2 * border); let len = child_height as usize;
let fits = len <= win_height; let fits = len <= win_height;
let scroll = self.scroll;
let scroll_style = cx.editor.theme.get("ui.menu.scroll"); let scroll_style = cx.editor.theme.get("ui.menu.scroll");
const fn div_ceil(a: usize, b: usize) -> usize { const fn div_ceil(a: usize, b: usize) -> usize {
@ -337,7 +355,8 @@ impl<T: Component> Component for Popup<T> {
let mut cell; let mut cell;
for i in 0..win_height { for i in 0..win_height {
cell = &mut surface[(inner.right() - 1, inner.top() + (border + i) as u16)]; cell =
&mut surface[(inner.right() - 1 + border as u16, inner.top() + i as u16)];
let half_block = if render_borders { "▌" } else { "▐" }; let half_block = if render_borders { "▌" } else { "▐" };

@ -1,16 +1,18 @@
use std::{collections::HashMap, time::Instant}; use std::{collections::HashMap, time::Instant};
use helix_lsp::LanguageServerId;
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct ProgressSpinners { pub struct ProgressSpinners {
inner: HashMap<usize, Spinner>, inner: HashMap<LanguageServerId, Spinner>,
} }
impl ProgressSpinners { impl ProgressSpinners {
pub fn get(&self, id: usize) -> Option<&Spinner> { pub fn get(&self, id: LanguageServerId) -> Option<&Spinner> {
self.inner.get(&id) self.inner.get(&id)
} }
pub fn get_or_create(&mut self, id: usize) -> &mut Spinner { pub fn get_or_create(&mut self, id: LanguageServerId) -> &mut Spinner {
self.inner.entry(id).or_default() self.inner.entry(id).or_default()
} }
} }

@ -4,6 +4,7 @@ use helix_view::document::DEFAULT_LANGUAGE_NAME;
use helix_view::{ use helix_view::{
document::{Mode, SCRATCH_BUFFER_NAME}, document::{Mode, SCRATCH_BUFFER_NAME},
graphics::Rect, graphics::Rect,
theme::Style,
Document, Editor, View, Document, Editor, View,
}; };
@ -19,6 +20,7 @@ pub struct RenderContext<'a> {
pub view: &'a View, pub view: &'a View,
pub focused: bool, pub focused: bool,
pub spinners: &'a ProgressSpinners, pub spinners: &'a ProgressSpinners,
pub parts: RenderBuffer<'a>,
} }
impl<'a> RenderContext<'a> { impl<'a> RenderContext<'a> {
@ -35,10 +37,18 @@ impl<'a> RenderContext<'a> {
view, view,
focused, focused,
spinners, spinners,
parts: RenderBuffer::default(),
} }
} }
} }
#[derive(Default)]
pub struct RenderBuffer<'a> {
pub left: Spans<'a>,
pub center: Spans<'a>,
pub right: Spans<'a>,
}
pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface) { pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface) {
let base_style = if context.focused { let base_style = if context.focused {
context.editor.theme.get("ui.statusline") context.editor.theme.get("ui.statusline")
@ -48,87 +58,85 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface
surface.set_style(viewport.with_height(1), base_style); surface.set_style(viewport.with_height(1), base_style);
let statusline = render_statusline(context, viewport.width as usize); let write_left = |context: &mut RenderContext, text, style| {
append(&mut context.parts.left, text, &base_style, style)
};
let write_center = |context: &mut RenderContext, text, style| {
append(&mut context.parts.center, text, &base_style, style)
};
let write_right = |context: &mut RenderContext, text, style| {
append(&mut context.parts.right, text, &base_style, style)
};
// Left side of the status line.
let config = context.editor.config();
let element_ids = &config.statusline.left;
element_ids
.iter()
.map(|element_id| get_render_function(*element_id))
.for_each(|render| render(context, write_left));
surface.set_spans( surface.set_spans(
viewport.x, viewport.x,
viewport.y, viewport.y,
&statusline, &context.parts.left,
statusline.width() as u16, context.parts.left.width() as u16,
); );
}
pub fn render_statusline<'a>(context: &mut RenderContext, width: usize) -> Spans<'a> { // Right side of the status line.
let config = context.editor.config();
let element_ids = &config.statusline.left; let element_ids = &config.statusline.right;
let mut left = element_ids element_ids
.iter() .iter()
.map(|element_id| get_render_function(*element_id)) .map(|element_id| get_render_function(*element_id))
.flat_map(|render| render(context).0) .for_each(|render| render(context, write_right));
.collect::<Vec<Span>>();
surface.set_spans(
viewport.x
+ viewport
.width
.saturating_sub(context.parts.right.width() as u16),
viewport.y,
&context.parts.right,
context.parts.right.width() as u16,
);
// Center of the status line.
let element_ids = &config.statusline.center; let element_ids = &config.statusline.center;
let mut center = element_ids element_ids
.iter() .iter()
.map(|element_id| get_render_function(*element_id)) .map(|element_id| get_render_function(*element_id))
.flat_map(|render| render(context).0) .for_each(|render| render(context, write_center));
.collect::<Vec<Span>>();
let element_ids = &config.statusline.right; // Width of the empty space between the left and center area and between the center and right area.
let mut right = element_ids let spacing = 1u16;
.iter()
.map(|element_id| get_render_function(*element_id))
.flat_map(|render| render(context).0)
.collect::<Vec<Span>>();
let left_area_width: usize = left.iter().map(|s| s.width()).sum();
let center_area_width: usize = center.iter().map(|s| s.width()).sum();
let right_area_width: usize = right.iter().map(|s| s.width()).sum();
let min_spacing_between_areas = 1usize;
let sides_space_required = left_area_width + right_area_width + min_spacing_between_areas;
let total_space_required = sides_space_required + center_area_width + min_spacing_between_areas;
let mut statusline: Vec<Span> = vec![];
if center_area_width > 0 && total_space_required <= width {
// SAFETY: this subtraction cannot underflow because `left_area_width + center_area_width + right_area_width`
// is smaller than `total_space_required`, which is smaller than `width` in this branch.
let total_spacers = width - (left_area_width + center_area_width + right_area_width);
// This is how much padding space it would take on either side to align the center area to the middle.
let center_margin = (width - center_area_width) / 2;
let left_spacers = if left_area_width < center_margin && right_area_width < center_margin {
// Align the center area to the middle if there is enough space on both sides.
center_margin - left_area_width
} else {
// Otherwise split the available space evenly and use it as margin.
// The center element won't be aligned to the middle but it will be evenly
// spaced between the left and right areas.
total_spacers / 2
};
let right_spacers = total_spacers - left_spacers;
statusline.append(&mut left); let edge_width = context.parts.left.width().max(context.parts.right.width()) as u16;
statusline.push(" ".repeat(left_spacers).into()); let center_max_width = viewport.width.saturating_sub(2 * edge_width + 2 * spacing);
statusline.append(&mut center); let center_width = center_max_width.min(context.parts.center.width() as u16);
statusline.push(" ".repeat(right_spacers).into());
statusline.append(&mut right); surface.set_spans(
} else if right_area_width > 0 && sides_space_required <= width { viewport.x + viewport.width / 2 - center_width / 2,
let side_areas_width = left_area_width + right_area_width; viewport.y,
statusline.append(&mut left); &context.parts.center,
statusline.push(" ".repeat(width - side_areas_width).into()); center_width,
statusline.append(&mut right); );
} else if left_area_width <= width {
statusline.append(&mut left);
} }
statusline.into() fn append(buffer: &mut Spans, text: String, base_style: &Style, style: Option<Style>) {
buffer.0.push(Span::styled(
text,
style.map_or(*base_style, |s| (*base_style).patch(s)),
));
} }
fn get_render_function<'a>( fn get_render_function<F>(element_id: StatusLineElementID) -> impl Fn(&mut RenderContext, F)
element_id: StatusLineElementID, where
) -> impl Fn(&RenderContext) -> Spans<'a> { F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
match element_id { match element_id {
helix_view::editor::StatusLineElement::Mode => render_mode, helix_view::editor::StatusLineElement::Mode => render_mode,
helix_view::editor::StatusLineElement::Spinner => render_lsp_spinner, helix_view::editor::StatusLineElement::Spinner => render_lsp_spinner,
@ -158,40 +166,48 @@ fn get_render_function<'a>(
} }
} }
fn render_mode<'a>(context: &RenderContext) -> Spans<'a> { fn render_mode<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let visible = context.focused; let visible = context.focused;
let config = context.editor.config(); let config = context.editor.config();
let modenames = &config.statusline.mode; let modenames = &config.statusline.mode;
let modename = if visible { write(
context,
format!(
" {} ",
if visible {
match context.editor.mode() { match context.editor.mode() {
Mode::Insert => modenames.insert.clone(), Mode::Insert => &modenames.insert,
Mode::Select => modenames.select.clone(), Mode::Select => &modenames.select,
Mode::Normal => modenames.normal.clone(), Mode::Normal => &modenames.normal,
} }
} else { } else {
// If not focused, explicitly leave an empty space. // If not focused, explicitly leave an empty space instead of returning None.
" ".into() " "
}; }
let modename = format!(" {} ", modename); ),
if visible && config.color_modes { if visible && config.color_modes {
Span::styled(
modename,
match context.editor.mode() { match context.editor.mode() {
Mode::Insert => context.editor.theme.get("ui.statusline.insert"), Mode::Insert => Some(context.editor.theme.get("ui.statusline.insert")),
Mode::Select => context.editor.theme.get("ui.statusline.select"), Mode::Select => Some(context.editor.theme.get("ui.statusline.select")),
Mode::Normal => context.editor.theme.get("ui.statusline.normal"), Mode::Normal => Some(context.editor.theme.get("ui.statusline.normal")),
},
)
.into()
} else {
Span::raw(modename).into()
} }
} else {
None
},
);
} }
// TODO think about handling multiple language servers // TODO think about handling multiple language servers
fn render_lsp_spinner<'a>(context: &RenderContext) -> Spans<'a> { fn render_lsp_spinner<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let language_server = context.doc.language_servers().next(); let language_server = context.doc.language_servers().next();
Span::raw( write(
context,
language_server language_server
.and_then(|srv| { .and_then(|srv| {
context context
@ -202,11 +218,14 @@ fn render_lsp_spinner<'a>(context: &RenderContext) -> Spans<'a> {
// Even if there's no spinner; reserve its space to avoid elements frequently shifting. // Even if there's no spinner; reserve its space to avoid elements frequently shifting.
.unwrap_or(" ") .unwrap_or(" ")
.to_string(), .to_string(),
) None,
.into() );
} }
fn render_diagnostics<'a>(context: &RenderContext) -> Spans<'a> { fn render_diagnostics<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let (warnings, errors) = context let (warnings, errors) = context
.doc .doc
.diagnostics() .diagnostics()
@ -221,28 +240,29 @@ fn render_diagnostics<'a>(context: &RenderContext) -> Spans<'a> {
counts counts
}); });
let mut output = Spans::default();
if warnings > 0 { if warnings > 0 {
output.0.push(Span::styled( write(
context,
"●".to_string(), "●".to_string(),
context.editor.theme.get("warning"), Some(context.editor.theme.get("warning")),
)); );
output.0.push(Span::raw(format!(" {} ", warnings))); write(context, format!(" {} ", warnings), None);
} }
if errors > 0 { if errors > 0 {
output.0.push(Span::styled( write(
context,
"●".to_string(), "●".to_string(),
context.editor.theme.get("error"), Some(context.editor.theme.get("error")),
)); );
output.0.push(Span::raw(format!(" {} ", errors))); write(context, format!(" {} ", errors), None);
} }
output
} }
fn render_workspace_diagnostics<'a>(context: &RenderContext) -> Spans<'a> { fn render_workspace_diagnostics<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let (warnings, errors) = let (warnings, errors) =
context context
.editor .editor
@ -258,49 +278,51 @@ fn render_workspace_diagnostics<'a>(context: &RenderContext) -> Spans<'a> {
counts counts
}); });
let mut output = Spans::default();
if warnings > 0 || errors > 0 { if warnings > 0 || errors > 0 {
output.0.push(Span::raw(" W ")); write(context, " W ".into(), None);
} }
if warnings > 0 { if warnings > 0 {
output.0.push(Span::styled( write(
context,
"●".to_string(), "●".to_string(),
context.editor.theme.get("warning"), Some(context.editor.theme.get("warning")),
)); );
output.0.push(Span::raw(format!(" {} ", warnings))); write(context, format!(" {} ", warnings), None);
} }
if errors > 0 { if errors > 0 {
output.0.push(Span::styled( write(
context,
"●".to_string(), "●".to_string(),
context.editor.theme.get("error"), Some(context.editor.theme.get("error")),
)); );
output.0.push(Span::raw(format!(" {} ", errors))); write(context, format!(" {} ", errors), None);
} }
output
} }
fn render_selections<'a>(context: &RenderContext) -> Spans<'a> { fn render_selections<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let count = context.doc.selection(context.view.id).len(); let count = context.doc.selection(context.view.id).len();
Span::raw(format!( write(
" {} sel{} ", context,
count, format!(" {} sel{} ", count, if count == 1 { "" } else { "s" }),
if count == 1 { "" } else { "s" } None,
)) );
.into()
} }
fn render_primary_selection_length<'a>(context: &RenderContext) -> Spans<'a> { fn render_primary_selection_length<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let tot_sel = context.doc.selection(context.view.id).primary().len(); let tot_sel = context.doc.selection(context.view.id).primary().len();
Span::raw(format!( write(
" {} char{} ", context,
tot_sel, format!(" {} char{} ", tot_sel, if tot_sel == 1 { "" } else { "s" }),
if tot_sel == 1 { "" } else { "s" } None,
)) );
.into()
} }
fn get_position(context: &RenderContext) -> Position { fn get_position(context: &RenderContext) -> Position {
@ -314,33 +336,55 @@ fn get_position(context: &RenderContext) -> Position {
) )
} }
fn render_position<'a>(context: &RenderContext) -> Spans<'a> { fn render_position<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let position = get_position(context); let position = get_position(context);
Span::raw(format!(" {}:{} ", position.row + 1, position.col + 1)).into() write(
context,
format!(" {}:{} ", position.row + 1, position.col + 1),
None,
);
} }
fn render_total_line_numbers<'a>(context: &RenderContext) -> Spans<'a> { fn render_total_line_numbers<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let total_line_numbers = context.doc.text().len_lines(); let total_line_numbers = context.doc.text().len_lines();
Span::raw(format!(" {} ", total_line_numbers)).into()
write(context, format!(" {} ", total_line_numbers), None);
} }
fn render_position_percentage<'a>(context: &RenderContext) -> Spans<'a> { fn render_position_percentage<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let position = get_position(context); let position = get_position(context);
let maxrows = context.doc.text().len_lines(); let maxrows = context.doc.text().len_lines();
Span::raw(format!("{}%", (position.row + 1) * 100 / maxrows)).into() write(
context,
format!("{}%", (position.row + 1) * 100 / maxrows),
None,
);
} }
fn render_file_encoding<'a>(context: &RenderContext) -> Spans<'a> { fn render_file_encoding<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let enc = context.doc.encoding(); let enc = context.doc.encoding();
if enc != encoding::UTF_8 { if enc != encoding::UTF_8 {
Span::raw(format!(" {} ", enc.name())).into() write(context, format!(" {} ", enc.name()), None);
} else {
Spans::default()
} }
} }
fn render_file_line_ending<'a>(context: &RenderContext) -> Spans<'a> { fn render_file_line_ending<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
use helix_core::LineEnding::*; use helix_core::LineEnding::*;
let line_ending = match context.doc.line_ending { let line_ending = match context.doc.line_ending {
Crlf => "CRLF", Crlf => "CRLF",
@ -359,16 +403,22 @@ fn render_file_line_ending<'a>(context: &RenderContext) -> Spans<'a> {
PS => "PS", // U+2029 -- ParagraphSeparator PS => "PS", // U+2029 -- ParagraphSeparator
}; };
Span::raw(format!(" {} ", line_ending)).into() write(context, format!(" {} ", line_ending), None);
} }
fn render_file_type<'a>(context: &RenderContext) -> Spans<'a> { fn render_file_type<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let file_type = context.doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME); let file_type = context.doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME);
Span::raw(format!(" {} ", file_type)).into() write(context, format!(" {} ", file_type), None);
} }
fn render_file_name<'a>(context: &RenderContext) -> Spans<'a> { fn render_file_name<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let title = { let title = {
let rel_path = context.doc.relative_path(); let rel_path = context.doc.relative_path();
let path = rel_path let path = rel_path
@ -378,10 +428,13 @@ fn render_file_name<'a>(context: &RenderContext) -> Spans<'a> {
format!(" {} ", path) format!(" {} ", path)
}; };
Span::raw(title).into() write(context, title, None);
} }
fn render_file_absolute_path<'a>(context: &RenderContext) -> Spans<'a> { fn render_file_absolute_path<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let title = { let title = {
let path = context.doc.path(); let path = context.doc.path();
let path = path let path = path
@ -391,10 +444,13 @@ fn render_file_absolute_path<'a>(context: &RenderContext) -> Spans<'a> {
format!(" {} ", path) format!(" {} ", path)
}; };
Span::raw(title).into() write(context, title, None);
} }
fn render_file_modification_indicator<'a>(context: &RenderContext) -> Spans<'a> { fn render_file_modification_indicator<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let title = (if context.doc.is_modified() { let title = (if context.doc.is_modified() {
"[+]" "[+]"
} else { } else {
@ -402,20 +458,26 @@ fn render_file_modification_indicator<'a>(context: &RenderContext) -> Spans<'a>
}) })
.to_string(); .to_string();
Span::raw(title).into() write(context, title, None);
} }
fn render_read_only_indicator<'a>(context: &RenderContext) -> Spans<'a> { fn render_read_only_indicator<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let title = if context.doc.readonly { let title = if context.doc.readonly {
" [readonly] " " [readonly] "
} else { } else {
"" ""
} }
.to_string(); .to_string();
Span::raw(title).into() write(context, title, None);
} }
fn render_file_base_name<'a>(context: &RenderContext) -> Spans<'a> { fn render_file_base_name<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let title = { let title = {
let rel_path = context.doc.relative_path(); let rel_path = context.doc.relative_path();
let path = rel_path let path = rel_path
@ -425,37 +487,47 @@ fn render_file_base_name<'a>(context: &RenderContext) -> Spans<'a> {
format!(" {} ", path) format!(" {} ", path)
}; };
Span::raw(title).into() write(context, title, None);
} }
fn render_separator<'a>(context: &RenderContext) -> Spans<'a> { fn render_separator<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let sep = &context.editor.config().statusline.separator; let sep = &context.editor.config().statusline.separator;
Span::styled( write(
context,
sep.to_string(), sep.to_string(),
context.editor.theme.get("ui.statusline.separator"), Some(context.editor.theme.get("ui.statusline.separator")),
) );
.into()
} }
fn render_spacer<'a>(_context: &RenderContext) -> Spans<'a> { fn render_spacer<F>(context: &mut RenderContext, write: F)
Span::raw(" ").into() where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
write(context, String::from(" "), None);
} }
fn render_version_control<'a>(context: &RenderContext) -> Spans<'a> { fn render_version_control<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let head = context let head = context
.doc .doc
.version_control_head() .version_control_head()
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
Span::raw(head).into() write(context, head, None);
} }
fn render_register<'a>(context: &RenderContext) -> Spans<'a> { fn render_register<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
if let Some(reg) = context.editor.selected_register { if let Some(reg) = context.editor.selected_register {
Span::raw(format!(" reg={} ", reg)).into() write(context, format!(" reg={} ", reg), None)
} else {
Spans::default()
} }
} }

@ -153,6 +153,28 @@ async fn test_goto_file_impl() -> anyhow::Result<()> {
) )
.await?; .await?;
// ';' is behind the path
test_key_sequence(
&mut AppBuilder::new().with_file(file.path(), None).build()?,
Some("iimport 'one.js';<esc>B;gf"),
Some(&|app| {
assert_eq!(1, match_paths(app, vec!["one.js"]));
}),
false,
)
.await?;
// allow numeric values in path
test_key_sequence(
&mut AppBuilder::new().with_file(file.path(), None).build()?,
Some("iimport 'one123.js'<esc>B;gf"),
Some(&|app| {
assert_eq!(1, match_paths(app, vec!["one123.js"]));
}),
false,
)
.await?;
Ok(()) Ok(())
} }
@ -618,3 +640,87 @@ async fn test_join_selections_space() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread")]
async fn test_read_file() -> anyhow::Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
let contents_to_read = "some contents";
let output_file = helpers::temp_file_with_contents(contents_to_read)?;
test_key_sequence(
&mut helpers::AppBuilder::new()
.with_file(file.path(), None)
.build()?,
Some(&format!(":r {:?}<ret><esc>:w<ret>", output_file.path())),
Some(&|app| {
assert!(!app.editor.is_err(), "error: {:?}", app.editor.get_status());
}),
false,
)
.await?;
let expected_contents = LineFeedHandling::Native.apply(contents_to_read);
helpers::assert_file_has_content(&mut file, &expected_contents)?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn surround_delete() -> anyhow::Result<()> {
// Test `surround_delete` when head < anchor
test(("(#[| ]#)", "mdm", "#[| ]#")).await?;
test(("(#[| ]#)", "md(", "#[| ]#")).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn surround_replace_ts() -> anyhow::Result<()> {
const INPUT: &str = r#"\
fn foo() {
if let Some(_) = None {
todo!("f#[|o]#o)");
}
}
"#;
test((
INPUT,
":lang rust<ret>mrm'",
r#"\
fn foo() {
if let Some(_) = None {
todo!('f#[|o]#o)');
}
}
"#,
))
.await?;
test((
INPUT,
":lang rust<ret>3mrm[",
r#"\
fn foo() {
if let Some(_) = None [
todo!("f#[|o]#o)");
]
}
"#,
))
.await?;
test((
INPUT,
":lang rust<ret>2mrm{",
r#"\
fn foo() {
if let Some(_) = None {
todo!{"f#[|o]#o)"};
}
}
"#,
))
.await?;
Ok(())
}

@ -450,3 +450,485 @@ async fn test_smart_tab_move_parent_node_end() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread")]
async fn select_all_siblings() -> anyhow::Result<()> {
let tests = vec![
// basic tests
(
indoc! {r##"
let foo = bar(#[a|]#, b, c);
"##},
"<A-a>",
indoc! {r##"
let foo = bar(#[a|]#, #(b|)#, #(c|)#);
"##},
),
(
indoc! {r##"
let a = [
#[1|]#,
2,
3,
4,
5,
];
"##},
"<A-a>",
indoc! {r##"
let a = [
#[1|]#,
#(2|)#,
#(3|)#,
#(4|)#,
#(5|)#,
];
"##},
),
// direction is preserved
(
indoc! {r##"
let a = [
#[|1]#,
2,
3,
4,
5,
];
"##},
"<A-a>",
indoc! {r##"
let a = [
#[|1]#,
#(|2)#,
#(|3)#,
#(|4)#,
#(|5)#,
];
"##},
),
// can't pick any more siblings - selection stays the same
(
indoc! {r##"
let a = [
#[1|]#,
#(2|)#,
#(3|)#,
#(4|)#,
#(5|)#,
];
"##},
"<A-a>",
indoc! {r##"
let a = [
#[1|]#,
#(2|)#,
#(3|)#,
#(4|)#,
#(5|)#,
];
"##},
),
// each cursor does the sibling select independently
(
indoc! {r##"
let a = [
#[1|]#,
2,
3,
4,
5,
];
let b = [
#("one"|)#,
"two",
"three",
"four",
"five",
];
"##},
"<A-a>",
indoc! {r##"
let a = [
#[1|]#,
#(2|)#,
#(3|)#,
#(4|)#,
#(5|)#,
];
let b = [
#("one"|)#,
#("two"|)#,
#("three"|)#,
#("four"|)#,
#("five"|)#,
];
"##},
),
// conflicting sibling selections get normalized. Here, the primary
// selection would choose every list item, but because the secondary
// range covers more than one item, the descendent is the entire list,
// which means the sibling is the assignment. The list item ranges just
// get normalized out since the list itself becomes selected.
(
indoc! {r##"
let a = [
#[1|]#,
2,
#(3,
4|)#,
5,
];
"##},
"<A-a>",
indoc! {r##"
let #(a|)# = #[[
1,
2,
3,
4,
5,
]|]#;
"##},
),
];
for test in tests {
test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn select_all_children() -> anyhow::Result<()> {
let tests = vec![
// basic tests
(
indoc! {r##"
let foo = bar#[(a, b, c)|]#;
"##},
"<A-I>",
indoc! {r##"
let foo = bar(#[a|]#, #(b|)#, #(c|)#);
"##},
),
(
indoc! {r##"
let a = #[[
1,
2,
3,
4,
5,
]|]#;
"##},
"<A-I>",
indoc! {r##"
let a = [
#[1|]#,
#(2|)#,
#(3|)#,
#(4|)#,
#(5|)#,
];
"##},
),
// direction is preserved
(
indoc! {r##"
let a = #[|[
1,
2,
3,
4,
5,
]]#;
"##},
"<A-I>",
indoc! {r##"
let a = [
#[|1]#,
#(|2)#,
#(|3)#,
#(|4)#,
#(|5)#,
];
"##},
),
// can't pick any more children - selection stays the same
(
indoc! {r##"
let a = [
#[1|]#,
#(2|)#,
#(3|)#,
#(4|)#,
#(5|)#,
];
"##},
"<A-I>",
indoc! {r##"
let a = [
#[1|]#,
#(2|)#,
#(3|)#,
#(4|)#,
#(5|)#,
];
"##},
),
// each cursor does the sibling select independently
(
indoc! {r##"
let a = #[|[
1,
2,
3,
4,
5,
]]#;
let b = #([
"one",
"two",
"three",
"four",
"five",
]|)#;
"##},
"<A-I>",
indoc! {r##"
let a = [
#[|1]#,
#(|2)#,
#(|3)#,
#(|4)#,
#(|5)#,
];
let b = [
#("one"|)#,
#("two"|)#,
#("three"|)#,
#("four"|)#,
#("five"|)#,
];
"##},
),
];
for test in tests {
test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_select_next_sibling() -> anyhow::Result<()> {
let tests = vec![
// basic test
(
indoc! {r##"
fn inc(x: usize) -> usize { x + 1 #[}|]#
fn dec(x: usize) -> usize { x - 1 }
fn ident(x: usize) -> usize { x }
"##},
"<A-n>",
indoc! {r##"
fn inc(x: usize) -> usize { x + 1 }
#[fn dec(x: usize) -> usize { x - 1 }|]#
fn ident(x: usize) -> usize { x }
"##},
),
// direction is not preserved and is always forward.
(
indoc! {r##"
fn inc(x: usize) -> usize { x + 1 #[}|]#
fn dec(x: usize) -> usize { x - 1 }
fn ident(x: usize) -> usize { x }
"##},
"<A-n><A-;><A-n>",
indoc! {r##"
fn inc(x: usize) -> usize { x + 1 }
fn dec(x: usize) -> usize { x - 1 }
#[fn ident(x: usize) -> usize { x }|]#
"##},
),
];
for test in tests {
test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_select_prev_sibling() -> anyhow::Result<()> {
let tests = vec![
// basic test
(
indoc! {r##"
fn inc(x: usize) -> usize { x + 1 }
fn dec(x: usize) -> usize { x - 1 }
#[|f]#n ident(x: usize) -> usize { x }
"##},
"<A-p>",
indoc! {r##"
fn inc(x: usize) -> usize { x + 1 }
#[|fn dec(x: usize) -> usize { x - 1 }]#
fn ident(x: usize) -> usize { x }
"##},
),
// direction is not preserved and is always backward.
(
indoc! {r##"
fn inc(x: usize) -> usize { x + 1 }
fn dec(x: usize) -> usize { x - 1 }
#[|f]#n ident(x: usize) -> usize { x }
"##},
"<A-p><A-;><A-p>",
indoc! {r##"
#[|fn inc(x: usize) -> usize { x + 1 }]#
fn dec(x: usize) -> usize { x - 1 }
fn ident(x: usize) -> usize { x }
"##},
),
];
for test in tests {
test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn match_bracket() -> anyhow::Result<()> {
let rust_tests = vec![
// fwd
(
indoc! {r##"
fn foo(x: usize) -> usize { #[x|]# + 1 }
"##},
"mm",
indoc! {r##"
fn foo(x: usize) -> usize { x + 1 #[}|]#
"##},
),
// backward
(
indoc! {r##"
fn foo(x: usize) -> usize { #[x|]# + 1 }
"##},
"mmmm",
indoc! {r##"
fn foo(x: usize) -> usize #[{|]# x + 1 }
"##},
),
// avoid false positive inside string literal
(
indoc! {r##"
fn foo() -> &'static str { "(hello#[ |]#world)" }
"##},
"mm",
indoc! {r##"
fn foo() -> &'static str { "(hello world)#["|]# }
"##},
),
// make sure matching on quotes works
(
indoc! {r##"
fn foo() -> &'static str { "(hello#[ |]#world)" }
"##},
"mm",
indoc! {r##"
fn foo() -> &'static str { "(hello world)#["|]# }
"##},
),
// .. on both ends
(
indoc! {r##"
fn foo() -> &'static str { "(hello#[ |]#world)" }
"##},
"mmmm",
indoc! {r##"
fn foo() -> &'static str { #["|]#(hello world)" }
"##},
),
// match on siblings nodes
(
indoc! {r##"
fn foo(bar: Option<usize>) -> usize {
match bar {
Some(b#[a|]#r) => bar,
None => 42,
}
}
"##},
"mmmm",
indoc! {r##"
fn foo(bar: Option<usize>) -> usize {
match bar {
Some#[(|]#bar) => bar,
None => 42,
}
}
"##},
),
// gracefully handle multiple sibling brackets (usally for errors/incomplete syntax trees)
// in the past we selected the first > instead of the second > here
(
indoc! {r##"
fn foo() {
foo::<b#[a|]#r<>>
}
"##},
"mm",
indoc! {r##"
fn foo() {
foo::<bar<>#[>|]#
}
"##},
),
];
let python_tests = vec![
// python quotes have a slightly more complex syntax tree
// that triggerd a bug in an old implementation so we test
// them here
(
indoc! {r##"
foo_python = "mm does not#[ |]#work on this string"
"##},
"mm",
indoc! {r##"
foo_python = "mm does not work on this string#["|]#
"##},
),
(
indoc! {r##"
foo_python = "mm does not#[ |]#work on this string"
"##},
"mmmm",
indoc! {r##"
foo_python = #["|]#mm does not work on this string"
"##},
),
];
for test in rust_tests {
println!("{test:?}");
test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?;
}
for test in python_tests {
println!("{test:?}");
test_with_config(AppBuilder::new().with_file("foo.py", None), test).await?;
}
Ok(())
}

@ -529,6 +529,81 @@ async fn test_write_all_insert_final_newline_do_not_add_if_unmodified() -> anyho
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread")]
async fn test_symlink_write() -> anyhow::Result<()> {
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[cfg(not(unix))]
use std::os::windows::fs::symlink_file as symlink;
let dir = tempfile::tempdir()?;
let mut file = tempfile::NamedTempFile::new_in(&dir)?;
let symlink_path = dir.path().join("linked");
symlink(file.path(), &symlink_path)?;
let mut app = helpers::AppBuilder::new()
.with_file(&symlink_path, None)
.build()?;
test_key_sequence(
&mut app,
Some("ithe gostak distims the doshes<ret><esc>:w<ret>"),
None,
false,
)
.await?;
reload_file(&mut file).unwrap();
let mut file_content = String::new();
file.as_file_mut().read_to_string(&mut file_content)?;
assert_eq!(
LineFeedHandling::Native.apply("the gostak distims the doshes"),
file_content
);
assert!(symlink_path.is_symlink());
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_symlink_write_fail() -> anyhow::Result<()> {
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[cfg(not(unix))]
use std::os::windows::fs::symlink_file as symlink;
let dir = tempfile::tempdir()?;
let file = helpers::new_readonly_tempfile_in_dir(&dir)?;
let symlink_path = dir.path().join("linked");
symlink(file.path(), &symlink_path)?;
let mut app = helpers::AppBuilder::new()
.with_file(&symlink_path, None)
.build()?;
test_key_sequence(
&mut app,
Some("ihello<esc>:wq<ret>"),
Some(&|app| {
let mut docs: Vec<_> = app.editor.documents().collect();
assert_eq!(1, docs.len());
let doc = docs.pop().unwrap();
assert_eq!(Some(&path::normalize(&symlink_path)), doc.path());
assert_eq!(&Severity::Error, app.editor.get_status().unwrap().1);
}),
false,
)
.await?;
assert!(symlink_path.is_symlink());
Ok(())
}
async fn edit_file_with_content(file_content: &[u8]) -> anyhow::Result<()> { async fn edit_file_with_content(file_content: &[u8]) -> anyhow::Result<()> {
let mut file = tempfile::NamedTempFile::new()?; let mut file = tempfile::NamedTempFile::new()?;

@ -305,6 +305,18 @@ pub fn new_readonly_tempfile() -> anyhow::Result<NamedTempFile> {
Ok(file) Ok(file)
} }
/// Creates a new temporary file in the directory that is set to read only. Useful for
/// testing write failures.
pub fn new_readonly_tempfile_in_dir(
dir: impl AsRef<std::path::Path>,
) -> anyhow::Result<NamedTempFile> {
let mut file = tempfile::NamedTempFile::new_in(dir)?;
let metadata = file.as_file().metadata()?;
let mut perms = metadata.permissions();
perms.set_readonly(true);
file.as_file_mut().set_permissions(perms)?;
Ok(file)
}
pub struct AppBuilder { pub struct AppBuilder {
args: Args, args: Args,
config: Config, config: Config,

@ -107,6 +107,14 @@ async fn surround_by_character() -> anyhow::Result<()> {
)) ))
.await?; .await?;
// Selection direction is preserved
test((
"(so [many {go#[|od]#} text] here)",
"mi{",
"(so [many {#[|good]#} text] here)",
))
.await?;
Ok(()) Ok(())
} }
@ -366,6 +374,41 @@ async fn surround_around_pair() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread")]
async fn match_around_closest_ts() -> anyhow::Result<()> {
test_with_config(
AppBuilder::new().with_file("foo.rs", None),
(
r#"fn main() {todo!{"f#[|oo]#)"};}"#,
"mam",
r#"fn main() {todo!{#[|"foo)"]#};}"#,
),
)
.await?;
test_with_config(
AppBuilder::new().with_file("foo.rs", None),
(
r##"fn main() { let _ = ("#[|1]#23", "#(|1)#23"); } "##,
"3mam",
r##"fn main() #[|{ let _ = ("123", "123"); }]# "##,
),
)
.await?;
test_with_config(
AppBuilder::new().with_file("foo.rs", None),
(
r##" fn main() { let _ = ("12#[|3", "12]#3"); } "##,
"1mam",
r##" fn main() { let _ = #[|("123", "123")]#; } "##,
),
)
.await?;
Ok(())
}
/// Ensure the very initial cursor in an opened file is the width of /// Ensure the very initial cursor in an opened file is the width of
/// the first grapheme /// the first grapheme
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
@ -666,7 +709,7 @@ async fn tree_sitter_motions_work_across_injections() -> anyhow::Result<()> {
( (
"<script>let #[|x]# = 1;</script>", "<script>let #[|x]# = 1;</script>",
"<A-n>", "<A-n>",
"<script>let x #[|=]# 1;</script>", "<script>let x #[=|]# 1;</script>",
), ),
) )
.await?; .await?;

@ -91,6 +91,10 @@ where
W: Write, W: Write,
{ {
pub fn new(buffer: W, config: &EditorConfig) -> CrosstermBackend<W> { pub fn new(buffer: W, config: &EditorConfig) -> CrosstermBackend<W> {
// helix is not usable without colors, but crossterm will disable
// them by default if NO_COLOR is set in the environment. Override
// this behaviour.
crossterm::style::force_color_output(true);
CrosstermBackend { CrosstermBackend {
buffer, buffer,
capabilities: Capabilities::from_env_or_default(config), capabilities: Capabilities::from_env_or_default(config),

@ -19,7 +19,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "p
parking_lot = "0.12" parking_lot = "0.12"
arc-swap = { version = "1.7.1" } arc-swap = { version = "1.7.1" }
gix = { version = "0.61.0", features = ["attributes", "status"], default-features = false, optional = true } gix = { version = "0.62.0", features = ["attributes", "status"], default-features = false, optional = true }
imara-diff = "0.1.5" imara-diff = "0.1.5"
anyhow = "1" anyhow = "1"

@ -17,16 +17,65 @@ use gix::status::{
}; };
use gix::{Commit, ObjectId, Repository, ThreadSafeRepository}; use gix::{Commit, ObjectId, Repository, ThreadSafeRepository};
use crate::{DiffProvider, FileChange}; use crate::FileChange;
#[cfg(test)] #[cfg(test)]
mod test; mod test;
#[derive(Clone, Copy)] pub fn get_diff_base(file: &Path) -> Result<Vec<u8>> {
pub struct Git; debug_assert!(!file.exists() || file.is_file());
debug_assert!(file.is_absolute());
// TODO cache repository lookup
impl Git { let repo_dir = file.parent().context("file has no parent directory")?;
fn open_repo(path: &Path, ceiling_dir: Option<&Path>) -> Result<ThreadSafeRepository> { let repo = open_repo(repo_dir)
.context("failed to open git repo")?
.to_thread_local();
let head = repo.head_commit()?;
let file_oid = find_file_in_commit(&repo, &head, file)?;
let file_object = repo.find_object(file_oid)?;
let data = file_object.detach().data;
// Get the actual data that git would make out of the git object.
// This will apply the user's git config or attributes like crlf conversions.
if let Some(work_dir) = repo.work_dir() {
let rela_path = file.strip_prefix(work_dir)?;
let rela_path = gix::path::try_into_bstr(rela_path)?;
let (mut pipeline, _) = repo.filter_pipeline(None)?;
let mut worktree_outcome =
pipeline.convert_to_worktree(&data, rela_path.as_ref(), Delay::Forbid)?;
let mut buf = Vec::with_capacity(data.len());
worktree_outcome.read_to_end(&mut buf)?;
Ok(buf)
} else {
Ok(data)
}
}
pub fn get_current_head_name(file: &Path) -> Result<Arc<ArcSwap<Box<str>>>> {
debug_assert!(!file.exists() || file.is_file());
debug_assert!(file.is_absolute());
let repo_dir = file.parent().context("file has no parent directory")?;
let repo = open_repo(repo_dir)
.context("failed to open git repo")?
.to_thread_local();
let head_ref = repo.head_ref()?;
let head_commit = repo.head_commit()?;
let name = match head_ref {
Some(reference) => reference.name().shorten().to_string(),
None => head_commit.id.to_hex_with_len(8).to_string(),
};
Ok(Arc::new(ArcSwap::from_pointee(name.into_boxed_str())))
}
pub fn for_each_changed_file(cwd: &Path, f: impl Fn(Result<FileChange>) -> bool) -> Result<()> {
status(&open_repo(cwd)?.to_thread_local(), f)
}
fn open_repo(path: &Path) -> Result<ThreadSafeRepository> {
// custom open options // custom open options
let mut git_open_opts_map = gix::sec::trust::Mapping::<gix::open::Options>::default(); let mut git_open_opts_map = gix::sec::trust::Mapping::<gix::open::Options>::default();
@ -55,9 +104,6 @@ impl Git {
}); });
let open_options = gix::discover::upwards::Options { let open_options = gix::discover::upwards::Options {
ceiling_dirs: ceiling_dir
.map(|dir| vec![dir.to_owned()])
.unwrap_or_default(),
dot_git_only: true, dot_git_only: true,
..Default::default() ..Default::default()
}; };
@ -137,72 +183,6 @@ impl Git {
Ok(()) Ok(())
} }
}
impl Git {
pub fn get_diff_base(&self, file: &Path) -> Result<Vec<u8>> {
debug_assert!(!file.exists() || file.is_file());
debug_assert!(file.is_absolute());
// TODO cache repository lookup
let repo_dir = file.parent().context("file has no parent directory")?;
let repo = Git::open_repo(repo_dir, None)
.context("failed to open git repo")?
.to_thread_local();
let head = repo.head_commit()?;
let file_oid = find_file_in_commit(&repo, &head, file)?;
let file_object = repo.find_object(file_oid)?;
let data = file_object.detach().data;
// Get the actual data that git would make out of the git object.
// This will apply the user's git config or attributes like crlf conversions.
if let Some(work_dir) = repo.work_dir() {
let rela_path = file.strip_prefix(work_dir)?;
let rela_path = gix::path::try_into_bstr(rela_path)?;
let (mut pipeline, _) = repo.filter_pipeline(None)?;
let mut worktree_outcome =
pipeline.convert_to_worktree(&data, rela_path.as_ref(), Delay::Forbid)?;
let mut buf = Vec::with_capacity(data.len());
worktree_outcome.read_to_end(&mut buf)?;
Ok(buf)
} else {
Ok(data)
}
}
pub fn get_current_head_name(&self, file: &Path) -> Result<Arc<ArcSwap<Box<str>>>> {
debug_assert!(!file.exists() || file.is_file());
debug_assert!(file.is_absolute());
let repo_dir = file.parent().context("file has no parent directory")?;
let repo = Git::open_repo(repo_dir, None)
.context("failed to open git repo")?
.to_thread_local();
let head_ref = repo.head_ref()?;
let head_commit = repo.head_commit()?;
let name = match head_ref {
Some(reference) => reference.name().shorten().to_string(),
None => head_commit.id.to_hex_with_len(8).to_string(),
};
Ok(Arc::new(ArcSwap::from_pointee(name.into_boxed_str())))
}
pub fn for_each_changed_file(
&self,
cwd: &Path,
f: impl Fn(Result<FileChange>) -> bool,
) -> Result<()> {
Self::status(&Self::open_repo(cwd, None)?.to_thread_local(), f)
}
}
impl From<Git> for DiffProvider {
fn from(value: Git) -> Self {
DiffProvider::Git(value)
}
}
/// Finds the object that contains the contents of a file at a specific commit. /// Finds the object that contains the contents of a file at a specific commit.
fn find_file_in_commit(repo: &Repository, commit: &Commit, file: &Path) -> Result<ObjectId> { fn find_file_in_commit(repo: &Repository, commit: &Commit, file: &Path) -> Result<ObjectId> {

@ -2,7 +2,7 @@ use std::{fs::File, io::Write, path::Path, process::Command};
use tempfile::TempDir; use tempfile::TempDir;
use crate::Git; use crate::git;
fn exec_git_cmd(args: &str, git_dir: &Path) { fn exec_git_cmd(args: &str, git_dir: &Path) {
let res = Command::new("git") let res = Command::new("git")
@ -54,7 +54,7 @@ fn missing_file() {
let file = temp_git.path().join("file.txt"); let file = temp_git.path().join("file.txt");
File::create(&file).unwrap().write_all(b"foo").unwrap(); File::create(&file).unwrap().write_all(b"foo").unwrap();
assert!(Git.get_diff_base(&file).is_err()); assert!(git::get_diff_base(&file).is_err());
} }
#[test] #[test]
@ -64,7 +64,7 @@ fn unmodified_file() {
let contents = b"foo".as_slice(); let contents = b"foo".as_slice();
File::create(&file).unwrap().write_all(contents).unwrap(); File::create(&file).unwrap().write_all(contents).unwrap();
create_commit(temp_git.path(), true); create_commit(temp_git.path(), true);
assert_eq!(Git.get_diff_base(&file).unwrap(), Vec::from(contents)); assert_eq!(git::get_diff_base(&file).unwrap(), Vec::from(contents));
} }
#[test] #[test]
@ -76,7 +76,7 @@ fn modified_file() {
create_commit(temp_git.path(), true); create_commit(temp_git.path(), true);
File::create(&file).unwrap().write_all(b"bar").unwrap(); File::create(&file).unwrap().write_all(b"bar").unwrap();
assert_eq!(Git.get_diff_base(&file).unwrap(), Vec::from(contents)); assert_eq!(git::get_diff_base(&file).unwrap(), Vec::from(contents));
} }
/// Test that `get_file_head` does not return content for a directory. /// Test that `get_file_head` does not return content for a directory.
@ -95,7 +95,7 @@ fn directory() {
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
File::create(&dir).unwrap().write_all(b"bar").unwrap(); File::create(&dir).unwrap().write_all(b"bar").unwrap();
assert!(Git.get_diff_base(&dir).is_err()); assert!(git::get_diff_base(&dir).is_err());
} }
/// Test that `get_file_head` does not return content for a symlink. /// Test that `get_file_head` does not return content for a symlink.
@ -116,6 +116,6 @@ fn symlink() {
symlink("file.txt", &file_link).unwrap(); symlink("file.txt", &file_link).unwrap();
create_commit(temp_git.path(), true); create_commit(temp_git.path(), true);
assert!(Git.get_diff_base(&file_link).is_err()); assert!(git::get_diff_base(&file_link).is_err());
assert_eq!(Git.get_diff_base(&file).unwrap(), Vec::from(contents)); assert_eq!(git::get_diff_base(&file).unwrap(), Vec::from(contents));
} }

@ -5,11 +5,6 @@ use std::{
sync::Arc, sync::Arc,
}; };
#[cfg(feature = "git")]
pub use git::Git;
#[cfg(not(feature = "git"))]
pub use Dummy as Git;
#[cfg(feature = "git")] #[cfg(feature = "git")]
mod git; mod git;
@ -21,33 +16,6 @@ mod status;
pub use status::FileChange; pub use status::FileChange;
#[doc(hidden)]
#[derive(Clone, Copy)]
pub struct Dummy;
impl Dummy {
fn get_diff_base(&self, _file: &Path) -> Result<Vec<u8>> {
bail!("helix was compiled without git support")
}
fn get_current_head_name(&self, _file: &Path) -> Result<Arc<ArcSwap<Box<str>>>> {
bail!("helix was compiled without git support")
}
fn for_each_changed_file(
&self,
_cwd: &Path,
_f: impl Fn(Result<FileChange>) -> bool,
) -> Result<()> {
bail!("helix was compiled without git support")
}
}
impl From<Dummy> for DiffProvider {
fn from(value: Dummy) -> Self {
DiffProvider::Dummy(value)
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct DiffProviderRegistry { pub struct DiffProviderRegistry {
providers: Vec<DiffProvider>, providers: Vec<DiffProvider>,
@ -104,34 +72,39 @@ impl Default for DiffProviderRegistry {
fn default() -> Self { fn default() -> Self {
// currently only git is supported // currently only git is supported
// TODO make this configurable when more providers are added // TODO make this configurable when more providers are added
let providers = vec![Git.into()]; let providers = vec![
#[cfg(feature = "git")]
DiffProvider::Git,
];
DiffProviderRegistry { providers } DiffProviderRegistry { providers }
} }
} }
/// A union type that includes all types that implement [DiffProvider]. We need this type to allow /// A union type that includes all types that implement [DiffProvider]. We need this type to allow
/// cloning [DiffProviderRegistry] as `Clone` cannot be used in trait objects. /// cloning [DiffProviderRegistry] as `Clone` cannot be used in trait objects.
#[derive(Clone)] ///
/// `Copy` is simply to ensure the `clone()` call is the simplest it can be.
#[derive(Copy, Clone)]
pub enum DiffProvider { pub enum DiffProvider {
Dummy(Dummy),
#[cfg(feature = "git")] #[cfg(feature = "git")]
Git(Git), Git,
None,
} }
impl DiffProvider { impl DiffProvider {
fn get_diff_base(&self, file: &Path) -> Result<Vec<u8>> { fn get_diff_base(&self, file: &Path) -> Result<Vec<u8>> {
match self { match self {
Self::Dummy(inner) => inner.get_diff_base(file),
#[cfg(feature = "git")] #[cfg(feature = "git")]
Self::Git(inner) => inner.get_diff_base(file), Self::Git => git::get_diff_base(file),
Self::None => bail!("No diff support compiled in"),
} }
} }
fn get_current_head_name(&self, file: &Path) -> Result<Arc<ArcSwap<Box<str>>>> { fn get_current_head_name(&self, file: &Path) -> Result<Arc<ArcSwap<Box<str>>>> {
match self { match self {
Self::Dummy(inner) => inner.get_current_head_name(file),
#[cfg(feature = "git")] #[cfg(feature = "git")]
Self::Git(inner) => inner.get_current_head_name(file), Self::Git => git::get_current_head_name(file),
Self::None => bail!("No diff support compiled in"),
} }
} }
@ -141,9 +114,9 @@ impl DiffProvider {
f: impl Fn(Result<FileChange>) -> bool, f: impl Fn(Result<FileChange>) -> bool,
) -> Result<()> { ) -> Result<()> {
match self { match self {
Self::Dummy(inner) => inner.for_each_changed_file(cwd, f),
#[cfg(feature = "git")] #[cfg(feature = "git")]
Self::Git(inner) => inner.for_each_changed_file(cwd, f), Self::Git => git::for_each_changed_file(cwd, f),
Self::None => bail!("No diff support compiled in"),
} }
} }
} }

@ -13,6 +13,7 @@ homepage.workspace = true
[features] [features]
default = [] default = []
term = ["crossterm"] term = ["crossterm"]
unicode-lines = []
[dependencies] [dependencies]
helix-stdx = { path = "../helix-stdx" } helix-stdx = { path = "../helix-stdx" }

@ -90,13 +90,13 @@ pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> {
} }
} }
#[cfg(target_os = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> { pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> {
// TODO: // TODO:
Box::new(provider::FallbackProvider::new()) Box::new(provider::FallbackProvider::new())
} }
#[cfg(not(any(windows, target_os = "wasm32", target_os = "macos")))] #[cfg(not(any(windows, target_arch = "wasm32", target_os = "macos")))]
pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> { pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> {
use helix_stdx::env::{binary_exists, env_var_is_set}; use helix_stdx::env::{binary_exists, env_var_is_set};
use provider::command::is_exit_success; use provider::command::is_exit_success;

@ -624,7 +624,7 @@ where
*mut_ref = f(mem::take(mut_ref)); *mut_ref = f(mem::take(mut_ref));
} }
use helix_lsp::{lsp, Client, LanguageServerName}; use helix_lsp::{lsp, Client, LanguageServerId, LanguageServerName};
use url::Url; use url::Url;
impl Document { impl Document {
@ -893,15 +893,18 @@ impl Document {
} }
} }
} }
let write_path = tokio::fs::read_link(&path)
.await
.unwrap_or_else(|_| path.clone());
if readonly(&path) { if readonly(&write_path) {
bail!(std::io::Error::new( bail!(std::io::Error::new(
std::io::ErrorKind::PermissionDenied, std::io::ErrorKind::PermissionDenied,
"Path is read only" "Path is read only"
)); ));
} }
let backup = if path.exists() { let backup = if path.exists() {
let path_ = path.clone(); let path_ = write_path.clone();
// hacks: we use tempfile to handle the complex task of creating // hacks: we use tempfile to handle the complex task of creating
// non clobbered temporary path for us we don't want // non clobbered temporary path for us we don't want
// the whole automatically delete path on drop thing // the whole automatically delete path on drop thing
@ -925,7 +928,7 @@ impl Document {
}; };
let write_result: anyhow::Result<_> = async { let write_result: anyhow::Result<_> = async {
let mut dst = tokio::fs::File::create(&path).await?; let mut dst = tokio::fs::File::create(&write_path).await?;
to_writer(&mut dst, encoding_with_bom_info, &text).await?; to_writer(&mut dst, encoding_with_bom_info, &text).await?;
Ok(()) Ok(())
} }
@ -934,14 +937,13 @@ impl Document {
if let Some(backup) = backup { if let Some(backup) = backup {
if write_result.is_err() { if write_result.is_err() {
// restore backup // restore backup
let _ = tokio::fs::rename(&backup, &path) let _ = tokio::fs::rename(&backup, &write_path)
.await .await
.map_err(|e| log::error!("Failed to restore backup on write failure: {e}")); .map_err(|e| log::error!("Failed to restore backup on write failure: {e}"));
} else { } else {
// copy metadata and delete backup // copy metadata and delete backup
let path_ = path.clone();
let _ = tokio::task::spawn_blocking(move || { let _ = tokio::task::spawn_blocking(move || {
let _ = copy_metadata(&backup, &path_) let _ = copy_metadata(&backup, &write_path)
.map_err(|e| log::error!("Failed to copy metadata on write: {e}")); .map_err(|e| log::error!("Failed to copy metadata on write: {e}"));
let _ = std::fs::remove_file(backup) let _ = std::fs::remove_file(backup)
.map_err(|e| log::error!("Failed to remove backup file on write: {e}")); .map_err(|e| log::error!("Failed to remove backup file on write: {e}"));
@ -961,13 +963,14 @@ impl Document {
for (_, language_server) in language_servers { for (_, language_server) in language_servers {
if !language_server.is_initialized() { if !language_server.is_initialized() {
return Ok(event); continue;
} }
if let Some(identifier) = &identifier { if let Some(notification) = identifier
if let Some(notification) = .clone()
language_server.text_document_did_save(identifier.clone(), &text) .and_then(|id| language_server.text_document_did_save(id, &text))
{ {
notification.await?; if let Err(err) = notification.await {
log::error!("Failed to send textDocument/didSave: {err}");
} }
} }
} }
@ -1293,11 +1296,7 @@ impl Document {
}); });
self.diagnostics.sort_unstable_by_key(|diagnostic| { self.diagnostics.sort_unstable_by_key(|diagnostic| {
( (diagnostic.range, diagnostic.severity, diagnostic.provider)
diagnostic.range,
diagnostic.severity,
diagnostic.language_server_id,
)
}); });
// Update the inlay hint annotations' positions, helping ensure they are displayed in the proper place // Update the inlay hint annotations' positions, helping ensure they are displayed in the proper place
@ -1641,7 +1640,7 @@ impl Document {
}) })
} }
pub fn supports_language_server(&self, id: usize) -> bool { pub fn supports_language_server(&self, id: LanguageServerId) -> bool {
self.language_servers().any(|l| l.id() == id) self.language_servers().any(|l| l.id() == id)
} }
@ -1764,7 +1763,7 @@ impl Document {
text: &Rope, text: &Rope,
language_config: Option<&LanguageConfiguration>, language_config: Option<&LanguageConfiguration>,
diagnostic: &helix_lsp::lsp::Diagnostic, diagnostic: &helix_lsp::lsp::Diagnostic,
language_server_id: usize, language_server_id: LanguageServerId,
offset_encoding: helix_lsp::OffsetEncoding, offset_encoding: helix_lsp::OffsetEncoding,
) -> Option<Diagnostic> { ) -> Option<Diagnostic> {
use helix_core::diagnostic::{Range, Severity::*}; use helix_core::diagnostic::{Range, Severity::*};
@ -1841,7 +1840,7 @@ impl Document {
tags, tags,
source: diagnostic.source.clone(), source: diagnostic.source.clone(),
data: diagnostic.data.clone(), data: diagnostic.data.clone(),
language_server_id, provider: language_server_id,
}) })
} }
@ -1854,13 +1853,13 @@ impl Document {
&mut self, &mut self,
diagnostics: impl IntoIterator<Item = Diagnostic>, diagnostics: impl IntoIterator<Item = Diagnostic>,
unchanged_sources: &[String], unchanged_sources: &[String],
language_server_id: Option<usize>, language_server_id: Option<LanguageServerId>,
) { ) {
if unchanged_sources.is_empty() { if unchanged_sources.is_empty() {
self.clear_diagnostics(language_server_id); self.clear_diagnostics(language_server_id);
} else { } else {
self.diagnostics.retain(|d| { self.diagnostics.retain(|d| {
if language_server_id.map_or(false, |id| id != d.language_server_id) { if language_server_id.map_or(false, |id| id != d.provider) {
return true; return true;
} }
@ -1873,18 +1872,14 @@ impl Document {
} }
self.diagnostics.extend(diagnostics); self.diagnostics.extend(diagnostics);
self.diagnostics.sort_unstable_by_key(|diagnostic| { self.diagnostics.sort_unstable_by_key(|diagnostic| {
( (diagnostic.range, diagnostic.severity, diagnostic.provider)
diagnostic.range,
diagnostic.severity,
diagnostic.language_server_id,
)
}); });
} }
/// clears diagnostics for a given language server id if set, otherwise all diagnostics are cleared /// clears diagnostics for a given language server id if set, otherwise all diagnostics are cleared
pub fn clear_diagnostics(&mut self, language_server_id: Option<usize>) { pub fn clear_diagnostics(&mut self, language_server_id: Option<LanguageServerId>) {
if let Some(id) = language_server_id { if let Some(id) = language_server_id {
self.diagnostics.retain(|d| d.language_server_id != id); self.diagnostics.retain(|d| d.provider != id);
} else { } else {
self.diagnostics.clear(); self.diagnostics.clear();
} }

@ -16,7 +16,7 @@ use helix_vcs::DiffProviderRegistry;
use futures_util::stream::select_all::SelectAll; use futures_util::stream::select_all::SelectAll;
use futures_util::{future, StreamExt}; use futures_util::{future, StreamExt};
use helix_lsp::Call; use helix_lsp::{Call, LanguageServerId};
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
use std::{ use std::{
@ -386,7 +386,7 @@ pub fn get_terminal_provider() -> Option<TerminalConfig> {
}) })
} }
#[cfg(not(any(windows, target_os = "wasm32")))] #[cfg(not(any(windows, target_arch = "wasm32")))]
pub fn get_terminal_provider() -> Option<TerminalConfig> { pub fn get_terminal_provider() -> Option<TerminalConfig> {
use helix_stdx::env::{binary_exists, env_var_is_set}; use helix_stdx::env::{binary_exists, env_var_is_set};
@ -960,7 +960,7 @@ pub struct Editor {
pub macro_recording: Option<(char, Vec<KeyEvent>)>, pub macro_recording: Option<(char, Vec<KeyEvent>)>,
pub macro_replaying: Vec<char>, pub macro_replaying: Vec<char>,
pub language_servers: helix_lsp::Registry, pub language_servers: helix_lsp::Registry,
pub diagnostics: BTreeMap<PathBuf, Vec<(lsp::Diagnostic, usize)>>, pub diagnostics: BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>,
pub diff_providers: DiffProviderRegistry, pub diff_providers: DiffProviderRegistry,
pub debugger: Option<dap::Client>, pub debugger: Option<dap::Client>,
@ -1020,7 +1020,7 @@ pub type Motion = Box<dyn Fn(&mut Editor)>;
pub enum EditorEvent { pub enum EditorEvent {
DocumentSaved(DocumentSavedEventResult), DocumentSaved(DocumentSavedEventResult),
ConfigEvent(ConfigEvent), ConfigEvent(ConfigEvent),
LanguageServerMessage((usize, Call)), LanguageServerMessage((LanguageServerId, Call)),
DebuggerEvent(dap::Payload), DebuggerEvent(dap::Payload),
IdleTimer, IdleTimer,
Redraw, Redraw,
@ -1260,8 +1260,13 @@ impl Editor {
} }
#[inline] #[inline]
pub fn language_server_by_id(&self, language_server_id: usize) -> Option<&helix_lsp::Client> { pub fn language_server_by_id(
self.language_servers.get_by_id(language_server_id) &self,
language_server_id: LanguageServerId,
) -> Option<&helix_lsp::Client> {
self.language_servers
.get_by_id(language_server_id)
.map(|client| &**client)
} }
/// Refreshes the language server for a given document /// Refreshes the language server for a given document
@ -1869,7 +1874,7 @@ impl Editor {
/// Returns all supported diagnostics for the document /// Returns all supported diagnostics for the document
pub fn doc_diagnostics<'a>( pub fn doc_diagnostics<'a>(
language_servers: &'a helix_lsp::Registry, language_servers: &'a helix_lsp::Registry,
diagnostics: &'a BTreeMap<PathBuf, Vec<(lsp::Diagnostic, usize)>>, diagnostics: &'a BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>,
document: &Document, document: &Document,
) -> impl Iterator<Item = helix_core::Diagnostic> + 'a { ) -> impl Iterator<Item = helix_core::Diagnostic> + 'a {
Editor::doc_diagnostics_with_filter(language_servers, diagnostics, document, |_, _| true) Editor::doc_diagnostics_with_filter(language_servers, diagnostics, document, |_, _| true)
@ -1879,10 +1884,9 @@ impl Editor {
/// filtered by `filter` which is invocated with the raw `lsp::Diagnostic` and the language server id it came from /// filtered by `filter` which is invocated with the raw `lsp::Diagnostic` and the language server id it came from
pub fn doc_diagnostics_with_filter<'a>( pub fn doc_diagnostics_with_filter<'a>(
language_servers: &'a helix_lsp::Registry, language_servers: &'a helix_lsp::Registry,
diagnostics: &'a BTreeMap<PathBuf, Vec<(lsp::Diagnostic, usize)>>, diagnostics: &'a BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>,
document: &Document, document: &Document,
filter: impl Fn(&lsp::Diagnostic, usize) -> bool + 'a, filter: impl Fn(&lsp::Diagnostic, LanguageServerId) -> bool + 'a,
) -> impl Iterator<Item = helix_core::Diagnostic> + 'a { ) -> impl Iterator<Item = helix_core::Diagnostic> + 'a {
let text = document.text().clone(); let text = document.text().clone();
let language_config = document.language.clone(); let language_config = document.language.clone();

@ -71,7 +71,7 @@ pub fn diagnostic<'doc>(
d.line == line d.line == line
&& doc && doc
.language_servers_with_feature(LanguageServerFeature::Diagnostics) .language_servers_with_feature(LanguageServerFeature::Diagnostics)
.any(|ls| ls.id() == d.language_server_id) .any(|ls| ls.id() == d.provider)
}); });
diagnostics_on_line.max_by_key(|d| d.severity).map(|d| { diagnostics_on_line.max_by_key(|d| d.severity).map(|d| {
write!(out, "●").ok(); write!(out, "●").ok();

@ -491,12 +491,17 @@ impl Tree {
} }
Layout::Vertical => { Layout::Vertical => {
let len = container.children.len(); let len = container.children.len();
let slots = container.calculate_slots_width(); let len_u16 = len as u16;
let slot_width: f32 = area.width as f32 / slots as f32;
let mut child_x = area.x;
let inner_gap = 1u16; let inner_gap = 1u16;
// let total_gap = inner_gap * (len as u16 - 1); let total_gap = inner_gap * len_u16.saturating_sub(2);
let used_area = area.width.saturating_sub(total_gap);
let slots = container.calculate_slots_width();
let slot_width: f32 = used_area as f32 / slots as f32;
let mut child_x = area.x;
for (i, child) in container.children.iter().enumerate() { for (i, child) in container.children.iter().enumerate() {
let bounds = container.node_bounds[i]; let bounds = container.node_bounds[i];
@ -1091,13 +1096,43 @@ mod test {
assert_eq!(3, tree.views().count()); assert_eq!(3, tree.views().count());
assert_eq!( assert_eq!(
vec![ vec![
tree_area_width / 3, tree_area_width / 3 - 1, // gap here
tree_area_width / 3, tree_area_width / 3 - 1, // gap here
tree_area_width / 3 - 2 // Rounding in `recalculate`. tree_area_width / 3
], ],
tree.views() tree.views()
.map(|(view, _)| view.area.width) .map(|(view, _)| view.area.width)
.collect::<Vec<_>>() .collect::<Vec<_>>()
); );
} }
#[test]
fn vsplit_gap_rounding() {
let (tree_area_width, tree_area_height) = (80, 24);
let mut tree = Tree::new(Rect {
x: 0,
y: 0,
width: tree_area_width,
height: tree_area_height,
});
let mut view = View::new(DocumentId::default(), GutterConfig::default());
view.area = Rect::new(0, 0, tree_area_width, tree_area_height);
tree.insert(view);
for _ in 0..9 {
let view = View::new(DocumentId::default(), GutterConfig::default());
tree.split(view, Layout::Vertical);
}
assert_eq!(10, tree.views().count());
assert_eq!(
std::iter::repeat(7)
.take(9)
.chain(Some(8)) // Rounding in `recalculate`.
.collect::<Vec<_>>(),
tree.views()
.map(|(view, _)| view.area.width)
.collect::<Vec<_>>()
);
}
} }

@ -8,6 +8,7 @@ use-grammars = { except = [ "hare", "wren", "gemini" ] }
als = { command = "als" } als = { command = "als" }
ada-language-server = { command = "ada_language_server" } ada-language-server = { command = "ada_language_server" }
ada-gpr-language-server = {command = "ada_language_server", args = ["--language-gpr"]} ada-gpr-language-server = {command = "ada_language_server", args = ["--language-gpr"]}
angular = {command = "ngserver", args = ["--stdio", "--tsProbeLocations", ".", "--ngProbeLocations", ".",]}
awk-language-server = { command = "awk-language-server" } awk-language-server = { command = "awk-language-server" }
bash-language-server = { command = "bash-language-server", args = ["start"] } bash-language-server = { command = "bash-language-server", args = ["start"] }
bass = { command = "bass", args = ["--lsp"] } bass = { command = "bass", args = ["--lsp"] }
@ -45,13 +46,14 @@ intelephense = { command = "intelephense", args = ["--stdio"] }
jdtls = { command = "jdtls" } jdtls = { command = "jdtls" }
jsonnet-language-server = { command = "jsonnet-language-server", args= ["-t", "--lint"] } jsonnet-language-server = { command = "jsonnet-language-server", args= ["-t", "--lint"] }
julia = { command = "julia", timeout = 60, args = [ "--startup-file=no", "--history-file=no", "--quiet", "-e", "using LanguageServer; runserver()", ] } julia = { command = "julia", timeout = 60, args = [ "--startup-file=no", "--history-file=no", "--quiet", "-e", "using LanguageServer; runserver()", ] }
koka = { command = "koka", args = ["--language-server", "--lsstdio"] }
kotlin-language-server = { command = "kotlin-language-server" } kotlin-language-server = { command = "kotlin-language-server" }
lean = { command = "lean", args = [ "--server" ] } lean = { command = "lean", args = [ "--server" ] }
ltex-ls = { command = "ltex-ls" } ltex-ls = { command = "ltex-ls" }
markdoc-ls = { command = "markdoc-ls", args = ["--stdio"] } markdoc-ls = { command = "markdoc-ls", args = ["--stdio"] }
markdown-oxide = { command = "markdown-oxide" } markdown-oxide = { command = "markdown-oxide" }
marksman = { command = "marksman", args = ["server"] } marksman = { command = "marksman", args = ["server"] }
metals = { command = "metals", config = { "isHttpEnabled" = true } } metals = { command = "metals", config = { "isHttpEnabled" = true, metals = { inlayHints = { typeParameters = {enable = true} , hintsInPatternMatch = {enable = true} } } } }
mint = { command = "mint", args = ["ls"] } mint = { command = "mint", args = ["ls"] }
nil = { command = "nil" } nil = { command = "nil" }
nimlangserver = { command = "nimlangserver" } nimlangserver = { command = "nimlangserver" }
@ -65,6 +67,7 @@ openscad-lsp = { command = "openscad-lsp", args = ["--stdio"] }
pasls = { command = "pasls", args = [] } pasls = { command = "pasls", args = [] }
pbkit = { command = "pb", args = [ "lsp" ] } pbkit = { command = "pb", args = [ "lsp" ] }
perlnavigator = { command = "perlnavigator", args= ["--stdio"] } perlnavigator = { command = "perlnavigator", args= ["--stdio"] }
pest-language-server = { command = "pest-language-server" }
prisma-language-server = { command = "prisma-language-server", args = ["--stdio"] } prisma-language-server = { command = "prisma-language-server", args = ["--stdio"] }
purescript-language-server = { command = "purescript-language-server", args = ["--stdio"] } purescript-language-server = { command = "purescript-language-server", args = ["--stdio"] }
pylsp = { command = "pylsp" } pylsp = { command = "pylsp" }
@ -100,6 +103,7 @@ yaml-language-server = { command = "yaml-language-server", args = ["--stdio"] }
zls = { command = "zls" } zls = { command = "zls" }
blueprint-compiler = { command = "blueprint-compiler", args = ["lsp"] } blueprint-compiler = { command = "blueprint-compiler", args = ["lsp"] }
typst-lsp = { command = "typst-lsp" } typst-lsp = { command = "typst-lsp" }
tinymist = { command = "tinymist" }
pkgbuild-language-server = { command = "pkgbuild-language-server" } pkgbuild-language-server = { command = "pkgbuild-language-server" }
helm_ls = { command = "helm_ls", args = ["serve"] } helm_ls = { command = "helm_ls", args = ["serve"] }
ember-language-server = { command = "ember-language-server", args = ["--stdio"] } ember-language-server = { command = "ember-language-server", args = ["--stdio"] }
@ -193,12 +197,35 @@ inlayHints.functionLikeReturnTypes.enabled = true
inlayHints.enumMemberValues.enabled = true inlayHints.enumMemberValues.enabled = true
inlayHints.parameterNames.enabled = "all" inlayHints.parameterNames.enabled = "all"
[language-server.vscode-eslint-language-server]
command = "vscode-eslint-language-server"
args = ["--stdio"]
[language-server.vscode-eslint-language-server.config]
validate = "on"
experimental = { useFlatConfig = false }
rulesCustomizations = []
run = "onType"
problems = { shortenToSingleLine = false }
nodePath = ""
[language-server.vscode-eslint-language-server.config.codeAction.disableRuleComment]
enable = true
location = "separateLine"
[language-server.vscode-eslint-language-server.config.codeAction.showDocumentation]
enable = true
[language-server.vscode-eslint-language-server.config.workingDirectory]
mode = "location"
[[language]] [[language]]
name = "rust" name = "rust"
scope = "source.rust" scope = "source.rust"
injection-regex = "rust" injection-regex = "rust"
file-types = ["rs"] file-types = ["rs"]
roots = ["Cargo.toml", "Cargo.lock"] roots = ["Cargo.toml", "Cargo.lock"]
shebangs = ["rust-script", "cargo"]
auto-format = true auto-format = true
comment-tokens = ["//", "///", "//!"] comment-tokens = ["//", "///", "//!"]
block-comment-tokens = [ block-comment-tokens = [
@ -248,7 +275,7 @@ args = { attachCommands = [ "platform select remote-gdb-server", "platform conne
[[grammar]] [[grammar]]
name = "rust" name = "rust"
source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "0431a2c60828731f27491ee9fdefe25e250ce9c9" } source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "473634230435c18033384bebaa6d6a17c2523281" }
[[language]] [[language]]
name = "sway" name = "sway"
@ -669,7 +696,7 @@ name = "javascript"
scope = "source.js" scope = "source.js"
injection-regex = "(js|javascript)" injection-regex = "(js|javascript)"
language-id = "javascript" language-id = "javascript"
file-types = ["js", "mjs", "cjs", "rules", "es6", "pac", { glob = "jakefile" }] file-types = ["js", "mjs", "cjs", "rules", "es6", "pac", { glob = ".node_repl_history" }, { glob = "jakefile" }]
shebangs = ["node"] shebangs = ["node"]
comment-token = "//" comment-token = "//"
block-comment-tokens = { start = "/*", end = "*/" } block-comment-tokens = { start = "/*", end = "*/" }
@ -708,9 +735,9 @@ grammar = "javascript"
name = "typescript" name = "typescript"
scope = "source.ts" scope = "source.ts"
injection-regex = "(ts|typescript)" injection-regex = "(ts|typescript)"
file-types = ["ts", "mts", "cts"]
language-id = "typescript" language-id = "typescript"
shebangs = ["deno", "ts-node"] file-types = ["ts", "mts", "cts"]
shebangs = ["deno", "bun", "ts-node"]
comment-token = "//" comment-token = "//"
block-comment-tokens = { start = "/*", end = "*/" } block-comment-tokens = { start = "/*", end = "*/" }
language-servers = [ "typescript-language-server" ] language-servers = [ "typescript-language-server" ]
@ -781,7 +808,7 @@ source = { git = "https://github.com/tree-sitter/tree-sitter-html", rev = "29f53
name = "python" name = "python"
scope = "source.python" scope = "source.python"
injection-regex = "python" injection-regex = "python"
file-types = ["py", "pyi", "py3", "pyw", "ptl", "rpy", "cpy", "ipy", "pyt", { glob = ".pythonstartup" }, { glob = ".pythonrc" }, { glob = "SConstruct" }, { glob = "SConscript" }] file-types = ["py", "pyi", "py3", "pyw", "ptl", "rpy", "cpy", "ipy", "pyt", { glob = ".python_history" }, { glob = ".pythonstartup" }, { glob = ".pythonrc" }, { glob = "SConstruct" }, { glob = "SConscript" }]
shebangs = ["python"] shebangs = ["python"]
roots = ["pyproject.toml", "setup.py", "poetry.lock", "pyrightconfig.json"] roots = ["pyproject.toml", "setup.py", "poetry.lock", "pyrightconfig.json"]
comment-token = "#" comment-token = "#"
@ -883,6 +910,10 @@ injection-regex = "(shell|bash|zsh|sh)"
file-types = [ file-types = [
"sh", "sh",
"bash", "bash",
"ash",
"dash",
"ksh",
"mksh",
"zsh", "zsh",
"zshenv", "zshenv",
"zlogin", "zlogin",
@ -894,7 +925,6 @@ file-types = [
"bazelrc", "bazelrc",
"Renviron", "Renviron",
"zsh-theme", "zsh-theme",
"ksh",
"cshrc", "cshrc",
"tcshrc", "tcshrc",
"bashrc_Apple_Terminal", "bashrc_Apple_Terminal",
@ -929,7 +959,7 @@ indent = { tab-width = 2, unit = " " }
[[grammar]] [[grammar]]
name = "bash" name = "bash"
source = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "275effdfc0edce774acf7d481f9ea195c6c403cd" } source = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "f8fb3274f72a30896075585b32b0c54cad65c086" }
[[language]] [[language]]
name = "php" name = "php"
@ -1032,6 +1062,13 @@ block-comment-tokens = { start = "/-", end = "-/" }
language-servers = [ "lean" ] language-servers = [ "lean" ]
indent = { tab-width = 2, unit = " " } indent = { tab-width = 2, unit = " " }
[language.auto-pairs]
'(' = ')'
'{' = '}'
'[' = ']'
'"' = '"'
'⟨' = '⟩'
[[grammar]] [[grammar]]
name = "lean" name = "lean"
source = { git = "https://github.com/Julian/tree-sitter-lean", rev = "d98426109258b266e1e92358c5f11716d2e8f638" } source = { git = "https://github.com/Julian/tree-sitter-lean", rev = "d98426109258b266e1e92358c5f11716d2e8f638" }
@ -1636,7 +1673,7 @@ source = { git = "https://github.com/mtoohey31/tree-sitter-gitattributes", rev =
[[language]] [[language]]
name = "git-ignore" name = "git-ignore"
scope = "source.gitignore" scope = "source.gitignore"
file-types = [{ glob = ".gitignore" }, { glob = ".gitignore_global" }, { glob = ".ignore" }, { glob = ".prettierignore" }, { glob = ".eslintignore" }, { glob = ".npmignore"}, { glob = "CODEOWNERS" }, { glob = ".config/helix/ignore" }, { glob = ".helix/ignore" }] file-types = [{ glob = ".gitignore_global" }, { glob = ".ignore" }, { glob = "CODEOWNERS" }, { glob = ".config/helix/ignore" }, { glob = ".helix/ignore" }, { glob = ".*ignore" }]
injection-regex = "git-ignore" injection-regex = "git-ignore"
comment-token = "#" comment-token = "#"
grammar = "gitignore" grammar = "gitignore"
@ -1782,7 +1819,7 @@ language-servers = [ "solc" ]
[[grammar]] [[grammar]]
name = "solidity" name = "solidity"
source = { git = "https://github.com/JoranHonig/tree-sitter-solidity", rev = "9004b86531cb424bd379424cf7266a4585f2af7d" } source = { git = "https://github.com/JoranHonig/tree-sitter-solidity", rev = "08338dcee32603383fcef08f36321900bb7a354b" }
[[language]] [[language]]
name = "gleam" name = "gleam"
@ -2047,15 +2084,38 @@ block-comment-tokens = { start = "/*", end = "*/" }
indent = { tab-width = 4, unit = "\t" } indent = { tab-width = 4, unit = "\t" }
formatter = { command = "odinfmt", args = [ "-stdin", "true" ] } formatter = { command = "odinfmt", args = [ "-stdin", "true" ] }
[language.debugger]
name = "lldb-dap"
transport = "stdio"
command = "lldb-dap"
[[language.debugger.templates]]
name = "binary"
request = "launch"
completion = [ { name = "binary", completion = "filename" } ]
args = { console = "internalConsole", program = "{0}" }
[[language.debugger.templates]]
name = "attach"
request = "attach"
completion = [ "pid" ]
args = { console = "internalConsole", pid = "{0}" }
[[language.debugger.templates]]
name = "gdbserver attach"
request = "attach"
completion = [ { name = "lldb connect url", default = "connect://localhost:3333" }, { name = "file", completion = "filename" }, "pid" ]
args = { console = "internalConsole", attachCommands = [ "platform select remote-gdb-server", "platform connect {0}", "file {1}", "attach {2}" ] }
[[grammar]] [[grammar]]
name = "odin" name = "odin"
source = { git = "https://github.com/ap29600/tree-sitter-odin", rev = "b219207e49ffca2952529d33e94ed63b1b75c4f1" } source = { git = "https://github.com/tree-sitter-grammars/tree-sitter-odin", rev = "b5f668ef8918aab13812ce73acd89fe191fb8c5e" }
[[language]] [[language]]
name = "meson" name = "meson"
scope = "source.meson" scope = "source.meson"
injection-regex = "meson" injection-regex = "meson"
file-types = [{ glob = "meson.build" }, { glob = "meson_options.txt" }] file-types = [{ glob = "meson.build" }, { glob = "meson.options" }, { glob = "meson_options.txt" }]
comment-token = "#" comment-token = "#"
indent = { tab-width = 2, unit = " " } indent = { tab-width = 2, unit = " " }
@ -2190,7 +2250,7 @@ source = { git = "https://github.com/sogaiu/tree-sitter-clojure", rev = "e57c569
name = "starlark" name = "starlark"
scope = "source.starlark" scope = "source.starlark"
injection-regex = "(starlark|bzl|bazel)" injection-regex = "(starlark|bzl|bazel)"
file-types = ["bzl", "bazel", "star", { glob = "BUILD" }, { glob = "BUILD.*" }, { glob = "Tiltfile" }] file-types = ["bzl", "bazel", "star", { glob = "BUILD" }, { glob = "BUILD.*" }, { glob = "Tiltfile" }, { glob = "WORKSPACE" }]
comment-token = "#" comment-token = "#"
indent = { tab-width = 4, unit = " " } indent = { tab-width = 4, unit = " " }
grammar = "python" grammar = "python"
@ -2631,6 +2691,21 @@ indent = { tab-width = 4, unit = "\t" }
name = "ini" name = "ini"
source = { git = "https://github.com/justinmk/tree-sitter-ini", rev = "4d247fb876b4ae6b347687de4a179511bf67fcbc" } source = { git = "https://github.com/justinmk/tree-sitter-ini", rev = "4d247fb876b4ae6b347687de4a179511bf67fcbc" }
[[language]]
name = "inko"
auto-format = true
scope = "source.inko"
injection-regex = "inko"
file-types = ["inko"]
roots = ["inko.pkg"]
comment-token = "#"
indent = { tab-width = 2, unit = " " }
formatter = { command = "inko", args = ["fmt", "-"] }
[[grammar]]
name = "inko"
source = { git = "https://github.com/inko-lang/tree-sitter-inko", rev = "6983354c13a14bc621d7a3619f1790149e901187" }
[[language]] [[language]]
name = "bicep" name = "bicep"
scope = "source.bicep" scope = "source.bicep"
@ -3066,7 +3141,7 @@ scope = "source.typst"
injection-regex = "typst" injection-regex = "typst"
file-types = ["typst", "typ"] file-types = ["typst", "typ"]
comment-token = "//" comment-token = "//"
language-servers = ["typst-lsp"] language-servers = ["tinymist", "typst-lsp"]
indent = { tab-width = 2, unit = " " } indent = { tab-width = 2, unit = " " }
[language.auto-pairs] [language.auto-pairs]
@ -3078,7 +3153,7 @@ indent = { tab-width = 2, unit = " " }
[[grammar]] [[grammar]]
name = "typst" name = "typst"
source = { git = "https://github.com/uben0/tree-sitter-typst", rev = "ecf8596336857adfcd5f7cbb3b2aa11a67badc37" } source = { git = "https://github.com/uben0/tree-sitter-typst", rev = "13863ddcbaa7b68ee6221cea2e3143415e64aea4" }
[[language]] [[language]]
name = "nunjucks" name = "nunjucks"
@ -3264,10 +3339,11 @@ injection-regex = "koka"
file-types = ["kk"] file-types = ["kk"]
comment-token = "//" comment-token = "//"
indent = { tab-width = 8, unit = " " } indent = { tab-width = 8, unit = " " }
language-servers = ["koka"]
[[grammar]] [[grammar]]
name = "koka" name = "koka"
source = { git = "https://github.com/mtoohey31/tree-sitter-koka", rev = "2527e152d4b6a79fd50aebd8d0b4b4336c94a034" } source = { git = "https://github.com/mtoohey31/tree-sitter-koka", rev = "96d070c3700692858035f3524cc0ad944cef2594" }
[[language]] [[language]]
name = "tact" name = "tact"
@ -3354,13 +3430,13 @@ indent = { tab-width = 2, unit = " " }
[[grammar]] [[grammar]]
name = "ld" name = "ld"
source = { git = "https://github.com/mtoohey31/tree-sitter-ld", rev = "81978cde3844bfc199851e39c80a20ec6444d35e" } source = { git = "https://github.com/mtoohey31/tree-sitter-ld", rev = "0e9695ae0ede47b8744a8e2ad44d4d40c5d4e4c9" }
[[language]] [[language]]
name = "hyprlang" name = "hyprlang"
scope = "source.hyprlang" scope = "source.hyprlang"
roots = ["hyprland.conf"] roots = ["hyprland.conf"]
file-types = [ { glob = "hyprland.conf"} ] file-types = [ { glob = "hyprland.conf" }, { glob = "hyprpaper.conf" }, { glob = "hypridle.conf" }, { glob = "hyprlock.conf" } ]
comment-token = "#" comment-token = "#"
grammar = "hyprlang" grammar = "hyprlang"
@ -3412,7 +3488,7 @@ scope = "source.helm"
roots = ["Chart.yaml"] roots = ["Chart.yaml"]
comment-token = "#" comment-token = "#"
language-servers = ["helm_ls"] language-servers = ["helm_ls"]
file-types = [ { glob = "templates/*.yaml" }, { glob = "templates/_helpers.tpl"}, { glob = "templates/NOTES.txt" } ] file-types = [ { glob = "templates/*.yaml" }, { glob = "templates/_*.tpl"}, { glob = "templates/NOTES.txt" } ]
[[language]] [[language]]
name = "glimmer" name = "glimmer"
@ -3472,4 +3548,126 @@ language-servers = ["earthlyls"]
[[grammar]] [[grammar]]
name = "earthfile" name = "earthfile"
source = { git = "https://github.com/glehmann/tree-sitter-earthfile", rev = "2a6ab191f5f962562e495a818aa4e7f45f8a556a" } source = { git = "https://github.com/glehmann/tree-sitter-earthfile", rev = "a079e6c472eeedd6b9a1e03ca0b6c82cd6a112a4" }
[[language]]
name = "adl"
scope = "source.adl"
injection-regex = "adl"
file-types = ["adl"]
roots = []
comment-token = "//"
indent = { tab-width = 2, unit = " " }
[language.auto-pairs]
'"' = '"'
'{' = '}'
'<' = '>'
[[grammar]]
name = "adl"
source = { git = "https://github.com/adl-lang/tree-sitter-adl", rev = "2787d04beadfbe154d3f2da6e98dc45a1b134bbf" }
[[language]]
name = "ldif"
scope = "source.ldif"
injection-regex = "ldif"
file-types = ["ldif"]
comment-token = "#"
[[grammar]]
name = "ldif"
source = { git = "https://github.com/kepet19/tree-sitter-ldif", rev = "0a917207f65ba3e3acfa9cda16142ee39c4c1aaa" }
[[language]]
name = "xtc"
scope = "source.xtc"
# Accept Xena Traffic Configuration, Xena Port Configuration and Xena OpenAutomation
file-types = [ "xtc", "xpc", "xoa" ]
comment-token = ";"
[[grammar]]
name = "xtc"
source = { git = "https://github.com/Alexis-Lapierre/tree-sitter-xtc", rev = "7bc11b736250c45e25cfb0215db2f8393779957e" }
[[language]]
name = "move"
scope = "source.move"
injection-regex = "move"
roots = ["Move.toml"]
file-types = ["move"]
comment-token = "//"
indent = { tab-width = 4, unit = " " }
language-servers = []
[[grammar]]
name = "move"
source = { git = "https://github.com/tzakian/tree-sitter-move", rev = "8bc0d1692caa8763fef54d48068238d9bf3c0264" }
[[language]]
name = "pest"
scope = "source.pest"
injection-regex = "pest"
file-types = ["pest"]
comment-tokens = ["//", "///", "//!"]
block-comment-tokens = { start = "/*", end = "*/" }
indent = { tab-width = 4, unit = " " }
language-servers = ["pest-language-server"]
[language.auto-pairs]
'(' = ')'
'{' = '}'
'[' = ']'
'"' = '"'
[[grammar]]
name = "pest"
source = { git = "https://github.com/pest-parser/tree-sitter-pest", rev = "a8a98a824452b1ec4da7f508386a187a2f234b85" }
[[language]]
name = "gjs"
scope = "source.gjs"
file-types = ["gjs"]
roots = ["package.json", "ember-cli-build.js"]
comment-token = "//"
block-comment-tokens = { start = "/*", end = "*/" }
language-servers = [
{ except-features = [
"format", "diagnostics",
], name = "typescript-language-server" },
"vscode-eslint-language-server",
"ember-language-server",
]
indent = { tab-width = 2, unit = " " }
grammar = "javascript"
[language.auto-pairs]
'<' = '>'
"'" = "'"
"{" = "}"
"(" = ")"
'"' = '"'
[[language]]
name = "gts"
scope = "source.gts"
file-types = ["gts"]
roots = ["package.json", "ember-cli-build.js"]
comment-token = "//"
block-comment-tokens = { start = "/*", end = "*/" }
language-servers = [
{ except-features = [
"format", "diagnostics",
], name = "typescript-language-server" },
"vscode-eslint-language-server",
"ember-language-server",
]
indent = { tab-width = 2, unit = " " }
grammar = "typescript"
[language.auto-pairs]
'<' = '>'
"'" = "'"
"{" = "}"
"(" = ")"
'"' = '"'

@ -0,0 +1,5 @@
[
(glimmer_opening_tag)
(glimmer_closing_tag)
] @constant.builtin

@ -0,0 +1,20 @@
; PARSE GLIMMER TEMPLATES
(call_expression
function: [
(identifier) @injection.language
(member_expression
property: (property_identifier) @injection.language)
]
arguments: (template_string) @injection.content)
; e.g.: <template><SomeComponent @arg={{double @value}} /></template>
((glimmer_template) @injection.content
(#set! injection.language "hbs"))
; Parse Ember/Glimmer/Handlebars/HTMLBars/etc. template literals
; e.g.: await render(hbs`<SomeComponent />`)
(call_expression
function: ((identifier) @_name
(#eq? @_name "hbs"))
arguments: ((template_string) @glimmer
(#offset! @glimmer 0 1 0 -1)))

@ -0,0 +1,37 @@
; adl
[
"module"
"struct"
"union"
"type"
"newtype"
"annotation"
] @keyword
(adl (scoped_name)) @namespace
(comment) @comment
(doc_comment) @comment.block.documentation
(name) @type
(fname) @variable.other.member
(type_expr (scoped_name) @type)
(type_expr_params (param (scoped_name) @type.parameter))
; json
(key) @string.special
(string) @string
(number) @constant.numeric
[
(null)
(true)
(false)
] @constant.builtin
(escape_sequence) @constant.character.escape

@ -0,0 +1,12 @@
[
(struct)
(union)
(array)
(object)
] @indent
; [
; "}"
; "]"
; ] @outdent

@ -0,0 +1 @@
(struct (_) @function.inside) @funtion.around

@ -60,7 +60,6 @@
">>" ">>"
"<" "<"
"|" "|"
(expansion_flags)
] @operator ] @operator
( (

@ -1,42 +1,48 @@
(string_array "," @punctuation.delimiter) (string_array "," @punctuation.delimiter)
(string_array ["[" "]"] @punctuation.bracket) (string_array ["[" "]"] @punctuation.bracket)
(arg_command "ARG" @keyword) [
(build_command "BUILD" @keyword) "ARG"
(cache_command "CACHE" @keyword) "AS LOCAL"
(cmd_command "CMD" @keyword) "BUILD"
(copy_command "COPY" @keyword) "CACHE"
(do_command "DO" @keyword) "CMD"
(entrypoint_command "ENTRYPOINT" @keyword) "COPY"
(env_command "ENV" @keyword) "DO"
(expose_command "EXPOSE" @keyword) "ENTRYPOINT"
(from_command "FROM" @keyword) "ENV"
(from_dockerfile_command "FROM DOCKERFILE" @keyword) "EXPOSE"
(function_command "FUNCTION" @keyword) "FROM DOCKERFILE"
(git_clone_command "GIT CLONE" @keyword) "FROM"
(host_command "HOST" @keyword) "FUNCTION"
(import_command "IMPORT" @keyword) "GIT CLONE"
(label_command "LABEL" @keyword) "HOST"
(let_command "LET" @keyword) "IMPORT"
(project_command "PROJECT" @keyword) "LABEL"
(run_command "RUN" @keyword) "LET"
(save_artifact_command ["SAVE ARTIFACT" "AS LOCAL"] @keyword) "PROJECT"
(save_image_command "SAVE IMAGE" @keyword) "RUN"
(set_command "SET" @keyword) "SAVE ARTIFACT"
(user_command "USER" @keyword) "SAVE IMAGE"
(version_command "VERSION" @keyword) "SET"
(volume_command "VOLUME" @keyword) "USER"
(with_docker_command "WITH DOCKER" @keyword) "VERSION"
(workdir_command "WORKDIR" @keyword) "VOLUME"
"WORKDIR"
] @keyword
(for_command ["FOR" "IN" "END"] @keyword.control.repeat) (for_command ["FOR" "IN" "END"] @keyword.control.repeat)
(if_command ["IF" "END"] @keyword.control.conditional) (if_command ["IF" "END"] @keyword.control.conditional)
(elif_block ["ELSE IF"] @keyword.control.conditional) (elif_block ["ELSE IF"] @keyword.control.conditional)
(else_block ["ELSE"] @keyword.control.conditional) (else_block ["ELSE"] @keyword.control.conditional)
(import_command ["IMPORT" "AS"] @keyword.control.import) (import_command ["IMPORT" "AS"] @keyword.control.import)
(try_command ["TRY" "FINALLY" "END"] @keyword.control.exception) (try_command ["TRY" "FINALLY" "END"] @keyword.control.exception)
(wait_command ["WAIT" "END"] @keyword.control)
(wait_command ["WAIT" "END"] @keyword.control)
(with_docker_command ["WITH DOCKER" "END"] @keyword.control)
[ [
(comment) (comment)
@ -65,10 +71,4 @@
(build_arg) @variable (build_arg) @variable
(options (_) @variable.parameter) (options (_) @variable.parameter)
(options (_ "=" @operator)) "=" @operator
(build_arg "=" @operator)
(arg_command "=" @operator)
(env_command "=" @operator)
(label "=" @operator)
(set_command "=" @operator)
(let_command "=" @operator)

@ -29,15 +29,24 @@
name: (identifier) @function) name: (identifier) @function)
(method_definition (method_definition
name: (property_identifier) @function.method) name: (property_identifier) @function.method)
(method_definition
name: (private_property_identifier) @function.method.private)
(pair (pair
key: (property_identifier) @function.method key: (property_identifier) @function.method
value: [(function) (arrow_function)]) value: [(function) (arrow_function)])
(pair
key: (private_property_identifier) @function.method.private
value: [(function) (arrow_function)])
(assignment_expression (assignment_expression
left: (member_expression left: (member_expression
property: (property_identifier) @function.method) property: (property_identifier) @function.method)
right: [(function) (arrow_function)]) right: [(function) (arrow_function)])
(assignment_expression
left: (member_expression
property: (private_property_identifier) @function.method.private)
right: [(function) (arrow_function)])
(variable_declarator (variable_declarator
name: (identifier) @function name: (identifier) @function
@ -64,6 +73,9 @@
(call_expression (call_expression
function: (member_expression function: (member_expression
property: (property_identifier) @function.method)) property: (property_identifier) @function.method))
(call_expression
function: (member_expression
property: (private_property_identifier) @function.method.private))
; Variables ; Variables
;---------- ;----------
@ -74,6 +86,7 @@
;----------- ;-----------
(property_identifier) @variable.other.member (property_identifier) @variable.other.member
(private_property_identifier) @variable.other.member.private
(shorthand_property_identifier) @variable.other.member (shorthand_property_identifier) @variable.other.member
(shorthand_property_identifier_pattern) @variable.other.member (shorthand_property_identifier_pattern) @variable.other.member

@ -0,0 +1 @@
; inherits: _gjs,_javascript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_javascript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_javascript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_javascript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_javascript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_javascript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_typescript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_typescript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_typescript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_typescript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_typescript,ecma

@ -0,0 +1 @@
; inherits: _gjs,_typescript,ecma

@ -0,0 +1,5 @@
(comment) @comment.inside
(comment)+ @comment.around
(entry (_) @function.inside) @function.around

@ -0,0 +1,193 @@
; Brackets and operators
[
"("
")"
"["
"]"
"{"
"}"
] @punctuation.bracket
[
","
"."
":"
] @punctuation.delimiter
[
"!="
"%"
"%="
"&"
"&="
"*"
"**"
"**="
"*="
"+"
"+="
"-"
"-="
"/"
"/="
"<"
"<<"
"<<="
"<="
"<="
"=="
">"
">="
">="
">>"
">>="
">>>"
">>>="
"^"
"^="
"|"
"|="
] @operator
; Keywords
[
"as"
"for"
"impl"
"let"
"mut"
"ref"
"uni"
"move"
"recover"
] @keyword
"fn" @keyword.function
"import" @keyword.control.import
[
"and"
"or"
] @keyword.operator
[
"class"
"trait"
] @keyword.storage.type
[
"extern"
(modifier)
(visibility)
] @keyword.storage.modifier
[
"loop"
"while"
(break)
(next)
] @keyword.control.repeat
"return" @keyword.control.return
[
"throw"
"try"
] @keyword.control.exception
[
"case"
"else"
"if"
"match"
] @keyword.control.conditional
; Comments
(line_comment) @comment.line
; Literals
(self) @variable.builtin
(nil) @constant.builtin
[
(true)
(false)
] @constant.builtin.boolean
(integer) @constant.numeric.integer
(float) @constant.numeric.float
(string) @string
(escape_sequence) @constant.character.escape
(interpolation
"${" @punctuation.special
"}" @punctuation.special)
(constant) @constant
; Patterns
(integer_pattern) @constant.numeric.integer
(string_pattern) @string
(constant_pattern) @constant
; Types
(generic_type
name: _ @type)
(type) @type
; Imports
(extern_import
path: _ @string)
; Classes
(class
name: _ @type)
(define_field
name: _ @variable.other.member)
; Traits
(trait
name: _ @type)
; Implementations
(implement_trait
class: _ @type)
(reopen_class
name: _ @type)
(bound
name: _ @type)
; Methods
(method
name: _ @function)
(external_function
name: _ @function)
(argument
name: _ @variable.parameter)
(named_argument
name: _ @variable.parameter)
(call
name: _ @function)
(field) @variable.other.member
; Identifiers/variable references
((identifier) @function
(#is-not? local))
(identifier) @variable

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

Loading…
Cancel
Save