Merge branch 'master' into situational-lsp

pull/8696/head
ontley 10 months ago committed by GitHub
commit 5f6a511120
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -1,5 +1,6 @@
watch_file shell.nix
watch_file flake.lock
watch_file rust-toolchain.toml
# try to use flakes, if it fails use normal nix (ie. shell.nix)
use flake || use nix

@ -55,6 +55,16 @@ body:
placeholder: wezterm 20220101-133340-7edc5b5a
validations:
required: true
- type: input
id: installation-method
attributes:
label: Installation Method
description: >
How you installed Helix - from a package manager like Homebrew or the
AUR, built from source, downloaded a binary from the releases page, etc.
placeholder: "source / brew / nixpkgs / flake / releases page"
validations:
required: true
- type: input
id: helix-version
attributes:

@ -39,14 +39,14 @@ jobs:
uses: actions/checkout@v4
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@1.65
uses: dtolnay/rust-toolchain@1.70
- uses: Swatinem/rust-cache@v2
with:
shared-key: "build"
- name: Cache test tree-sitter grammar
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: runtime/grammars
key: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
@ -70,7 +70,7 @@ jobs:
uses: actions/checkout@v4
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@1.65
uses: dtolnay/rust-toolchain@1.70
with:
components: rustfmt, clippy
@ -97,7 +97,7 @@ jobs:
uses: actions/checkout@v4
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@1.65
uses: dtolnay/rust-toolchain@1.70
- uses: Swatinem/rust-cache@v2
with:

@ -14,10 +14,10 @@ jobs:
uses: actions/checkout@v4
- name: Install nix
uses: cachix/install-nix-action@v23
uses: cachix/install-nix-action@v25
- name: Authenticate with Cachix
uses: cachix/cachix-action@v12
uses: cachix/cachix-action@v14
with:
name: helix
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

@ -36,7 +36,7 @@ jobs:
- name: Bundle grammars
run: tar cJf grammars.tar.xz -C runtime/grammars/sources .
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: grammars
path: grammars.tar.xz
@ -106,7 +106,7 @@ jobs:
uses: actions/checkout@v4
- name: Download grammars
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
- name: Move grammars under runtime
if: "!startsWith(matrix.os, 'windows')"
@ -220,7 +220,7 @@ jobs:
fi
cp -r runtime dist
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: bins-${{ matrix.build }}
path: dist
@ -233,7 +233,7 @@ jobs:
- name: Checkout sources
uses: actions/checkout@v4
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
- name: Build archive
shell: bash
@ -288,7 +288,7 @@ jobs:
overwrite: true
- name: Upload binaries as artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: env.preview == 'true'
with:
name: release

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

915
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -1,4 +1,5 @@
[workspace]
resolver = "2"
members = [
"helix-core",
"helix-view",
@ -10,6 +11,7 @@ members = [
"helix-loader",
"helix-vcs",
"helix-parsec",
"helix-stdx",
"xtask",
]
@ -35,5 +37,15 @@ package.helix-tui.opt-level = 2
package.helix-term.opt-level = 2
[workspace.dependencies]
tree-sitter = { version = "0.20", git = "https://github.com/tree-sitter/tree-sitter", rev = "ab09ae20d640711174b8da8a654f6b3dec93da1a" }
tree-sitter = { version = "0.20", git = "https://github.com/helix-editor/tree-sitter", rev = "660481dbf71413eba5a928b0b0ab8da50c1109e0" }
nucleo = "0.2.0"
[workspace.package]
version = "23.10.0"
edition = "2021"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
categories = ["editor"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
license = "MPL-2.0"
rust-version = "1.70"

@ -18,7 +18,7 @@
![Screenshot](./screenshot.png)
A Kakoune / Neovim inspired editor, written in Rust.
A [Kakoune](https://github.com/mawww/kakoune) / [Neovim](https://github.com/neovim/neovim) inspired editor, written in Rust.
The editing model is very heavily based on Kakoune; during development I found
myself agreeing with most of Kakoune's design decisions.

@ -1 +0,0 @@
23.10

@ -51,7 +51,8 @@ Its settings will be merged with the configuration directory `config.toml` and t
| `auto-completion` | Enable automatic pop up of auto-completion | `true` |
| `auto-format` | Enable automatic formatting on save | `true` |
| `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant | `250` |
| `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` |
@ -65,6 +66,8 @@ Its settings will be merged with the configuration directory `config.toml` and t
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
| `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` |
| `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` |
| `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` |
| `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid`
### `[editor.statusline]` Section
@ -166,16 +169,31 @@ 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.excludefile` option | true
|`git-exclude` | Enables reading `.git/info/exclude` files | true
|`hidden` | Enables ignoring hidden files | `true`
|`follow-symlinks` | Follow symlinks instead of ignoring them | `true`
|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | `true`
|`parents` | Enables reading ignore files from parent directories | `true`
|`ignore` | Enables reading `.ignore` files | `true`
|`git-ignore` | Enables reading `.gitignore` files | `true`
|`git-global` | Enables reading global `.gitignore`, whose path is specified in git's config: `core.excludesfile` option | `true`
|`git-exclude` | Enables reading `.git/info/exclude` files | `true`
|`max-depth` | Set with an integer value for maximum depth to recurse | Defaults to `None`.
Ignore files can be placed locally as `.ignore` or put in your home directory as `~/.ignore`. They support the usual ignore and negative ignore (unignore) rules used in `.gitignore` files.
Additionally, you can use Helix-specific ignore files by creating a local `.helix/ignore` file in the current workspace or a global `ignore` file located in your Helix config directory:
- Linux and Mac: `~/.config/helix/ignore`
- Windows: `%AppData%\helix\ignore`
Example:
```ini
# unignore in file picker and global search
!.github/
!.gitignore
!.gitattributes
```
### `[editor.auto-pairs]` Section
Enables automatic insertion of pairs to parentheses, brackets, etc. Can be a

@ -1,5 +1,6 @@
| Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default LSP |
| --- | --- | --- | --- | --- |
| agda | ✓ | | | |
| astro | ✓ | | | |
| awk | ✓ | ✓ | | `awk-language-server` |
| bash | ✓ | ✓ | ✓ | `bash-language-server` |
@ -10,20 +11,21 @@
| blueprint | ✓ | | | `blueprint-compiler` |
| c | ✓ | ✓ | ✓ | `clangd` |
| c-sharp | ✓ | ✓ | | `OmniSharp` |
| cabal | | | | |
| cabal | | | | `haskell-language-server-wrapper` |
| cairo | ✓ | ✓ | ✓ | `cairo-language-server` |
| capnp | ✓ | | ✓ | |
| clojure | ✓ | | | `clojure-lsp` |
| cmake | ✓ | ✓ | ✓ | `cmake-language-server` |
| comment | ✓ | | | |
| common-lisp | ✓ | | | `cl-lsp` |
| common-lisp | ✓ | | | `cl-lsp` |
| cpon | ✓ | | ✓ | |
| cpp | ✓ | ✓ | ✓ | `clangd` |
| crystal | ✓ | ✓ | | `crystalline` |
| css | ✓ | | | `vscode-css-language-server` |
| css | ✓ | | | `vscode-css-language-server` |
| cue | ✓ | | | `cuelsp` |
| d | ✓ | ✓ | ✓ | `serve-d` |
| dart | ✓ | | ✓ | `dart` |
| dart | ✓ | ✓ | ✓ | `dart` |
| dbml | ✓ | | | |
| devicetree | ✓ | | | |
| dhall | ✓ | ✓ | | `dhall-lsp-server` |
| diff | ✓ | | | |
@ -54,6 +56,7 @@
| git-rebase | ✓ | | | |
| gleam | ✓ | ✓ | | `gleam` |
| glsl | ✓ | ✓ | ✓ | |
| gn | ✓ | | | |
| go | ✓ | ✓ | ✓ | `gopls`, `golangci-lint-langserver` |
| godot-resource | ✓ | | | |
| gomod | ✓ | | | `gopls` |
@ -65,12 +68,14 @@
| haskell-persistent | ✓ | | | |
| hcl | ✓ | | ✓ | `terraform-ls` |
| heex | ✓ | ✓ | | `elixir-ls` |
| hocon | ✓ | | ✓ | |
| hosts | ✓ | | | |
| html | ✓ | | | `vscode-html-language-server` |
| hurl | ✓ | | ✓ | |
| idris | | | | `idris2-lsp` |
| iex | ✓ | | | |
| ini | ✓ | | | |
| janet | ✓ | | | |
| java | ✓ | ✓ | ✓ | `jdtls` |
| javascript | ✓ | ✓ | ✓ | `typescript-language-server` |
| jinja | ✓ | | | |
@ -81,7 +86,7 @@
| jsx | ✓ | ✓ | ✓ | `typescript-language-server` |
| julia | ✓ | ✓ | ✓ | `julia` |
| just | ✓ | ✓ | ✓ | |
| kdl | ✓ | | | |
| kdl | ✓ | | | |
| kotlin | ✓ | | | `kotlin-language-server` |
| latex | ✓ | ✓ | | `texlab` |
| lean | ✓ | | | `lean` |
@ -89,9 +94,10 @@
| llvm | ✓ | ✓ | ✓ | |
| llvm-mir | ✓ | ✓ | ✓ | |
| llvm-mir-yaml | ✓ | | ✓ | |
| log | ✓ | | | |
| lpf | ✓ | | | |
| lua | ✓ | ✓ | ✓ | `lua-language-server` |
| make | ✓ | | | |
| make | ✓ | | | |
| markdoc | ✓ | | | `markdoc-ls` |
| markdown | ✓ | | | `marksman` |
| markdown.inline | ✓ | | | |
@ -104,7 +110,7 @@
| nickel | ✓ | | ✓ | `nls` |
| nim | ✓ | ✓ | ✓ | `nimlangserver` |
| nix | ✓ | | | `nil` |
| nu | ✓ | | | |
| nu | ✓ | | | `nu` |
| nunjucks | ✓ | | | |
| ocaml | ✓ | | ✓ | `ocamllsp` |
| ocaml-interface | ✓ | | | `ocamllsp` |
@ -122,13 +128,13 @@
| ponylang | ✓ | ✓ | ✓ | |
| prisma | ✓ | | | `prisma-language-server` |
| prolog | | | | `swipl` |
| protobuf | ✓ | | ✓ | `bufls`, `pb` |
| protobuf | ✓ | | ✓ | `bufls`, `pb` |
| prql | ✓ | | | |
| purescript | ✓ | ✓ | | `purescript-language-server` |
| python | ✓ | ✓ | ✓ | `pylsp` |
| qml | ✓ | | ✓ | `qmlls` |
| r | ✓ | | | `R` |
| racket | ✓ | | | `racket` |
| racket | ✓ | | | `racket` |
| regex | ✓ | | | |
| rego | ✓ | | | `regols` |
| rescript | ✓ | ✓ | | `rescript-language-server` |
@ -139,10 +145,11 @@
| ruby | ✓ | ✓ | ✓ | `solargraph` |
| rust | ✓ | ✓ | ✓ | `rust-analyzer` |
| sage | ✓ | ✓ | | |
| scala | ✓ | | ✓ | `metals` |
| scheme | ✓ | | | |
| scala | ✓ | | ✓ | `metals` |
| scheme | ✓ | | | |
| scss | ✓ | | | `vscode-css-language-server` |
| slint | ✓ | | ✓ | `slint-lsp` |
| slint | ✓ | ✓ | ✓ | `slint-lsp` |
| smali | ✓ | | ✓ | |
| smithy | ✓ | | | `cs` |
| sml | ✓ | | | |
| solidity | ✓ | | | `solc` |
@ -155,6 +162,7 @@
| swift | ✓ | | | `sourcekit-lsp` |
| t32 | ✓ | | | |
| tablegen | ✓ | ✓ | ✓ | |
| tact | ✓ | ✓ | ✓ | |
| task | ✓ | | | |
| templ | ✓ | | | `templ` |
| tfvars | ✓ | | ✓ | `terraform-ls` |
@ -166,7 +174,7 @@
| typescript | ✓ | ✓ | ✓ | `typescript-language-server` |
| typst | ✓ | | | `typst-lsp` |
| ungrammar | ✓ | | | |
| unison | ✓ | | | |
| unison | ✓ | | | |
| uxntal | ✓ | | | |
| v | ✓ | ✓ | ✓ | `v-analyzer` |
| vala | ✓ | | | `vala-language-server` |

@ -17,7 +17,7 @@
| `:write-buffer-close!`, `:wbc!` | Force write changes to disk creating necessary subdirectories and closes the buffer. Accepts an optional path (:write-buffer-close! some/path.txt) |
| `:new`, `:n` | Create a new scratch buffer. |
| `:format`, `:fmt` | Format the file using the LSP formatter. |
| `:indent-style` | Set the indentation style for editing. ('t' for tabs or 1-8 for number of spaces.) |
| `:indent-style` | Set the indentation style for editing. ('t' for tabs or 1-16 for number of spaces.) |
| `:line-ending` | Set the document's default line ending. Options: crlf, lf. |
| `:earlier`, `:ear` | Jump back to an earlier point in edit history. Accepts a number of steps or a time span. |
| `:later`, `:lat` | Jump to a later point in edit history. Accepts a number of steps or a time span. |
@ -85,3 +85,4 @@
| `:reset-diff-change`, `:diffget`, `:diffg` | Reset the diff change at the cursor position. |
| `:clear-register` | Clear given register. If no argument is provided, clear all registers. |
| `:redraw` | Clear and re-render the whole UI |
| `:move` | Move the current buffer and its corresponding file to a different path |

@ -36,6 +36,7 @@ below.
3. Refer to the
[tree-sitter website](https://tree-sitter.github.io/tree-sitter/syntax-highlighting#queries)
for more information on writing queries.
4. A list of highlight captures can be found [on the themes page](https://docs.helix-editor.com/themes.html#scopes).
> 💡 In Helix, the first matching query takes precedence when evaluating
> queries, which is different from other editors such as Neovim where the last
@ -51,3 +52,4 @@ below.
grammars.
- If a parser is causing a segfault, or you want to remove it, make sure to
remove the compiled parser located at `runtime/grammars/<name>.so`.
- If you are attempting to add queries and Helix is unable to locate them, ensure that the environment variable `HELIX_RUNTIME` is set to the location of the `runtime` folder you're developing in.

@ -12,6 +12,15 @@ Note that it matters where these added indents begin. For example,
multiple indent level increases that start on the same line only increase
the total indent level by 1. See [Capture types](#capture-types).
By default, Helix uses the `hybrid` indentation heuristic. This means that
indent queries are not used to compute the expected absolute indentation of a
line but rather the expected difference in indentation between the new and an
already existing line. This difference is then added to the actual indentation
of the already existing line. Since this makes errors in the indent queries
harder to find, it is recommended to disable it when testing via
`:set indent-heuristic tree-sitter`. The rest of this guide assumes that
the `tree-sitter` heuristic is used.
## Indent queries
When Helix is inserting a new line through `o`, `O`, or `<ret>`, to determine
@ -306,6 +315,10 @@ The first argument (a capture) must/must not be equal to the second argument
The first argument (a capture) must/must not match the regex given in the
second argument (a string).
- `#any-of?`/`#not-any-of?`:
The first argument (a capture) must/must not be one of the other arguments
(strings).
Additionally, we support some custom predicates for indent queries:
- `#not-kind-eq?`:

@ -54,4 +54,7 @@ The first argument (a capture) must be equal to the second argument
The first argument (a capture) must match the regex given in the
second argument (a string).
- `#any-of?` (standard):
The first argument (a capture) must be one of the other arguments (strings).
[upstream-docs]: http://tree-sitter.github.io/tree-sitter/syntax-highlighting#language-injection

@ -65,10 +65,7 @@ sudo apt install helix
### Fedora/RHEL
Enable the `COPR` repository for Helix:
```sh
sudo dnf copr enable varlad/helix
sudo dnf install helix
```
@ -207,6 +204,8 @@ RUSTFLAGS="-C target-feature=-crt-static"
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
@ -217,12 +216,12 @@ RUSTFLAGS="-C target-feature=-crt-static"
#### Linux and macOS
The **runtime** directory is one below the Helix source, so either set a
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
HELIX_RUNTIME=~/src/helix/runtime
export HELIX_RUNTIME=~/src/helix/runtime
```
Or, create a symbolic link:

@ -25,7 +25,7 @@
## Normal mode
Normal mode is the default mode when you launch helix. Return to it from other modes by typing `Escape`.
Normal mode is the default mode when you launch helix. You can return to it from other modes by pressing the `Escape` key.
### Movement
@ -205,7 +205,7 @@ Jumps to various locations.
| ----- | ----------- | ------- |
| `g` | Go to line number `<n>` else start of file | `goto_file_start` |
| `e` | Go to the end of the file | `goto_last_line` |
| `f` | Go to files in the selection | `goto_file` |
| `f` | Go to files in the selections | `goto_file` |
| `h` | Go to the start of the line | `goto_line_start` |
| `l` | Go to the end of the line | `goto_line_end` |
| `s` | Go to first non-whitespace character of the line | `goto_first_nonwhitespace` |
@ -253,8 +253,8 @@ This layer is similar to Vim keybindings as Kakoune does not support windows.
| `w`, `Ctrl-w` | Switch to next window | `rotate_view` |
| `v`, `Ctrl-v` | Vertical right split | `vsplit` |
| `s`, `Ctrl-s` | Horizontal bottom split | `hsplit` |
| `f` | Go to files in the selection in horizontal splits | `goto_file` |
| `F` | Go to files in the selection in vertical splits | `goto_file` |
| `f` | Go to files in the selections in horizontal splits | `goto_file` |
| `F` | Go to files in the selections in vertical splits | `goto_file` |
| `h`, `Ctrl-h`, `Left` | Move to left split | `jump_view_left` |
| `j`, `Ctrl-j`, `Down` | Move to split below | `jump_view_down` |
| `k`, `Ctrl-k`, `Up` | Move to split above | `jump_view_up` |

@ -66,8 +66,10 @@ These configuration keys are available:
| `language-servers` | The Language Servers used for this language. See below for more information in the section [Configuring Language Servers for a language](#configuring-language-servers-for-a-language) |
| `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) |
| `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout |
| `soft-wrap` | [editor.softwrap](./configuration.md#editorsoft-wrap-section)
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. |
| `persistent-diagnostic-sources` | An array of LSP diagnostic sources assumed unchanged when the language server resends the same set of diagnostics. Helix can track the position for these diagnostics internally instead. Useful for diagnostics that are recomputed on save.
### File-type detection and the `file-types` key

@ -155,6 +155,7 @@ We use a similar set of scopes as
- `type` - Types
- `builtin` - Primitive types provided by the language (`int`, `usize`)
- `parameter` - Generic type parameters (`T`)
- `enum`
- `variant`
- `constructor`
@ -295,11 +296,14 @@ These scopes are used for theming the editor interface:
| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) |
| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) |
| `ui.statusline.separator` | Separator character in statusline |
| `ui.bufferline` | Style for the buffer line |
| `ui.bufferline.active` | Style for the active buffer in buffer line |
| `ui.bufferline.background` | Style for bufferline background |
| `ui.popup` | Documentation popups (e.g. Space + k) |
| `ui.popup.info` | Prompt for multiple key options |
| `ui.window` | Borderlines separating splits |
| `ui.help` | Description box for commands |
| `ui.text` | Command prompts, popup text, etc. |
| `ui.text` | Default text style, command prompts, popup text, etc. |
| `ui.text.focus` | The currently selected line in the picker |
| `ui.text.inactive` | Same as `ui.text` but when the text is inactive (e.g. suggestions) |
| `ui.text.info` | The key: command text in `ui.popup.info` boxes |

@ -59,8 +59,8 @@ Some registers have special behavior when read from and written to.
| `#` | 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 |
| `+` | 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

@ -1,16 +1,23 @@
## Checklist
Helix releases are versioned in the Calendar Versioning scheme:
`YY.0M(.MICRO)`, for example, `22.05` for May of 2022. In these instructions
we'll use `<tag>` as a placeholder for the tag being published.
`YY.0M(.MICRO)`, for example, `22.05` for May of 2022, or in a patch release,
`22.05.1`. In these instructions we'll use `<tag>` as a placeholder for the tag
being published.
* Merge the changelog PR
* Add new `<release>` entry in `contrib/Helix.appdata.xml` with release information according to the [AppStream spec](https://www.freedesktop.org/software/appstream/docs/sect-Metadata-Releases.html)
* Tag and push
* `git tag -s -m "<tag>" -a <tag> && git push`
* Make sure to switch to master and pull first
* Edit the `VERSION` file and change the date to the next planned release
* Releases are planned to happen every two months, so `22.05` would change to `22.07`
* Edit the `Cargo.toml` file and change the date in the `version` field to the next planned release
* Due to Cargo having a strict requirement on SemVer with 3 or more version
numbers, a `0` is required in the micro version; however, unless we are
publishing a patch release after a major release, the `.0` is dropped in
the user facing version.
* Releases are planned to happen every two months, so `22.05.0` would change to `22.07.0`
* If we are pushing a patch/bugfix release in the same month as the previous
release, bump the micro version, e.g. `22.07.0` to `22.07.1`
* Wait for the Release CI to finish
* It will automatically turn the git tag into a GitHub release when it uploads artifacts
* Edit the new release

@ -2,23 +2,16 @@
"nodes": {
"crane": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": [
"rust-overlay"
]
},
"locked": {
"lastModified": 1688772518,
"narHash": "sha256-ol7gZxwvgLnxNSZwFTDJJ49xVY5teaSvF7lzlo3YQfM=",
"lastModified": 1701025348,
"narHash": "sha256-42GHmYH+GF7VjwGSt+fVT1CQuNpGanJbNgVHTAZppUM=",
"owner": "ipetkov",
"repo": "crane",
"rev": "8b08e96c9af8c6e3a2b69af5a7fa168750fcf88e",
"rev": "42afaeb1a0325194a7cdb526332d2cb92fddd07b",
"type": "github"
},
"original": {
@ -27,32 +20,16 @@
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
@ -63,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1690272529,
"narHash": "sha256-MakzcKXEdv/I4qJUtq/k/eG+rVmyOZLnYNC2w1mB59Y=",
"lastModified": 1700794826,
"narHash": "sha256-RyJTnTNKhO0yqRpDISk03I/4A67/dp96YRxc86YOPgU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ef99fa5c5ed624460217c31ac4271cfb5cb2502c",
"rev": "5a09cb4b393d58f9ed0d9ca1555016a8543c2ac8",
"type": "github"
},
"original": {
@ -95,11 +72,11 @@
]
},
"locked": {
"lastModified": 1690424156,
"narHash": "sha256-Bpml+L280tHTQpwpC5/BJbU4HSvEzMvW8IZ4gAXimhE=",
"lastModified": 1701137803,
"narHash": "sha256-0LcPAdql5IhQSUXJx3Zna0dYTgdIoYO7zUrsKgiBd04=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "f335a0213504c7e6481c359dc1009be9cf34432c",
"rev": "9dd940c967502f844eacea52a61e9596268d4f70",
"type": "github"
},
"original": {

@ -13,8 +13,6 @@
};
crane = {
url = "github:ipetkov/crane";
inputs.rust-overlay.follows = "rust-overlay";
inputs.flake-utils.follows = "flake-utils";
inputs.nixpkgs.follows = "nixpkgs";
};
};
@ -123,9 +121,10 @@
rustToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
craneLibMSRV = (crane.mkLib pkgs).overrideToolchain rustToolchain;
craneLibStable = (crane.mkLib pkgs).overrideToolchain pkgs.pkgsBuildHost.rust-bin.stable.latest.default;
commonArgs =
{
commonArgs = {
inherit stdenv;
inherit (craneLibMSRV.crateNameFromCargoToml {cargoToml = ./helix-term/Cargo.toml;}) pname;
inherit (craneLibMSRV.crateNameFromCargoToml {cargoToml = ./Cargo.toml;}) version;
src = filteredSource;
# disable fetching and building of tree-sitter grammars in the helix-term build.rs
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";
@ -133,8 +132,7 @@
# disable tests
doCheck = false;
meta.mainProgram = "hx";
}
// craneLibMSRV.crateNameFromCargoToml {cargoToml = ./helix-term/Cargo.toml;};
};
cargoArtifacts = craneLibMSRV.buildDepsOnly commonArgs;
in {
packages = {

@ -5,6 +5,7 @@
runCommand,
yj,
includeGrammarIf ? _: true,
grammarOverlays ? [],
...
}: let
# HACK: nix < 2.6 has a bug in the toml parser, so we convert to JSON
@ -27,7 +28,17 @@
owner = builtins.elemAt match 0;
repo = builtins.elemAt match 1;
};
gitGrammars = builtins.filter isGitGrammar languagesConfig.grammar;
# If `use-grammars.only` is set, use only those grammars.
# If `use-grammars.except` is set, use all other grammars.
# Otherwise use all grammars.
useGrammar = grammar:
if languagesConfig?use-grammars.only then
builtins.elem grammar.name languagesConfig.use-grammars.only
else if languagesConfig?use-grammars.except then
!(builtins.elem grammar.name languagesConfig.use-grammars.except)
else true;
grammarsToUse = builtins.filter useGrammar languagesConfig.grammar;
gitGrammars = builtins.filter isGitGrammar grammarsToUse;
buildGrammar = grammar: let
gh = toGitHubFetcher grammar.source.git;
sourceGit = builtins.fetchTree {
@ -48,22 +59,22 @@
then sourceGitHub
else sourceGit;
in
stdenv.mkDerivation rec {
stdenv.mkDerivation {
# see https://github.com/NixOS/nixpkgs/blob/fbdd1a7c0bc29af5325e0d7dd70e804a972eb465/pkgs/development/tools/parsing/tree-sitter/grammar.nix
pname = "helix-tree-sitter-${grammar.name}";
version = grammar.source.rev;
src =
if builtins.hasAttr "subpath" grammar.source
then "${source}/${grammar.source.subpath}"
else source;
src = source;
sourceRoot = if builtins.hasAttr "subpath" grammar.source then
"source/${grammar.source.subpath}"
else
"source";
dontUnpack = true;
dontConfigure = true;
FLAGS = [
"-I${src}/src"
"-Isrc"
"-g"
"-O3"
"-fPIC"
@ -76,13 +87,13 @@
buildPhase = ''
runHook preBuild
if [[ -e "$src/src/scanner.cc" ]]; then
$CXX -c "$src/src/scanner.cc" -o scanner.o $FLAGS
elif [[ -e "$src/src/scanner.c" ]]; then
$CC -c "$src/src/scanner.c" -o scanner.o $FLAGS
if [[ -e src/scanner.cc ]]; then
$CXX -c src/scanner.cc -o scanner.o $FLAGS
elif [[ -e src/scanner.c ]]; then
$CC -c src/scanner.c -o scanner.o $FLAGS
fi
$CC -c "$src/src/parser.c" -o parser.o $FLAGS
$CC -c src/parser.c -o parser.o $FLAGS
$CXX -shared -o $NAME.so *.o
ls -al
@ -105,15 +116,17 @@
'';
};
grammarsToBuild = builtins.filter includeGrammarIf gitGrammars;
builtGrammars =
builtins.map (grammar: {
builtGrammars = builtins.map (grammar: {
inherit (grammar) name;
artifact = buildGrammar grammar;
})
grammarsToBuild;
grammarLinks =
builtins.map (grammar: "ln -s ${grammar.artifact}/${grammar.name}.so $out/${grammar.name}.so")
builtGrammars;
value = buildGrammar grammar;
}) grammarsToBuild;
extensibleGrammars =
lib.makeExtensible (self: builtins.listToAttrs builtGrammars);
overlayedGrammars = lib.pipe extensibleGrammars
(builtins.map (overlay: grammar: grammar.extend overlay) grammarOverlays);
grammarLinks = lib.mapAttrsToList
(name: artifact: "ln -s ${artifact}/${name}.so $out/${name}.so")
(lib.filterAttrs (n: v: lib.isDerivation v) overlayedGrammars);
in
runCommand "consolidated-helix-grammars" {} ''
mkdir -p $out

@ -1,24 +1,26 @@
[package]
name = "helix-core"
version = "0.6.0"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
edition = "2021"
license = "MPL-2.0"
description = "Helix editor core editing primitives"
categories = ["editor"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
include = ["src/**/*", "README.md"]
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
categories.workspace = true
repository.workspace = true
homepage.workspace = true
[features]
unicode-lines = ["ropey/unicode_lines"]
integration = []
[dependencies]
helix-loader = { version = "0.6", path = "../helix-loader" }
helix-stdx = { path = "../helix-stdx" }
helix-loader = { path = "../helix-loader" }
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
smallvec = "1.11"
smallvec = "1.13"
smartstring = "1.0.1"
unicode-segmentation = "1.10"
unicode-width = "0.1"
@ -26,12 +28,12 @@ unicode-general-category = "0.6"
# slab = "0.4.2"
slotmap = "1.0"
tree-sitter.workspace = true
once_cell = "1.18"
once_cell = "1.19"
arc-swap = "1"
regex = "1"
bitflags = "2.4"
ahash = "0.8.6"
hashbrown = { version = "0.14.2", features = ["raw"] }
hashbrown = { version = "0.14.3", features = ["raw"] }
dunce = "1.0"
log = "0.4"

@ -39,6 +39,10 @@ pub enum DiagnosticTag {
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub range: Range,
// whether this diagnostic ends at the end of(or inside) a word
pub ends_at_word: bool,
pub starts_at_word: bool,
pub zero_width: bool,
pub line: usize,
pub message: String,
pub severity: Option<Severity>,

@ -4,10 +4,11 @@ use tree_sitter::{Query, QueryCursor, QueryPredicateArg};
use crate::{
chars::{char_is_line_ending, char_is_whitespace},
find_first_non_whitespace_char,
graphemes::{grapheme_width, tab_width_at},
syntax::{LanguageConfiguration, RopeProvider, Syntax},
syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax},
tree_sitter::Node,
Rope, RopeGraphemes, RopeSlice,
Position, Rope, RopeGraphemes, RopeSlice,
};
/// Enum representing indentation style.
@ -21,7 +22,7 @@ pub enum IndentStyle {
// 16 spaces
const INDENTS: &str = " ";
const MAX_INDENT: u8 = 16;
pub const MAX_INDENT: u8 = 16;
impl IndentStyle {
/// Creates an `IndentStyle` from an indentation string.
@ -196,6 +197,56 @@ pub fn indent_level_for_line(line: RopeSlice, tab_width: usize, indent_width: us
len / indent_width
}
/// Create a string of tabs & spaces that has the same visual width as the given RopeSlice (independent of the tab width).
fn whitespace_with_same_width(text: RopeSlice) -> String {
let mut s = String::new();
for grapheme in RopeGraphemes::new(text) {
if grapheme == "\t" {
s.push('\t');
} else {
s.extend(std::iter::repeat(' ').take(grapheme_width(&Cow::from(grapheme))));
}
}
s
}
fn add_indent_level(
mut base_indent: String,
added_indent_level: isize,
indent_style: &IndentStyle,
tab_width: usize,
) -> String {
if added_indent_level >= 0 {
// Adding a non-negative indent is easy, we can simply append the indent string
base_indent.push_str(&indent_style.as_str().repeat(added_indent_level as usize));
base_indent
} else {
// In this case, we want to return a prefix of `base_indent`.
// Since the width of a tab depends on its offset, we cannot simply iterate over
// the chars of `base_indent` in reverse until we have the desired indent reduction,
// instead we iterate over them twice in forward direction.
let base_indent_rope = RopeSlice::from(base_indent.as_str());
#[allow(deprecated)]
let base_indent_width =
crate::visual_coords_at_pos(base_indent_rope, base_indent_rope.len_chars(), tab_width)
.col;
let target_indent_width = base_indent_width
.saturating_sub((-added_indent_level) as usize * indent_style.indent_width(tab_width));
#[allow(deprecated)]
let char_end_idx = crate::pos_at_visual_coords(
base_indent_rope,
Position {
row: 0,
col: target_indent_width,
},
tab_width,
);
let byte_end_idx = base_indent_rope.char_to_byte(char_end_idx);
base_indent.truncate(byte_end_idx);
base_indent
}
}
/// Computes for node and all ancestors whether they are the first node on their line.
/// The first entry in the return value represents the root node, the last one the node itself
fn get_first_in_line(mut node: Node, new_line_byte_pos: Option<usize>) -> Vec<bool> {
@ -241,21 +292,21 @@ fn get_first_in_line(mut node: Node, new_line_byte_pos: Option<usize>) -> Vec<bo
/// - max(0, indent - outdent) tabs, if tabs are used for indentation
/// - max(0, indent - outdent)*indent_width spaces, if spaces are used for indentation
#[derive(Default, Debug, PartialEq, Eq, Clone)]
pub struct Indentation {
pub struct Indentation<'a> {
indent: usize,
indent_always: usize,
outdent: usize,
outdent_always: usize,
/// The alignment, as a string containing only tabs & spaces. Storing this as a string instead of e.g.
/// the (visual) width ensures that the alignment is preserved even if the tab width changes.
align: Option<String>,
align: Option<RopeSlice<'a>>,
}
impl Indentation {
impl<'a> Indentation<'a> {
/// Add some other [Indentation] to this.
/// The added indent should be the total added indent from one line.
/// Indent should always be added starting from the bottom (or equivalently, the innermost tree-sitter node).
fn add_line(&mut self, added: Indentation) {
fn add_line(&mut self, added: Indentation<'a>) {
// Align overrides the indent from outer scopes.
if self.align.is_some() {
return;
@ -274,7 +325,7 @@ impl Indentation {
/// Only captures that apply to the same line should be added together in this way (otherwise use `add_line`)
/// and the captures should be added starting from the innermost tree-sitter node (currently this only matters
/// if multiple `@align` patterns occur on the same line).
fn add_capture(&mut self, added: IndentCaptureType) {
fn add_capture(&mut self, added: IndentCaptureType<'a>) {
match added {
IndentCaptureType::Indent => {
if self.indent_always == 0 {
@ -303,43 +354,62 @@ impl Indentation {
}
}
}
fn into_string(self, indent_style: &IndentStyle) -> String {
let indent = self.indent_always + self.indent;
let outdent = self.outdent_always + self.outdent;
let indent_level = if indent >= outdent {
indent - outdent
} else {
log::warn!("Encountered more outdent than indent nodes while calculating indentation: {} outdent, {} indent", self.outdent, self.indent);
0
};
let mut indent_string = if let Some(align) = self.align {
align
fn net_indent(&self) -> isize {
(self.indent + self.indent_always) as isize
- ((self.outdent + self.outdent_always) as isize)
}
/// Convert `self` into a string, taking into account the computed and actual indentation of some other line.
fn relative_indent(
&self,
other_computed_indent: &Self,
other_leading_whitespace: RopeSlice,
indent_style: &IndentStyle,
tab_width: usize,
) -> Option<String> {
if self.align == other_computed_indent.align {
// If self and baseline are either not aligned to anything or both aligned the same way,
// we can simply take `other_leading_whitespace` and add some indent / outdent to it (in the second
// case, the alignment should already be accounted for in `other_leading_whitespace`).
let indent_diff = self.net_indent() - other_computed_indent.net_indent();
Some(add_indent_level(
String::from(other_leading_whitespace),
indent_diff,
indent_style,
tab_width,
))
} else {
String::new()
};
indent_string.push_str(&indent_style.as_str().repeat(indent_level));
indent_string
// If the alignment of both lines is different, we cannot compare their indentation in any meaningful way
None
}
}
pub fn to_string(&self, indent_style: &IndentStyle, tab_width: usize) -> String {
add_indent_level(
self.align
.map_or_else(String::new, whitespace_with_same_width),
self.net_indent(),
indent_style,
tab_width,
)
}
}
/// An indent definition which corresponds to a capture from the indent query
#[derive(Debug)]
struct IndentCapture {
capture_type: IndentCaptureType,
struct IndentCapture<'a> {
capture_type: IndentCaptureType<'a>,
scope: IndentScope,
}
#[derive(Debug, Clone, PartialEq)]
enum IndentCaptureType {
enum IndentCaptureType<'a> {
Indent,
IndentAlways,
Outdent,
OutdentAlways,
/// Alignment given as a string of whitespace
Align(String),
Align(RopeSlice<'a>),
}
impl IndentCaptureType {
impl<'a> IndentCaptureType<'a> {
fn default_scope(&self) -> IndentScope {
match self {
IndentCaptureType::Indent | IndentCaptureType::IndentAlways => IndentScope::Tail,
@ -371,8 +441,8 @@ enum ExtendCapture {
/// each node (identified by its ID) the relevant captures (already filtered
/// by predicates).
#[derive(Debug)]
struct IndentQueryResult {
indent_captures: HashMap<usize, Vec<IndentCapture>>,
struct IndentQueryResult<'a> {
indent_captures: HashMap<usize, Vec<IndentCapture<'a>>>,
extend_captures: HashMap<usize, Vec<ExtendCapture>>,
}
@ -393,14 +463,14 @@ fn get_node_end_line(node: Node, new_line_byte_pos: Option<usize>) -> usize {
node_line
}
fn query_indents(
fn query_indents<'a>(
query: &Query,
syntax: &Syntax,
cursor: &mut QueryCursor,
text: RopeSlice,
text: RopeSlice<'a>,
range: std::ops::Range<usize>,
new_line_byte_pos: Option<usize>,
) -> IndentQueryResult {
) -> IndentQueryResult<'a> {
let mut indent_captures: HashMap<usize, Vec<IndentCapture>> = HashMap::new();
let mut extend_captures: HashMap<usize, Vec<ExtendCapture>> = HashMap::new();
cursor.set_byte_range(range);
@ -481,14 +551,14 @@ fn query_indents(
// The row/column position of the optional anchor in this query
let mut anchor: Option<tree_sitter::Node> = None;
for capture in m.captures {
let capture_name = query.capture_names()[capture.index as usize].as_str();
let capture_name = query.capture_names()[capture.index as usize];
let capture_type = match capture_name {
"indent" => IndentCaptureType::Indent,
"indent.always" => IndentCaptureType::IndentAlways,
"outdent" => IndentCaptureType::Outdent,
"outdent.always" => IndentCaptureType::OutdentAlways,
// The alignment will be updated to the correct value at the end, when the anchor is known.
"align" => IndentCaptureType::Align(String::from("")),
"align" => IndentCaptureType::Align(RopeSlice::from("")),
"anchor" => {
if anchor.is_some() {
log::error!("Invalid indent query: Encountered more than one @anchor in the same match.")
@ -560,23 +630,11 @@ fn query_indents(
}
Some(anchor) => anchor,
};
// Create a string of tabs & spaces that should have the same width
// as the string that precedes the anchor (independent of the tab width).
let mut align = String::new();
for grapheme in RopeGraphemes::new(
capture.capture_type = IndentCaptureType::Align(
text.line(anchor.start_position().row)
.byte_slice(0..anchor.start_position().column),
) {
if grapheme == "\t" {
align.push('\t');
} else {
align.extend(
std::iter::repeat(' ').take(grapheme_width(&Cow::from(grapheme))),
);
}
}
capture.capture_type = IndentCaptureType::Align(align);
}
indent_captures
.entry(node_id)
.or_insert_with(|| Vec::with_capacity(1))
@ -661,56 +719,20 @@ fn extend_nodes<'a>(
}
}
/// Use the syntax tree to determine the indentation for a given position.
/// This can be used in 2 ways:
///
/// - To get the correct indentation for an existing line (new_line=false), not necessarily equal to the current indentation.
/// - In this case, pos should be inside the first tree-sitter node on that line.
/// In most cases, this can just be the first non-whitespace on that line.
/// - To get the indentation for a new line (new_line=true). This behaves like the first usecase if the part of the current line
/// after pos were moved to a new line.
///
/// The indentation is determined by traversing all the tree-sitter nodes containing the position.
/// Each of these nodes produces some [Indentation] for:
///
/// - The line of the (beginning of the) node. This is defined by the scope `all` if this is the first node on its line.
/// - The line after the node. This is defined by:
/// - The scope `tail`.
/// - The scope `all` if this node is not the first node on its line.
/// Intuitively, `all` applies to everything contained in this node while `tail` applies to everything except for the first line of the node.
/// The indents from different nodes for the same line are then combined.
/// The result [Indentation] is simply the sum of the [Indentation] for all lines.
///
/// Specifying which line exactly an [Indentation] applies to is important because indents on the same line combine differently than indents on different lines:
/// ```ignore
/// some_function(|| {
/// // Both the function parameters as well as the contained block should be indented.
/// // Because they are on the same line, this only yields one indent level
/// });
/// ```
///
/// ```ignore
/// some_function(
/// param1,
/// || {
/// // Here we get 2 indent levels because the 'parameters' and the 'block' node begin on different lines
/// },
/// );
/// ```
/// Prepare an indent query by computing:
/// - The node from which to start the query (this is non-trivial due to `@extend` captures)
/// - The indent captures for all relevant nodes.
#[allow(clippy::too_many_arguments)]
pub fn treesitter_indent_for_pos(
fn init_indent_query<'a, 'b>(
query: &Query,
syntax: &Syntax,
indent_style: &IndentStyle,
syntax: &'a Syntax,
text: RopeSlice<'b>,
tab_width: usize,
indent_width: usize,
text: RopeSlice,
line: usize,
pos: usize,
new_line: bool,
) -> Option<String> {
let byte_pos = text.char_to_byte(pos);
let new_line_byte_pos = new_line.then_some(byte_pos);
byte_pos: usize,
new_line_byte_pos: Option<usize>,
) -> Option<(Node<'a>, HashMap<usize, Vec<IndentCapture<'b>>>)> {
// The innermost tree-sitter node which is considered for the indent
// computation. It may change if some predeceding node is extended
let mut node = syntax
@ -754,7 +776,6 @@ pub fn treesitter_indent_for_pos(
(query_result, deepest_preceding)
})
};
let mut indent_captures = query_result.indent_captures;
let extend_captures = query_result.extend_captures;
// Check for extend captures, potentially changing the node that the indent calculation starts with
@ -769,6 +790,68 @@ pub fn treesitter_indent_for_pos(
indent_width,
);
}
Some((node, query_result.indent_captures))
}
/// Use the syntax tree to determine the indentation for a given position.
/// This can be used in 2 ways:
///
/// - To get the correct indentation for an existing line (new_line=false), not necessarily equal to the current indentation.
/// - In this case, pos should be inside the first tree-sitter node on that line.
/// In most cases, this can just be the first non-whitespace on that line.
/// - To get the indentation for a new line (new_line=true). This behaves like the first usecase if the part of the current line
/// after pos were moved to a new line.
///
/// The indentation is determined by traversing all the tree-sitter nodes containing the position.
/// Each of these nodes produces some [Indentation] for:
///
/// - The line of the (beginning of the) node. This is defined by the scope `all` if this is the first node on its line.
/// - The line after the node. This is defined by:
/// - The scope `tail`.
/// - The scope `all` if this node is not the first node on its line.
/// Intuitively, `all` applies to everything contained in this node while `tail` applies to everything except for the first line of the node.
/// The indents from different nodes for the same line are then combined.
/// The result [Indentation] is simply the sum of the [Indentation] for all lines.
///
/// Specifying which line exactly an [Indentation] applies to is important because indents on the same line combine differently than indents on different lines:
/// ```ignore
/// some_function(|| {
/// // Both the function parameters as well as the contained block should be indented.
/// // Because they are on the same line, this only yields one indent level
/// });
/// ```
///
/// ```ignore
/// some_function(
/// param1,
/// || {
/// // Here we get 2 indent levels because the 'parameters' and the 'block' node begin on different lines
/// },
/// );
/// ```
#[allow(clippy::too_many_arguments)]
pub fn treesitter_indent_for_pos<'a>(
query: &Query,
syntax: &Syntax,
tab_width: usize,
indent_width: usize,
text: RopeSlice<'a>,
line: usize,
pos: usize,
new_line: bool,
) -> Option<Indentation<'a>> {
let byte_pos = text.char_to_byte(pos);
let new_line_byte_pos = new_line.then_some(byte_pos);
let (mut node, mut indent_captures) = init_indent_query(
query,
syntax,
text,
tab_width,
indent_width,
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();
@ -836,7 +919,7 @@ pub fn treesitter_indent_for_pos(
break;
}
}
Some(result.into_string(indent_style))
Some(result)
}
/// Returns the indentation for a new line.
@ -845,6 +928,7 @@ pub fn treesitter_indent_for_pos(
pub fn indent_for_newline(
language_config: Option<&LanguageConfiguration>,
syntax: Option<&Syntax>,
indent_heuristic: &IndentationHeuristic,
indent_style: &IndentStyle,
tab_width: usize,
text: RopeSlice,
@ -853,14 +937,18 @@ pub fn indent_for_newline(
current_line: usize,
) -> String {
let indent_width = indent_style.indent_width(tab_width);
if let (Some(query), Some(syntax)) = (
if let (
IndentationHeuristic::TreeSitter | IndentationHeuristic::Hybrid,
Some(query),
Some(syntax),
) = (
indent_heuristic,
language_config.and_then(|config| config.indent_query()),
syntax,
) {
if let Some(indent) = treesitter_indent_for_pos(
query,
syntax,
indent_style,
tab_width,
indent_width,
text,
@ -868,9 +956,57 @@ pub fn indent_for_newline(
line_before_end_pos,
true,
) {
if *indent_heuristic == IndentationHeuristic::Hybrid {
// We want to compute the indentation not only based on the
// syntax tree but also on the actual indentation of a previous
// line. This makes indentation computation more resilient to
// incomplete queries, incomplete source code & differing indentation
// styles for the same language.
// However, using the indent of a previous line as a baseline may not
// make sense, e.g. if it has a different alignment than the new line.
// In order to prevent edge cases with long running times, we only try
// a constant number of (non-empty) lines.
const MAX_ATTEMPTS: usize = 4;
let mut num_attempts = 0;
for line_idx in (0..=line_before).rev() {
let line = text.line(line_idx);
let first_non_whitespace_char = match find_first_non_whitespace_char(line) {
Some(i) => i,
None => {
continue;
}
};
if let Some(indent) = (|| {
let computed_indent = treesitter_indent_for_pos(
query,
syntax,
tab_width,
indent_width,
text,
line_idx,
text.line_to_char(line_idx) + first_non_whitespace_char,
false,
)?;
let leading_whitespace = line.slice(0..first_non_whitespace_char);
indent.relative_indent(
&computed_indent,
leading_whitespace,
indent_style,
tab_width,
)
})() {
return indent;
}
num_attempts += 1;
if num_attempts == MAX_ATTEMPTS {
break;
}
}
}
return indent.to_string(indent_style, tab_width);
};
}
// Fallback in case we either don't have indent queries or they failed for some reason
let indent_level = indent_level_for_line(text.line(current_line), tab_width, indent_width);
indent_style.as_str().repeat(indent_level)
}
@ -962,10 +1098,13 @@ mod test {
..Default::default()
};
let add_capture = |mut indent: Indentation, capture| {
fn add_capture<'a>(
mut indent: Indentation<'a>,
capture: IndentCaptureType<'a>,
) -> Indentation<'a> {
indent.add_capture(capture);
indent
};
}
// adding an indent to no indent makes an indent
assert_eq!(
@ -1060,4 +1199,74 @@ mod test {
)
);
}
#[test]
fn test_relative_indent() {
let indent_style = IndentStyle::Spaces(4);
let tab_width: usize = 4;
let no_align = [
Indentation::default(),
Indentation {
indent: 1,
..Default::default()
},
Indentation {
indent: 5,
outdent: 1,
..Default::default()
},
];
let align = no_align.clone().map(|indent| Indentation {
align: Some(RopeSlice::from("12345")),
..indent
});
let different_align = Indentation {
align: Some(RopeSlice::from("123456")),
..Default::default()
};
// Check that relative and absolute indentation computation are the same when the line we compare to is
// indented as we expect.
let check_consistency = |indent: &Indentation, other: &Indentation| {
assert_eq!(
indent.relative_indent(
other,
RopeSlice::from(other.to_string(&indent_style, tab_width).as_str()),
&indent_style,
tab_width
),
Some(indent.to_string(&indent_style, tab_width))
);
};
for a in &no_align {
for b in &no_align {
check_consistency(a, b);
}
}
for a in &align {
for b in &align {
check_consistency(a, b);
}
}
// Relative indent computation makes no sense if the alignment differs
assert_eq!(
align[0].relative_indent(
&no_align[0],
RopeSlice::from(" "),
&indent_style,
tab_width
),
None
);
assert_eq!(
align[0].relative_indent(
&different_align,
RopeSlice::from(" "),
&indent_style,
tab_width
),
None
);
}
}

@ -17,7 +17,6 @@ pub mod macros;
pub mod match_brackets;
pub mod movement;
pub mod object;
pub mod path;
mod position;
pub mod search;
pub mod selection;

@ -57,10 +57,10 @@ fn find_pair(
pos_: usize,
traverse_parents: bool,
) -> Option<usize> {
let tree = syntax.tree();
let pos = doc.char_to_byte(pos_);
let mut node = tree.root_node().descendant_for_byte_range(pos, pos)?;
let root = syntax.tree_for_byte_range(pos, pos + 1).root_node();
let mut node = root.descendant_for_byte_range(pos, pos + 1)?;
loop {
if node.is_named() {
@ -118,7 +118,7 @@ fn find_pair(
};
node = parent;
}
let node = tree.root_node().named_descendant_for_byte_range(pos, pos)?;
let node = root.named_descendant_for_byte_range(pos, pos + 1)?;
if node.child_count() != 0 {
return None;
}
@ -141,7 +141,7 @@ fn find_pair(
#[must_use]
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.char(cursor_pos);
let bracket = doc.get_char(cursor_pos)?;
if !is_valid_bracket(bracket) {
return None;
}
@ -265,6 +265,12 @@ fn as_char(doc: RopeSlice, node: &Node) -> Option<(usize, char)> {
mod tests {
use super::*;
#[test]
fn find_matching_bracket_empty_file() {
let actual = find_matching_bracket_plaintext("".into(), 0);
assert_eq!(actual, None);
}
#[test]
fn test_find_matching_bracket_current_line_plaintext() {
let assert = |input: &str, pos, expected| {

@ -573,16 +573,11 @@ pub fn move_parent_node_end(
dir: Direction,
movement: Movement,
) -> Selection {
let tree = syntax.tree();
selection.transform(|range| {
let start_from = text.char_to_byte(range.from());
let start_to = text.char_to_byte(range.to());
let mut node = match tree
.root_node()
.named_descendant_for_byte_range(start_from, start_to)
{
let mut node = match syntax.named_descendant_for_byte_range(start_from, start_to) {
Some(node) => node,
None => {
log::debug!(

@ -101,6 +101,7 @@ pub struct LanguageConfiguration {
pub file_types: Vec<FileType>, // filename extension or ends_with? <Gemfile, rb, etc>
#[serde(default)]
pub shebangs: Vec<String>, // interpreter(s) associated with language
#[serde(default)]
pub roots: Vec<String>, // these indicate project roots <.git, Cargo.toml>
pub comment_token: Option<String>,
pub text_width: Option<usize>,
@ -154,6 +155,8 @@ pub struct LanguageConfiguration {
/// Hardcoded LSP root directories relative to the workspace root, like `examples` or `tools/fuzz`.
/// Falling back to the current working directory if none are configured.
pub workspace_lsp_roots: Option<Vec<PathBuf>>,
#[serde(default)]
pub persistent_diagnostic_sources: Vec<String>,
}
#[derive(Debug, PartialEq, Eq, Hash)]
@ -211,10 +214,7 @@ impl<'de> Deserialize<'de> for FileType {
{
match map.next_entry::<String, String>()? {
Some((key, suffix)) if key == "suffix" => Ok(FileType::Suffix({
// FIXME: use `suffix.replace('/', std::path::MAIN_SEPARATOR_STR)`
// if MSRV is updated to 1.68
let mut separator = [0; 1];
suffix.replace('/', std::path::MAIN_SEPARATOR.encode_utf8(&mut separator))
suffix.replace('/', std::path::MAIN_SEPARATOR_STR)
})),
Some((key, _value)) => Err(serde::de::Error::custom(format!(
"unknown key in `file-types` list: {}",
@ -263,7 +263,7 @@ impl Display for LanguageServerFeature {
GotoDeclaration => "goto-declaration",
GotoDefinition => "goto-definition",
GotoTypeDefinition => "goto-type-definition",
GotoReference => "goto-type-definition",
GotoReference => "goto-reference",
GotoImplementation => "goto-implementation",
SignatureHelp => "signature-help",
Hover => "hover",
@ -447,6 +447,22 @@ pub struct IndentationConfiguration {
pub unit: String,
}
/// How the indentation for a newly inserted line should be determined.
/// If the selected heuristic is not available (e.g. because the current
/// language has no tree-sitter indent queries), a simpler one will be used.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IndentationHeuristic {
/// Just copy the indentation of the line that the cursor is currently on.
Simple,
/// Use tree-sitter indent queries to compute the expected absolute indentation level of the new line.
TreeSitter,
/// Use tree-sitter indent queries to compute the expected difference in indentation between the new line
/// and the line before. Add this to the actual indentation level of the line before.
#[default]
Hybrid,
}
/// Configuration for auto pairs
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)]
@ -1325,6 +1341,32 @@ impl Syntax {
result
}
pub fn tree_for_byte_range(&self, start: usize, end: usize) -> &Tree {
let mut container_id = self.root;
for (layer_id, layer) in self.layers.iter() {
if layer.depth > self.layers[container_id].depth
&& layer.contains_byte_range(start, end)
{
container_id = layer_id;
}
}
self.layers[container_id].tree()
}
pub fn named_descendant_for_byte_range(&self, start: usize, end: usize) -> Option<Node<'_>> {
self.tree_for_byte_range(start, end)
.root_node()
.named_descendant_for_byte_range(start, end)
}
pub fn descendant_for_byte_range(&self, start: usize, end: usize) -> Option<Node<'_>> {
self.tree_for_byte_range(start, end)
.root_node()
.descendant_for_byte_range(start, end)
}
// Commenting
// comment_strings_for_pos
// is_commented
@ -1421,6 +1463,32 @@ impl LanguageLayer {
self.tree = Some(tree);
Ok(())
}
/// Whether the layer contains the given byte range.
///
/// If the layer has multiple ranges (i.e. combined injections), the
/// given range is considered contained if it is within the start and
/// end bytes of the first and last ranges **and** if the given range
/// starts or ends within any of the layer's ranges.
fn contains_byte_range(&self, start: usize, end: usize) -> bool {
let layer_start = self
.ranges
.first()
.expect("ranges should not be empty")
.start_byte;
let layer_end = self
.ranges
.last()
.expect("ranges should not be empty")
.end_byte;
layer_start <= start
&& layer_end >= end
&& self.ranges.iter().any(|range| {
let byte_range = range.start_byte..range.end_byte;
byte_range.contains(&start) || byte_range.contains(&end)
})
}
}
pub(crate) fn generate_edits(
@ -1714,7 +1782,7 @@ impl HighlightConfiguration {
let mut local_scope_capture_index = None;
for (i, name) in query.capture_names().iter().enumerate() {
let i = Some(i as u32);
match name.as_str() {
match *name {
"local.definition" => local_def_capture_index = i,
"local.definition-value" => local_def_value_capture_index = i,
"local.reference" => local_ref_capture_index = i,
@ -1725,7 +1793,7 @@ impl HighlightConfiguration {
for (i, name) in injections_query.capture_names().iter().enumerate() {
let i = Some(i as u32);
match name.as_str() {
match *name {
"injection.content" => injection_content_capture_index = i,
"injection.language" => injection_language_capture_index = i,
"injection.filename" => injection_filename_capture_index = i,
@ -1755,7 +1823,7 @@ impl HighlightConfiguration {
}
/// Get a slice containing all of the highlight names used in the configuration.
pub fn names(&self) -> &[String] {
pub fn names(&self) -> &[&str] {
self.query.capture_names()
}
@ -1782,7 +1850,6 @@ impl HighlightConfiguration {
let mut best_index = None;
let mut best_match_len = 0;
for (i, recognized_name) in recognized_names.iter().enumerate() {
let recognized_name = recognized_name;
let mut len = 0;
let mut matches = true;
for (i, part) in recognized_name.split('.').enumerate() {
@ -2251,6 +2318,7 @@ impl<'a> Iterator for HighlightIter<'a> {
// highlighting patterns that are disabled for local variables.
if definition_highlight.is_some() || reference_highlight.is_some() {
while layer.config.non_local_variable_patterns[match_.pattern_index] {
match_.remove();
if let Some((next_match, next_capture_index)) = captures.peek() {
let next_capture = next_match.captures[*next_capture_index];
if next_capture.node == capture.node {

@ -1,7 +1,7 @@
use ropey::RopeSlice;
use smallvec::SmallVec;
use crate::{Range, Rope, Selection, Tendril};
use crate::{chars::char_is_word, Range, Rope, Selection, Tendril};
use std::{borrow::Cow, iter::once};
/// (from, to, replacement)
@ -23,6 +23,30 @@ pub enum Operation {
pub enum Assoc {
Before,
After,
/// Acts like `After` if a word character is inserted
/// after the position, otherwise acts like `Before`
AfterWord,
/// Acts like `Before` if a word character is inserted
/// before the position, otherwise acts like `After`
BeforeWord,
}
impl Assoc {
/// Whether to stick to gaps
fn stay_at_gaps(self) -> bool {
!matches!(self, Self::BeforeWord | Self::AfterWord)
}
fn insert_offset(self, s: &str) -> usize {
let chars = s.chars().count();
match self {
Assoc::After => chars,
Assoc::AfterWord => s.chars().take_while(|&c| char_is_word(c)).count(),
// return position before inserted text
Assoc::Before => 0,
Assoc::BeforeWord => chars - s.chars().rev().take_while(|&c| char_is_word(c)).count(),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
@ -415,8 +439,6 @@ impl ChangeSet {
map!(|pos, _| (old_end > pos).then_some(new_pos), i);
}
Insert(s) => {
let ins = s.chars().count();
// a subsequent delete means a replace, consume it
if let Some((_, Delete(len))) = iter.peek() {
iter.next();
@ -424,13 +446,13 @@ impl ChangeSet {
old_end = old_pos + len;
// in range of replaced text
map!(
|pos, assoc| (old_end > pos).then(|| {
|pos, assoc: Assoc| (old_end > pos).then(|| {
// at point or tracking before
if pos == old_pos || assoc == Assoc::Before {
if pos == old_pos && assoc.stay_at_gaps() {
new_pos
} else {
// place to end of insert
new_pos + ins
new_pos + assoc.insert_offset(s)
}
}),
i
@ -438,20 +460,15 @@ impl ChangeSet {
} else {
// at insert point
map!(
|pos, assoc| (old_pos == pos).then(|| {
|pos, assoc: Assoc| (old_pos == pos).then(|| {
// return position before inserted text
if assoc == Assoc::Before {
new_pos
} else {
// after text
new_pos + ins
}
new_pos + assoc.insert_offset(s)
}),
i
);
}
new_pos += ins;
new_pos += s.chars().count();
}
}
old_pos = old_end;
@ -884,6 +901,48 @@ mod test {
let mut positions = [4, 2];
cs.update_positions(positions.iter_mut().map(|pos| (pos, Assoc::After)));
assert_eq!(positions, [4, 2]);
// stays at word boundary
let cs = ChangeSet {
changes: vec![
Retain(2), // <space><space>
Insert(" ab".into()),
Retain(2), // cd
Insert("de ".into()),
],
len: 4,
len_after: 10,
};
assert_eq!(cs.map_pos(2, Assoc::BeforeWord), 3);
assert_eq!(cs.map_pos(4, Assoc::AfterWord), 9);
let cs = ChangeSet {
changes: vec![
Retain(1), // <space>
Insert(" b".into()),
Delete(1), // c
Retain(1), // d
Insert("e ".into()),
Delete(1), // <space>
],
len: 5,
len_after: 7,
};
assert_eq!(cs.map_pos(1, Assoc::BeforeWord), 2);
assert_eq!(cs.map_pos(3, Assoc::AfterWord), 5);
let cs = ChangeSet {
changes: vec![
Retain(1), // <space>
Insert("a".into()),
Delete(2), // <space>b
Retain(1), // d
Insert("e".into()),
Delete(1), // f
Retain(1), // <space>
],
len: 5,
len_after: 7,
};
assert_eq!(cs.map_pos(2, Assoc::BeforeWord), 1);
assert_eq!(cs.map_pos(4, Assoc::AfterWord), 4);
}
#[test]

@ -210,7 +210,6 @@ fn test_treesitter_indent(
let suggested_indent = treesitter_indent_for_pos(
indent_query,
&syntax,
&indent_style,
tab_width,
indent_style.indent_width(tab_width),
text,
@ -218,7 +217,8 @@ fn test_treesitter_indent(
text.line_to_char(i) + pos,
false,
)
.unwrap();
.unwrap()
.to_string(&indent_style, tab_width);
assert!(
line.get_slice(..pos).map_or(false, |s| s == suggested_indent),
"Wrong indentation for file {:?} on line {}:\n\"{}\" (original line)\n\"{}\" (suggested indentation)\n",

@ -1,25 +1,27 @@
[package]
name = "helix-dap"
version = "0.6.0"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
edition = "2018"
license = "MPL-2.0"
description = "DAP client implementation for Helix project"
categories = ["editor"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
categories.workspace = true
repository.workspace = true
homepage.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
helix-core = { version = "0.6", path = "../helix-core" }
helix-stdx = { path = "../helix-stdx" }
helix-core = { path = "../helix-core" }
anyhow = "1.0"
log = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "net", "sync"] }
which = "4.4"
[dev-dependencies]
fern = "0.6"

@ -9,7 +9,6 @@ use helix_core::syntax::DebuggerQuirks;
use serde_json::Value;
use anyhow::anyhow;
pub use log::{error, info};
use std::{
collections::HashMap,
future::Future,
@ -114,7 +113,7 @@ impl Client {
id: usize,
) -> Result<(Self, UnboundedReceiver<Payload>)> {
// Resolve path to the binary
let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?;
let cmd = helix_stdx::env::which(cmd)?;
let process = Command::new(cmd)
.args(args)

@ -19,6 +19,8 @@ pub enum Error {
#[error("server closed the stream")]
StreamClosed,
#[error(transparent)]
ExecutableNotFound(#[from] helix_stdx::env::ExecutableNotFoundError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type Result<T> = core::result::Result<T, Error>;

@ -1,15 +1,29 @@
[package]
name = "helix-event"
version = "0.6.0"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
edition = "2021"
license = "MPL-2.0"
categories = ["editor"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
categories.workspace = true
repository.workspace = true
homepage.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot"] }
parking_lot = { version = "0.12", features = ["send_guard"] }
ahash = "0.8.3"
hashbrown = "0.14.0"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot", "macros"] }
# the event registry is essentially read only but must be an rwlock so we can
# setup new events on initialization, hardware-lock-elision hugely benefits this case
# as it essentially makes the lock entirely free as long as there is no writes
parking_lot = { version = "0.12", features = ["hardware-lock-elision"] }
once_cell = "1.18"
anyhow = "1"
log = "0.4"
futures-executor = "0.3.28"
[features]
integration_test = []

@ -0,0 +1,19 @@
use std::future::Future;
pub use oneshot::channel as cancelation;
use tokio::sync::oneshot;
pub type CancelTx = oneshot::Sender<()>;
pub type CancelRx = oneshot::Receiver<()>;
pub async fn cancelable_future<T>(future: impl Future<Output = T>, cancel: CancelRx) -> Option<T> {
tokio::select! {
biased;
_ = cancel => {
None
}
res = future => {
Some(res)
}
}
}

@ -0,0 +1,67 @@
//! Utilities for declaring an async (usually debounced) hook
use std::time::Duration;
use futures_executor::block_on;
use tokio::sync::mpsc::{self, error::TrySendError, Sender};
use tokio::time::Instant;
/// Async hooks provide a convenient framework for implementing (debounced)
/// async event handlers. Most synchronous event hooks will likely need to
/// debounce their events, coordinate multiple different hooks and potentially
/// track some state. `AsyncHooks` facilitate these use cases by running as
/// a background tokio task that waits for events (usually an enum) to be
/// sent through a channel.
pub trait AsyncHook: Sync + Send + 'static + Sized {
type Event: Sync + Send + 'static;
/// Called immediately whenever an event is received, this function can
/// consume the event immediately or debounce it. In case of debouncing,
/// it can either define a new debounce timeout or continue the current one
fn handle_event(&mut self, event: Self::Event, timeout: Option<Instant>) -> Option<Instant>;
/// Called whenever the debounce timeline is reached
fn finish_debounce(&mut self);
fn spawn(self) -> mpsc::Sender<Self::Event> {
// the capacity doesn't matter too much here, unless the cpu is totally overwhelmed
// the cap will never be reached since we always immediately drain the channel
// so it should only be reached in case of total CPU overload.
// However, a bounded channel is much more efficient so it's nice to use here
let (tx, rx) = mpsc::channel(128);
tokio::spawn(run(self, rx));
tx
}
}
async fn run<Hook: AsyncHook>(mut hook: Hook, mut rx: mpsc::Receiver<Hook::Event>) {
let mut deadline = None;
loop {
let event = match deadline {
Some(deadline_) => {
let res = tokio::time::timeout_at(deadline_, rx.recv()).await;
match res {
Ok(event) => event,
Err(_) => {
hook.finish_debounce();
deadline = None;
continue;
}
}
}
None => rx.recv().await,
};
let Some(event) = event else {
break;
};
deadline = hook.handle_event(event, deadline);
}
}
pub fn send_blocking<T>(tx: &Sender<T>, data: T) {
// block_on has some overhead and in practice the channel should basically
// never be full anyway so first try sending without blocking
if let Err(TrySendError::Full(data)) = tx.try_send(data) {
// set a timeout so that we just drop a message instead of freezing the editor in the worst case
let _ = block_on(tx.send_timeout(data, Duration::from_millis(10)));
}
}

@ -0,0 +1,91 @@
//! rust dynamic dispatch is extremely limited so we have to build our
//! own vtable implementation. Otherwise implementing the event system would not be possible.
//! A nice bonus of this approach is that we can optimize the vtable a bit more. Normally
//! a dyn Trait fat pointer contains two pointers: A pointer to the data itself and a
//! pointer to a global (static) vtable entry which itself contains multiple other pointers
//! (the various functions of the trait, drop, size and align). That makes dynamic
//! dispatch pretty slow (double pointer indirections). However, we only have a single function
//! in the hook trait and don't need a drop implementation (event system is global anyway
//! and never dropped) so we can just store the entire vtable inline.
use anyhow::Result;
use std::ptr::{self, NonNull};
use crate::Event;
/// Opaque handle type that represents an erased type parameter.
///
/// If extern types were stable, this could be implemented as `extern { pub type Opaque; }` but
/// until then we can use this.
///
/// Care should be taken that we don't use a concrete instance of this. It should only be used
/// through a reference, so we can maintain something else's lifetime.
struct Opaque(());
pub(crate) struct ErasedHook {
data: NonNull<Opaque>,
call: unsafe fn(NonNull<Opaque>, NonNull<Opaque>, NonNull<Opaque>),
}
impl ErasedHook {
pub(crate) fn new_dynamic<H: Fn() -> Result<()> + 'static + Send + Sync>(
hook: H,
) -> ErasedHook {
unsafe fn call<F: Fn() -> Result<()> + 'static + Send + Sync>(
hook: NonNull<Opaque>,
_event: NonNull<Opaque>,
result: NonNull<Opaque>,
) {
let hook: NonNull<F> = hook.cast();
let result: NonNull<Result<()>> = result.cast();
let hook: &F = hook.as_ref();
let res = hook();
ptr::write(result.as_ptr(), res)
}
unsafe {
ErasedHook {
data: NonNull::new_unchecked(Box::into_raw(Box::new(hook)) as *mut Opaque),
call: call::<H>,
}
}
}
pub(crate) fn new<E: Event, F: Fn(&mut E) -> Result<()>>(hook: F) -> ErasedHook {
unsafe fn call<E: Event, F: Fn(&mut E) -> Result<()>>(
hook: NonNull<Opaque>,
event: NonNull<Opaque>,
result: NonNull<Opaque>,
) {
let hook: NonNull<F> = hook.cast();
let mut event: NonNull<E> = event.cast();
let result: NonNull<Result<()>> = result.cast();
let hook: &F = hook.as_ref();
let res = hook(event.as_mut());
ptr::write(result.as_ptr(), res)
}
unsafe {
ErasedHook {
data: NonNull::new_unchecked(Box::into_raw(Box::new(hook)) as *mut Opaque),
call: call::<E, F>,
}
}
}
pub(crate) unsafe fn call<E: Event>(&self, event: &mut E) -> Result<()> {
let mut res = Ok(());
unsafe {
(self.call)(
self.data,
NonNull::from(event).cast(),
NonNull::from(&mut res).cast(),
);
}
res
}
}
unsafe impl Sync for ErasedHook {}
unsafe impl Send for ErasedHook {}

@ -1,8 +1,203 @@
//! `helix-event` contains systems that allow (often async) communication between
//! different editor components without strongly coupling them. Currently this
//! crate only contains some smaller facilities but the intend is to add more
//! functionality in the future ( like a generic hook system)
//! different editor components without strongly coupling them. Specifically
//! it allows defining synchronous hooks that run when certain editor events
//! occur.
//!
//! The core of the event system are hook callbacks and the [`Event`] trait. A
//! hook is essentially just a closure `Fn(event: &mut impl Event) -> Result<()>`
//! that gets called every time an appropriate event is dispatched. The implementation
//! details of the [`Event`] trait are considered private. The [`events`] macro is
//! provided which automatically declares event types. Similarly the `register_hook`
//! macro should be used to (safely) declare event hooks.
//!
//! Hooks run synchronously which can be advantageous since they can modify the
//! current editor state right away (for example to immediately hide the completion
//! popup). However, they can not contain their own state without locking since
//! they only receive immutable references. For handler that want to track state, do
//! expensive background computations or debouncing an [`AsyncHook`] is preferable.
//! Async hooks are based around a channels that receive events specific to
//! that `AsyncHook` (usually an enum). These events can be sent by synchronous
//! hooks. Due to some limitations around tokio channels the [`send_blocking`]
//! function exported in this crate should be used instead of the builtin
//! `blocking_send`.
//!
//! In addition to the core event system, this crate contains some message queues
//! that allow transfer of data back to the main event loop from async hooks and
//! hooks that may not have access to all application data (for example in helix-view).
//! This include the ability to control rendering ([`lock_frame`], [`request_redraw`]) and
//! display status messages ([`status`]).
//!
//! Hooks declared in helix-term can furthermore dispatch synchronous jobs to be run on the
//! main loop (including access to the compositor). Ideally that queue will be moved
//! to helix-view in the future if we manage to detach the compositor from its rendering backend.
use anyhow::Result;
pub use cancel::{cancelable_future, cancelation, CancelRx, CancelTx};
pub use debounce::{send_blocking, AsyncHook};
pub use redraw::{lock_frame, redraw_requested, request_redraw, start_frame, RenderLockGuard};
pub use registry::Event;
mod cancel;
mod debounce;
mod hook;
mod redraw;
mod registry;
#[doc(hidden)]
pub mod runtime;
pub mod status;
#[cfg(test)]
mod test;
pub fn register_event<E: Event + 'static>() {
registry::with_mut(|registry| registry.register_event::<E>())
}
/// Registers a hook that will be called when an event of type `E` is dispatched.
/// This function should usually not be used directly, use the [`register_hook`]
/// macro instead.
///
///
/// # Safety
///
/// `hook` must be totally generic over all lifetime parameters of `E`. For
/// example if `E` was a known type `Foo<'a, 'b>`, then the correct trait bound
/// would be `F: for<'a, 'b, 'c> Fn(&'a mut Foo<'b, 'c>)`, but there is no way to
/// express that kind of constraint for a generic type with the Rust type system
/// as of this writing.
pub unsafe fn register_hook_raw<E: Event>(
hook: impl Fn(&mut E) -> Result<()> + 'static + Send + Sync,
) {
registry::with_mut(|registry| registry.register_hook(hook))
}
/// Register a hook solely by event name
pub fn register_dynamic_hook(
hook: impl Fn() -> Result<()> + 'static + Send + Sync,
id: &str,
) -> Result<()> {
registry::with_mut(|reg| reg.register_dynamic_hook(hook, id))
}
pub fn dispatch(e: impl Event) {
registry::with(|registry| registry.dispatch(e));
}
/// Macro to declare events
///
/// # Examples
///
/// ``` no-compile
/// events! {
/// FileWrite(&Path)
/// ViewScrolled{ view: View, new_pos: ViewOffset }
/// DocumentChanged<'a> { old_doc: &'a Rope, doc: &'a mut Document, changes: &'a ChangeSet }
/// }
///
/// fn init() {
/// register_event::<FileWrite>();
/// register_event::<ViewScrolled>();
/// register_event::<DocumentChanged>();
/// }
///
/// fn save(path: &Path, content: &str){
/// std::fs::write(path, content);
/// dispatch(FileWrite(path));
/// }
/// ```
#[macro_export]
macro_rules! events {
($name: ident<$($lt: lifetime),*> { $($data:ident : $data_ty:ty),* } $($rem:tt)*) => {
pub struct $name<$($lt),*> { $(pub $data: $data_ty),* }
unsafe impl<$($lt),*> $crate::Event for $name<$($lt),*> {
const ID: &'static str = stringify!($name);
const LIFETIMES: usize = $crate::events!(@sum $(1, $lt),*);
type Static = $crate::events!(@replace_lt $name, $('static, $lt),*);
}
$crate::events!{ $($rem)* }
};
($name: ident { $($data:ident : $data_ty:ty),* } $($rem:tt)*) => {
pub struct $name { $(pub $data: $data_ty),* }
unsafe impl $crate::Event for $name {
const ID: &'static str = stringify!($name);
const LIFETIMES: usize = 0;
type Static = Self;
}
$crate::events!{ $($rem)* }
};
() => {};
(@replace_lt $name: ident, $($lt1: lifetime, $lt2: lifetime),* ) => {$name<$($lt1),*>};
(@sum $($val: expr, $lt1: lifetime),* ) => {0 $(+ $val)*};
}
/// Safely register statically typed event hooks
#[macro_export]
macro_rules! register_hook {
// Safety: this is safe because we fully control the type of the event here and
// ensure all lifetime arguments are fully generic and the correct number of lifetime arguments
// is present
(move |$event:ident: &mut $event_ty: ident<$($lt: lifetime),*>| $body: expr) => {
let val = move |$event: &mut $event_ty<$($lt),*>| $body;
unsafe {
// Lifetimes are a bit of a pain. We want to allow events being
// non-static. Lifetimes don't actually exist at runtime so its
// fine to essentially transmute the lifetimes as long as we can
// prove soundness. The hook must therefore accept any combination
// of lifetimes. In other words fn(&'_ mut Event<'_, '_>) is ok
// but examples like fn(&'_ mut Event<'_, 'static>) or fn<'a>(&'a
// mut Event<'a, 'a>) are not. To make this safe we use a macro to
// forbid the user from specifying lifetimes manually (all lifetimes
// specified are always function generics and passed to the event so
// lifetimes can't be used multiple times and using 'static causes a
// syntax error).
//
// There is one soundness hole tough: Type Aliases allow
// "accidentally" creating these problems. For example:
//
// type Event2 = Event<'static>.
// type Event2<'a> = Event<'a, a>.
//
// These cases can be caught by counting the number of lifetimes
// parameters at the parameter declaration site and then at the hook
// declaration site. By asserting the number of lifetime parameters
// are equal we can catch all bad type aliases under one assumption:
// There are no unused lifetime parameters. Introducing a static
// would reduce the number of arguments of the alias by one in the
// above example Event2 has zero lifetime arguments while the original
// event has one lifetime argument. Similar logic applies to using
// a lifetime argument multiple times. The ASSERT below performs a
// a compile time assertion to ensure exactly this property.
//
// With unused lifetime arguments it is still one way to cause unsound code:
//
// type Event2<'a, 'b> = Event<'a, 'a>;
//
// However, this case will always emit a compiler warning/cause CI
// failures so a user would have to introduce #[allow(unused)] which
// is easily caught in review (and a very theoretical case anyway).
// If we want to be pedantic we can simply compile helix with
// forbid(unused). All of this is just a safety net to prevent
// very theoretical misuse. This won't come up in real code (and is
// easily caught in review).
#[allow(unused)]
const ASSERT: () = {
if <$event_ty as $crate::Event>::LIFETIMES != 0 + $crate::events!(@sum $(1, $lt),*){
panic!("invalid type alias");
}
};
$crate::register_hook_raw::<$crate::events!(@replace_lt $event_ty, $('static, $lt),*)>(val);
}
};
(move |$event:ident: &mut $event_ty: ident| $body: expr) => {
let val = move |$event: &mut $event_ty| $body;
unsafe {
#[allow(unused)]
const ASSERT: () = {
if <$event_ty as $crate::Event>::LIFETIMES != 0{
panic!("invalid type alias");
}
};
$crate::register_hook_raw::<$event_ty>(val);
}
};
}

@ -5,16 +5,20 @@ use std::future::Future;
use parking_lot::{RwLock, RwLockReadGuard};
use tokio::sync::Notify;
/// A `Notify` instance that can be used to (asynchronously) request
/// the editor the render a new frame.
static REDRAW_NOTIFY: Notify = Notify::const_new();
/// A `RwLock` that prevents the next frame from being
/// drawn until an exclusive (write) lock can be acquired.
/// This allows asynchsonous tasks to acquire `non-exclusive`
/// locks (read) to prevent the next frame from being drawn
/// until a certain computation has finished.
static RENDER_LOCK: RwLock<()> = RwLock::new(());
use crate::runtime_local;
runtime_local! {
/// A `Notify` instance that can be used to (asynchronously) request
/// the editor to render a new frame.
static REDRAW_NOTIFY: Notify = Notify::const_new();
/// A `RwLock` that prevents the next frame from being
/// drawn until an exclusive (write) lock can be acquired.
/// This allows asynchronous tasks to acquire `non-exclusive`
/// locks (read) to prevent the next frame from being drawn
/// until a certain computation has finished.
static RENDER_LOCK: RwLock<()> = RwLock::new(());
}
pub type RenderLockGuard = RwLockReadGuard<'static, ()>;

@ -0,0 +1,131 @@
//! A global registry where events are registered and can be
//! subscribed to by registering hooks. The registry identifies event
//! types using their type name so multiple event with the same type name
//! may not be registered (will cause a panic to ensure soundness)
use std::any::TypeId;
use anyhow::{bail, Result};
use hashbrown::hash_map::Entry;
use hashbrown::HashMap;
use parking_lot::RwLock;
use crate::hook::ErasedHook;
use crate::runtime_local;
pub struct Registry {
events: HashMap<&'static str, TypeId, ahash::RandomState>,
handlers: HashMap<&'static str, Vec<ErasedHook>, ahash::RandomState>,
}
impl Registry {
pub fn register_event<E: Event + 'static>(&mut self) {
let ty = TypeId::of::<E>();
assert_eq!(ty, TypeId::of::<E::Static>());
match self.events.entry(E::ID) {
Entry::Occupied(entry) => {
if entry.get() == &ty {
// don't warn during tests to avoid log spam
#[cfg(not(feature = "integration_test"))]
panic!("Event {} was registered multiple times", E::ID);
} else {
panic!("Multiple events with ID {} were registered", E::ID);
}
}
Entry::Vacant(ent) => {
ent.insert(ty);
self.handlers.insert(E::ID, Vec::new());
}
}
}
/// # Safety
///
/// `hook` must be totally generic over all lifetime parameters of `E`. For
/// example if `E` was a known type `Foo<'a, 'b> then the correct trait bound
/// would be `F: for<'a, 'b, 'c> Fn(&'a mut Foo<'b, 'c>)` but there is no way to
/// express that kind of constraint for a generic type with the rust type system
/// right now.
pub unsafe fn register_hook<E: Event>(
&mut self,
hook: impl Fn(&mut E) -> Result<()> + 'static + Send + Sync,
) {
// ensure event type ids match so we can rely on them always matching
let id = E::ID;
let Some(&event_id) = self.events.get(id) else {
panic!("Tried to register handler for unknown event {id}");
};
assert!(
TypeId::of::<E::Static>() == event_id,
"Tried to register invalid hook for event {id}"
);
let hook = ErasedHook::new(hook);
self.handlers.get_mut(id).unwrap().push(hook);
}
pub fn register_dynamic_hook(
&mut self,
hook: impl Fn() -> Result<()> + 'static + Send + Sync,
id: &str,
) -> Result<()> {
// ensure event type ids match so we can rely on them always matching
if self.events.get(id).is_none() {
bail!("Tried to register handler for unknown event {id}");
};
let hook = ErasedHook::new_dynamic(hook);
self.handlers.get_mut(id).unwrap().push(hook);
Ok(())
}
pub fn dispatch<E: Event>(&self, mut event: E) {
let Some(hooks) = self.handlers.get(E::ID) else {
log::error!("Dispatched unknown event {}", E::ID);
return;
};
let event_id = self.events[E::ID];
assert_eq!(
TypeId::of::<E::Static>(),
event_id,
"Tried to dispatch invalid event {}",
E::ID
);
for hook in hooks {
// safety: event type is the same
if let Err(err) = unsafe { hook.call(&mut event) } {
log::error!("{} hook failed: {err:#?}", E::ID);
crate::status::report_blocking(err);
}
}
}
}
runtime_local! {
static REGISTRY: RwLock<Registry> = RwLock::new(Registry {
// hardcoded random number is good enough here we don't care about DOS resistance
// and avoids the additional complexity of `Option<Registry>`
events: HashMap::with_hasher(ahash::RandomState::with_seeds(423, 9978, 38322, 3280080)),
handlers: HashMap::with_hasher(ahash::RandomState::with_seeds(423, 99078, 382322, 3282938)),
});
}
pub(crate) fn with<T>(f: impl FnOnce(&Registry) -> T) -> T {
f(&REGISTRY.read())
}
pub(crate) fn with_mut<T>(f: impl FnOnce(&mut Registry) -> T) -> T {
f(&mut REGISTRY.write())
}
/// # Safety
/// The number of specified lifetimes and the static type *must* be correct.
/// This is ensured automatically by the [`events`](crate::events)
/// macro.
pub unsafe trait Event: Sized {
/// Globally unique (case sensitive) string that identifies this type.
/// A good candidate is the events type name
const ID: &'static str;
const LIFETIMES: usize;
type Static: Event + 'static;
}

@ -0,0 +1,88 @@
//! The event system makes use of global to decouple different systems.
//! However, this can cause problems for the integration test system because
//! it runs multiple helix applications in parallel. Making the globals
//! thread-local does not work because a applications can/does have multiple
//! runtime threads. Instead this crate implements a similar notion to a thread
//! local but instead of being local to a single thread, the statics are local to
//! a single tokio-runtime. The implementation requires locking so it's not exactly efficient.
//!
//! Therefore this function is only enabled during integration tests and behaves like
//! a normal static otherwise. I would prefer this module to be fully private and to only
//! export the macro but the macro still need to construct these internals so it's marked
//! `doc(hidden)` instead
use std::ops::Deref;
#[cfg(not(feature = "integration_test"))]
pub struct RuntimeLocal<T: 'static> {
/// inner API used in the macro, not part of public API
#[doc(hidden)]
pub __data: T,
}
#[cfg(not(feature = "integration_test"))]
impl<T> Deref for RuntimeLocal<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.__data
}
}
#[cfg(not(feature = "integration_test"))]
#[macro_export]
macro_rules! runtime_local {
($($(#[$attr:meta])* $vis: vis static $name:ident: $ty: ty = $init: expr;)*) => {
$($(#[$attr])* $vis static $name: $crate::runtime::RuntimeLocal<$ty> = $crate::runtime::RuntimeLocal {
__data: $init
};)*
};
}
#[cfg(feature = "integration_test")]
pub struct RuntimeLocal<T: 'static> {
data:
parking_lot::RwLock<hashbrown::HashMap<tokio::runtime::Id, &'static T, ahash::RandomState>>,
init: fn() -> T,
}
#[cfg(feature = "integration_test")]
impl<T> RuntimeLocal<T> {
/// inner API used in the macro, not part of public API
#[doc(hidden)]
pub const fn __new(init: fn() -> T) -> Self {
Self {
data: parking_lot::RwLock::new(hashbrown::HashMap::with_hasher(
ahash::RandomState::with_seeds(423, 9978, 38322, 3280080),
)),
init,
}
}
}
#[cfg(feature = "integration_test")]
impl<T> Deref for RuntimeLocal<T> {
type Target = T;
fn deref(&self) -> &T {
let id = tokio::runtime::Handle::current().id();
let guard = self.data.read();
match guard.get(&id) {
Some(res) => res,
None => {
drop(guard);
let data = Box::leak(Box::new((self.init)()));
let mut guard = self.data.write();
guard.insert(id, data);
data
}
}
}
}
#[cfg(feature = "integration_test")]
#[macro_export]
macro_rules! runtime_local {
($($(#[$attr:meta])* $vis: vis static $name:ident: $ty: ty = $init: expr;)*) => {
$($(#[$attr])* $vis static $name: $crate::runtime::RuntimeLocal<$ty> = $crate::runtime::RuntimeLocal::__new(|| $init);)*
};
}

@ -0,0 +1,68 @@
//! A queue of async messages/errors that will be shown in the editor
use std::borrow::Cow;
use std::time::Duration;
use crate::{runtime_local, send_blocking};
use once_cell::sync::OnceCell;
use tokio::sync::mpsc::{Receiver, Sender};
/// Describes the severity level of a [`StatusMessage`].
#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)]
pub enum Severity {
Hint,
Info,
Warning,
Error,
}
pub struct StatusMessage {
pub severity: Severity,
pub message: Cow<'static, str>,
}
impl From<anyhow::Error> for StatusMessage {
fn from(err: anyhow::Error) -> Self {
StatusMessage {
severity: Severity::Error,
message: err.to_string().into(),
}
}
}
impl From<&'static str> for StatusMessage {
fn from(msg: &'static str) -> Self {
StatusMessage {
severity: Severity::Info,
message: msg.into(),
}
}
}
runtime_local! {
static MESSAGES: OnceCell<Sender<StatusMessage>> = OnceCell::new();
}
pub async fn report(msg: impl Into<StatusMessage>) {
// if the error channel overflows just ignore it
let _ = MESSAGES
.wait()
.send_timeout(msg.into(), Duration::from_millis(10))
.await;
}
pub fn report_blocking(msg: impl Into<StatusMessage>) {
let messages = MESSAGES.wait();
send_blocking(messages, msg.into())
}
/// Must be called once during editor startup exactly once
/// before any of the messages in this module can be used
///
/// # Panics
/// If called multiple times
pub fn setup() -> Receiver<StatusMessage> {
let (tx, rx) = tokio::sync::mpsc::channel(128);
let _ = MESSAGES.set(tx);
rx
}

@ -0,0 +1,90 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use parking_lot::Mutex;
use crate::{dispatch, events, register_dynamic_hook, register_event, register_hook};
#[test]
fn smoke_test() {
events! {
Event1 { content: String }
Event2 { content: usize }
}
register_event::<Event1>();
register_event::<Event2>();
// setup hooks
let res1: Arc<Mutex<String>> = Arc::default();
let acc = Arc::clone(&res1);
register_hook!(move |event: &mut Event1| {
acc.lock().push_str(&event.content);
Ok(())
});
let res2: Arc<AtomicUsize> = Arc::default();
let acc = Arc::clone(&res2);
register_hook!(move |event: &mut Event2| {
acc.fetch_add(event.content, Ordering::Relaxed);
Ok(())
});
// triggers events
let thread = std::thread::spawn(|| {
for i in 0..1000 {
dispatch(Event2 { content: i });
}
});
std::thread::sleep(Duration::from_millis(1));
dispatch(Event1 {
content: "foo".to_owned(),
});
dispatch(Event2 { content: 42 });
dispatch(Event1 {
content: "bar".to_owned(),
});
dispatch(Event1 {
content: "hello world".to_owned(),
});
thread.join().unwrap();
// check output
assert_eq!(&**res1.lock(), "foobarhello world");
assert_eq!(
res2.load(Ordering::Relaxed),
42 + (0..1000usize).sum::<usize>()
);
}
#[test]
fn dynamic() {
events! {
Event3 {}
Event4 { count: usize }
};
register_event::<Event3>();
register_event::<Event4>();
let count = Arc::new(AtomicUsize::new(0));
let count1 = count.clone();
let count2 = count.clone();
register_dynamic_hook(
move || {
count1.fetch_add(2, Ordering::Relaxed);
Ok(())
},
"Event3",
)
.unwrap();
register_dynamic_hook(
move || {
count2.fetch_add(3, Ordering::Relaxed);
Ok(())
},
"Event4",
)
.unwrap();
dispatch(Event3 {});
dispatch(Event4 { count: 0 });
dispatch(Event3 {});
assert_eq!(count.load(Ordering::Relaxed), 7)
}

@ -1,34 +1,36 @@
[package]
name = "helix-loader"
version = "0.6.0"
description = "A post-modern text editor."
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
edition = "2021"
license = "MPL-2.0"
categories = ["editor"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
description = "Build bootstrapping for Helix crates"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
categories.workspace = true
repository.workspace = true
homepage.workspace = true
[[bin]]
name = "hx-loader"
path = "src/main.rs"
[dependencies]
helix-stdx = { path = "../helix-stdx" }
anyhow = "1"
serde = { version = "1.0", features = ["derive"] }
toml = "0.7"
etcetera = "0.8"
tree-sitter.workspace = true
once_cell = "1.18"
once_cell = "1.19"
log = "0.4"
which = "4.4"
# TODO: these two should be on !wasm32 only
# cloning/compiling tree-sitter grammars
cc = { version = "1" }
threadpool = { version = "1.0" }
tempfile = "3.8.1"
tempfile = "3.9.0"
dunce = "1.0.4"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]

@ -2,7 +2,17 @@ use std::borrow::Cow;
use std::path::Path;
use std::process::Command;
const VERSION: &str = include_str!("../VERSION");
const MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR");
const MINOR: &str = env!("CARGO_PKG_VERSION_MINOR");
const PATCH: &str = env!("CARGO_PKG_VERSION_PATCH");
fn get_calver() -> String {
if PATCH == "0" {
format!("{MAJOR}.{MINOR}")
} else {
format!("{MAJOR}.{MINOR}.{PATCH}")
}
}
fn main() {
let git_hash = Command::new("git")
@ -12,9 +22,10 @@ fn main() {
.filter(|output| output.status.success())
.and_then(|x| String::from_utf8(x.stdout).ok());
let calver = get_calver();
let version: Cow<_> = match &git_hash {
Some(git_hash) => format!("{} ({})", VERSION, &git_hash[..8]).into(),
None => VERSION.into(),
Some(git_hash) => format!("{} ({})", calver, &git_hash[..8]).into(),
None => calver.into(),
};
println!(
@ -22,7 +33,6 @@ fn main() {
std::env::var("TARGET").unwrap()
);
println!("cargo:rerun-if-changed=../VERSION");
println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", version);
if git_hash.is_none() {

@ -86,10 +86,8 @@ pub fn get_language(name: &str) -> Result<Language> {
}
fn ensure_git_is_available() -> Result<()> {
match which::which("git") {
Ok(_cmd) => Ok(()),
Err(err) => Err(anyhow::anyhow!("'git' could not be found ({err})")),
}
helix_stdx::env::which("git")?;
Ok(())
}
pub fn fetch_grammars() -> Result<()> {

@ -1,14 +1,13 @@
pub mod config;
pub mod grammar;
use helix_stdx::{env::current_working_dir, path};
use etcetera::base_strategy::{choose_base_strategy, BaseStrategy};
use std::path::{Path, PathBuf};
use std::sync::RwLock;
pub const VERSION_AND_GIT_HASH: &str = env!("VERSION_AND_GIT_HASH");
static CWD: RwLock<Option<PathBuf>> = RwLock::new(None);
static RUNTIME_DIRS: once_cell::sync::Lazy<Vec<PathBuf>> =
once_cell::sync::Lazy::new(prioritize_runtime_dirs);
@ -16,31 +15,6 @@ static CONFIG_FILE: once_cell::sync::OnceCell<PathBuf> = once_cell::sync::OnceCe
static LOG_FILE: once_cell::sync::OnceCell<PathBuf> = once_cell::sync::OnceCell::new();
// Get the current working directory.
// This information is managed internally as the call to std::env::current_dir
// might fail if the cwd has been deleted.
pub fn current_working_dir() -> PathBuf {
if let Some(path) = &*CWD.read().unwrap() {
return path.clone();
}
let path = std::env::current_dir()
.and_then(dunce::canonicalize)
.expect("Couldn't determine current working directory");
let mut cwd = CWD.write().unwrap();
*cwd = Some(path.clone());
path
}
pub fn set_current_working_dir(path: impl AsRef<Path>) -> std::io::Result<()> {
let path = dunce::canonicalize(path)?;
std::env::set_current_dir(&path)?;
let mut cwd = CWD.write().unwrap();
*cwd = Some(path);
Ok(())
}
pub fn initialize_config_file(specified_file: Option<PathBuf>) {
let config_file = specified_file.unwrap_or_else(default_config_file);
ensure_parent_dir(&config_file);
@ -79,7 +53,8 @@ fn prioritize_runtime_dirs() -> Vec<PathBuf> {
rt_dirs.push(conf_rt_dir);
if let Ok(dir) = std::env::var("HELIX_RUNTIME") {
rt_dirs.push(dir.into());
let dir = path::expand_tilde(dir);
rt_dirs.push(path::normalize(dir));
}
// If this variable is set during build time, it will always be included
@ -280,21 +255,9 @@ fn ensure_parent_dir(path: &Path) {
mod merge_toml_tests {
use std::str;
use super::{current_working_dir, merge_toml_values, set_current_working_dir};
use super::merge_toml_values;
use toml::Value;
#[test]
fn current_dir_is_set() {
let new_path = dunce::canonicalize(std::env::temp_dir()).unwrap();
let cwd = current_working_dir();
assert_ne!(cwd, new_path);
set_current_working_dir(&new_path).expect("Couldn't set new path");
let cwd = current_working_dir();
assert_eq!(cwd, new_path);
}
#[test]
fn language_toml_map_merges() {
const USER: &str = r#"

@ -1,31 +1,32 @@
[package]
name = "helix-lsp"
version = "0.6.0"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
edition = "2021"
license = "MPL-2.0"
description = "LSP client implementation for Helix project"
categories = ["editor"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
categories.workspace = true
repository.workspace = true
homepage.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
helix-core = { version = "0.6", path = "../helix-core" }
helix-loader = { version = "0.6", path = "../helix-loader" }
helix-parsec = { version = "0.6", path = "../helix-parsec" }
helix-stdx = { path = "../helix-stdx" }
helix-core = { path = "../helix-core" }
helix-loader = { path = "../helix-loader" }
helix-parsec = { path = "../helix-parsec" }
anyhow = "1.0"
futures-executor = "0.3"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
globset = "0.4.13"
globset = "0.4.14"
log = "0.4"
lsp-types = { version = "0.94" }
lsp-types = { version = "0.95" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
tokio = { version = "1.33", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio = { version = "1.35", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio-stream = "0.1.14"
which = "4.4"
parking_lot = "0.12.1"

@ -1,27 +1,29 @@
use crate::{
file_operations::FileOperationsInterest,
find_lsp_workspace, jsonrpc,
transport::{Payload, Transport},
Call, Error, OffsetEncoding, Result,
};
use helix_core::{find_workspace, path, syntax::LanguageServerFeature, ChangeSet, Rope};
use helix_core::{find_workspace, syntax::LanguageServerFeature, ChangeSet, Rope};
use helix_loader::{self, VERSION_AND_GIT_HASH};
use helix_stdx::path;
use lsp::{
notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport,
DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, WorkspaceFolder,
WorkspaceFoldersChangeEvent,
DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, SignatureHelp, Url,
WorkspaceFolder, WorkspaceFoldersChangeEvent,
};
use lsp_types as lsp;
use parking_lot::Mutex;
use serde::Deserialize;
use serde_json::Value;
use std::future::Future;
use std::process::Stdio;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use std::{collections::HashMap, path::PathBuf};
use std::{future::Future, sync::OnceLock};
use std::{path::Path, process::Stdio};
use tokio::{
io::{BufReader, BufWriter},
process::{Child, Command},
@ -50,6 +52,7 @@ pub struct Client {
server_tx: UnboundedSender<Payload>,
request_counter: AtomicU64,
pub(crate) capabilities: OnceCell<lsp::ServerCapabilities>,
pub(crate) file_operation_interest: OnceLock<FileOperationsInterest>,
config: Option<Value>,
root_path: std::path::PathBuf,
root_uri: Option<lsp::Url>,
@ -68,7 +71,7 @@ impl Client {
may_support_workspace: bool,
) -> bool {
let (workspace, workspace_is_cwd) = find_workspace();
let workspace = path::get_normalized_path(&workspace);
let workspace = path::normalize(workspace);
let root = find_lsp_workspace(
doc_path
.and_then(|x| x.parent().and_then(|x| x.to_str()))
@ -181,7 +184,7 @@ impl Client {
req_timeout: u64,
) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc<Notify>)> {
// Resolve path to the binary
let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?;
let cmd = helix_stdx::env::which(cmd)?;
let process = Command::new(cmd)
.envs(server_environment)
@ -215,6 +218,7 @@ impl Client {
server_tx,
request_counter: AtomicU64::new(0),
capabilities: OnceCell::new(),
file_operation_interest: OnceLock::new(),
config,
req_timeout,
root_path,
@ -260,6 +264,11 @@ impl Client {
.expect("language server not yet initialized!")
}
pub(crate) fn file_operations_intests(&self) -> &FileOperationsInterest {
self.file_operation_interest
.get_or_init(|| FileOperationsInterest::new(self.capabilities()))
}
/// Client has to be initialized otherwise this function panics
#[inline]
pub fn supports_feature(&self, feature: LanguageServerFeature) -> bool {
@ -384,12 +393,22 @@ impl Client {
&self,
params: R::Params,
) -> impl Future<Output = Result<Value>>
where
R::Params: serde::Serialize,
{
self.call_with_timeout::<R>(params, self.req_timeout)
}
fn call_with_timeout<R: lsp::request::Request>(
&self,
params: R::Params,
timeout_secs: u64,
) -> impl Future<Output = Result<Value>>
where
R::Params: serde::Serialize,
{
let server_tx = self.server_tx.clone();
let id = self.next_request_id();
let timeout_secs = self.req_timeout;
async move {
use std::time::Duration;
@ -531,6 +550,11 @@ impl Client {
dynamic_registration: Some(true),
relative_pattern_support: Some(false),
}),
file_operations: Some(lsp::WorkspaceFileOperationsClientCapabilities {
will_rename: Some(true),
did_rename: Some(true),
..Default::default()
}),
..Default::default()
}),
text_document: Some(lsp::TextDocumentClientCapabilities {
@ -635,6 +659,7 @@ impl Client {
version: Some(String::from(VERSION_AND_GIT_HASH)),
}),
locale: None, // TODO
work_done_progress_params: lsp::WorkDoneProgressParams::default(),
};
self.request::<lsp::request::Initialize>(params).await
@ -683,6 +708,66 @@ impl Client {
})
}
pub fn will_rename(
&self,
old_path: &Path,
new_path: &Path,
is_dir: bool,
) -> Option<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
let capabilities = self.file_operations_intests();
if !capabilities.will_rename.has_interest(old_path, is_dir) {
return None;
}
let url_from_path = |path| {
let url = if is_dir {
Url::from_directory_path(path)
} else {
Url::from_file_path(path)
};
Some(url.ok()?.to_string())
};
let files = vec![lsp::FileRename {
old_uri: url_from_path(old_path)?,
new_uri: url_from_path(new_path)?,
}];
let request = self.call_with_timeout::<lsp::request::WillRenameFiles>(
lsp::RenameFilesParams { files },
5,
);
Some(async move {
let json = request.await?;
let response: Option<lsp::WorkspaceEdit> = serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
})
}
pub fn did_rename(
&self,
old_path: &Path,
new_path: &Path,
is_dir: bool,
) -> Option<impl Future<Output = std::result::Result<(), Error>>> {
let capabilities = self.file_operations_intests();
if !capabilities.did_rename.has_interest(new_path, is_dir) {
return None;
}
let url_from_path = |path| {
let url = if is_dir {
Url::from_directory_path(path)
} else {
Url::from_file_path(path)
};
Some(url.ok()?.to_string())
};
let files = vec![lsp::FileRename {
old_uri: url_from_path(old_path)?,
new_uri: url_from_path(new_path)?,
}];
Some(self.notify::<lsp::notification::DidRenameFiles>(lsp::RenameFilesParams { files }))
}
// -------------------------------------------------------------------------------------------
// Text document
// -------------------------------------------------------------------------------------------
@ -878,20 +963,19 @@ impl Client {
) -> Option<impl Future<Output = Result<()>>> {
let capabilities = self.capabilities.get().unwrap();
let include_text = match &capabilities.text_document_sync {
Some(lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions {
save: Some(options),
let include_text = match &capabilities.text_document_sync.as_ref()? {
lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions {
save: options,
..
})) => match options {
}) => match options.as_ref()? {
lsp::TextDocumentSyncSaveOptions::Supported(true) => false,
lsp::TextDocumentSyncSaveOptions::SaveOptions(lsp_types::SaveOptions {
include_text,
}) => include_text.unwrap_or(false),
// Supported(false)
_ => return None,
lsp::TextDocumentSyncSaveOptions::Supported(false) => return None,
},
// unsupported
_ => return None,
// see: https://github.com/microsoft/language-server-protocol/issues/288
lsp::TextDocumentSyncCapability::Kind(..) => false,
};
Some(self.notify::<lsp::notification::DidSaveTextDocument>(
@ -907,6 +991,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
context: lsp::CompletionContext,
) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
@ -918,13 +1003,12 @@ impl Client {
text_document,
position,
},
context: Some(context),
// TODO: support these tokens by async receiving and updating the choice list
work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
partial_result_params: lsp::PartialResultParams {
partial_result_token: None,
},
context: None,
// lsp::CompletionContext { trigger_kind: , trigger_character: Some(), }
};
Some(self.call::<lsp::request::Completion>(params))
@ -971,7 +1055,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<SignatureHelp>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support signature help.
@ -987,7 +1071,8 @@ impl Client {
// lsp::SignatureHelpContext
};
Some(self.call::<lsp::request::SignatureHelpRequest>(params))
let res = self.call::<lsp::request::SignatureHelpRequest>(params);
Some(async move { Ok(serde_json::from_value(res.await?)?) })
}
pub fn text_document_range_inlay_hints(

@ -0,0 +1,105 @@
use std::path::Path;
use globset::{GlobBuilder, GlobSet};
use crate::lsp;
#[derive(Default, Debug)]
pub(crate) struct FileOperationFilter {
dir_globs: GlobSet,
file_globs: GlobSet,
}
impl FileOperationFilter {
fn new(capability: Option<&lsp::FileOperationRegistrationOptions>) -> FileOperationFilter {
let Some(cap) = capability else {
return FileOperationFilter::default();
};
let mut dir_globs = GlobSet::builder();
let mut file_globs = GlobSet::builder();
for filter in &cap.filters {
// TODO: support other url schemes
let is_non_file_schema = filter
.scheme
.as_ref()
.is_some_and(|schema| schema != "file");
if is_non_file_schema {
continue;
}
let ignore_case = filter
.pattern
.options
.as_ref()
.and_then(|opts| opts.ignore_case)
.unwrap_or(false);
let mut glob_builder = GlobBuilder::new(&filter.pattern.glob);
glob_builder.case_insensitive(!ignore_case);
let glob = match glob_builder.build() {
Ok(glob) => glob,
Err(err) => {
log::error!("invalid glob send by LS: {err}");
continue;
}
};
match filter.pattern.matches {
Some(lsp::FileOperationPatternKind::File) => {
file_globs.add(glob);
}
Some(lsp::FileOperationPatternKind::Folder) => {
dir_globs.add(glob);
}
None => {
file_globs.add(glob.clone());
dir_globs.add(glob);
}
};
}
let file_globs = file_globs.build().unwrap_or_else(|err| {
log::error!("invalid globs send by LS: {err}");
GlobSet::empty()
});
let dir_globs = dir_globs.build().unwrap_or_else(|err| {
log::error!("invalid globs send by LS: {err}");
GlobSet::empty()
});
FileOperationFilter {
dir_globs,
file_globs,
}
}
pub(crate) fn has_interest(&self, path: &Path, is_dir: bool) -> bool {
if is_dir {
self.dir_globs.is_match(path)
} else {
self.file_globs.is_match(path)
}
}
}
#[derive(Default, Debug)]
pub(crate) struct FileOperationsInterest {
// TODO: support other notifications
// did_create: FileOperationFilter,
// will_create: FileOperationFilter,
pub did_rename: FileOperationFilter,
pub will_rename: FileOperationFilter,
// did_delete: FileOperationFilter,
// will_delete: FileOperationFilter,
}
impl FileOperationsInterest {
pub fn new(capabilities: &lsp::ServerCapabilities) -> FileOperationsInterest {
let capabilities = capabilities
.workspace
.as_ref()
.and_then(|capabilities| capabilities.file_operations.as_ref());
let Some(capabilities) = capabilities else {
return FileOperationsInterest::default();
};
FileOperationsInterest {
did_rename: FileOperationFilter::new(capabilities.did_rename.as_ref()),
will_rename: FileOperationFilter::new(capabilities.will_rename.as_ref()),
}
}
}

@ -1,5 +1,6 @@
mod client;
pub mod file_event;
mod file_operations;
pub mod jsonrpc;
pub mod snippet;
mod transport;
@ -11,10 +12,10 @@ pub use lsp::{Position, Url};
pub use lsp_types as lsp;
use futures_util::stream::select_all::SelectAll;
use helix_core::{
path,
syntax::{LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures},
use helix_core::syntax::{
LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures,
};
use helix_stdx::path;
use tokio::sync::mpsc::UnboundedReceiver;
use std::{
@ -44,6 +45,8 @@ pub enum Error {
#[error("Unhandled")]
Unhandled,
#[error(transparent)]
ExecutableNotFound(#[from] helix_stdx::env::ExecutableNotFoundError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
@ -549,6 +552,7 @@ pub enum MethodCall {
WorkspaceConfiguration(lsp::ConfigurationParams),
RegisterCapability(lsp::RegistrationParams),
UnregisterCapability(lsp::UnregistrationParams),
ShowDocument(lsp::ShowDocumentParams),
}
impl MethodCall {
@ -576,6 +580,10 @@ impl MethodCall {
let params: lsp::UnregistrationParams = params.parse()?;
Self::UnregisterCapability(params)
}
lsp::request::ShowDocument::METHOD => {
let params: lsp::ShowDocumentParams = params.parse()?;
Self::ShowDocument(params)
}
_ => {
return Err(Error::Unhandled);
}
@ -971,10 +979,17 @@ fn start_client(
}
// next up, notify<initialized>
_client
let notification_result = _client
.notify::<lsp::notification::Initialized>(lsp::InitializedParams {})
.await
.unwrap();
.await;
if let Err(e) = notification_result {
log::error!(
"failed to notify language server of its initialization: {}",
e
);
return;
}
initialize_notify.notify_one();
});
@ -1002,10 +1017,10 @@ pub fn find_lsp_workspace(
let mut file = if file.is_absolute() {
file.to_path_buf()
} else {
let current_dir = helix_loader::current_working_dir();
let current_dir = helix_stdx::env::current_working_dir();
current_dir.join(file)
};
file = path::get_normalized_path(&file);
file = path::normalize(&file);
if !file.starts_with(workspace) {
return None;
@ -1022,7 +1037,7 @@ pub fn find_lsp_workspace(
if root_dirs
.iter()
.any(|root_dir| path::get_normalized_path(&workspace.join(root_dir)) == ancestor)
.any(|root_dir| path::normalize(workspace.join(root_dir)) == ancestor)
{
// if the worskapce is the cwd do not search any higher for workspaces
// but specify

@ -270,7 +270,14 @@ impl Transport {
}
};
}
Err(Error::StreamClosed) => {
Err(err) => {
if !matches!(err, Error::StreamClosed) {
error!(
"Exiting {} after unexpected error: {err:?}",
&transport.name
);
}
// Close any outstanding requests.
for (id, tx) in transport.pending_requests.lock().await.drain() {
match tx.send(Err(Error::StreamClosed)).await {
@ -300,10 +307,6 @@ impl Transport {
}
break;
}
Err(err) => {
error!("{} err: <- {err:?}", transport.name);
break;
}
}
}
}

@ -1,13 +1,14 @@
[package]
name = "helix-parsec"
version = "0.6.0"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
edition = "2021"
license = "MPL-2.0"
description = "Parser combinators for Helix"
categories = ["editor"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
include = ["src/**/*", "README.md"]
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
categories.workspace = true
repository.workspace = true
homepage.workspace = true
[dependencies]

@ -0,0 +1,21 @@
[package]
name = "helix-stdx"
description = "Standard library extensions"
include = ["src/**/*", "README.md"]
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
categories.workspace = true
repository.workspace = true
homepage.workspace = true
[dependencies]
dunce = "1.0"
etcetera = "0.8"
ropey = { version = "1.6.1", default-features = false }
which = "6.0"
[dev-dependencies]
tempfile = "3.9"

@ -0,0 +1,80 @@
use std::{
ffi::OsStr,
path::{Path, PathBuf},
sync::RwLock,
};
static CWD: RwLock<Option<PathBuf>> = RwLock::new(None);
// Get the current working directory.
// This information is managed internally as the call to std::env::current_dir
// might fail if the cwd has been deleted.
pub fn current_working_dir() -> PathBuf {
if let Some(path) = &*CWD.read().unwrap() {
return path.clone();
}
let path = std::env::current_dir()
.map(crate::path::normalize)
.expect("Couldn't determine current working directory");
let mut cwd = CWD.write().unwrap();
*cwd = Some(path.clone());
path
}
pub fn set_current_working_dir(path: impl AsRef<Path>) -> std::io::Result<()> {
let path = crate::path::canonicalize(path);
std::env::set_current_dir(&path)?;
let mut cwd = CWD.write().unwrap();
*cwd = Some(path);
Ok(())
}
pub fn env_var_is_set(env_var_name: &str) -> bool {
std::env::var_os(env_var_name).is_some()
}
pub fn binary_exists<T: AsRef<OsStr>>(binary_name: T) -> bool {
which::which(binary_name).is_ok()
}
pub fn which<T: AsRef<OsStr>>(
binary_name: T,
) -> Result<std::path::PathBuf, ExecutableNotFoundError> {
which::which(binary_name.as_ref()).map_err(|err| ExecutableNotFoundError {
command: binary_name.as_ref().to_string_lossy().into_owned(),
inner: err,
})
}
#[derive(Debug)]
pub struct ExecutableNotFoundError {
command: String,
inner: which::Error,
}
impl std::fmt::Display for ExecutableNotFoundError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "command '{}' not found: {}", self.command, self.inner)
}
}
impl std::error::Error for ExecutableNotFoundError {}
#[cfg(test)]
mod tests {
use super::{current_working_dir, set_current_working_dir};
#[test]
fn current_dir_is_set() {
let new_path = dunce::canonicalize(std::env::temp_dir()).unwrap();
let cwd = current_working_dir();
assert_ne!(cwd, new_path);
set_current_working_dir(&new_path).expect("Couldn't set new path");
let cwd = current_working_dir();
assert_eq!(cwd, new_path);
}
}

@ -0,0 +1,3 @@
pub mod env;
pub mod path;
pub mod rope;

@ -1,6 +1,9 @@
use etcetera::home_dir;
pub use etcetera::home_dir;
use std::path::{Component, Path, PathBuf};
use crate::env::current_working_dir;
/// Replaces users home directory from `path` with tilde `~` if the directory
/// is available, otherwise returns the path unchanged.
pub fn fold_home_dir(path: &Path) -> PathBuf {
@ -16,7 +19,8 @@ pub fn fold_home_dir(path: &Path) -> PathBuf {
/// Expands tilde `~` into users home directory if available, otherwise returns the path
/// unchanged. The tilde will only be expanded when present as the first component of the path
/// and only slash follows it.
pub fn expand_tilde(path: &Path) -> PathBuf {
pub fn expand_tilde(path: impl AsRef<Path>) -> PathBuf {
let path = path.as_ref();
let mut components = path.components().peekable();
if let Some(Component::Normal(c)) = components.peek() {
if c == &"~" {
@ -30,32 +34,11 @@ pub fn expand_tilde(path: &Path) -> PathBuf {
path.to_path_buf()
}
/// Normalize a path, removing things like `.` and `..`.
///
/// CAUTION: This does not resolve symlinks (unlike
/// [`std::fs::canonicalize`]). This may cause incorrect or surprising
/// behavior at times. This should be used carefully. Unfortunately,
/// [`std::fs::canonicalize`] can be hard to use correctly, since it can often
/// fail, or on Windows returns annoying device paths. This is a problem Cargo
/// needs to improve on.
/// Copied from cargo: <https://github.com/rust-lang/cargo/blob/070e459c2d8b79c5b2ac5218064e7603329c92ae/crates/cargo-util/src/paths.rs#L81>
pub fn get_normalized_path(path: &Path) -> PathBuf {
// normalization strategy is to canonicalize first ancestor path that exists (i.e., canonicalize as much as possible),
// then run handrolled normalization on the non-existent remainder
let (base, path) = path
.ancestors()
.find_map(|base| {
let canonicalized_base = dunce::canonicalize(base).ok()?;
let remainder = path.strip_prefix(base).ok()?.into();
Some((canonicalized_base, remainder))
})
.unwrap_or_else(|| (PathBuf::new(), PathBuf::from(path)));
if path.as_os_str().is_empty() {
return base;
}
let mut components = path.components().peekable();
/// Normalize a path without resolving symlinks.
// Strategy: start from the first component and move up. Cannonicalize previous path,
// join component, cannonicalize new path, strip prefix and join to the final result.
pub fn normalize(path: impl AsRef<Path>) -> PathBuf {
let mut components = path.as_ref().components().peekable();
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
components.next();
PathBuf::from(c.as_os_str())
@ -70,37 +53,77 @@ pub fn get_normalized_path(path: &Path) -> PathBuf {
ret.push(component.as_os_str());
}
Component::CurDir => {}
#[cfg(not(windows))]
Component::ParentDir => {
ret.pop();
}
#[cfg(windows)]
Component::ParentDir => {
if let Some(head) = ret.components().next_back() {
match head {
Component::Prefix(_) | Component::RootDir => {}
Component::CurDir => unreachable!(),
// If we left previous component as ".." it means we met a symlink before and we can't pop path.
Component::ParentDir => {
ret.push("..");
}
Component::Normal(_) => {
if ret.is_symlink() {
ret.push("..");
} else {
ret.pop();
}
}
}
}
}
#[cfg(not(windows))]
Component::Normal(c) => {
ret.push(c);
}
#[cfg(windows)]
Component::Normal(c) => 'normal: {
use std::fs::canonicalize;
let new_path = ret.join(c);
if new_path.is_symlink() {
ret = new_path;
break 'normal;
}
let (can_new, can_old) = (canonicalize(&new_path), canonicalize(&ret));
match (can_new, can_old) {
(Ok(can_new), Ok(can_old)) => {
let striped = can_new.strip_prefix(can_old);
ret.push(striped.unwrap_or_else(|_| c.as_ref()));
}
_ => ret.push(c),
}
}
}
}
base.join(ret)
dunce::simplified(&ret).to_path_buf()
}
/// Returns the canonical, absolute form of a path with all intermediate components normalized.
///
/// This function is used instead of `std::fs::canonicalize` because we don't want to verify
/// This function is used instead of [`std::fs::canonicalize`] because we don't want to verify
/// here if the path exists, just normalize it's components.
pub fn get_canonicalized_path(path: &Path) -> PathBuf {
pub fn canonicalize(path: impl AsRef<Path>) -> PathBuf {
let path = expand_tilde(path);
let path = if path.is_relative() {
helix_loader::current_working_dir().join(path)
current_working_dir().join(path)
} else {
path
};
get_normalized_path(path.as_path())
normalize(path)
}
pub fn get_relative_path(path: &Path) -> PathBuf {
let path = PathBuf::from(path);
pub fn get_relative_path(path: impl AsRef<Path>) -> PathBuf {
let path = PathBuf::from(path.as_ref());
let path = if path.is_absolute() {
let cwdir = get_normalized_path(&helix_loader::current_working_dir());
get_normalized_path(&path)
let cwdir = normalize(current_working_dir());
normalize(&path)
.strip_prefix(cwdir)
.map(PathBuf::from)
.unwrap_or(path)
@ -117,7 +140,7 @@ pub fn get_relative_path(path: &Path) -> PathBuf {
/// Note that this function does not check if the truncated path is unambiguous.
///
/// ```
/// use helix_core::path::get_truncated_path;
/// use helix_stdx::path::get_truncated_path;
/// use std::path::Path;
///
/// assert_eq!(
@ -139,8 +162,8 @@ pub fn get_relative_path(path: &Path) -> PathBuf {
/// assert_eq!(get_truncated_path("").as_path(), Path::new(""));
/// ```
///
pub fn get_truncated_path<P: AsRef<Path>>(path: P) -> PathBuf {
let cwd = helix_loader::current_working_dir();
pub fn get_truncated_path(path: impl AsRef<Path>) -> PathBuf {
let cwd = current_working_dir();
let path = path
.as_ref()
.strip_prefix(cwd)

@ -0,0 +1,26 @@
use ropey::RopeSlice;
pub trait RopeSliceExt: Sized {
fn ends_with(self, text: &str) -> bool;
fn starts_with(self, text: &str) -> bool;
}
impl RopeSliceExt for RopeSlice<'_> {
fn ends_with(self, text: &str) -> bool {
let len = self.len_bytes();
if len < text.len() {
return false;
}
self.get_byte_slice(len - text.len()..)
.map_or(false, |end| end == text)
}
fn starts_with(self, text: &str) -> bool {
let len = self.len_bytes();
if len < text.len() {
return false;
}
self.get_byte_slice(..len - text.len())
.map_or(false, |start| start == text)
}
}

@ -0,0 +1,124 @@
#![cfg(windows)]
use std::{
env::set_current_dir,
error::Error,
path::{Component, Path, PathBuf},
};
use helix_stdx::path;
use tempfile::Builder;
// Paths on Windows are almost always case-insensitive.
// Normalization should return the original path.
// E.g. mkdir `CaSe`, normalize(`case`) = `CaSe`.
#[test]
fn test_case_folding_windows() -> Result<(), Box<dyn Error>> {
// tmp/root/case
let tmp_prefix = std::env::temp_dir();
set_current_dir(&tmp_prefix)?;
let root = Builder::new().prefix("root-").tempdir()?;
let case = Builder::new().prefix("CaSe-").tempdir_in(&root)?;
let root_without_prefix = root.path().strip_prefix(&tmp_prefix)?;
let lowercase_case = format!(
"case-{}",
case.path()
.file_name()
.unwrap()
.to_string_lossy()
.split_at(5)
.1
);
let test_path = root_without_prefix.join(lowercase_case);
assert_eq!(
path::normalize(&test_path),
case.path().strip_prefix(&tmp_prefix)?
);
Ok(())
}
#[test]
fn test_normalize_path() -> Result<(), Box<dyn Error>> {
/*
tmp/root/
link -> dir1/orig_file
dir1/
orig_file
dir2/
dir_link -> ../dir1/
*/
let tmp_prefix = std::env::temp_dir();
set_current_dir(&tmp_prefix)?;
// Create a tree structure as shown above
let root = Builder::new().prefix("root-").tempdir()?;
let dir1 = Builder::new().prefix("dir1-").tempdir_in(&root)?;
let orig_file = Builder::new().prefix("orig_file-").tempfile_in(&dir1)?;
let dir2 = Builder::new().prefix("dir2-").tempdir_in(&root)?;
// Create path and delete existing file
let dir_link = Builder::new()
.prefix("dir_link-")
.tempfile_in(&dir2)?
.path()
.to_owned();
let link = Builder::new()
.prefix("link-")
.tempfile_in(&root)?
.path()
.to_owned();
use std::os::windows;
windows::fs::symlink_dir(&dir1, &dir_link)?;
windows::fs::symlink_file(&orig_file, &link)?;
// root/link
let path = link.strip_prefix(&tmp_prefix)?;
assert_eq!(
path::normalize(path),
path,
"input {:?} and symlink last component shouldn't be resolved",
path
);
// root/dir2/dir_link/orig_file/../..
let path = dir_link
.strip_prefix(&tmp_prefix)
.unwrap()
.join(orig_file.path().file_name().unwrap())
.join(Component::ParentDir)
.join(Component::ParentDir);
let expected = dir_link
.strip_prefix(&tmp_prefix)
.unwrap()
.join(Component::ParentDir);
assert_eq!(
path::normalize(&path),
expected,
"input {:?} and \"..\" should not erase the simlink that goes ahead",
&path
);
// root/link/.././../dir2/../
let path = link
.strip_prefix(&tmp_prefix)
.unwrap()
.join(Component::ParentDir)
.join(Component::CurDir)
.join(Component::ParentDir)
.join(dir2.path().file_name().unwrap())
.join(Component::ParentDir);
let expected = link
.strip_prefix(&tmp_prefix)
.unwrap()
.join(Component::ParentDir)
.join(Component::ParentDir);
assert_eq!(path::normalize(&path), expected, "input {:?}", &path);
Ok(())
}

@ -1,21 +1,21 @@
[package]
name = "helix-term"
version = "0.6.0"
description = "A post-modern text editor."
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
edition = "2021"
license = "MPL-2.0"
categories = ["editor", "command-line-utilities"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
include = ["src/**/*", "README.md"]
default-run = "hx"
rust-version = "1.65"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
categories.workspace = true
repository.workspace = true
homepage.workspace = true
[features]
default = ["git"]
unicode-lines = ["helix-core/unicode-lines"]
integration = []
integration = ["helix-event/integration_test"]
git = ["helix-vcs/git"]
[[bin]]
@ -23,18 +23,17 @@ name = "hx"
path = "src/main.rs"
[dependencies]
helix-core = { version = "0.6", path = "../helix-core" }
helix-event = { version = "0.6", path = "../helix-event" }
helix-view = { version = "0.6", path = "../helix-view" }
helix-lsp = { version = "0.6", path = "../helix-lsp" }
helix-dap = { version = "0.6", path = "../helix-dap" }
helix-vcs = { version = "0.6", path = "../helix-vcs" }
helix-loader = { version = "0.6", path = "../helix-loader" }
helix-stdx = { path = "../helix-stdx" }
helix-core = { path = "../helix-core" }
helix-event = { path = "../helix-event" }
helix-view = { path = "../helix-view" }
helix-lsp = { path = "../helix-lsp" }
helix-dap = { path = "../helix-dap" }
helix-vcs = { path = "../helix-vcs" }
helix-loader = { path = "../helix-loader" }
anyhow = "1"
once_cell = "1.18"
which = "4.4"
once_cell = "1.19"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] }
tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] }
@ -53,10 +52,14 @@ log = "0.4"
nucleo.workspace = true
ignore = "0.4"
# markdown doc rendering
pulldown-cmark = { version = "0.9", default-features = false }
pulldown-cmark = { version = "0.10", default-features = false }
# file type detection
content_inspector = "0.2.4"
# opening URLs
open = "5.0.1"
url = "2.5.0"
# config
toml = "0.7"
@ -64,20 +67,20 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
# ripgrep for global search
grep-regex = "0.1.11"
grep-searcher = "0.1.11"
grep-regex = "0.1.12"
grep-searcher = "0.1.13"
[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
libc = "0.2.149"
libc = "0.2.153"
[target.'cfg(target_os = "macos")'.dependencies]
crossterm = { version = "0.27", features = ["event-stream", "use-dev-tty"] }
[build-dependencies]
helix-loader = { version = "0.6", path = "../helix-loader" }
helix-loader = { path = "../helix-loader" }
[dev-dependencies]
smallvec = "1.11"
smallvec = "1.13"
indoc = "2.0.4"
tempfile = "3.8.1"
tempfile = "3.9.0"

@ -6,4 +6,150 @@ fn main() {
build_grammars(Some(std::env::var("TARGET").unwrap()))
.expect("Failed to compile tree-sitter grammars");
}
#[cfg(windows)]
windows_rc::link_icon_in_windows_exe("../contrib/helix-256p.ico");
}
#[cfg(windows)]
mod windows_rc {
use std::io::prelude::Write;
use std::{env, io, path::Path, path::PathBuf, process};
pub(crate) fn link_icon_in_windows_exe(icon_path: &str) {
let rc_exe = find_rc_exe().expect("Windows SDK is to be installed along with MSVC");
let output = env::var("OUT_DIR").expect("Env var OUT_DIR should have been set by compiler");
let output_dir = PathBuf::from(output);
let rc_path = output_dir.join("resource.rc");
write_resource_file(&rc_path, icon_path).unwrap();
let resource_file = PathBuf::from(&output_dir).join("resource.lib");
compile_with_toolkit_msvc(rc_exe, resource_file, rc_path);
println!("cargo:rustc-link-search=native={}", output_dir.display());
println!("cargo:rustc-link-lib=dylib=resource");
}
fn compile_with_toolkit_msvc(rc_exe: PathBuf, output: PathBuf, input: PathBuf) {
let mut command = process::Command::new(rc_exe);
let command = command.arg(format!(
"/I{}",
env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR should have been set by Cargo")
));
let status = command
.arg(format!("/fo{}", output.display()))
.arg(format!("{}", input.display()))
.output()
.unwrap();
println!(
"RC Output:\n{}\n------",
String::from_utf8_lossy(&status.stdout)
);
println!(
"RC Error:\n{}\n------",
String::from_utf8_lossy(&status.stderr)
);
}
fn find_rc_exe() -> io::Result<PathBuf> {
let find_reg_key = process::Command::new("reg")
.arg("query")
.arg(r"HKLM\SOFTWARE\Microsoft\Windows Kits\Installed Roots")
.arg("/reg:32")
.arg("/v")
.arg("KitsRoot10")
.output();
match find_reg_key {
Err(find_reg_key) => {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("Failed to run registry query: {}", find_reg_key),
))
}
Ok(find_reg_key) => {
if find_reg_key.status.code().unwrap() != 0 {
return Err(io::Error::new(
io::ErrorKind::Other,
"Can not find Windows SDK",
));
} else {
let lines = String::from_utf8(find_reg_key.stdout)
.expect("Should be able to parse the output");
let mut lines: Vec<&str> = lines.lines().collect();
let mut rc_exe_paths: Vec<PathBuf> = Vec::new();
lines.reverse();
for line in lines {
if line.trim().starts_with("KitsRoot") {
let kit: String = line
.chars()
.skip(line.find("REG_SZ").unwrap() + 6)
.skip_while(|c| c.is_whitespace())
.collect();
let p = PathBuf::from(&kit);
let rc = if cfg!(target_arch = "x86_64") {
p.join(r"bin\x64\rc.exe")
} else {
p.join(r"bin\x86\rc.exe")
};
if rc.exists() {
println!("{:?}", rc);
rc_exe_paths.push(rc.to_owned());
}
if let Ok(bin) = p.join("bin").read_dir() {
for e in bin.filter_map(|e| e.ok()) {
let p = if cfg!(target_arch = "x86_64") {
e.path().join(r"x64\rc.exe")
} else {
e.path().join(r"x86\rc.exe")
};
if p.exists() {
println!("{:?}", p);
rc_exe_paths.push(p.to_owned());
}
}
}
}
}
if rc_exe_paths.is_empty() {
return Err(io::Error::new(
io::ErrorKind::Other,
"Can not find Windows SDK",
));
}
println!("{:?}", rc_exe_paths);
let rc_path = rc_exe_paths.pop().unwrap();
let rc_exe = if !rc_path.exists() {
if cfg!(target_arch = "x86_64") {
PathBuf::from(rc_path.parent().unwrap()).join(r"bin\x64\rc.exe")
} else {
PathBuf::from(rc_path.parent().unwrap()).join(r"bin\x86\rc.exe")
}
} else {
rc_path
};
println!("Selected RC path: '{}'", rc_exe.display());
Ok(rc_exe)
}
}
}
}
fn write_resource_file(rc_path: &Path, icon_path: &str) -> io::Result<()> {
let mut f = std::fs::File::create(rc_path)?;
writeln!(f, "{} ICON \"{}\"", 1, icon_path)?;
Ok(())
}
}

@ -1,15 +1,12 @@
use arc_swap::{access::Map, ArcSwap};
use futures_util::Stream;
use helix_core::{
diagnostic::{DiagnosticTag, NumberOrString},
path::get_relative_path,
pos_at_coords, syntax, Selection,
};
use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Selection};
use helix_lsp::{
lsp::{self, notification::Notification},
util::lsp_pos_to_pos,
util::lsp_range_to_range,
LspProgressMap,
};
use helix_stdx::path::get_relative_path;
use helix_view::{
align_view,
document::DocumentSavedEventResult,
@ -24,15 +21,15 @@ use tui::backend::Backend;
use crate::{
args::Args,
commands::apply_workspace_edit,
compositor::{Compositor, Event},
config::Config,
handlers,
job::Jobs,
keymap::Keymaps,
ui::{self, overlay::overlaid},
};
use log::{debug, error, warn};
use log::{debug, error, info, warn};
#[cfg(not(feature = "integration"))]
use std::io::stdout;
use std::{collections::btree_map::Entry, io::stdin, path::Path, sync::Arc};
@ -141,6 +138,7 @@ impl Application {
let area = terminal.size().expect("couldn't get terminal size");
let mut compositor = Compositor::new(area);
let config = Arc::new(ArcSwap::from_pointee(config));
let handlers = handlers::setup(config.clone());
let mut editor = Editor::new(
area,
theme_loader.clone(),
@ -148,6 +146,7 @@ impl Application {
Arc::new(Map::new(Arc::clone(&config), |config: &Config| {
&config.editor
})),
handlers,
);
let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| {
@ -162,14 +161,19 @@ impl Application {
// Unset path to prevent accidentally saving to the original tutor file.
doc_mut!(editor).set_path(None);
} else if !args.files.is_empty() {
if args.open_cwd {
// NOTE: The working directory is already set to args.files[0] in main()
editor.new_file(Action::VerticalSplit);
let picker = ui::file_picker(".".into(), &config.load().editor);
let mut files_it = args.files.into_iter().peekable();
// If the first file is a directory, skip it and open a picker
if let Some((first, _)) = files_it.next_if(|(p, _)| p.is_dir()) {
let picker = ui::file_picker(first, &config.load().editor);
compositor.push(Box::new(overlaid(picker)));
} else {
let nr_of_files = args.files.len();
for (i, (file, pos)) in args.files.into_iter().enumerate() {
}
// If there are any more files specified, open them
if files_it.peek().is_some() {
let mut nr_of_files = 0;
for (file, pos) in files_it {
nr_of_files += 1;
if file.is_dir() {
return Err(anyhow::anyhow!(
"expected a path to file, found a directory. (to open a directory pass it as first argument)"
@ -181,7 +185,7 @@ impl Application {
// option. If neither of those two arguments are passed
// in, just load the files normally.
let action = match args.split {
_ if i == 0 => Action::VerticalSplit,
_ if nr_of_files == 1 => Action::VerticalSplit,
Some(Layout::Vertical) => Action::VerticalSplit,
Some(Layout::Horizontal) => Action::HorizontalSplit,
None => Action::Load,
@ -208,6 +212,8 @@ impl Application {
// does not affect views without pos since it is at the top
let (view, doc) = current!(editor);
align_view(doc, view, Align::Center);
} else {
editor.new_file(Action::VerticalSplit);
}
} else if stdin().is_tty() || cfg!(feature = "integration") {
editor.new_file(Action::VerticalSplit);
@ -317,10 +323,21 @@ impl Application {
Some(event) = input_stream.next() => {
self.handle_terminal_events(event).await;
}
Some(callback) = self.jobs.futures.next() => {
self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback);
Some(callback) = self.jobs.callbacks.recv() => {
self.jobs.handle_callback(&mut self.editor, &mut self.compositor, Ok(Some(callback)));
self.render().await;
}
Some(msg) = self.jobs.status_messages.recv() => {
let severity = match msg.severity{
helix_event::status::Severity::Hint => Severity::Hint,
helix_event::status::Severity::Info => Severity::Info,
helix_event::status::Severity::Warning => Severity::Warning,
helix_event::status::Severity::Error => Severity::Error,
};
// TODO: show multiple status messages at once to avoid clobbering
self.editor.status_msg = Some((msg.message, severity));
helix_event::request_redraw();
}
Some(callback) = self.jobs.wait_futures.next() => {
self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback);
self.render().await;
@ -384,6 +401,12 @@ impl Application {
self.editor.syn_loader = self.syn_loader.clone();
for document in self.editor.documents.values_mut() {
document.detect_language(self.syn_loader.clone());
let diagnostics = Editor::doc_diagnostics(
&self.editor.language_servers,
&self.editor.diagnostics,
document,
);
document.replace_diagnostics(diagnostics, &[], None);
}
Ok(())
@ -549,18 +572,8 @@ impl Application {
let lines = doc_save_event.text.len_lines();
let bytes = doc_save_event.text.len_bytes();
if doc.path() != Some(&doc_save_event.path) {
doc.set_path(Some(&doc_save_event.path));
let loader = self.editor.syn_loader.clone();
// borrowing the same doc again to get around the borrow checker
let doc = doc_mut!(self.editor, &doc_save_event.doc_id);
let id = doc.id();
doc.detect_language(loader);
self.editor.refresh_language_servers(id);
}
self.editor
.set_doc_path(doc_save_event.doc_id, &doc_save_event.path);
// TODO: fix being overwritten by lsp
self.editor.set_status(format!(
"'{}' written, {}L {}B",
@ -667,9 +680,13 @@ impl Application {
Call::Notification(helix_lsp::jsonrpc::Notification { method, params, .. }) => {
let notification = match Notification::parse(&method, params) {
Ok(notification) => notification,
Err(helix_lsp::Error::Unhandled) => {
info!("Ignoring Unhandled notification from Language Server");
return;
}
Err(err) => {
log::error!(
"received malformed notification from Language Server: {}",
error!(
"Ignoring unknown notification from Language Server: {}",
err
);
return;
@ -710,7 +727,7 @@ impl Application {
));
}
}
Notification::PublishDiagnostics(params) => {
Notification::PublishDiagnostics(mut params) => {
let path = match params.uri.to_file_path() {
Ok(path) => path,
Err(_) => {
@ -723,144 +740,97 @@ impl Application {
log::error!("Discarding publishDiagnostic notification sent by an uninitialized server: {}", language_server.name());
return;
}
let offset_encoding = language_server.offset_encoding();
let doc = self.editor.document_by_path_mut(&path).filter(|doc| {
// have to inline the function because of borrow checking...
let doc = self.editor.documents.values_mut()
.find(|doc| doc.path().map(|p| p == &path).unwrap_or(false))
.filter(|doc| {
if let Some(version) = params.version {
if version != doc.version() {
log::info!("Version ({version}) is out of date for {path:?} (expected ({}), dropping PublishDiagnostic notification", doc.version());
return false;
}
}
true
});
if let Some(doc) = doc {
let lang_conf = doc.language_config();
let text = doc.text();
let diagnostics = params
.diagnostics
.iter()
.filter_map(|diagnostic| {
use helix_core::diagnostic::{Diagnostic, Range, Severity::*};
use lsp::DiagnosticSeverity;
let mut unchanged_diag_sources = Vec::new();
if let Some(doc) = &doc {
let lang_conf = doc.language.clone();
// TODO: convert inside server
let start = if let Some(start) = lsp_pos_to_pos(
text,
diagnostic.range.start,
offset_encoding,
) {
start
} else {
log::warn!("lsp position out of bounds - {:?}", diagnostic);
return None;
};
let end = if let Some(end) =
lsp_pos_to_pos(text, diagnostic.range.end, offset_encoding)
if let Some(lang_conf) = &lang_conf {
if let Some(old_diagnostics) =
self.editor.diagnostics.get(&params.uri)
{
end
} else {
log::warn!("lsp position out of bounds - {:?}", diagnostic);
return None;
};
let severity =
diagnostic.severity.map(|severity| match severity {
DiagnosticSeverity::ERROR => Error,
DiagnosticSeverity::WARNING => Warning,
DiagnosticSeverity::INFORMATION => Info,
DiagnosticSeverity::HINT => Hint,
severity => unreachable!(
"unrecognized diagnostic severity: {:?}",
severity
),
});
if let Some(lang_conf) = lang_conf {
if let Some(severity) = severity {
if severity < lang_conf.diagnostic_severity {
return None;
if !lang_conf.persistent_diagnostic_sources.is_empty() {
// Sort diagnostics first by severity and then by line numbers.
// Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order
params
.diagnostics
.sort_unstable_by_key(|d| (d.severity, d.range.start));
}
for source in &lang_conf.persistent_diagnostic_sources {
let new_diagnostics = params
.diagnostics
.iter()
.filter(|d| d.source.as_ref() == Some(source));
let old_diagnostics = old_diagnostics
.iter()
.filter(|(d, d_server)| {
*d_server == server_id
&& d.source.as_ref() == Some(source)
})
.map(|(d, _)| d);
if new_diagnostics.eq(old_diagnostics) {
unchanged_diag_sources.push(source.clone())
}
};
let code = match diagnostic.code.clone() {
Some(x) => match x {
lsp::NumberOrString::Number(x) => {
Some(NumberOrString::Number(x))
}
lsp::NumberOrString::String(x) => {
Some(NumberOrString::String(x))
}
},
None => None,
};
let tags = if let Some(tags) = &diagnostic.tags {
let new_tags = tags
.iter()
.filter_map(|tag| match *tag {
lsp::DiagnosticTag::DEPRECATED => {
Some(DiagnosticTag::Deprecated)
}
lsp::DiagnosticTag::UNNECESSARY => {
Some(DiagnosticTag::Unnecessary)
}
_ => None,
})
.collect();
new_tags
} else {
Vec::new()
};
Some(Diagnostic {
range: Range { start, end },
line: diagnostic.range.start.line as usize,
message: diagnostic.message.clone(),
severity,
code,
tags,
source: diagnostic.source.clone(),
data: diagnostic.data.clone(),
language_server_id: server_id,
})
})
.collect();
doc.replace_diagnostics(diagnostics, server_id);
}
let mut diagnostics = params
.diagnostics
.into_iter()
.map(|d| (d, server_id))
.collect();
let diagnostics = params.diagnostics.into_iter().map(|d| (d, server_id));
// Insert the original lsp::Diagnostics here because we may have no open document
// for diagnosic message and so we can't calculate the exact position.
// When using them later in the diagnostics picker, we calculate them on-demand.
match self.editor.diagnostics.entry(params.uri) {
let diagnostics = match self.editor.diagnostics.entry(params.uri) {
Entry::Occupied(o) => {
let current_diagnostics = o.into_mut();
// there may entries of other language servers, which is why we can't overwrite the whole entry
current_diagnostics.retain(|(_, lsp_id)| *lsp_id != server_id);
current_diagnostics.append(&mut diagnostics);
// Sort diagnostics first by severity and then by line numbers.
// Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order
current_diagnostics.extend(diagnostics);
current_diagnostics
.sort_unstable_by_key(|(d, _)| (d.severity, d.range.start));
}
Entry::Vacant(v) => {
diagnostics
.sort_unstable_by_key(|(d, _)| (d.severity, d.range.start));
v.insert(diagnostics);
// Sort diagnostics first by severity and then by line numbers.
}
Entry::Vacant(v) => v.insert(diagnostics.collect()),
};
// Sort diagnostics first by severity and then by line numbers.
// Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order
diagnostics.sort_unstable_by_key(|(d, server_id)| {
(d.severity, d.range.start, *server_id)
});
if let Some(doc) = doc {
let diagnostic_of_language_server_and_not_in_unchanged_sources =
|diagnostic: &lsp::Diagnostic, ls_id| {
ls_id == server_id
&& diagnostic.source.as_ref().map_or(true, |source| {
!unchanged_diag_sources.contains(source)
})
};
let diagnostics = Editor::doc_diagnostics_with_filter(
&self.editor.language_servers,
&self.editor.diagnostics,
doc,
diagnostic_of_language_server_and_not_in_unchanged_sources,
);
doc.replace_diagnostics(
diagnostics,
&unchanged_diag_sources,
Some(server_id),
);
}
}
Notification::ShowMessage(params) => {
log::warn!("unhandled window/showMessage: {:?}", params);
@ -968,7 +938,7 @@ impl Application {
// Clear any diagnostics for documents with this server open.
for doc in self.editor.documents_mut() {
doc.clear_diagnostics(server_id);
doc.clear_diagnostics(Some(server_id));
}
// Remove the language server from the registry.
@ -1022,11 +992,9 @@ impl Application {
let language_server = language_server!();
if language_server.is_initialized() {
let offset_encoding = language_server.offset_encoding();
let res = apply_workspace_edit(
&mut self.editor,
offset_encoding,
&params.edit,
);
let res = self
.editor
.apply_workspace_edit(offset_encoding, &params.edit);
Ok(json!(lsp::ApplyWorkspaceEditResponse {
applied: res.is_ok(),
@ -1127,6 +1095,13 @@ impl Application {
}
Ok(serde_json::Value::Null)
}
Ok(MethodCall::ShowDocument(params)) => {
let language_server = language_server!();
let offset_encoding = language_server.offset_encoding();
let result = self.handle_show_document(params, offset_encoding);
Ok(json!(result))
}
};
tokio::spawn(language_server!().reply(id, reply));
@ -1135,6 +1110,68 @@ impl Application {
}
}
fn handle_show_document(
&mut self,
params: lsp::ShowDocumentParams,
offset_encoding: helix_lsp::OffsetEncoding,
) -> lsp::ShowDocumentResult {
if let lsp::ShowDocumentParams {
external: Some(true),
uri,
..
} = params
{
self.jobs.callback(crate::open_external_url_callback(uri));
return lsp::ShowDocumentResult { success: true };
};
let lsp::ShowDocumentParams {
uri,
selection,
take_focus,
..
} = params;
let path = match uri.to_file_path() {
Ok(path) => path,
Err(err) => {
log::error!("unsupported file URI: {}: {:?}", uri, err);
return lsp::ShowDocumentResult { success: false };
}
};
let action = match take_focus {
Some(true) => helix_view::editor::Action::Replace,
_ => helix_view::editor::Action::VerticalSplit,
};
let doc_id = match self.editor.open(&path, action) {
Ok(id) => id,
Err(err) => {
log::error!("failed to open path: {:?}: {:?}", uri, err);
return lsp::ShowDocumentResult { success: false };
}
};
let doc = doc_mut!(self.editor, &doc_id);
if let Some(range) = selection {
// TODO: convert inside server
if let Some(new_range) = lsp_range_to_range(doc.text(), range, offset_encoding) {
let view = view_mut!(self.editor);
// we flip the range so that the cursor sits on the start of the symbol
// (for example start of the function).
doc.set_selection(view.id, Selection::single(new_range.head, new_range.anchor));
if action.align_view(view, doc.id()) {
align_view(doc, view, Align::Center);
}
} else {
log::warn!("lsp position out of bounds - {:?}", range);
};
};
lsp::ShowDocumentResult { success: true }
}
async fn claim_term(&mut self) -> std::io::Result<()> {
let terminal_config = self.config.load().editor.clone().into();
self.terminal.claim(terminal_config)

@ -17,7 +17,6 @@ pub struct Args {
pub log_file: Option<PathBuf>,
pub config_file: Option<PathBuf>,
pub files: Vec<(PathBuf, Position)>,
pub open_cwd: bool,
pub working_directory: Option<PathBuf>,
}
@ -91,10 +90,9 @@ impl Args {
}
}
arg if arg.starts_with('+') => {
let arg = &arg[1..];
line_number = match arg.parse::<usize>() {
Ok(n) => n.saturating_sub(1),
_ => anyhow::bail!("bad line number after +"),
match arg[1..].parse::<usize>() {
Ok(n) => line_number = n.saturating_sub(1),
_ => args.files.push(parse_file(arg)),
};
}
arg => args.files.push(parse_file(arg)),

@ -5,7 +5,6 @@ pub(crate) mod typed;
pub use dap::*;
use helix_vcs::Hunk;
pub use lsp::*;
use tokio::sync::oneshot;
use tui::widgets::Row;
pub use typed::*;
@ -33,7 +32,7 @@ use helix_core::{
};
use helix_view::{
document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
editor::{Action, CompleteAction},
editor::Action,
info::Info,
input::KeyEvent,
keyboard::KeyCode,
@ -52,16 +51,17 @@ use crate::{
filter_picker_entry,
job::Callback,
keymap::ReverseKeymap,
ui::{
self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, Picker,
Popup, Prompt, PromptEvent,
},
ui::{self, overlay::overlaid, Picker, Popup, Prompt, PromptEvent},
};
use crate::job::{self, Jobs};
use futures_util::{stream::FuturesUnordered, TryStreamExt};
use std::{collections::HashMap, fmt, future::Future};
use std::{collections::HashSet, num::NonZeroUsize};
use std::{
collections::{HashMap, HashSet},
fmt,
future::Future,
io::Read,
num::NonZeroUsize,
};
use std::{
borrow::Cow,
@ -70,6 +70,7 @@ use std::{
use once_cell::sync::Lazy;
use serde::de::{self, Deserialize, Deserializer};
use url::Url;
use grep_regex::RegexMatcherBuilder;
use grep_searcher::{sinks, BinaryDetection, SearcherBuilder};
@ -82,7 +83,7 @@ pub struct Context<'a> {
pub count: Option<NonZeroUsize>,
pub editor: &'a mut Editor,
pub callback: Option<crate::compositor::Callback>,
pub callback: Vec<crate::compositor::Callback>,
pub on_next_key_callback: Option<OnKeyCallback>,
pub jobs: &'a mut Jobs,
}
@ -90,14 +91,16 @@ pub struct Context<'a> {
impl<'a> Context<'a> {
/// Push a new component onto the compositor.
pub fn push_layer(&mut self, component: Box<dyn Component>) {
self.callback = Some(Box::new(|compositor: &mut Compositor, _| {
self.callback
.push(Box::new(|compositor: &mut Compositor, _| {
compositor.push(component)
}));
}
/// Call `replace_or_push` on the Compositor
pub fn replace_or_push_layer<T: Component>(&mut self, id: &'static str, component: T) {
self.callback = Some(Box::new(move |compositor: &mut Compositor, _| {
self.callback
.push(Box::new(move |compositor: &mut Compositor, _| {
compositor.replace_or_push(id, component);
}));
}
@ -331,9 +334,9 @@ impl MappableCommand {
goto_implementation, "Goto implementation",
goto_file_start, "Goto line number <n> else file start",
goto_file_end, "Goto file end",
goto_file, "Goto files in selection",
goto_file_hsplit, "Goto files in selection (hsplit)",
goto_file_vsplit, "Goto files in selection (vsplit)",
goto_file, "Goto files/URLs in selections",
goto_file_hsplit, "Goto files in selections (hsplit)",
goto_file_vsplit, "Goto files in selections (vsplit)",
goto_reference, "Goto references",
goto_window_top, "Goto window top",
goto_window_center, "Goto window center",
@ -789,7 +792,7 @@ fn goto_buffer(editor: &mut Editor, direction: Direction) {
let iter = editor.documents.keys();
let mut iter = iter.rev().skip_while(|id| *id != &current);
iter.next(); // skip current item
iter.next().or_else(|| editor.documents.keys().rev().next())
iter.next().or_else(|| editor.documents.keys().next_back())
}
}
.unwrap();
@ -984,6 +987,7 @@ fn align_selections(cx: &mut Context) {
let transaction = Transaction::change(doc.text(), changes.into_iter());
doc.apply(&transaction, view.id);
exit_select_mode(cx);
}
fn goto_window(cx: &mut Context, align: Align) {
@ -1189,9 +1193,17 @@ fn goto_file_impl(cx: &mut Context, action: Action) {
.to_string(),
);
}
for sel in paths {
let p = sel.trim();
if !p.is_empty() {
if p.is_empty() {
continue;
}
if let Ok(url) = Url::parse(p) {
return open_url(cx, url, action);
}
let path = &rel_path.join(p);
if path.is_dir() {
let picker = ui::file_picker(path.into(), &cx.editor.config());
@ -1200,6 +1212,43 @@ fn goto_file_impl(cx: &mut Context, action: Action) {
cx.editor.set_error(format!("Open file failed: {:?}", e));
}
}
}
/// Opens the given url. If the URL points to a valid textual file it is open in helix.
// Otherwise, the file is open using external program.
fn open_url(cx: &mut Context, url: Url, action: Action) {
let doc = doc!(cx.editor);
let rel_path = doc
.relative_path()
.map(|path| path.parent().unwrap().to_path_buf())
.unwrap_or_default();
if url.scheme() != "file" {
return cx.jobs.callback(crate::open_external_url_callback(url));
}
let content_type = std::fs::File::open(url.path()).and_then(|file| {
// Read up to 1kb to detect the content type
let mut read_buffer = Vec::new();
let n = file.take(1024).read_to_end(&mut read_buffer)?;
Ok(content_inspector::inspect(&read_buffer[..n]))
});
// we attempt to open binary files - files that can't be open in helix - using external
// program as well, e.g. pdf files or images
match content_type {
Ok(content_inspector::ContentType::BINARY) => {
cx.jobs.callback(crate::open_external_url_callback(url))
}
Ok(_) | Err(_) => {
let path = &rel_path.join(url.path());
if path.is_dir() {
let picker = ui::file_picker(path.into(), &cx.editor.config());
cx.push_layer(Box::new(overlaid(picker)));
} else if let Err(e) = cx.editor.open(path, action) {
cx.editor.set_error(format!("Open file failed: {:?}", e));
}
}
}
}
@ -1251,7 +1300,7 @@ fn extend_next_long_word_end(cx: &mut Context) {
extend_word_impl(cx, movement::move_next_long_word_end)
}
/// Separate branch to find_char designed only for <ret> char.
/// Separate branch to find_char designed only for `<ret>` char.
//
// This is necessary because the one document can have different line endings inside. And we
// cannot predict what character to find when <ret> is pressed. On the current line it can be `lf`
@ -1527,6 +1576,7 @@ where
});
doc.apply(&transaction, view.id);
exit_select_mode(cx);
}
fn switch_case(cx: &mut Context) {
@ -2116,7 +2166,7 @@ fn global_search(cx: &mut Context) {
type Data = Option<PathBuf>;
fn format(&self, current_path: &Self::Data) -> Row {
let relative_path = helix_core::path::get_relative_path(&self.path)
let relative_path = helix_stdx::path::get_relative_path(&self.path)
.to_string_lossy()
.into_owned();
if current_path
@ -2165,7 +2215,7 @@ fn global_search(cx: &mut Context) {
.case_smart(smart_case)
.build(regex.as_str())
{
let search_root = helix_loader::current_working_dir();
let search_root = helix_stdx::env::current_working_dir();
if !search_root.exists() {
cx.editor
.set_error("Current working directory does not exist");
@ -2538,7 +2588,6 @@ fn delete_by_selection_insert_mode(
);
}
doc.apply(&transaction, view.id);
lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
}
fn delete_selection(cx: &mut Context) {
@ -2612,10 +2661,6 @@ fn insert_mode(cx: &mut Context) {
.transform(|range| Range::new(range.to(), range.from()));
doc.set_selection(view.id, selection);
// [TODO] temporary workaround until we're not using the idle timer to
// trigger auto completions any more
cx.editor.clear_idle_timer();
}
// inserts at the end of each selection
@ -2678,7 +2723,7 @@ fn file_picker_in_current_buffer_directory(cx: &mut Context) {
}
fn file_picker_in_current_directory(cx: &mut Context) {
let cwd = helix_loader::current_working_dir();
let cwd = helix_stdx::env::current_working_dir();
if !cwd.exists() {
cx.editor
.set_error("Current working directory does not exist");
@ -2706,7 +2751,7 @@ fn buffer_picker(cx: &mut Context) {
let path = self
.path
.as_deref()
.map(helix_core::path::get_relative_path);
.map(helix_stdx::path::get_relative_path);
let path = match path.as_deref().and_then(Path::to_str) {
Some(path) => path,
None => SCRATCH_BUFFER_NAME,
@ -2736,7 +2781,7 @@ fn buffer_picker(cx: &mut Context) {
.editor
.documents
.values()
.map(|doc| new_meta(doc))
.map(new_meta)
.collect::<Vec<BufferMeta>>();
// mru
@ -2773,7 +2818,7 @@ fn jumplist_picker(cx: &mut Context) {
let path = self
.path
.as_deref()
.map(helix_core::path::get_relative_path);
.map(helix_stdx::path::get_relative_path);
let path = match path.as_deref().and_then(Path::to_str) {
Some(path) => path,
None => SCRATCH_BUFFER_NAME,
@ -2881,7 +2926,7 @@ pub fn command_palette(cx: &mut Context) {
let register = cx.register;
let count = cx.count;
cx.callback = Some(Box::new(
cx.callback.push(Box::new(
move |compositor: &mut Compositor, cx: &mut compositor::Context| {
let keymap = compositor.find::<ui::EditorView>().unwrap().keymaps.map()
[&cx.editor.mode]
@ -2901,7 +2946,7 @@ pub fn command_palette(cx: &mut Context) {
register,
count,
editor: cx.editor,
callback: None,
callback: Vec::new(),
on_next_key_callback: None,
jobs: cx.jobs,
};
@ -2929,7 +2974,7 @@ pub fn command_palette(cx: &mut Context) {
fn last_picker(cx: &mut Context) {
// TODO: last picker does not seem to work well with buffer_picker
cx.callback = Some(Box::new(|compositor, cx| {
cx.callback.push(Box::new(|compositor, cx| {
if let Some(picker) = compositor.last_picker.take() {
compositor.push(picker);
} else {
@ -2985,6 +3030,7 @@ fn insert_with_indent(cx: &mut Context, cursor_fallback: IndentFallbackPos) {
let indent = indent::indent_for_newline(
language_config,
syntax,
&doc.config.load().indent_heuristic,
&doc.indent_style,
tab_width,
text,
@ -3113,6 +3159,7 @@ fn open(cx: &mut Context, open: Open) {
let indent = indent::indent_for_newline(
doc.language_config(),
doc.syntax(),
&doc.config.load().indent_heuristic,
&doc.indent_style,
doc.tab_width(),
text,
@ -3280,7 +3327,7 @@ fn exit_select_mode(cx: &mut Context) {
fn goto_first_diag(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let selection = match doc.shown_diagnostics().next() {
let selection = match doc.diagnostics().first() {
Some(diag) => Selection::single(diag.range.start, diag.range.end),
None => return,
};
@ -3289,7 +3336,7 @@ fn goto_first_diag(cx: &mut Context) {
fn goto_last_diag(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let selection = match doc.shown_diagnostics().last() {
let selection = match doc.diagnostics().last() {
Some(diag) => Selection::single(diag.range.start, diag.range.end),
None => return,
};
@ -3305,9 +3352,10 @@ fn goto_next_diag(cx: &mut Context) {
.cursor(doc.text().slice(..));
let diag = doc
.shown_diagnostics()
.diagnostics()
.iter()
.find(|diag| diag.range.start > cursor_pos)
.or_else(|| doc.shown_diagnostics().next());
.or_else(|| doc.diagnostics().first());
let selection = match diag {
Some(diag) => Selection::single(diag.range.start, diag.range.end),
@ -3325,10 +3373,11 @@ fn goto_prev_diag(cx: &mut Context) {
.cursor(doc.text().slice(..));
let diag = doc
.shown_diagnostics()
.diagnostics()
.iter()
.rev()
.find(|diag| diag.range.start < cursor_pos)
.or_else(|| doc.shown_diagnostics().last());
.or_else(|| doc.diagnostics().last());
let selection = match diag {
// NOTE: the selection is reversed because we're jumping to the
@ -3437,9 +3486,10 @@ fn hunk_range(hunk: Hunk, text: RopeSlice) -> Range {
}
pub mod insert {
use crate::events::PostInsertChar;
use super::*;
pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
pub type PostHook = fn(&mut Context, char);
/// Exclude the cursor in range.
fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range {
@ -3453,88 +3503,6 @@ pub mod insert {
}
}
// It trigger completion when idle timer reaches deadline
// Only trigger completion if the word under cursor is longer than n characters
pub fn idle_completion(cx: &mut Context) {
let config = cx.editor.config();
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
use helix_core::chars::char_is_word;
let mut iter = text.chars_at(cursor);
iter.reverse();
for _ in 0..config.completion_trigger_len {
match iter.next() {
Some(c) if char_is_word(c) => {}
_ => return,
}
}
super::completion(cx);
}
fn language_server_completion(cx: &mut Context, ch: char) {
let config = cx.editor.config();
if !config.auto_completion {
return;
}
use helix_lsp::lsp;
// if ch matches completion char, trigger completion
let doc = doc_mut!(cx.editor);
let trigger_completion = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.any(|ls| {
// TODO: what if trigger is multiple chars long
matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions {
trigger_characters: Some(triggers),
..
}) if triggers.iter().any(|trigger| trigger.contains(ch)))
});
if trigger_completion {
cx.editor.clear_idle_timer();
super::completion(cx);
}
}
fn signature_help(cx: &mut Context, ch: char) {
use helix_lsp::lsp;
// if ch matches signature_help char, trigger
let doc = doc_mut!(cx.editor);
// TODO support multiple language servers (not just the first that is found), likely by merging UI somehow
let Some(language_server) = doc
.language_servers_with_feature(LanguageServerFeature::SignatureHelp)
.next()
else {
return;
};
let capabilities = language_server.capabilities();
if let lsp::ServerCapabilities {
signature_help_provider:
Some(lsp::SignatureHelpOptions {
trigger_characters: Some(triggers),
// TODO: retrigger_characters
..
}),
..
} = capabilities
{
// TODO: what if trigger is multiple chars long
let is_trigger = triggers.iter().any(|trigger| trigger.contains(ch));
// lsp doesn't tell us when to close the signature help, so we request
// the help information again after common close triggers which should
// return None, which in turn closes the popup.
let close_triggers = &[')', ';', '.'];
if is_trigger || close_triggers.contains(&ch) {
super::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
}
}
}
// The default insert hook: simply insert the character
#[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature
fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
@ -3564,12 +3532,7 @@ pub mod insert {
doc.apply(&t, view.id);
}
// TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc)
// this could also generically look at Transaction, but it's a bit annoying to look at
// Operation instead of Change.
for hook in &[language_server_completion, signature_help] {
hook(cx, c);
}
helix_event::dispatch(PostInsertChar { c, cx });
}
pub fn smart_tab(cx: &mut Context) {
@ -3652,6 +3615,7 @@ pub mod insert {
let indent = indent::indent_for_newline(
doc.language_config(),
doc.syntax(),
&doc.config.load().indent_heuristic,
&doc.indent_style,
doc.tab_width(),
text,
@ -3687,7 +3651,7 @@ pub mod insert {
(pos, pos, local_offs)
};
let new_range = if doc.restore_cursor {
let new_range = if range.cursor(text) > range.anchor {
// when appending, extend the range by local_offs
Range::new(
range.anchor + global_offs,
@ -3793,8 +3757,6 @@ pub mod insert {
});
let (view, doc) = current!(cx.editor);
doc.apply(&transaction, view.id);
lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
}
pub fn delete_char_forward(cx: &mut Context) {
@ -3897,12 +3859,12 @@ fn yank(cx: &mut Context) {
}
fn yank_to_clipboard(cx: &mut Context) {
yank_impl(cx.editor, '*');
yank_impl(cx.editor, '+');
exit_select_mode(cx);
}
fn yank_to_primary_clipboard(cx: &mut Context) {
yank_impl(cx.editor, '+');
yank_impl(cx.editor, '*');
exit_select_mode(cx);
}
@ -3959,13 +3921,13 @@ fn yank_joined(cx: &mut Context) {
fn yank_joined_to_clipboard(cx: &mut Context) {
let line_ending = doc!(cx.editor).line_ending;
yank_joined_impl(cx.editor, line_ending.as_str(), '*');
yank_joined_impl(cx.editor, line_ending.as_str(), '+');
exit_select_mode(cx);
}
fn yank_joined_to_primary_clipboard(cx: &mut Context) {
let line_ending = doc!(cx.editor).line_ending;
yank_joined_impl(cx.editor, line_ending.as_str(), '+');
yank_joined_impl(cx.editor, line_ending.as_str(), '*');
exit_select_mode(cx);
}
@ -3982,12 +3944,12 @@ fn yank_primary_selection_impl(editor: &mut Editor, register: char) {
}
fn yank_main_selection_to_clipboard(cx: &mut Context) {
yank_primary_selection_impl(cx.editor, '*');
yank_primary_selection_impl(cx.editor, '+');
exit_select_mode(cx);
}
fn yank_main_selection_to_primary_clipboard(cx: &mut Context) {
yank_primary_selection_impl(cx.editor, '+');
yank_primary_selection_impl(cx.editor, '*');
exit_select_mode(cx);
}
@ -4085,22 +4047,27 @@ pub(crate) fn paste_bracketed_value(cx: &mut Context, contents: String) {
};
let (view, doc) = current!(cx.editor);
paste_impl(&[contents], doc, view, paste, count, cx.editor.mode);
exit_select_mode(cx);
}
fn paste_clipboard_after(cx: &mut Context) {
paste(cx.editor, '*', Paste::After, cx.count());
paste(cx.editor, '+', Paste::After, cx.count());
exit_select_mode(cx);
}
fn paste_clipboard_before(cx: &mut Context) {
paste(cx.editor, '*', Paste::Before, cx.count());
paste(cx.editor, '+', Paste::Before, cx.count());
exit_select_mode(cx);
}
fn paste_primary_clipboard_after(cx: &mut Context) {
paste(cx.editor, '+', Paste::After, cx.count());
paste(cx.editor, '*', Paste::After, cx.count());
exit_select_mode(cx);
}
fn paste_primary_clipboard_before(cx: &mut Context) {
paste(cx.editor, '+', Paste::Before, cx.count());
paste(cx.editor, '*', Paste::Before, cx.count());
exit_select_mode(cx);
}
fn replace_with_yanked(cx: &mut Context) {
@ -4109,9 +4076,13 @@ fn replace_with_yanked(cx: &mut Context) {
}
fn replace_with_yanked_impl(editor: &mut Editor, register: char, count: usize) {
let Some(values) = editor.registers
let Some(values) = editor
.registers
.read(register, editor)
.filter(|values| values.len() > 0) else { return };
.filter(|values| values.len() > 0)
else {
return;
};
let values: Vec<_> = values.map(|value| value.to_string()).collect();
let (view, doc) = current!(editor);
@ -4138,15 +4109,19 @@ fn replace_with_yanked_impl(editor: &mut Editor, register: char, count: usize) {
}
fn replace_selections_with_clipboard(cx: &mut Context) {
replace_with_yanked_impl(cx.editor, '*', cx.count());
replace_with_yanked_impl(cx.editor, '+', cx.count());
exit_select_mode(cx);
}
fn replace_selections_with_primary_clipboard(cx: &mut Context) {
replace_with_yanked_impl(cx.editor, '+', cx.count());
replace_with_yanked_impl(cx.editor, '*', cx.count());
exit_select_mode(cx);
}
fn paste(editor: &mut Editor, register: char, pos: Paste, count: usize) {
let Some(values) = editor.registers.read(register, editor) else { return };
let Some(values) = editor.registers.read(register, editor) else {
return;
};
let values: Vec<_> = values.map(|value| value.to_string()).collect();
let (view, doc) = current!(editor);
@ -4160,6 +4135,7 @@ fn paste_after(cx: &mut Context) {
Paste::After,
cx.count(),
);
exit_select_mode(cx);
}
fn paste_before(cx: &mut Context) {
@ -4169,6 +4145,7 @@ fn paste_before(cx: &mut Context) {
Paste::Before,
cx.count(),
);
exit_select_mode(cx);
}
fn get_lines(doc: &Document, view_id: ViewId) -> Vec<usize> {
@ -4207,6 +4184,7 @@ fn indent(cx: &mut Context) {
}),
);
doc.apply(&transaction, view.id);
exit_select_mode(cx);
}
fn unindent(cx: &mut Context) {
@ -4246,6 +4224,7 @@ fn unindent(cx: &mut Context) {
let transaction = Transaction::change(doc.text(), changes.into_iter());
doc.apply(&transaction, view.id);
exit_select_mode(cx);
}
fn format_selections(cx: &mut Context) {
@ -4312,10 +4291,9 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) {
use movement::skip_while;
let (view, doc) = current!(cx.editor);
let text = doc.text();
let slice = doc.text().slice(..);
let slice = text.slice(..);
let mut changes = Vec::new();
let fragment = Tendril::from(" ");
for selection in doc.selection(view.id) {
let (start, mut end) = selection.line_range(slice);
@ -4331,9 +4309,13 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) {
let mut end = text.line_to_char(line + 1);
end = skip_while(slice, end, |ch| matches!(ch, ' ' | '\t')).unwrap_or(end);
// need to skip from start, not end
let change = (start, end, Some(fragment.clone()));
changes.push(change);
let separator = if end == line_end_char_index(&slice, line + 1) {
// the joining line contains only space-characters => don't include a whitespace when joining
None
} else {
Some(Tendril::from(" "))
};
changes.push((start, end, separator));
}
}
@ -4345,9 +4327,6 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) {
changes.sort_unstable_by_key(|(from, _to, _text)| *from);
changes.dedup();
// TODO: joining multiple empty lines should be replaced by a single space.
// need to merge change ranges that touch
// select inserted spaces
let transaction = if select_space {
let ranges: SmallVec<_> = changes
@ -4359,9 +4338,9 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) {
})
.collect();
let selection = Selection::new(ranges, 0);
Transaction::change(doc.text(), changes.into_iter()).with_selection(selection)
Transaction::change(text, changes.into_iter()).with_selection(selection)
} else {
Transaction::change(doc.text(), changes.into_iter())
Transaction::change(text, changes.into_iter())
};
doc.apply(&transaction, view.id);
@ -4431,151 +4410,14 @@ fn remove_primary_selection(cx: &mut Context) {
}
pub fn completion(cx: &mut Context) {
use helix_lsp::{lsp, util::pos_to_lsp_pos};
let (view, doc) = current!(cx.editor);
let range = doc.selection(view.id).primary();
let text = doc.text().slice(..);
let cursor = range.cursor(text);
let savepoint = if let Some(CompleteAction::Selected { savepoint }) = &cx.editor.last_completion
{
savepoint.clone()
} else {
doc.savepoint(view)
};
let text = savepoint.text.clone();
let cursor = savepoint.cursor();
let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.map(|language_server| {
let language_server_id = language_server.id();
let offset_encoding = language_server.offset_encoding();
let pos = pos_to_lsp_pos(&text, cursor, offset_encoding);
let doc_id = doc.identifier();
let completion_request = language_server.completion(doc_id, pos, None).unwrap();
async move {
let json = completion_request.await?;
let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
let items = match response {
Some(lsp::CompletionResponse::Array(items)) => items,
// TODO: do something with is_incomplete
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete: _is_incomplete,
items,
})) => items,
None => Vec::new(),
}
.into_iter()
.map(|item| CompletionItem {
item,
language_server_id,
resolved: false,
})
.collect();
anyhow::Ok(items)
}
})
.collect();
// setup a channel that allows the request to be canceled
let (tx, rx) = oneshot::channel();
// set completion_request so that this request can be canceled
// by setting completion_request, the old channel stored there is dropped
// and the associated request is automatically dropped
cx.editor.completion_request_handle = Some(tx);
let future = async move {
let items_future = async move {
let mut items = Vec::new();
// TODO if one completion request errors, all other completion requests are discarded (even if they're valid)
while let Some(mut lsp_items) = futures.try_next().await? {
items.append(&mut lsp_items);
}
anyhow::Ok(items)
};
tokio::select! {
biased;
_ = rx => {
Ok(Vec::new())
}
res = items_future => {
res
}
}
};
let trigger_offset = cursor;
// TODO: trigger_offset should be the cursor offset but we also need a starting offset from where we want to apply
// completion filtering. For example logger.te| should filter the initial suggestion list with "te".
use helix_core::chars;
let mut iter = text.chars_at(cursor);
iter.reverse();
let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count();
let start_offset = cursor.saturating_sub(offset);
let trigger_doc = doc.id();
let trigger_view = view.id;
// FIXME: The commands Context can only have a single callback
// which means it gets overwritten when executing keybindings
// with multiple commands or macros. This would mean that completion
// might be incorrectly applied when repeating the insertmode action
//
// TODO: to solve this either make cx.callback a Vec of callbacks or
// alternatively move `last_insert` to `helix_view::Editor`
cx.callback = Some(Box::new(
move |compositor: &mut Compositor, _cx: &mut compositor::Context| {
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
},
));
cx.jobs.callback(async move {
let items = future.await?;
let call = move |editor: &mut Editor, compositor: &mut Compositor| {
let (view, doc) = current_ref!(editor);
// check if the completion request is stale.
//
// Completions are completed asynchronously and therefore the user could
//switch document/view or leave insert mode. In all of thoise cases the
// completion should be discarded
if editor.mode != Mode::Insert || view.id != trigger_view || doc.id() != trigger_doc {
return;
}
if items.is_empty() {
// editor.set_error("No completion available");
return;
}
let size = compositor.size();
let ui = compositor.find::<ui::EditorView>().unwrap();
let completion_area = ui.set_completion(
editor,
savepoint,
items,
start_offset,
trigger_offset,
size,
);
let size = compositor.size();
let signature_help_area = compositor
.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
.map(|signature_help| signature_help.area(size, editor));
// Delete the signature help popup if they intersect.
if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b))
{
compositor.remove(SignatureHelp::ID);
}
};
Ok(Callback::EditorCompositor(Box::new(call)))
});
cx.editor
.handlers
.trigger_completions(cursor, doc.id(), view.id);
}
// comments
@ -4754,10 +4596,6 @@ fn move_node_bound_impl(cx: &mut Context, dir: Direction, movement: Movement) {
);
doc.set_selection(view.id, selection);
// [TODO] temporary workaround until we're not using the idle timer to
// trigger auto completions any more
editor.clear_idle_timer();
}
};
@ -5527,12 +5365,18 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) {
};
// These `usize`s cannot underflow because selection ranges cannot overlap.
// Once the MSRV is 1.66.0 (mixed_integer_ops is stabilized), we can use checked
// arithmetic to assert this.
let anchor = (to as isize + offset - deleted_len as isize) as usize;
let anchor = to
.checked_add_signed(offset)
.expect("Selection ranges cannot overlap")
.checked_sub(deleted_len)
.expect("Selection ranges cannot overlap");
let new_range = Range::new(anchor, anchor + output_len).with_direction(range.direction());
ranges.push(new_range);
offset = offset + output_len as isize - deleted_len as isize;
offset = offset
.checked_add_unsigned(output_len)
.expect("Selection ranges cannot overlap")
.checked_sub_unsigned(deleted_len)
.expect("Selection ranges cannot overlap");
changes.push((from, to, Some(output)));
}
@ -5671,6 +5515,7 @@ fn increment_impl(cx: &mut Context, increment_direction: IncrementDirection) {
let transaction = Transaction::change(doc.text(), changes.into_iter());
let transaction = transaction.with_selection(new_selection);
doc.apply(&transaction, view.id);
exit_select_mode(cx);
}
}
@ -5738,7 +5583,7 @@ fn replay_macro(cx: &mut Context) {
cx.editor.macro_replaying.push(reg);
let count = cx.count();
cx.callback = Some(Box::new(move |compositor, cx| {
cx.callback.push(Box::new(move |compositor, cx| {
for _ in 0..count {
for &key in keys.iter() {
compositor.handle_event(&compositor::Event::Key(key), cx);

@ -8,7 +8,7 @@ use dap::{StackFrame, Thread, ThreadStates};
use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate};
use helix_dap::{self as dap, Client};
use helix_lsp::block_on;
use helix_view::editor::Breakpoint;
use helix_view::{editor::Breakpoint, graphics::Margin};
use serde_json::{to_value, Value};
use tokio_stream::wrappers::UnboundedReceiverStream;
@ -78,7 +78,7 @@ fn thread_picker(
})
.with_preview(move |editor, thread| {
let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?;
let frame = frames.get(0)?;
let frame = frames.first()?;
let path = frame.source.as_ref()?.path.clone()?;
let pos = Some((
frame.line.saturating_sub(1),
@ -166,7 +166,7 @@ pub fn dap_start_impl(
// TODO: avoid refetching all of this... pass a config in
let template = match name {
Some(name) => config.templates.iter().find(|t| t.name == name),
None => config.templates.get(0),
None => config.templates.first(),
}
.ok_or_else(|| anyhow!("No debug config with given name"))?;
@ -217,7 +217,7 @@ pub fn dap_start_impl(
}
}
args.insert("cwd", to_value(helix_loader::current_working_dir())?);
args.insert("cwd", to_value(helix_stdx::env::current_working_dir())?);
let args = to_value(args).unwrap();
@ -581,7 +581,12 @@ pub fn dap_variables(cx: &mut Context) {
}
let contents = Text::from(tui::text::Text::from(variables));
let popup = Popup::new("dap-variables", contents);
let margin = if cx.editor.popup_border() {
Margin::all(1)
} else {
Margin::none()
};
let popup = Popup::new("dap-variables", contents).margin(margin);
cx.replace_or_push_layer("dap-variables", popup);
}

@ -1,4 +1,4 @@
use futures_util::{future::BoxFuture, stream::FuturesUnordered, FutureExt};
use futures_util::{stream::FuturesUnordered, FutureExt};
use helix_lsp::{
block_on,
lsp::{
@ -8,21 +8,21 @@ use helix_lsp::{
util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range},
Client, OffsetEncoding,
};
use serde_json::Value;
use tokio_stream::StreamExt;
use tui::{
text::{Span, Spans},
widgets::Row,
};
use super::{align_view, push_jump, Align, Context, Editor, Open};
use super::{align_view, push_jump, Align, Context, Editor};
use helix_core::{
path, syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection,
};
use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection};
use helix_stdx::path;
use helix_view::{
document::{DocumentInlayHints, DocumentInlayHintsId, Mode},
document::{DocumentInlayHints, DocumentInlayHintsId},
editor::Action,
graphics::Margin,
handlers::lsp::SignatureHelpInvoked,
theme::Style,
Document, View,
};
@ -30,10 +30,7 @@ use helix_view::{
use crate::{
compositor::{self, Compositor},
job::Callback,
ui::{
self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup,
PromptEvent,
},
ui::{self, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup, PromptEvent},
};
use std::{
@ -42,14 +39,13 @@ use std::{
fmt::Write,
future::Future,
path::PathBuf,
sync::Arc,
};
/// Gets the first language server that is attached to a document which supports a specific feature.
/// If there is no configured language server that supports the feature, this displays a status message.
/// Using this macro in a context where the editor automatically queries the LSP
/// (instead of when the user explicitly does so via a keybind like `gd`)
/// will spam the "No configured language server supports <feature>" status message confusingly.
/// will spam the "No configured language server supports \<feature>" status message confusingly.
#[macro_export]
macro_rules! language_server_with_feature {
($editor:expr, $doc:expr, $feature:expr) => {{
@ -730,8 +726,7 @@ pub fn code_action(cx: &mut Context) {
resolved_code_action.as_ref().unwrap_or(code_action);
if let Some(ref workspace_edit) = resolved_code_action.edit {
log::debug!("edit: {:?}", workspace_edit);
let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit);
let _ = editor.apply_workspace_edit(offset_encoding, workspace_edit);
}
// if code action provides both edit and command first the edit
@ -744,7 +739,16 @@ pub fn code_action(cx: &mut Context) {
});
picker.move_down(); // pre-select the first item
let popup = Popup::new("code-action", picker).with_scrollbar(false);
let margin = if editor.menu_border() {
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);
};
@ -782,63 +786,6 @@ pub fn execute_lsp_command(editor: &mut Editor, language_server_id: usize, cmd:
});
}
pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> {
use lsp::ResourceOp;
use std::fs;
match op {
ResourceOp::Create(op) => {
let path = op.uri.to_file_path().unwrap();
let ignore_if_exists = op.options.as_ref().map_or(false, |options| {
!options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false)
});
if ignore_if_exists && path.exists() {
Ok(())
} else {
// Create directory if it does not exist
if let Some(dir) = path.parent() {
if !dir.is_dir() {
fs::create_dir_all(dir)?;
}
}
fs::write(&path, [])
}
}
ResourceOp::Delete(op) => {
let path = op.uri.to_file_path().unwrap();
if path.is_dir() {
let recursive = op
.options
.as_ref()
.and_then(|options| options.recursive)
.unwrap_or(false);
if recursive {
fs::remove_dir_all(&path)
} else {
fs::remove_dir(&path)
}
} else if path.is_file() {
fs::remove_file(&path)
} else {
Ok(())
}
}
ResourceOp::Rename(op) => {
let from = op.old_uri.to_file_path().unwrap();
let to = op.new_uri.to_file_path().unwrap();
let ignore_if_exists = op.options.as_ref().map_or(false, |options| {
!options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false)
});
if ignore_if_exists && to.exists() {
Ok(())
} else {
fs::rename(from, &to)
}
}
}
}
#[derive(Debug)]
pub struct ApplyEditError {
pub kind: ApplyEditErrorKind,
@ -866,168 +813,20 @@ impl ToString for ApplyEditErrorKind {
}
}
///TODO make this transactional (and set failureMode to transactional)
pub fn apply_workspace_edit(
editor: &mut Editor,
offset_encoding: OffsetEncoding,
workspace_edit: &lsp::WorkspaceEdit,
) -> Result<(), ApplyEditError> {
let mut apply_edits = |uri: &helix_lsp::Url,
version: Option<i32>,
text_edits: Vec<lsp::TextEdit>|
-> Result<(), ApplyEditErrorKind> {
let path = match uri.to_file_path() {
Ok(path) => path,
Err(_) => {
let err = format!("unable to convert URI to filepath: {}", uri);
log::error!("{}", err);
editor.set_error(err);
return Err(ApplyEditErrorKind::UnknownURISchema);
}
};
let current_view_id = view!(editor).id;
let doc_id = match editor.open(&path, Action::Load) {
Ok(doc_id) => doc_id,
Err(err) => {
let err = format!("failed to open document: {}: {}", uri, err);
log::error!("{}", err);
editor.set_error(err);
return Err(ApplyEditErrorKind::FileNotFound);
}
};
let doc = doc_mut!(editor, &doc_id);
if let Some(version) = version {
if version != doc.version() {
let err = format!("outdated workspace edit for {path:?}");
log::error!("{err}, expected {} but got {version}", doc.version());
editor.set_error(err);
return Err(ApplyEditErrorKind::DocumentChanged);
}
}
// Need to determine a view for apply/append_changes_to_history
let selections = doc.selections();
let view_id = if selections.contains_key(&current_view_id) {
// use current if possible
current_view_id
} else {
// Hack: we take the first available view_id
selections
.keys()
.next()
.copied()
.expect("No view_id available")
};
let transaction = helix_lsp::util::generate_transaction_from_edits(
doc.text(),
text_edits,
offset_encoding,
);
let view = view_mut!(editor, view_id);
doc.apply(&transaction, view.id);
doc.append_changes_to_history(view);
Ok(())
};
if let Some(ref document_changes) = workspace_edit.document_changes {
match document_changes {
lsp::DocumentChanges::Edits(document_edits) => {
for (i, document_edit) in document_edits.iter().enumerate() {
let edits = document_edit
.edits
.iter()
.map(|edit| match edit {
lsp::OneOf::Left(text_edit) => text_edit,
lsp::OneOf::Right(annotated_text_edit) => {
&annotated_text_edit.text_edit
}
})
.cloned()
.collect();
apply_edits(
&document_edit.text_document.uri,
document_edit.text_document.version,
edits,
)
.map_err(|kind| ApplyEditError {
kind,
failed_change_idx: i,
})?;
}
}
lsp::DocumentChanges::Operations(operations) => {
log::debug!("document changes - operations: {:?}", operations);
for (i, operation) in operations.iter().enumerate() {
match operation {
lsp::DocumentChangeOperation::Op(op) => {
apply_document_resource_op(op).map_err(|io| ApplyEditError {
kind: ApplyEditErrorKind::IoError(io),
failed_change_idx: i,
})?;
}
lsp::DocumentChangeOperation::Edit(document_edit) => {
let edits = document_edit
.edits
.iter()
.map(|edit| match edit {
lsp::OneOf::Left(text_edit) => text_edit,
lsp::OneOf::Right(annotated_text_edit) => {
&annotated_text_edit.text_edit
}
})
.cloned()
.collect();
apply_edits(
&document_edit.text_document.uri,
document_edit.text_document.version,
edits,
)
.map_err(|kind| ApplyEditError {
kind,
failed_change_idx: i,
})?;
}
}
}
}
}
return Ok(());
}
if let Some(ref changes) = workspace_edit.changes {
log::debug!("workspace changes: {:?}", changes);
for (i, (uri, text_edits)) in changes.iter().enumerate() {
let text_edits = text_edits.to_vec();
apply_edits(uri, None, text_edits).map_err(|kind| ApplyEditError {
kind,
failed_change_idx: i,
})?;
}
}
Ok(())
}
/// Precondition: `locations` should be non-empty.
fn goto_impl(
editor: &mut Editor,
compositor: &mut Compositor,
locations: Vec<lsp::Location>,
offset_encoding: OffsetEncoding,
) {
let cwdir = helix_loader::current_working_dir();
let cwdir = helix_stdx::env::current_working_dir();
match locations.as_slice() {
[location] => {
jump_to_location(editor, location, offset_encoding, Action::Replace);
}
[] => {
editor.set_error("No definition found.");
}
[] => unreachable!("`locations` should be non-empty for `goto_impl`"),
_locations => {
let picker = Picker::new(locations, cwdir, move |cx, location, action| {
jump_to_location(cx.editor, location, offset_encoding, action)
@ -1069,7 +868,11 @@ where
future,
move |editor, compositor, response: Option<lsp::GotoDefinitionResponse>| {
let items = to_locations(response);
if items.is_empty() {
editor.set_error("No definition found.");
} else {
goto_impl(editor, compositor, items, offset_encoding);
}
},
);
}
@ -1129,151 +932,19 @@ pub fn goto_reference(cx: &mut Context) {
future,
move |editor, compositor, response: Option<Vec<lsp::Location>>| {
let items = response.unwrap_or_default();
if items.is_empty() {
editor.set_error("No references found.");
} else {
goto_impl(editor, compositor, items, offset_encoding);
}
},
);
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum SignatureHelpInvoked {
Manual,
Automatic,
}
pub fn signature_help(cx: &mut Context) {
signature_help_impl(cx, SignatureHelpInvoked::Manual)
}
pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) {
let (view, doc) = current!(cx.editor);
// TODO merge multiple language server signature help into one instead of just taking the first language server that supports it
let future = doc
.language_servers_with_feature(LanguageServerFeature::SignatureHelp)
.find_map(|language_server| {
let pos = doc.position(view.id, language_server.offset_encoding());
language_server.text_document_signature_help(doc.identifier(), pos, None)
});
let Some(future) = future else {
// Do not show the message if signature help was invoked
// automatically on backspace, trigger characters, etc.
if invoked == SignatureHelpInvoked::Manual {
cx.editor
.set_error("No configured language server supports signature-help");
}
return;
};
signature_help_impl_with_future(cx, future.boxed(), invoked);
}
pub fn signature_help_impl_with_future(
cx: &mut Context,
future: BoxFuture<'static, helix_lsp::Result<Value>>,
invoked: SignatureHelpInvoked,
) {
cx.callback(
future,
move |editor, compositor, response: Option<lsp::SignatureHelp>| {
let config = &editor.config();
if !(config.lsp.auto_signature_help
|| SignatureHelp::visible_popup(compositor).is_some()
|| invoked == SignatureHelpInvoked::Manual)
{
return;
}
// If the signature help invocation is automatic, don't show it outside of Insert Mode:
// it very probably means the server was a little slow to respond and the user has
// already moved on to something else, making a signature help popup will just be an
// annoyance, see https://github.com/helix-editor/helix/issues/3112
if invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert {
return;
}
let response = match response {
// According to the spec the response should be None if there
// are no signatures, but some servers don't follow this.
Some(s) if !s.signatures.is_empty() => s,
_ => {
compositor.remove(SignatureHelp::ID);
return;
}
};
let doc = doc!(editor);
let language = doc.language_name().unwrap_or("");
let signature = match response
.signatures
.get(response.active_signature.unwrap_or(0) as usize)
{
Some(s) => s,
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 {
signature.documentation.as_ref().map(|doc| match doc {
lsp::Documentation::String(s) => s.clone(),
lsp::Documentation::MarkupContent(markup) => markup.value.clone(),
})
} else {
None
};
contents.set_signature_doc(signature_doc);
let active_param_range = || -> 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))
}
}
};
contents.set_active_param_range(active_param_range());
let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
let mut popup = Popup::new(SignatureHelp::ID, contents)
.position(old_popup.and_then(|p| p.get_position()))
.position_bias(Open::Above)
.ignore_escape_key(true);
// Don't create a popup if it intersects the auto-complete menu.
let size = compositor.size();
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.as_mut()
.map(|completion| completion.area(size, editor))
.filter(|area| area.intersects(popup.area(size, editor)))
.is_some()
{
return;
}
compositor.replace_or_push(SignatureHelp::ID, popup);
},
);
.handlers
.trigger_signature_help(SignatureHelpInvoked::Manual, cx.editor)
}
pub fn hover(cx: &mut Context) {
@ -1398,7 +1069,7 @@ pub fn rename_symbol(cx: &mut Context) {
match block_on(future) {
Ok(edits) => {
let _ = apply_workspace_edit(cx.editor, offset_encoding, &edits);
let _ = cx.editor.apply_workspace_edit(offset_encoding, &edits);
}
Err(err) => cx.editor.set_error(err.to_string()),
}
@ -1411,6 +1082,16 @@ pub fn rename_symbol(cx: &mut Context) {
let (view, doc) = current_ref!(cx.editor);
if doc
.language_servers_with_feature(LanguageServerFeature::RenameSymbol)
.next()
.is_none()
{
cx.editor
.set_error("No configured language server supports symbol renaming");
return;
}
let language_server_with_prepare_rename_support = doc
.language_servers_with_feature(LanguageServerFeature::RenameSymbol)
.find(|ls| {

@ -6,6 +6,7 @@ use crate::job::Job;
use super::*;
use helix_core::fuzzy::fuzzy_match;
use helix_core::indent::MAX_INDENT;
use helix_core::{encoding, line_ending, shellwords::Shellwords};
use helix_view::document::DEFAULT_LANGUAGE_NAME;
use helix_view::editor::{Action, CloseError, ConfigEvent};
@ -109,7 +110,7 @@ fn open(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) ->
ensure!(!args.is_empty(), "wrong argument count");
for arg in args {
let (path, pos) = args::parse_file(arg);
let path = helix_core::path::expand_tilde(&path);
let path = helix_stdx::path::expand_tilde(&path);
// If the path is a directory, open a file picker on that directory and update the status
// message
if let Ok(true) = std::fs::canonicalize(&path).map(|p| p.is_dir()) {
@ -475,20 +476,19 @@ fn set_indent_style(
cx.editor.set_status(match style {
Tabs => "tabs".to_owned(),
Spaces(1) => "1 space".to_owned(),
Spaces(n) if (2..=8).contains(&n) => format!("{} spaces", n),
_ => unreachable!(), // Shouldn't happen.
Spaces(n) => format!("{} spaces", n),
});
return Ok(());
}
// Attempt to parse argument as an indent style.
let style = match args.get(0) {
let style = match args.first() {
Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs),
Some(Cow::Borrowed("0")) => Some(Tabs),
Some(arg) => arg
.parse::<u8>()
.ok()
.filter(|n| (1..=8).contains(n))
.filter(|n| (1..=MAX_INDENT).contains(n))
.map(Spaces),
_ => None,
};
@ -534,7 +534,7 @@ fn set_line_ending(
}
let arg = args
.get(0)
.first()
.context("argument missing")?
.to_ascii_lowercase();
@ -573,7 +573,6 @@ fn set_line_ending(
Ok(())
}
fn earlier(
cx: &mut compositor::Context,
args: &[Cow<str>],
@ -674,13 +673,15 @@ pub fn write_all_impl(
let mut errors: Vec<&'static str> = Vec::new();
let config = cx.editor.config();
let jobs = &mut cx.jobs;
let current_view = view!(cx.editor);
let saves: Vec<_> = cx
.editor
.documents
.values_mut()
.filter_map(|doc| {
.keys()
.cloned()
.collect::<Vec<_>>()
.into_iter()
.filter_map(|id| {
let doc = doc!(cx.editor, &id);
if !doc.is_modified() {
return None;
}
@ -691,22 +692,9 @@ pub fn write_all_impl(
return None;
}
// Look for a view to apply the formatting change to. If the document
// is in the current view, just use that. Otherwise, since we don't
// have any other metric available for better selection, just pick
// the first view arbitrarily so that we still commit the document
// state for undos. If somehow we have a document that has not been
// initialized with any view, initialize it with the current view.
let target_view = if doc.selections().contains_key(&current_view.id) {
current_view.id
} else if let Some(view) = doc.selections().keys().next() {
*view
} else {
doc.ensure_view_init(current_view.id);
current_view.id
};
Some((doc.id(), target_view))
// Look for a view to apply the formatting change to.
let target_view = cx.editor.get_synced_view_id(doc.id());
Some((id, target_view))
})
.collect();
@ -921,7 +909,7 @@ fn yank_main_selection_to_clipboard(
return Ok(());
}
yank_primary_selection_impl(cx.editor, '*');
yank_primary_selection_impl(cx.editor, '+');
Ok(())
}
@ -956,7 +944,7 @@ fn yank_joined_to_clipboard(
let doc = doc!(cx.editor);
let default_sep = Cow::Borrowed(doc.line_ending.as_str());
let separator = args.first().unwrap_or(&default_sep);
yank_joined_impl(cx.editor, separator, '*');
yank_joined_impl(cx.editor, separator, '+');
Ok(())
}
@ -969,7 +957,7 @@ fn yank_main_selection_to_primary_clipboard(
return Ok(());
}
yank_primary_selection_impl(cx.editor, '+');
yank_primary_selection_impl(cx.editor, '*');
Ok(())
}
@ -985,7 +973,7 @@ fn yank_joined_to_primary_clipboard(
let doc = doc!(cx.editor);
let default_sep = Cow::Borrowed(doc.line_ending.as_str());
let separator = args.first().unwrap_or(&default_sep);
yank_joined_impl(cx.editor, separator, '+');
yank_joined_impl(cx.editor, separator, '*');
Ok(())
}
@ -998,7 +986,7 @@ fn paste_clipboard_after(
return Ok(());
}
paste(cx.editor, '*', Paste::After, 1);
paste(cx.editor, '+', Paste::After, 1);
Ok(())
}
@ -1011,7 +999,7 @@ fn paste_clipboard_before(
return Ok(());
}
paste(cx.editor, '*', Paste::Before, 1);
paste(cx.editor, '+', Paste::Before, 1);
Ok(())
}
@ -1024,7 +1012,7 @@ fn paste_primary_clipboard_after(
return Ok(());
}
paste(cx.editor, '+', Paste::After, 1);
paste(cx.editor, '*', Paste::After, 1);
Ok(())
}
@ -1037,7 +1025,7 @@ fn paste_primary_clipboard_before(
return Ok(());
}
paste(cx.editor, '+', Paste::Before, 1);
paste(cx.editor, '*', Paste::Before, 1);
Ok(())
}
@ -1050,7 +1038,7 @@ fn replace_selections_with_clipboard(
return Ok(());
}
replace_with_yanked_impl(cx.editor, '*', 1);
replace_with_yanked_impl(cx.editor, '+', 1);
Ok(())
}
@ -1063,7 +1051,7 @@ fn replace_selections_with_primary_clipboard(
return Ok(());
}
replace_with_yanked_impl(cx.editor, '+', 1);
replace_with_yanked_impl(cx.editor, '*', 1);
Ok(())
}
@ -1090,18 +1078,17 @@ fn change_current_directory(
return Ok(());
}
let dir = helix_core::path::expand_tilde(
let dir = helix_stdx::path::expand_tilde(
args.first()
.context("target directory not provided")?
.as_ref()
.as_ref(),
);
helix_loader::set_current_working_dir(dir)?;
helix_stdx::env::set_current_working_dir(dir)?;
cx.editor.set_status(format!(
"Current working directory is now {}",
helix_loader::current_working_dir().display()
helix_stdx::env::current_working_dir().display()
));
Ok(())
}
@ -1115,7 +1102,7 @@ fn show_current_directory(
return Ok(());
}
let cwd = helix_loader::current_working_dir();
let cwd = helix_stdx::env::current_working_dir();
let message = format!("Current working directory is {}", cwd.display());
if cwd.exists() {
@ -1502,7 +1489,7 @@ fn lsp_stop(
for doc in cx.editor.documents_mut() {
if let Some(client) = doc.remove_language_server_by_name(ls_name) {
doc.clear_diagnostics(client.id());
doc.clear_diagnostics(Some(client.id()));
}
}
}
@ -1558,10 +1545,7 @@ fn tree_sitter_highlight_name(
let text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
let byte = text.char_to_byte(cursor);
let node = syntax
.tree()
.root_node()
.descendant_for_byte_range(byte, byte)?;
let node = syntax.descendant_for_byte_range(byte, byte)?;
// Query the same range as the one used in syntax highlighting.
let range = {
// Calculate viewport byte ranges:
@ -2008,6 +1992,10 @@ fn language(
let id = doc.id();
cx.editor.refresh_language_servers(id);
let doc = doc_mut!(cx.editor);
let diagnostics =
Editor::doc_diagnostics(&cx.editor.language_servers, &cx.editor.diagnostics, doc);
doc.replace_diagnostics(diagnostics, &[], None);
Ok(())
}
@ -2085,7 +2073,7 @@ fn reflow(
// - The configured text-width for this language in languages.toml
// - The configured text-width in the config.toml
let text_width: usize = args
.get(0)
.first()
.map(|num| num.parse::<usize>())
.transpose()?
.or_else(|| doc.language_config().and_then(|config| config.text_width))
@ -2124,11 +2112,7 @@ fn tree_sitter_subtree(
let text = doc.text();
let from = text.char_to_byte(primary_selection.from());
let to = text.char_to_byte(primary_selection.to());
if let Some(selected_node) = syntax
.tree()
.root_node()
.descendant_for_byte_range(from, to)
{
if let Some(selected_node) = syntax.descendant_for_byte_range(from, to) {
let mut contents = String::from("```tsq\n");
helix_core::syntax::pretty_print_tree(&mut contents, selected_node)?;
contents.push_str("\n```");
@ -2408,6 +2392,28 @@ fn redraw(
Ok(())
}
fn move_buffer(
cx: &mut compositor::Context,
args: &[Cow<str>],
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
ensure!(args.len() == 1, format!(":move takes one argument"));
let doc = doc!(cx.editor);
let old_path = doc
.path()
.context("Scratch buffer cannot be moved. Use :write instead")?
.clone();
let new_path = args.first().unwrap().to_string();
if let Err(err) = cx.editor.move_path(&old_path, new_path.as_ref()) {
bail!("Could not move file: {err}");
}
Ok(())
}
pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
TypableCommand {
name: "quit",
@ -2531,7 +2537,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
TypableCommand {
name: "indent-style",
aliases: &[],
doc: "Set the indentation style for editing. ('t' for tabs or 1-8 for number of spaces.)",
doc: "Set the indentation style for editing. ('t' for tabs or 1-16 for number of spaces.)",
fun: set_indent_style,
signature: CommandSignature::none(),
},
@ -3008,6 +3014,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
fun: redraw,
signature: CommandSignature::none(),
},
TypableCommand {
name: "move",
aliases: &[],
doc: "Move the current buffer and its corresponding file to a different path",
fun: move_buffer,
signature: CommandSignature::positional(&[completers::filename]),
},
];
pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableCommand>> =

@ -0,0 +1,20 @@
use helix_event::{events, register_event};
use helix_view::document::Mode;
use helix_view::events::{DocumentDidChange, SelectionDidChange};
use crate::commands;
use crate::keymap::MappableCommand;
events! {
OnModeSwitch<'a, 'cx> { old_mode: Mode, new_mode: Mode, cx: &'a mut commands::Context<'cx> }
PostInsertChar<'a, 'cx> { c: char, cx: &'a mut commands::Context<'cx> }
PostCommand<'a, 'cx> { command: & 'a MappableCommand, cx: &'a mut commands::Context<'cx> }
}
pub fn register() {
register_event::<OnModeSwitch>();
register_event::<PostInsertChar>();
register_event::<PostCommand>();
register_event::<DocumentDidChange>();
register_event::<SelectionDidChange>();
}

@ -0,0 +1,30 @@
use std::sync::Arc;
use arc_swap::ArcSwap;
use helix_event::AsyncHook;
use crate::config::Config;
use crate::events;
use crate::handlers::completion::CompletionHandler;
use crate::handlers::signature_help::SignatureHelpHandler;
pub use completion::trigger_auto_completion;
pub use helix_view::handlers::lsp::SignatureHelpInvoked;
pub use helix_view::handlers::Handlers;
mod completion;
mod signature_help;
pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
events::register();
let completions = CompletionHandler::new(config).spawn();
let signature_hints = SignatureHelpHandler::new().spawn();
let handlers = Handlers {
completions,
signature_hints,
};
completion::register_hooks(&handlers);
signature_help::register_hooks(&handlers);
handlers
}

@ -0,0 +1,465 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use futures_util::stream::FuturesUnordered;
use helix_core::chars::char_is_word;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{
cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
};
use helix_lsp::lsp;
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::{Mode, SavePoint};
use helix_view::handlers::lsp::CompletionEvent;
use helix_view::{DocumentId, Editor, ViewId};
use tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use tokio_stream::StreamExt;
use crate::commands;
use crate::compositor::Compositor;
use crate::config::Config;
use crate::events::{OnModeSwitch, PostCommand, PostInsertChar};
use crate::job::{dispatch, dispatch_blocking};
use crate::keymap::MappableCommand;
use crate::ui::editor::InsertEvent;
use crate::ui::lsp::SignatureHelp;
use crate::ui::{self, CompletionItem, Popup};
use super::Handlers;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum TriggerKind {
Auto,
TriggerChar,
Manual,
}
#[derive(Debug, Clone, Copy)]
struct Trigger {
pos: usize,
view: ViewId,
doc: DocumentId,
kind: TriggerKind,
}
#[derive(Debug)]
pub(super) struct CompletionHandler {
/// currently active trigger which will cause a
/// completion request after the timeout
trigger: Option<Trigger>,
/// A handle for currently active completion request.
/// This can be used to determine whether the current
/// request is still active (and new triggers should be
/// ignored) and can also be used to abort the current
/// request (by dropping the handle)
request: Option<CancelTx>,
config: Arc<ArcSwap<Config>>,
}
impl CompletionHandler {
pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
Self {
config,
request: None,
trigger: None,
}
}
}
impl helix_event::AsyncHook for CompletionHandler {
type Event = CompletionEvent;
fn handle_event(
&mut self,
event: Self::Event,
_old_timeout: Option<Instant>,
) -> Option<Instant> {
match event {
CompletionEvent::AutoTrigger {
cursor: trigger_pos,
doc,
view,
} => {
// techically it shouldn't be possible to switch views/documents in insert mode
// but people may create weird keymaps/use the mouse so lets be extra careful
if self
.trigger
.as_ref()
.map_or(true, |trigger| trigger.doc != doc || trigger.view != view)
{
self.trigger = Some(Trigger {
pos: trigger_pos,
view,
doc,
kind: TriggerKind::Auto,
});
}
}
CompletionEvent::TriggerChar { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.request = None;
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::TriggerChar,
});
}
CompletionEvent::ManualTrigger { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.request = None;
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::Manual,
});
// stop debouncing immediately and request the completion
self.finish_debounce();
return None;
}
CompletionEvent::Cancel => {
self.trigger = None;
self.request = None;
}
CompletionEvent::DeleteText { cursor } => {
// if we deleted the original trigger, abort the completion
if matches!(self.trigger, Some(Trigger{ pos, .. }) if cursor < pos) {
self.trigger = None;
self.request = None;
}
}
}
self.trigger.map(|trigger| {
// if the current request was closed forget about it
// otherwise immediately restart the completion request
let cancel = self.request.take().map_or(false, |req| !req.is_closed());
let timeout = if trigger.kind == TriggerKind::Auto && !cancel {
self.config.load().editor.completion_timeout
} else {
// we want almost instant completions for trigger chars
// and restarting completion requests. The small timeout here mainly
// serves to better handle cases where the completion handler
// may fall behind (so multiple events in the channel) and macros
Duration::from_millis(5)
};
Instant::now() + timeout
})
}
fn finish_debounce(&mut self) {
let trigger = self.trigger.take().expect("debounce always has a trigger");
let (tx, rx) = cancelation();
self.request = Some(tx);
dispatch_blocking(move |editor, compositor| {
request_completion(trigger, rx, editor, compositor)
});
}
}
fn request_completion(
mut trigger: Trigger,
cancel: CancelRx,
editor: &mut Editor,
compositor: &mut Compositor,
) {
let (view, doc) = current!(editor);
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.is_some()
|| editor.mode != Mode::Insert
{
return;
}
let text = doc.text();
let cursor = doc.selection(view.id).primary().cursor(text.slice(..));
if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
return;
}
// this looks odd... Why are we not using the trigger position from
// the `trigger` here? Won't that mean that the trigger char doesn't get
// send to the LS if we type fast enougn? Yes that is true but it's
// not actually a problem. The LSP will resolve the completion to the identifier
// anyway (in fact sending the later position is necessary to get the right results
// from LSPs that provide incomplete completion list). We rely on trigger offset
// and primary cursor matching for multi-cursor completions so this is definitely
// necessary from our side too.
trigger.pos = cursor;
let trigger_text = text.slice(..cursor);
let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.map(|ls| {
let language_server_id = ls.id();
let offset_encoding = ls.offset_encoding();
let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
let doc_id = doc.identifier();
let context = if trigger.kind == TriggerKind::Manual {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
} else {
let trigger_char =
ls.capabilities()
.completion_provider
.as_ref()
.and_then(|provider| {
provider
.trigger_characters
.as_deref()?
.iter()
.find(|&trigger| trigger_text.ends_with(trigger))
});
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
};
let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
async move {
let json = completion_response.await?;
let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
let items = match response {
Some(lsp::CompletionResponse::Array(items)) => items,
// TODO: do something with is_incomplete
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete: _is_incomplete,
items,
})) => items,
None => Vec::new(),
}
.into_iter()
.map(|item| CompletionItem {
item,
language_server_id,
resolved: false,
})
.collect();
anyhow::Ok(items)
}
})
.collect();
let future = async move {
let mut items = Vec::new();
while let Some(lsp_items) = futures.next().await {
match lsp_items {
Ok(mut lsp_items) => items.append(&mut lsp_items),
Err(err) => {
log::debug!("completion request failed: {err:?}");
}
};
}
items
};
let savepoint = doc.savepoint(view);
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
tokio::spawn(async move {
let items = cancelable_future(future, cancel).await.unwrap_or_default();
if items.is_empty() {
return;
}
dispatch(move |editor, compositor| {
show_completion(editor, compositor, items, trigger, savepoint)
})
.await
});
}
fn show_completion(
editor: &mut Editor,
compositor: &mut Compositor,
items: Vec<CompletionItem>,
trigger: Trigger,
savepoint: Arc<SavePoint>,
) {
let (view, doc) = current_ref!(editor);
// check if the completion request is stale.
//
// Completions are completed asynchronously and therefore the user could
//switch document/view or leave insert mode. In all of thoise cases the
// completion should be discarded
if editor.mode != Mode::Insert || view.id != trigger.view || doc.id() != trigger.doc {
return;
}
let size = compositor.size();
let ui = compositor.find::<ui::EditorView>().unwrap();
if ui.completion.is_some() {
return;
}
let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size);
let signature_help_area = compositor
.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
.map(|signature_help| signature_help.area(size, editor));
// Delete the signature help popup if they intersect.
if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b)) {
compositor.remove(SignatureHelp::ID);
}
}
pub fn trigger_auto_completion(
tx: &Sender<CompletionEvent>,
editor: &Editor,
trigger_char_only: bool,
) {
let config = editor.config.load();
if !config.auto_completion {
return;
}
let (view, doc): (&helix_view::View, &helix_view::Document) = current_ref!(editor);
let mut text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
text = doc.text().slice(..cursor);
let is_trigger_char = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.any(|ls| {
matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions {
trigger_characters: Some(triggers),
..
}) if triggers.iter().any(|trigger| text.ends_with(trigger)))
});
if is_trigger_char {
send_blocking(
tx,
CompletionEvent::TriggerChar {
cursor,
doc: doc.id(),
view: view.id,
},
);
return;
}
let is_auto_trigger = !trigger_char_only
&& doc
.text()
.chars_at(cursor)
.reversed()
.take(config.completion_trigger_len as usize)
.all(char_is_word);
if is_auto_trigger {
send_blocking(
tx,
CompletionEvent::AutoTrigger {
cursor,
doc: doc.id(),
view: view.id,
},
);
}
}
fn update_completions(cx: &mut commands::Context, c: Option<char>) {
cx.callback.push(Box::new(move |compositor, cx| {
let editor_view = compositor.find::<ui::EditorView>().unwrap();
if let Some(completion) = &mut editor_view.completion {
completion.update_filter(c);
if completion.is_empty() {
editor_view.clear_completion(cx.editor);
// clearing completions might mean we want to immediately rerequest them (usually
// this occurs if typing a trigger char)
if c.is_some() {
trigger_auto_completion(&cx.editor.handlers.completions, cx.editor, false);
}
}
}
}))
}
fn clear_completions(cx: &mut commands::Context) {
cx.callback.push(Box::new(|compositor, cx| {
let editor_view = compositor.find::<ui::EditorView>().unwrap();
editor_view.clear_completion(cx.editor);
}))
}
fn completion_post_command_hook(
tx: &Sender<CompletionEvent>,
PostCommand { command, cx }: &mut PostCommand<'_, '_>,
) -> anyhow::Result<()> {
if cx.editor.mode == Mode::Insert {
if cx.editor.last_completion.is_some() {
match command {
MappableCommand::Static {
name: "delete_word_forward" | "delete_char_forward" | "completion",
..
} => (),
MappableCommand::Static {
name: "delete_char_backward",
..
} => update_completions(cx, None),
_ => clear_completions(cx),
}
} else {
let event = match command {
MappableCommand::Static {
name: "delete_char_backward" | "delete_word_forward" | "delete_char_forward",
..
} => {
let (view, doc) = current!(cx.editor);
let primary_cursor = doc
.selection(view.id)
.primary()
.cursor(doc.text().slice(..));
CompletionEvent::DeleteText {
cursor: primary_cursor,
}
}
// hacks: some commands are handeled elsewhere and we don't want to
// cancel in that case
MappableCommand::Static {
name: "completion" | "insert_mode" | "append_mode",
..
} => return Ok(()),
_ => CompletionEvent::Cancel,
};
send_blocking(tx, event);
}
}
Ok(())
}
pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.completions.clone();
register_hook!(move |event: &mut PostCommand<'_, '_>| completion_post_command_hook(&tx, event));
let tx = handlers.completions.clone();
register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
if event.old_mode == Mode::Insert {
send_blocking(&tx, CompletionEvent::Cancel);
clear_completions(event.cx);
} else if event.new_mode == Mode::Insert {
trigger_auto_completion(&tx, event.cx.editor, false)
}
Ok(())
});
let tx = handlers.completions.clone();
register_hook!(move |event: &mut PostInsertChar<'_, '_>| {
if event.cx.editor.last_completion.is_some() {
update_completions(event.cx, Some(event.c))
} else {
trigger_auto_completion(&tx, event.cx.editor, false);
}
Ok(())
});
}

@ -0,0 +1,335 @@
use std::sync::Arc;
use std::time::Duration;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{
cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
};
use helix_lsp::lsp;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::Mode;
use helix_view::events::{DocumentDidChange, SelectionDidChange};
use helix_view::handlers::lsp::{SignatureHelpEvent, SignatureHelpInvoked};
use helix_view::Editor;
use tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use crate::commands::Open;
use crate::compositor::Compositor;
use crate::events::{OnModeSwitch, PostInsertChar};
use crate::handlers::Handlers;
use crate::ui::lsp::SignatureHelp;
use crate::ui::Popup;
use crate::{job, ui};
#[derive(Debug)]
enum State {
Open,
Closed,
Pending { request: CancelTx },
}
/// debounce timeout in ms, value taken from VSCode
/// TODO: make this configurable?
const TIMEOUT: u64 = 120;
#[derive(Debug)]
pub(super) struct SignatureHelpHandler {
trigger: Option<SignatureHelpInvoked>,
state: State,
}
impl SignatureHelpHandler {
pub fn new() -> SignatureHelpHandler {
SignatureHelpHandler {
trigger: None,
state: State::Closed,
}
}
}
impl helix_event::AsyncHook for SignatureHelpHandler {
type Event = SignatureHelpEvent;
fn handle_event(
&mut self,
event: Self::Event,
timeout: Option<tokio::time::Instant>,
) -> Option<Instant> {
match event {
SignatureHelpEvent::Invoked => {
self.trigger = Some(SignatureHelpInvoked::Manual);
self.state = State::Closed;
self.finish_debounce();
return None;
}
SignatureHelpEvent::Trigger => {}
SignatureHelpEvent::ReTrigger => {
// don't retrigger if we aren't open/pending yet
if matches!(self.state, State::Closed) {
return timeout;
}
}
SignatureHelpEvent::Cancel => {
self.state = State::Closed;
return None;
}
SignatureHelpEvent::RequestComplete { open } => {
// don't cancel rerequest that was already triggered
if let State::Pending { request } = &self.state {
if !request.is_closed() {
return timeout;
}
}
self.state = if open { State::Open } else { State::Closed };
return timeout;
}
}
if self.trigger.is_none() {
self.trigger = Some(SignatureHelpInvoked::Automatic)
}
Some(Instant::now() + Duration::from_millis(TIMEOUT))
}
fn finish_debounce(&mut self) {
let invocation = self.trigger.take().unwrap();
let (tx, rx) = cancelation();
self.state = State::Pending { request: tx };
job::dispatch_blocking(move |editor, _| request_signature_help(editor, invocation, rx))
}
}
pub fn request_signature_help(
editor: &mut Editor,
invoked: SignatureHelpInvoked,
cancel: CancelRx,
) {
let (view, doc) = current!(editor);
// TODO merge multiple language server signature help into one instead of just taking the first language server that supports it
let future = doc
.language_servers_with_feature(LanguageServerFeature::SignatureHelp)
.find_map(|language_server| {
let pos = doc.position(view.id, language_server.offset_encoding());
language_server.text_document_signature_help(doc.identifier(), pos, None)
});
let Some(future) = future else {
// Do not show the message if signature help was invoked
// automatically on backspace, trigger characters, etc.
if invoked == SignatureHelpInvoked::Manual {
editor
.set_error("No configured language server supports signature-help");
}
return;
};
tokio::spawn(async move {
match cancelable_future(future, cancel).await {
Some(Ok(res)) => {
job::dispatch(move |editor, compositor| {
show_signature_help(editor, compositor, invoked, res)
})
.await
}
Some(Err(err)) => log::error!("signature help request failed: {err}"),
None => (),
}
});
}
pub fn show_signature_help(
editor: &mut Editor,
compositor: &mut Compositor,
invoked: SignatureHelpInvoked,
response: Option<lsp::SignatureHelp>,
) {
let config = &editor.config();
if !(config.lsp.auto_signature_help
|| SignatureHelp::visible_popup(compositor).is_some()
|| invoked == SignatureHelpInvoked::Manual)
{
return;
}
// If the signature help invocation is automatic, don't show it outside of Insert Mode:
// it very probably means the server was a little slow to respond and the user has
// already moved on to something else, making a signature help popup will just be an
// annoyance, see https://github.com/helix-editor/helix/issues/3112
// For the most part this should not be needed as the request gets canceled automatically now
// but it's technically possible for the mode change to just preempt this callback so better safe than sorry
if invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert {
return;
}
let response = match response {
// According to the spec the response should be None if there
// are no signatures, but some servers don't follow this.
Some(s) if !s.signatures.is_empty() => s,
_ => {
send_blocking(
&editor.handlers.signature_hints,
SignatureHelpEvent::RequestComplete { open: false },
);
compositor.remove(SignatureHelp::ID);
return;
}
};
send_blocking(
&editor.handlers.signature_hints,
SignatureHelpEvent::RequestComplete { open: true },
);
let doc = doc!(editor);
let language = doc.language_name().unwrap_or("");
let signature = match response
.signatures
.get(response.active_signature.unwrap_or(0) as usize)
{
Some(s) => s,
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 {
signature.documentation.as_ref().map(|doc| match doc {
lsp::Documentation::String(s) => s.clone(),
lsp::Documentation::MarkupContent(markup) => markup.value.clone(),
})
} else {
None
};
contents.set_signature_doc(signature_doc);
let active_param_range = || -> 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))
}
}
};
contents.set_active_param_range(active_param_range());
let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
let mut popup = Popup::new(SignatureHelp::ID, contents)
.position(old_popup.and_then(|p| p.get_position()))
.position_bias(Open::Above)
.ignore_escape_key(true);
// Don't create a popup if it intersects the auto-complete menu.
let size = compositor.size();
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.as_mut()
.map(|completion| completion.area(size, editor))
.filter(|area| area.intersects(popup.area(size, editor)))
.is_some()
{
return;
}
compositor.replace_or_push(SignatureHelp::ID, popup);
}
fn signature_help_post_insert_char_hook(
tx: &Sender<SignatureHelpEvent>,
PostInsertChar { cx, .. }: &mut PostInsertChar<'_, '_>,
) -> anyhow::Result<()> {
if !cx.editor.config().lsp.auto_signature_help {
return Ok(());
}
let (view, doc) = current!(cx.editor);
// TODO support multiple language servers (not just the first that is found), likely by merging UI somehow
let Some(language_server) = doc
.language_servers_with_feature(LanguageServerFeature::SignatureHelp)
.next()
else {
return Ok(());
};
let capabilities = language_server.capabilities();
if let lsp::ServerCapabilities {
signature_help_provider:
Some(lsp::SignatureHelpOptions {
trigger_characters: Some(triggers),
// TODO: retrigger_characters
..
}),
..
} = capabilities
{
let mut text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
text = text.slice(..cursor);
if triggers.iter().any(|trigger| text.ends_with(trigger)) {
send_blocking(tx, SignatureHelpEvent::Trigger)
}
}
Ok(())
}
pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.signature_hints.clone();
register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
match (event.old_mode, event.new_mode) {
(Mode::Insert, _) => {
send_blocking(&tx, SignatureHelpEvent::Cancel);
event.cx.callback.push(Box::new(|compositor, _| {
compositor.remove(SignatureHelp::ID);
}));
}
(_, Mode::Insert) => {
if event.cx.editor.config().lsp.auto_signature_help {
send_blocking(&tx, SignatureHelpEvent::Trigger);
}
}
_ => (),
}
Ok(())
});
let tx = handlers.signature_hints.clone();
register_hook!(
move |event: &mut PostInsertChar<'_, '_>| signature_help_post_insert_char_hook(&tx, event)
);
let tx = handlers.signature_hints.clone();
register_hook!(move |event: &mut DocumentDidChange<'_>| {
if event.doc.config.load().lsp.auto_signature_help {
send_blocking(&tx, SignatureHelpEvent::ReTrigger);
}
Ok(())
});
let tx = handlers.signature_hints.clone();
register_hook!(move |event: &mut SelectionDidChange<'_>| {
if event.doc.config.load().lsp.auto_signature_help {
send_blocking(&tx, SignatureHelpEvent::ReTrigger);
}
Ok(())
});
}

@ -145,7 +145,7 @@ pub fn languages_all() -> std::io::Result<()> {
}
};
let mut headings = vec!["Language", "LSP", "DAP"];
let mut headings = vec!["Language", "LSP", "DAP", "Formatter"];
for feat in TsFeature::all() {
headings.push(feat.short_title())
@ -182,7 +182,7 @@ pub fn languages_all() -> std::io::Result<()> {
.sort_unstable_by_key(|l| l.language_id.clone());
let check_binary = |cmd: Option<&str>| match cmd {
Some(cmd) => match which::which(cmd) {
Some(cmd) => match helix_stdx::env::which(cmd) {
Ok(_) => column(&format!("✓ {}", cmd), Color::Green),
Err(_) => column(&format!("✘ {}", cmd), Color::Red),
},
@ -203,6 +203,12 @@ pub fn languages_all() -> std::io::Result<()> {
let dap = lang.debugger.as_ref().map(|dap| dap.command.as_str());
check_binary(dap);
let formatter = lang
.formatter
.as_ref()
.map(|formatter| formatter.command.as_str());
check_binary(formatter);
for ts_feat in TsFeature::all() {
match load_runtime_file(&lang.language_id, ts_feat.runtime_filename()).is_ok() {
true => column("✓", Color::Green),
@ -285,6 +291,13 @@ pub fn language(lang_str: String) -> std::io::Result<()> {
lang.debugger.as_ref().map(|dap| dap.command.to_string()),
)?;
probe_protocol(
"formatter",
lang.formatter
.as_ref()
.map(|formatter| formatter.command.to_string()),
)?;
for ts_feat in TsFeature::all() {
probe_treesitter_feature(&lang_str, *ts_feat)?
}
@ -309,7 +322,7 @@ fn probe_protocols<'a, I: Iterator<Item = &'a str> + 'a>(
writeln!(stdout)?;
for cmd in server_cmds {
let (path, icon) = match which::which(cmd) {
let (path, icon) = match helix_stdx::env::which(cmd) {
Ok(path) => (path.display().to_string().green(), "✓".green()),
Err(_) => (format!("'{}' not found in $PATH", cmd).red(), "✘".red()),
};
@ -331,7 +344,7 @@ fn probe_protocol(protocol_name: &str, server_cmd: Option<String>) -> std::io::R
writeln!(stdout, "Configured {}: {}", protocol_name, cmd_name)?;
if let Some(cmd) = server_cmd {
let path = match which::which(&cmd) {
let path = match helix_stdx::env::which(&cmd) {
Ok(path) => path.display().to_string().green(),
Err(_) => format!("'{}' not found in $PATH", cmd).red(),
};

@ -1,13 +1,37 @@
use helix_event::status::StatusMessage;
use helix_event::{runtime_local, send_blocking};
use helix_view::Editor;
use once_cell::sync::OnceCell;
use crate::compositor::Compositor;
use futures_util::future::{BoxFuture, Future, FutureExt};
use futures_util::stream::{FuturesUnordered, StreamExt};
use tokio::sync::mpsc::{channel, Receiver, Sender};
pub type EditorCompositorCallback = Box<dyn FnOnce(&mut Editor, &mut Compositor) + Send>;
pub type EditorCallback = Box<dyn FnOnce(&mut Editor) + Send>;
runtime_local! {
static JOB_QUEUE: OnceCell<Sender<Callback>> = OnceCell::new();
}
pub async fn dispatch_callback(job: Callback) {
let _ = JOB_QUEUE.wait().send(job).await;
}
pub async fn dispatch(job: impl FnOnce(&mut Editor, &mut Compositor) + Send + 'static) {
let _ = JOB_QUEUE
.wait()
.send(Callback::EditorCompositor(Box::new(job)))
.await;
}
pub fn dispatch_blocking(job: impl FnOnce(&mut Editor, &mut Compositor) + Send + 'static) {
let jobs = JOB_QUEUE.wait();
send_blocking(jobs, Callback::EditorCompositor(Box::new(job)))
}
pub enum Callback {
EditorCompositor(EditorCompositorCallback),
Editor(EditorCallback),
@ -21,11 +45,11 @@ pub struct Job {
pub wait: bool,
}
#[derive(Default)]
pub struct Jobs {
pub futures: FuturesUnordered<JobFuture>,
/// These are the ones that need to complete before we exit.
/// jobs that need to complete before we exit.
pub wait_futures: FuturesUnordered<JobFuture>,
pub callbacks: Receiver<Callback>,
pub status_messages: Receiver<StatusMessage>,
}
impl Job {
@ -52,8 +76,16 @@ impl Job {
}
impl Jobs {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self::default()
let (tx, rx) = channel(1024);
let _ = JOB_QUEUE.set(tx);
let status_messages = helix_event::status::setup();
Self {
wait_futures: FuturesUnordered::new(),
callbacks: rx,
status_messages,
}
}
pub fn spawn<F: Future<Output = anyhow::Result<()>> + Send + 'static>(&mut self, f: F) {
@ -85,18 +117,17 @@ impl Jobs {
}
}
pub async fn next_job(&mut self) -> Option<anyhow::Result<Option<Callback>>> {
tokio::select! {
event = self.futures.next() => { event }
event = self.wait_futures.next() => { event }
}
}
pub fn add(&self, j: Job) {
if j.wait {
self.wait_futures.push(j.future);
} else {
self.futures.push(j.future);
tokio::spawn(async move {
match j.future.await {
Ok(Some(cb)) => dispatch_callback(cb).await,
Ok(None) => (),
Err(err) => helix_event::status::report(err).await,
}
});
}
}

@ -319,7 +319,7 @@ impl Keymaps {
self.sticky = None;
}
let first = self.state.get(0).unwrap_or(&key);
let first = self.state.first().unwrap_or(&key);
let trie_node = match self.sticky {
Some(ref trie) => Cow::Owned(KeyTrie::Node(trie.clone())),
None => Cow::Borrowed(keymap),

@ -6,13 +6,20 @@ pub mod args;
pub mod commands;
pub mod compositor;
pub mod config;
pub mod events;
pub mod health;
pub mod job;
pub mod keymap;
pub mod ui;
use std::path::Path;
use futures_util::Future;
mod handlers;
use ignore::DirEntry;
use url::Url;
pub use keymap::macros::*;
#[cfg(not(windows))]
@ -47,3 +54,22 @@ fn filter_picker_entry(entry: &DirEntry, root: &Path, dedup_symlinks: bool) -> b
true
}
/// Opens URL in external program.
fn open_external_url_callback(
url: Url,
) -> impl Future<Output = Result<job::Callback, anyhow::Error>> + Send + 'static {
let commands = open::commands(url.as_str());
async {
for cmd in commands {
let mut command = tokio::process::Command::new(cmd.get_program());
command.args(cmd.get_args());
if command.output().await.is_ok() {
return Ok(job::Callback::Editor(Box::new(|_| {})));
}
}
Ok(job::Callback::Editor(Box::new(move |editor| {
editor.set_error("Opening URL in external program failed")
})))
}
}

@ -116,16 +116,18 @@ FLAGS:
setup_logging(args.verbosity).context("failed to initialize logging")?;
// Before setting the working directory, resolve all the paths in args.files
for (path, _) in args.files.iter_mut() {
*path = helix_stdx::path::canonicalize(&path);
}
// NOTE: Set the working directory early so the correct configuration is loaded. Be aware that
// Application::new() depends on this logic so it must be updated if this changes.
if let Some(path) = &args.working_directory {
helix_loader::set_current_working_dir(path)?;
}
// If the first file is a directory, it will be the working directory and a file picker will be opened
if let Some((path, _)) = args.files.first().filter(|p| p.0.is_dir()) {
helix_loader::set_current_working_dir(path)?;
args.open_cwd = true; // Signal Application that we want to open the picker on "."
helix_stdx::env::set_current_working_dir(path)?;
} else if let Some((path, _)) = args.files.first().filter(|p| p.0.is_dir()) {
// If the first file is a directory, it will be the working directory unless -w was specified
helix_stdx::env::set_current_working_dir(path)?;
}
let config = match Config::load_default() {

@ -1,7 +1,12 @@
use crate::compositor::{Component, Context, Event, EventResult};
use crate::{
compositor::{Component, Context, Event, EventResult},
handlers::trigger_auto_completion,
};
use helix_view::{
document::SavePoint,
editor::CompleteAction,
graphics::Margin,
handlers::lsp::SignatureHelpInvoked,
theme::{Modifier, Style},
ViewId,
};
@ -9,7 +14,7 @@ use tui::{buffer::Buffer as Surface, text::Span};
use std::{borrow::Cow, sync::Arc};
use helix_core::{Change, Transaction};
use helix_core::{chars, Change, Transaction};
use helix_view::{graphics::Rect, Document, Editor};
use crate::commands;
@ -94,10 +99,9 @@ pub struct CompletionItem {
/// Wraps a Menu.
pub struct Completion {
popup: Popup<Menu<CompletionItem>>,
start_offset: usize,
#[allow(dead_code)]
trigger_offset: usize,
// TODO: maintain a completioncontext with trigger kind & trigger char
filter: String,
}
impl Completion {
@ -107,7 +111,6 @@ impl Completion {
editor: &Editor,
savepoint: Arc<SavePoint>,
mut items: Vec<CompletionItem>,
start_offset: usize,
trigger_offset: usize,
) -> Self {
let preview_completion_insert = editor.config().preview_completion_insert;
@ -245,7 +248,7 @@ impl Completion {
// (also without sending the transaction to the LS) *before any further transaction is applied*.
// Otherwise incremental sync breaks (since the state of the LS doesn't match the state the transaction
// is applied to).
if editor.last_completion.is_none() {
if matches!(editor.last_completion, Some(CompleteAction::Triggered)) {
editor.last_completion = Some(CompleteAction::Selected {
savepoint: doc.savepoint(view),
})
@ -323,20 +326,55 @@ impl Completion {
doc.apply(&transaction, view.id);
}
}
// we could have just inserted a trigger char (like a `crate::` completion for rust
// so we want to retrigger immediately when accepting a completion.
trigger_auto_completion(&editor.handlers.completions, editor, true);
}
};
// In case the popup was deleted because of an intersection w/ the auto-complete menu.
if event != PromptEvent::Update {
editor
.handlers
.trigger_signature_help(SignatureHelpInvoked::Automatic, editor);
}
});
let margin = if editor.menu_border() {
Margin::vertical(1)
} else {
Margin::none()
};
let popup = Popup::new(Self::ID, menu)
.with_scrollbar(false)
.ignore_escape_key(true);
.ignore_escape_key(true)
.margin(margin);
let (view, doc) = current_ref!(editor);
let text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
let offset = text
.chars_at(cursor)
.reversed()
.take_while(|ch| chars::char_is_word(*ch))
.count();
let start_offset = cursor.saturating_sub(offset);
let fragment = doc.text().slice(start_offset..cursor);
let mut completion = Self {
popup,
start_offset,
trigger_offset,
// TODO: expand nucleo api to allow moving straight to a Utf32String here
// and avoid allocation during matching
filter: String::from(fragment),
};
// need to recompute immediately in case start_offset != trigger_offset
completion.recompute_filter(editor);
completion
.popup
.contents_mut()
.score(&completion.filter, false);
completion
}
@ -356,39 +394,22 @@ impl Completion {
}
}
pub fn recompute_filter(&mut self, editor: &Editor) {
/// Appends (`c: Some(c)`) or removes (`c: None`) a character to/from the filter
/// this should be called whenever the user types or deletes a character in insert mode.
pub fn update_filter(&mut self, c: Option<char>) {
// recompute menu based on matches
let menu = self.popup.contents_mut();
let (view, doc) = current_ref!(editor);
// cx.hooks()
// cx.add_hook(enum type, ||)
// cx.trigger_hook(enum type, &str, ...) <-- there has to be enough to identify doc/view
// callback with editor & compositor
//
// trigger_hook sends event into channel, that's consumed in the global loop and
// triggers all registered callbacks
// TODO: hooks should get processed immediately so maybe do it after select!(), before
// looping?
let cursor = doc
.selection(view.id)
.primary()
.cursor(doc.text().slice(..));
if self.trigger_offset <= cursor {
let fragment = doc.text().slice(self.start_offset..cursor);
let text = Cow::from(fragment);
// TODO: logic is same as ui/picker
menu.score(&text);
} else {
// we backspaced before the start offset, clear the menu
// this will cause the editor to remove the completion popup
match c {
Some(c) => self.filter.push(c),
None => {
self.filter.pop();
if self.filter.is_empty() {
menu.clear();
return;
}
}
pub fn update(&mut self, cx: &mut commands::Context) {
self.recompute_filter(cx.editor)
}
menu.score(&self.filter, c.is_some());
}
pub fn is_empty(&self) -> bool {
@ -569,6 +590,12 @@ impl Component for Completion {
// clear area
let background = cx.editor.theme.get("ui.popup");
surface.clear_with(doc_area, background);
if cx.editor.popup_border() {
use tui::widgets::{Block, Borders, Widget};
Widget::render(Block::default().borders(Borders::ALL), doc_area, surface);
}
markdown_doc.render(doc_area, surface, cx);
}
}

@ -97,7 +97,8 @@ pub fn render_document(
doc: &Document,
offset: ViewPosition,
doc_annotations: &TextAnnotations,
highlight_iter: impl Iterator<Item = HighlightEvent>,
syntax_highlight_iter: impl Iterator<Item = HighlightEvent>,
overlay_highlight_iter: impl Iterator<Item = HighlightEvent>,
theme: &Theme,
line_decoration: &mut [Box<dyn LineDecoration + '_>],
translated_positions: &mut [TranslatedPosition],
@ -109,7 +110,8 @@ pub fn render_document(
offset,
&doc.text_format(viewport.width, Some(theme)),
doc_annotations,
highlight_iter,
syntax_highlight_iter,
overlay_highlight_iter,
theme,
line_decoration,
translated_positions,
@ -157,7 +159,8 @@ pub fn render_text<'t>(
offset: ViewPosition,
text_fmt: &TextFormat,
text_annotations: &TextAnnotations,
highlight_iter: impl Iterator<Item = HighlightEvent>,
syntax_highlight_iter: impl Iterator<Item = HighlightEvent>,
overlay_highlight_iter: impl Iterator<Item = HighlightEvent>,
theme: &Theme,
line_decorations: &mut [Box<dyn LineDecoration + '_>],
translated_positions: &mut [TranslatedPosition],
@ -178,10 +181,16 @@ pub fn render_text<'t>(
let (mut formatter, mut first_visible_char_idx) =
DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, text_annotations, offset.anchor);
let mut styles = StyleIter {
let mut syntax_styles = StyleIter {
text_style: renderer.text_style,
active_highlights: Vec::with_capacity(64),
highlight_iter,
highlight_iter: syntax_highlight_iter,
theme,
};
let mut overlay_styles = StyleIter {
text_style: Style::default(),
active_highlights: Vec::with_capacity(64),
highlight_iter: overlay_highlight_iter,
theme,
};
@ -193,7 +202,10 @@ pub fn render_text<'t>(
};
let mut is_in_indent_area = true;
let mut last_line_indent_level = 0;
let mut style_span = styles
let mut syntax_style_span = syntax_styles
.next()
.unwrap_or_else(|| (Style::default(), usize::MAX));
let mut overlay_style_span = overlay_styles
.next()
.unwrap_or_else(|| (Style::default(), usize::MAX));
@ -221,9 +233,16 @@ pub fn render_text<'t>(
// skip any graphemes on visual lines before the block start
if pos.row < row_off {
if char_pos >= style_span.1 {
style_span = if let Some(style_span) = styles.next() {
style_span
if char_pos >= syntax_style_span.1 {
syntax_style_span = if let Some(syntax_style_span) = syntax_styles.next() {
syntax_style_span
} else {
break;
}
}
if char_pos >= overlay_style_span.1 {
overlay_style_span = if let Some(overlay_style_span) = overlay_styles.next() {
overlay_style_span
} else {
break;
}
@ -260,8 +279,15 @@ pub fn render_text<'t>(
}
// acquire the correct grapheme style
if char_pos >= style_span.1 {
style_span = styles.next().unwrap_or((Style::default(), usize::MAX));
if char_pos >= syntax_style_span.1 {
syntax_style_span = syntax_styles
.next()
.unwrap_or((Style::default(), usize::MAX));
}
if char_pos >= overlay_style_span.1 {
overlay_style_span = overlay_styles
.next()
.unwrap_or((Style::default(), usize::MAX));
}
char_pos += grapheme.doc_chars();
@ -275,22 +301,25 @@ pub fn render_text<'t>(
pos,
);
let grapheme_style = if let GraphemeSource::VirtualText { highlight } = grapheme.source {
let style = renderer.text_style;
let (syntax_style, overlay_style) =
if let GraphemeSource::VirtualText { highlight } = grapheme.source {
let mut style = renderer.text_style;
if let Some(highlight) = highlight {
style.patch(theme.highlight(highlight.0))
} else {
style
style = style.patch(theme.highlight(highlight.0))
}
(style, Style::default())
} else {
style_span.0
(syntax_style_span.0, overlay_style_span.0)
};
let virt = grapheme.is_virtual();
let is_virtual = grapheme.is_virtual();
renderer.draw_grapheme(
grapheme.grapheme,
grapheme_style,
virt,
GraphemeStyle {
syntax_style,
overlay_style,
},
is_virtual,
&mut last_line_indent_level,
&mut is_in_indent_area,
pos,
@ -322,6 +351,11 @@ pub struct TextRenderer<'a> {
pub viewport: Rect,
}
pub struct GraphemeStyle {
syntax_style: Style,
overlay_style: Style,
}
impl<'a> TextRenderer<'a> {
pub fn new(
surface: &'a mut Surface,
@ -395,7 +429,7 @@ impl<'a> TextRenderer<'a> {
pub fn draw_grapheme(
&mut self,
grapheme: Grapheme,
mut style: Style,
grapheme_style: GraphemeStyle,
is_virtual: bool,
last_indent_level: &mut usize,
is_in_indent_area: &mut bool,
@ -405,9 +439,11 @@ impl<'a> TextRenderer<'a> {
let is_whitespace = grapheme.is_whitespace();
// TODO is it correct to apply the whitespace style to all unicode white spaces?
let mut style = grapheme_style.syntax_style;
if is_whitespace {
style = style.patch(self.whitespace_style);
}
style = style.patch(grapheme_style.overlay_style);
let width = grapheme.width();
let space = if is_virtual { " " } else { &self.space };

@ -1,7 +1,7 @@
use crate::{
commands::{self, OnKeyCallback},
compositor::{Component, Context, Event, EventResult},
job::{self, Callback},
events::{OnModeSwitch, PostCommand},
key,
keymap::{KeymapResult, Keymaps},
ui::{
@ -33,8 +33,8 @@ use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
use tui::{buffer::Buffer as Surface, text::Span};
use super::document::LineDecoration;
use super::{completion::CompletionItem, statusline};
use super::{document::LineDecoration, lsp::SignatureHelp};
pub struct EditorView {
pub keymaps: Keymaps,
@ -124,16 +124,20 @@ impl EditorView {
line_decorations.push(Box::new(line_decoration));
}
let mut highlights =
let syntax_highlights =
Self::doc_syntax_highlights(doc, view.offset.anchor, inner.height, theme);
let overlay_highlights = Self::overlay_syntax_highlights(
let mut overlay_highlights =
Self::empty_highlight_iter(doc, view.offset.anchor, inner.height);
let overlay_syntax_highlights = Self::overlay_syntax_highlights(
doc,
view.offset.anchor,
inner.height,
&text_annotations,
);
if !overlay_highlights.is_empty() {
highlights = Box::new(syntax::merge(highlights, overlay_highlights));
if !overlay_syntax_highlights.is_empty() {
overlay_highlights =
Box::new(syntax::merge(overlay_highlights, overlay_syntax_highlights));
}
for diagnostic in Self::doc_diagnostics_highlights(doc, theme) {
@ -142,29 +146,28 @@ impl EditorView {
if diagnostic.is_empty() {
continue;
}
highlights = Box::new(syntax::merge(highlights, diagnostic));
overlay_highlights = Box::new(syntax::merge(overlay_highlights, diagnostic));
}
let highlights: Box<dyn Iterator<Item = HighlightEvent>> = if is_focused {
if is_focused {
let highlights = syntax::merge(
highlights,
overlay_highlights,
Self::doc_selection_highlights(
editor.mode(),
doc,
view,
theme,
&config.cursor_shape,
self.terminal_focused,
),
);
let focused_view_elements = Self::highlight_focused_view_elements(view, doc, theme);
if focused_view_elements.is_empty() {
Box::new(highlights)
overlay_highlights = Box::new(highlights)
} else {
Box::new(syntax::merge(highlights, focused_view_elements))
overlay_highlights = Box::new(syntax::merge(highlights, focused_view_elements))
}
}
} else {
Box::new(highlights)
};
let gutter_overflow = view.gutter_offset(doc) == 0;
if !gutter_overflow {
@ -197,7 +200,8 @@ impl EditorView {
doc,
view.offset,
&text_annotations,
highlights,
syntax_highlights,
overlay_highlights,
theme,
&mut line_decorations,
&mut translated_positions,
@ -257,16 +261,11 @@ impl EditorView {
.for_each(|area| surface.set_style(area, ruler_theme))
}
pub fn overlay_syntax_highlights(
doc: &Document,
anchor: usize,
fn viewport_byte_range(
text: helix_core::RopeSlice,
row: usize,
height: u16,
text_annotations: &TextAnnotations,
) -> Vec<(usize, std::ops::Range<usize>)> {
let text = doc.text().slice(..);
let row = text.char_to_line(anchor.min(text.len_chars()));
let range = {
) -> std::ops::Range<usize> {
// Calculate viewport byte ranges:
// Saturating subs to make it inclusive zero indexing.
let last_line = text.len_lines().saturating_sub(1);
@ -275,9 +274,26 @@ impl EditorView {
let end = text.line_to_byte(last_visible_line + 1);
start..end
};
}
text_annotations.collect_overlay_highlights(range)
pub fn empty_highlight_iter(
doc: &Document,
anchor: usize,
height: u16,
) -> Box<dyn Iterator<Item = HighlightEvent>> {
let text = doc.text().slice(..);
let row = text.char_to_line(anchor.min(text.len_chars()));
// Calculate viewport byte ranges:
// Saturating subs to make it inclusive zero indexing.
let range = Self::viewport_byte_range(text, row, height);
Box::new(
[HighlightEvent::Source {
start: text.byte_to_char(range.start),
end: text.byte_to_char(range.end),
}]
.into_iter(),
)
}
/// Get syntax highlights for a document in a view represented by the first line
@ -292,16 +308,7 @@ impl EditorView {
let text = doc.text().slice(..);
let row = text.char_to_line(anchor.min(text.len_chars()));
let range = {
// Calculate viewport byte ranges:
// Saturating subs to make it inclusive zero indexing.
let last_line = text.len_lines().saturating_sub(1);
let last_visible_line = (row + height as usize).saturating_sub(1).min(last_line);
let start = text.line_to_byte(row.min(last_line));
let end = text.line_to_byte(last_visible_line + 1);
start..end
};
let range = Self::viewport_byte_range(text, row, height);
match doc.syntax() {
Some(syntax) => {
@ -334,6 +341,20 @@ impl EditorView {
}
}
pub fn overlay_syntax_highlights(
doc: &Document,
anchor: usize,
height: u16,
text_annotations: &TextAnnotations,
) -> Vec<(usize, std::ops::Range<usize>)> {
let text = doc.text().slice(..);
let row = text.char_to_line(anchor.min(text.len_chars()));
let range = Self::viewport_byte_range(text, row, height);
text_annotations.collect_overlay_highlights(range)
}
/// Get highlight spans for document diagnostics
pub fn doc_diagnostics_highlights(
doc: &Document,
@ -365,7 +386,7 @@ impl EditorView {
let mut warning_vec = Vec::new();
let mut error_vec = Vec::new();
for diagnostic in doc.shown_diagnostics() {
for diagnostic in doc.diagnostics() {
// Separate diagnostics into different Vecs by severity.
let (vec, scope) = match diagnostic.severity {
Some(Severity::Info) => (&mut info_vec, info),
@ -400,6 +421,7 @@ impl EditorView {
view: &View,
theme: &Theme,
cursor_shape_config: &CursorShapeConfig,
is_terminal_focused: bool,
) -> Vec<(usize, std::ops::Range<usize>)> {
let text = doc.text().slice(..);
let selection = doc.selection(view.id);
@ -447,7 +469,7 @@ impl EditorView {
// Special-case: cursor at end of the rope.
if range.head == range.anchor && range.head == text.len_chars() {
if !selection_is_primary || cursor_is_block {
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
// Bar and underline cursors are drawn by the terminal
// BUG: If the editor area loses focus while having a bar or
// underline cursor (eg. when a regex prompt has focus) then
@ -470,13 +492,17 @@ impl EditorView {
cursor_start
};
spans.push((selection_scope, range.anchor..selection_end));
if !selection_is_primary || cursor_is_block {
// add block cursors
// skip primary cursor if terminal is unfocused - crossterm cursor is used in that case
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
spans.push((cursor_scope, cursor_start..range.head));
}
} else {
// Reverse case.
let cursor_end = next_grapheme_boundary(text, range.head);
if !selection_is_primary || cursor_is_block {
// add block cursors
// skip primary cursor if terminal is unfocused - crossterm cursor is used in that case
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
spans.push((cursor_scope, range.head..cursor_end));
}
// non block cursors look like they exclude the cursor
@ -658,7 +684,7 @@ impl EditorView {
.primary()
.cursor(doc.text().slice(..));
let diagnostics = doc.shown_diagnostics().filter(|diagnostic| {
let diagnostics = doc.diagnostics().iter().filter(|diagnostic| {
diagnostic.range.start <= cursor && diagnostic.range.end >= cursor
});
@ -809,35 +835,26 @@ impl EditorView {
let mut execute_command = |command: &commands::MappableCommand| {
command.execute(cxt);
helix_event::dispatch(PostCommand { command, cx: cxt });
let current_mode = cxt.editor.mode();
match (last_mode, current_mode) {
(Mode::Normal, Mode::Insert) => {
if current_mode != last_mode {
helix_event::dispatch(OnModeSwitch {
old_mode: last_mode,
new_mode: current_mode,
cx: cxt,
});
// HAXX: if we just entered insert mode from normal, clear key buf
// and record the command that got us into this mode.
if current_mode == Mode::Insert {
// how we entered insert mode is important, and we should track that so
// we can repeat the side effect.
self.last_insert.0 = command.clone();
self.last_insert.1.clear();
commands::signature_help_impl(cxt, commands::SignatureHelpInvoked::Automatic);
}
(Mode::Insert, Mode::Normal) => {
// if exiting insert mode, remove completion
self.clear_completion(cxt.editor);
cxt.editor.completion_request_handle = None;
// TODO: Use an on_mode_change hook to remove signature help
cxt.jobs.callback(async {
let call: job::Callback =
Callback::EditorCompositor(Box::new(|_editor, compositor| {
compositor.remove(SignatureHelp::ID);
}));
Ok(call)
});
}
_ => (),
}
last_mode = current_mode;
};
@ -965,12 +982,10 @@ impl EditorView {
editor: &mut Editor,
savepoint: Arc<SavePoint>,
items: Vec<CompletionItem>,
start_offset: usize,
trigger_offset: usize,
size: Rect,
) -> Option<Rect> {
let mut completion =
Completion::new(editor, savepoint, items, start_offset, trigger_offset);
let mut completion = Completion::new(editor, savepoint, items, trigger_offset);
if completion.is_empty() {
// skip if we got no completion results
@ -978,7 +993,7 @@ impl EditorView {
}
let area = completion.area(size, editor);
editor.last_completion = None;
editor.last_completion = Some(CompleteAction::Triggered);
self.last_insert.1.push(InsertEvent::TriggerCompletion);
// TODO : propagate required size on resize to completion too
@ -991,6 +1006,7 @@ impl EditorView {
self.completion = None;
if let Some(last_completion) = editor.last_completion.take() {
match last_completion {
CompleteAction::Triggered => (),
CompleteAction::Applied {
trigger_offset,
changes,
@ -1004,9 +1020,6 @@ impl EditorView {
}
}
}
// Clear any savepoints
editor.clear_idle_timer(); // don't retrigger
}
pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult {
@ -1020,13 +1033,7 @@ impl EditorView {
};
}
if cx.editor.mode != Mode::Insert || !cx.editor.config().auto_completion {
return EventResult::Ignored(None);
}
crate::commands::insert::idle_completion(cx);
EventResult::Consumed(None)
EventResult::Ignored(None)
}
}
@ -1239,7 +1246,7 @@ impl Component for EditorView {
editor: context.editor,
count: None,
register: None,
callback: None,
callback: Vec::new(),
on_next_key_callback: None,
jobs: context.jobs,
};
@ -1276,8 +1283,6 @@ impl Component for EditorView {
cx.editor.status_msg = None;
let mode = cx.editor.mode();
let (view, _) = current!(cx.editor);
let focus = view.id;
if let Some(on_next_key) = self.on_next_key.take() {
// if there's a command waiting input, do that first
@ -1314,12 +1319,6 @@ impl Component for EditorView {
if callback.is_some() {
// assume close_fn
self.clear_completion(cx.editor);
// In case the popup was deleted because of an intersection w/ the auto-complete menu.
commands::signature_help_impl(
&mut cx,
commands::SignatureHelpInvoked::Automatic,
);
}
}
}
@ -1330,14 +1329,6 @@ impl Component for EditorView {
// record last_insert key
self.last_insert.1.push(InsertEvent::Key(key));
// lastly we recalculate completion
if let Some(completion) = &mut self.completion {
completion.update(&mut cx);
if completion.is_empty() {
self.clear_completion(cx.editor);
}
}
}
}
mode => self.command_mode(mode, &mut cx, key),
@ -1351,7 +1342,7 @@ impl Component for EditorView {
}
// appease borrowck
let callback = cx.callback.take();
let callbacks = take(&mut cx.callback);
// if the command consumed the last view, skip the render.
// on the next loop cycle the Application will then terminate.
@ -1359,12 +1350,9 @@ impl Component for EditorView {
return EventResult::Ignored(None);
}
// if the focused view still exists and wasn't closed
if cx.editor.tree.contains(focus) {
let config = cx.editor.config();
let mode = cx.editor.mode();
let view = view_mut!(cx.editor, focus);
let doc = doc_mut!(cx.editor, &view.doc);
let (view, doc) = current!(cx.editor);
view.ensure_cursor_in_view(doc, config.scrolloff);
@ -1373,7 +1361,16 @@ impl Component for EditorView {
if mode != Mode::Insert {
doc.append_changes_to_history(view);
}
let callback = if callbacks.is_empty() {
None
} else {
let callback: crate::compositor::Callback = Box::new(move |compositor, cx| {
for callback in callbacks {
callback(compositor, cx)
}
});
Some(callback)
};
EventResult::Consumed(callback)
}
@ -1500,8 +1497,15 @@ impl Component for EditorView {
fn cursor(&self, _area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) {
match editor.cursor() {
// All block cursors are drawn manually
(pos, CursorKind::Block) => (pos, CursorKind::Hidden),
// all block cursors are drawn manually
(pos, CursorKind::Block) => {
if self.terminal_focused {
(pos, CursorKind::Hidden)
} else {
// use crossterm cursor when terminal loses focus
(pos, CursorKind::Underline)
}
}
cursor => cursor,
}
}

@ -92,7 +92,9 @@ impl Component for SignatureHelp {
Some(doc) => Markdown::new(doc.clone(), Arc::clone(&self.config_loader)),
};
let sig_doc = sig_doc.parse(Some(&cx.editor.theme));
let sig_doc_area = area.clip_top(sig_text_area.height + 2);
let sig_doc_area = area
.clip_top(sig_text_area.height + 2)
.clip_bottom(u16::from(cx.editor.popup_border()));
let sig_doc_para = Paragraph::new(sig_doc)
.wrap(Wrap { trim: false })
.scroll((cx.scroll.unwrap_or_default() as u16, 0));

@ -6,7 +6,7 @@ use tui::{
use std::sync::Arc;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag};
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use helix_core::{
syntax::{self, HighlightEvent, InjectionLanguageMarker, Syntax},
@ -209,7 +209,7 @@ impl Markdown {
list_stack.push(list);
}
Event::End(Tag::List(_)) => {
Event::End(TagEnd::List(_)) => {
list_stack.pop();
// whenever top-level list closes, empty line
@ -249,7 +249,10 @@ impl Markdown {
Event::End(tag) => {
tags.pop();
match tag {
Tag::Heading(_, _, _) | Tag::Paragraph | Tag::CodeBlock(_) | Tag::Item => {
TagEnd::Heading(_)
| TagEnd::Paragraph
| TagEnd::CodeBlock
| TagEnd::Item => {
push_line(&mut spans, &mut lines);
}
_ => (),
@ -257,7 +260,7 @@ impl Markdown {
// whenever heading, code block or paragraph closes, empty line
match tag {
Tag::Heading(_, _, _) | Tag::Paragraph | Tag::CodeBlock(_) => {
TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock => {
lines.push(Spans::default());
}
_ => (),
@ -279,7 +282,7 @@ impl Markdown {
lines.extend(tui_text.lines.into_iter());
} else {
let style = match tags.last() {
Some(Tag::Heading(level, ..)) => match level {
Some(Tag::Heading { level, .. }) => match level {
HeadingLevel::H1 => heading_styles[0],
HeadingLevel::H2 => heading_styles[1],
HeadingLevel::H3 => heading_styles[2],

@ -7,11 +7,18 @@ use crate::{
use helix_core::fuzzy::MATCHER;
use nucleo::pattern::{Atom, AtomKind, CaseMatching};
use nucleo::{Config, Utf32Str};
use tui::{buffer::Buffer as Surface, widgets::Table};
use tui::{
buffer::Buffer as Surface,
widgets::{Block, Borders, Table, Widget},
};
pub use tui::widgets::{Cell, Row};
use helix_view::{editor::SmartTabConfig, graphics::Rect, Editor};
use helix_view::{
editor::SmartTabConfig,
graphics::{Margin, Rect},
Editor,
};
use tui::layout::Constraint;
pub trait Item: Sync + Send + 'static {
@ -89,13 +96,26 @@ impl<T: Item> Menu<T> {
}
}
pub fn score(&mut self, pattern: &str) {
// reuse the matches allocation
self.matches.clear();
pub fn score(&mut self, pattern: &str, incremental: bool) {
let mut matcher = MATCHER.lock();
matcher.config = Config::DEFAULT;
let pattern = Atom::new(pattern, CaseMatching::Ignore, AtomKind::Fuzzy, false);
let mut buf = Vec::new();
if incremental {
self.matches.retain_mut(|(index, score)| {
let option = &self.options[*index as usize];
let text = option.filter_text(&self.editor_data);
let new_score = pattern.score(Utf32Str::new(&text, &mut buf), &mut matcher);
match new_score {
Some(new_score) => {
*score = new_score as u32;
true
}
None => false,
}
})
} else {
self.matches.clear();
let matches = self.options.iter().enumerate().filter_map(|(i, option)| {
let text = option.filter_text(&self.editor_data);
pattern
@ -103,6 +123,7 @@ impl<T: Item> Menu<T> {
.map(|score| (i as u32, score as u32))
});
self.matches.extend(matches);
}
self.matches
.sort_unstable_by_key(|&(i, score)| (Reverse(score), i));
@ -322,6 +343,15 @@ impl<T: Item + 'static> Component for Menu<T> {
let selected = theme.get("ui.menu.selected");
surface.clear_with(area, style);
let render_borders = cx.editor.menu_border();
let area = if render_borders {
Widget::render(Block::default().borders(Borders::ALL), area, surface);
area.inner(&Margin::vertical(1))
} else {
area
};
let scroll = self.scroll;
let options: Vec<_> = self
@ -362,6 +392,9 @@ impl<T: Item + 'static> Component for Menu<T> {
false,
);
let render_borders = cx.editor.menu_border();
if !render_borders {
if let Some(cursor) = self.cursor {
let offset_from_top = cursor - scroll;
let left = &mut surface[(area.left(), area.y + offset_from_top as u16)];
@ -372,6 +405,7 @@ impl<T: Item + 'static> Component for Menu<T> {
)];
right.set_style(selected);
}
}
let fits = len <= win_height;
@ -385,12 +419,13 @@ impl<T: Item + 'static> Component for Menu<T> {
for i in 0..win_height {
cell = &mut surface[(area.right() - 1, area.top() + i as u16)];
cell.set_symbol("▐"); // right half block
let half_block = if render_borders { "▌" } else { "▐" };
if scroll_line <= i && i < scroll_line + scroll_height {
// Draw scroll thumb
cell.set_symbol(half_block);
cell.set_fg(scroll_style.fg.unwrap_or(helix_view::theme::Color::Reset));
} else {
} else if !render_borders {
// Draw scroll track
cell.set_fg(scroll_style.bg.unwrap_or(helix_view::theme::Color::Reset));
}

@ -409,7 +409,7 @@ pub mod completers {
use std::path::Path;
let is_tilde = input == "~";
let path = helix_core::path::expand_tilde(Path::new(input));
let path = helix_stdx::path::expand_tilde(Path::new(input));
let (dir, file_name) = if input.ends_with(std::path::MAIN_SEPARATOR) {
(path, None)
@ -430,7 +430,7 @@ pub mod completers {
match path.parent() {
Some(path) if !path.as_os_str().is_empty() => path.to_path_buf(),
// Path::new("h")'s parent is Some("")...
_ => helix_loader::current_working_dir(),
_ => helix_stdx::env::current_working_dir(),
}
};

@ -63,7 +63,7 @@ impl PathOrId {
fn get_canonicalized(self) -> Self {
use PathOrId::*;
match self {
Path(path) => Path(helix_core::path::get_canonicalized_path(&path)),
Path(path) => Path(helix_stdx::path::canonicalize(path)),
Id(id) => Id(id),
}
}
@ -480,8 +480,7 @@ impl<T: Item + 'static> Picker<T> {
.find::<Overlay<DynamicPicker<T>>>()
.map(|overlay| &mut overlay.content.file_picker),
};
let Some(picker) = picker
else {
let Some(picker) = picker else {
log::info!("picker closed before syntax highlighting finished");
return;
};
@ -489,7 +488,15 @@ impl<T: Item + 'static> Picker<T> {
let doc = match current_file {
PathOrId::Id(doc_id) => doc_mut!(editor, &doc_id),
PathOrId::Path(path) => match picker.preview_cache.get_mut(&path) {
Some(CachedPreview::Document(ref mut doc)) => doc,
Some(CachedPreview::Document(ref mut doc)) => {
let diagnostics = Editor::doc_diagnostics(
&editor.language_servers,
&editor.diagnostics,
doc,
);
doc.replace_diagnostics(diagnostics, &[], None);
doc
}
_ => return,
},
};
@ -736,17 +743,20 @@ impl<T: Item + 'static> Picker<T> {
}
}
let mut highlights = EditorView::doc_syntax_highlights(
let syntax_highlights = EditorView::doc_syntax_highlights(
doc,
offset.anchor,
area.height,
&cx.editor.theme,
);
let mut overlay_highlights =
EditorView::empty_highlight_iter(doc, offset.anchor, area.height);
for spans in EditorView::doc_diagnostics_highlights(doc, &cx.editor.theme) {
if spans.is_empty() {
continue;
}
highlights = Box::new(helix_core::syntax::merge(highlights, spans));
overlay_highlights = Box::new(helix_core::syntax::merge(overlay_highlights, spans));
}
let mut decorations: Vec<Box<dyn LineDecoration>> = Vec::new();
@ -777,7 +787,8 @@ impl<T: Item + 'static> Picker<T> {
offset,
// TODO: compute text annotations asynchronously here (like inlay hints)
&TextAnnotations::default(),
highlights,
syntax_highlights,
overlay_highlights,
&cx.editor.theme,
&mut decorations,
&mut [],

@ -3,7 +3,10 @@ use crate::{
compositor::{Callback, Component, Context, Event, EventResult},
ctrl, key,
};
use tui::buffer::Buffer as Surface;
use tui::{
buffer::Buffer as Surface,
widgets::{Block, Borders, Widget},
};
use helix_core::Position;
use helix_view::{
@ -252,13 +255,29 @@ impl<T: Component> Component for Popup<T> {
let background = cx.editor.theme.get("ui.popup");
surface.clear_with(area, background);
let inner = area.inner(&self.margin);
let render_borders = cx.editor.popup_border();
let inner = if self
.contents
.type_name()
.starts_with("helix_term::ui::menu::Menu")
{
area
} else {
area.inner(&self.margin)
};
let border = usize::from(render_borders);
if render_borders {
Widget::render(Block::default().borders(Borders::ALL), area, surface);
}
self.contents.render(inner, surface, cx);
// render scrollbar if contents do not fit
if self.has_scrollbar {
let win_height = inner.height as usize;
let len = self.child_size.1 as usize;
let win_height = (inner.height as usize).saturating_sub(2 * border);
let len = (self.child_size.1 as usize).saturating_sub(2 * border);
let fits = len <= win_height;
let scroll = self.scroll;
let scroll_style = cx.editor.theme.get("ui.menu.scroll");
@ -274,14 +293,15 @@ impl<T: Component> Component for Popup<T> {
let mut cell;
for i in 0..win_height {
cell = &mut surface[(inner.right() - 1, inner.top() + i as u16)];
cell = &mut surface[(inner.right() - 1, inner.top() + (border + i) as u16)];
cell.set_symbol("▐"); // right half block
let half_block = if render_borders { "▌" } else { "▐" };
if scroll_line <= i && i < scroll_line + scroll_height {
// Draw scroll thumb
cell.set_symbol(half_block);
cell.set_fg(scroll_style.fg.unwrap_or(helix_view::theme::Color::Reset));
} else {
} else if !render_borders {
// Draw scroll track
cell.set_fg(scroll_style.bg.unwrap_or(helix_view::theme::Color::Reset));
}

@ -393,7 +393,7 @@ impl Prompt {
height,
);
if !self.completion.is_empty() {
if completion_area.height > 0 && !self.completion.is_empty() {
let area = completion_area;
let background = theme.get("ui.menu");

@ -11,7 +11,7 @@ impl ProgressSpinners {
}
pub fn get_or_create(&mut self, id: usize) -> &mut Spinner {
self.inner.entry(id).or_insert_with(Spinner::default)
self.inner.entry(id).or_default()
}
}

@ -227,7 +227,8 @@ where
{
let (warnings, errors) = context
.doc
.shown_diagnostics()
.diagnostics()
.iter()
.fold((0, 0), |mut counts, diag| {
use helix_core::diagnostic::Severity;
match diag.severity {

@ -480,3 +480,49 @@ fn bar() {#(\n|)#\
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_join_selections() -> anyhow::Result<()> {
// normal join
test((
platform_line(indoc! {"\
#[a|]#bc
def
"}),
"J",
platform_line(indoc! {"\
#[a|]#bc def
"}),
))
.await?;
// join with empty line
test((
platform_line(indoc! {"\
#[a|]#bc
def
"}),
"JJ",
platform_line(indoc! {"\
#[a|]#bc def
"}),
))
.await?;
// join with additional space in non-empty line
test((
platform_line(indoc! {"\
#[a|]#bc
def
"}),
"JJ",
platform_line(indoc! {"\
#[a|]#bc def
"}),
))
.await?;
Ok(())
}

@ -3,7 +3,8 @@ use std::{
ops::RangeInclusive,
};
use helix_core::{diagnostic::Severity, path::get_normalized_path};
use helix_core::diagnostic::Severity;
use helix_stdx::path;
use helix_view::doc;
use super::*;
@ -23,7 +24,7 @@ async fn test_write_quit_fail() -> anyhow::Result<()> {
assert_eq!(1, docs.len());
let doc = docs.pop().unwrap();
assert_eq!(Some(&get_normalized_path(file.path())), doc.path());
assert_eq!(Some(&path::normalize(file.path())), doc.path());
assert_eq!(&Severity::Error, app.editor.get_status().unwrap().1);
}),
false,
@ -269,7 +270,7 @@ async fn test_write_scratch_to_new_path() -> anyhow::Result<()> {
assert_eq!(1, docs.len());
let doc = docs.pop().unwrap();
assert_eq!(Some(&get_normalized_path(file.path())), doc.path());
assert_eq!(Some(&path::normalize(file.path())), doc.path());
}),
false,
)
@ -341,7 +342,7 @@ async fn test_write_new_path() -> anyhow::Result<()> {
Some(&|app| {
let doc = doc!(app.editor);
assert!(!app.editor.is_err());
assert_eq!(&get_normalized_path(file1.path()), doc.path().unwrap());
assert_eq!(&path::normalize(file1.path()), doc.path().unwrap());
}),
),
(
@ -349,7 +350,7 @@ async fn test_write_new_path() -> anyhow::Result<()> {
Some(&|app| {
let doc = doc!(app.editor);
assert!(!app.editor.is_err());
assert_eq!(&get_normalized_path(file2.path()), doc.path().unwrap());
assert_eq!(&path::normalize(file2.path()), doc.path().unwrap());
assert!(app.editor.document_by_path(file1.path()).is_none());
}),
),

@ -1,6 +1,6 @@
use super::*;
use helix_core::path::get_normalized_path;
use helix_stdx::path;
#[tokio::test(flavor = "multi_thread")]
async fn test_split_write_quit_all() -> anyhow::Result<()> {
@ -27,21 +27,21 @@ async fn test_split_write_quit_all() -> anyhow::Result<()> {
let doc1 = docs
.iter()
.find(|doc| doc.path().unwrap() == &get_normalized_path(file1.path()))
.find(|doc| doc.path().unwrap() == &path::normalize(file1.path()))
.unwrap();
assert_eq!("hello1", doc1.text().to_string());
let doc2 = docs
.iter()
.find(|doc| doc.path().unwrap() == &get_normalized_path(file2.path()))
.find(|doc| doc.path().unwrap() == &path::normalize(file2.path()))
.unwrap();
assert_eq!("hello2", doc2.text().to_string());
let doc3 = docs
.iter()
.find(|doc| doc.path().unwrap() == &get_normalized_path(file3.path()))
.find(|doc| doc.path().unwrap() == &path::normalize(file3.path()))
.unwrap();
assert_eq!("hello3", doc3.text().to_string());

@ -1,28 +1,28 @@
[package]
name = "helix-tui"
version = "0.6.0"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
description = """
A library to build rich terminal user interfaces or dashboards
"""
edition = "2021"
license = "MPL-2.0"
categories = ["editor"]
repository = "https://github.com/helix-editor/helix"
homepage = "https://helix-editor.com"
description = """A library to build rich terminal user interfaces or dashboards"""
include = ["src/**/*", "README.md"]
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
categories.workspace = true
repository.workspace = true
homepage.workspace = true
[features]
default = ["crossterm"]
[dependencies]
helix-view = { path = "../helix-view", features = ["term"] }
helix-core = { path = "../helix-core" }
bitflags = "2.4"
cassowary = "0.3"
unicode-segmentation = "1.10"
crossterm = { version = "0.27", optional = true }
termini = "1.0"
serde = { version = "1", "optional" = true, features = ["derive"]}
once_cell = "1.18"
once_cell = "1.19"
log = "~0.4"
helix-view = { version = "0.6", path = "../helix-view", features = ["term"] }
helix-core = { version = "0.6", path = "../helix-core" }

@ -79,6 +79,7 @@ pub struct CrosstermBackend<W: Write> {
capabilities: Capabilities,
supports_keyboard_enhancement_protocol: OnceCell<bool>,
mouse_capture_enabled: bool,
supports_bracketed_paste: bool,
}
impl<W> CrosstermBackend<W>
@ -91,6 +92,7 @@ where
capabilities: Capabilities::from_env_or_default(config),
supports_keyboard_enhancement_protocol: OnceCell::new(),
mouse_capture_enabled: false,
supports_bracketed_paste: true,
}
}
@ -134,9 +136,16 @@ where
execute!(
self.buffer,
terminal::EnterAlternateScreen,
EnableBracketedPaste,
EnableFocusChange
)?;
match execute!(self.buffer, EnableBracketedPaste,) {
Err(err) if err.kind() == io::ErrorKind::Unsupported => {
log::warn!("Bracketed paste is not supported on this terminal.");
self.supports_bracketed_paste = false;
}
Err(err) => return Err(err),
Ok(_) => (),
};
execute!(self.buffer, terminal::Clear(terminal::ClearType::All))?;
if config.enable_mouse_capture {
execute!(self.buffer, EnableMouseCapture)?;
@ -177,9 +186,11 @@ where
if self.supports_keyboard_enhancement_protocol() {
execute!(self.buffer, PopKeyboardEnhancementFlags)?;
}
if self.supports_bracketed_paste {
execute!(self.buffer, DisableBracketedPaste,)?;
}
execute!(
self.buffer,
DisableBracketedPaste,
DisableFocusChange,
terminal::LeaveAlternateScreen
)?;
@ -195,12 +206,8 @@ where
// disable without calling enable previously
let _ = execute!(stdout, DisableMouseCapture);
let _ = execute!(stdout, PopKeyboardEnhancementFlags);
execute!(
stdout,
DisableBracketedPaste,
DisableFocusChange,
terminal::LeaveAlternateScreen
)?;
let _ = execute!(stdout, DisableBracketedPaste);
execute!(stdout, DisableFocusChange, terminal::LeaveAlternateScreen)?;
terminal::disable_raw_mode()
}

@ -213,7 +213,7 @@ impl Buffer {
&& y < self.area.bottom()
}
/// Returns the index in the Vec<Cell> for the given global (x, y) coordinates.
/// Returns the index in the `Vec<Cell>` for the given global (x, y) coordinates.
///
/// Global coordinates are offset by the Buffer's area offset (`x`/`y`).
///
@ -242,7 +242,7 @@ impl Buffer {
((y - self.area.y) as usize) * (self.area.width as usize) + ((x - self.area.x) as usize)
}
/// Returns the index in the Vec<Cell> for the given global (x, y) coordinates,
/// Returns the index in the `Vec<Cell>` for the given global (x, y) coordinates,
/// or `None` if the coordinates are outside the buffer's area.
fn index_of_opt(&self, x: u16, y: u16) -> Option<usize> {
if self.in_bounds(x, y) {

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

Loading…
Cancel
Save