diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index 2d37b36a..bc72bb78 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -14,10 +14,10 @@ jobs: uses: actions/checkout@v3 - name: Install nix - uses: cachix/install-nix-action@v17 + uses: cachix/install-nix-action@v18 - name: Authenticate with Cachix - uses: cachix/cachix-action@v10 + uses: cachix/cachix-action@v11 with: name: helix authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 45eb9023..163b3ad4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "0.7.18" @@ -22,9 +33,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.65" +version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602" +checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" [[package]] name = "arc-swap" @@ -277,15 +288,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf" +checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" [[package]] name = "futures-executor" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ff63c23854bee61b6e9cd331d523909f238fc7636290b96826e9cfa5faa00ab" +checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" dependencies = [ "futures-core", "futures-task", @@ -294,15 +305,15 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6508c467c73851293f390476d4491cf4d227dbabcd4170f3bb6044959b294f1" +checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" [[package]] name = "futures-util" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fb6cb1be61cc1d2e43b262516aafcf63b241cffdb1d3fa115f91d9c7b09c90" +checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" dependencies = [ "futures-core", "futures-task", @@ -383,11 +394,21 @@ dependencies = [ "memmap2", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + [[package]] name = "helix-core" version = "0.6.0" dependencies = [ "arc-swap", + "bitflags", "chrono", "encoding_rs", "etcetera", @@ -507,6 +528,7 @@ dependencies = [ "helix-core", "helix-view", "serde", + "termini", "unicode-segmentation", ] @@ -917,18 +939,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.145" +version = "1.0.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" +checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.145" +version = "1.0.147" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" +checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" dependencies = [ "proc-macro2", "quote", @@ -937,9 +959,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.85" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" +checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" dependencies = [ "itoa", "ryu", @@ -1099,11 +1121,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "termini" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0f7ecb9c2a380d2686a747e4fc574043712326e8d39fbd220ab3bd29768a12" +dependencies = [ + "dirs-next", +] + [[package]] name = "textwrap" -version = "0.15.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "949517c0cf1bf4ee812e2e07e08ab448e3ae0d23472aee8a06c985f0c8815b16" +checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" dependencies = [ "smawk", "unicode-linebreak", @@ -1196,9 +1227,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6edf2d6bc038a43d31353570e27270603f4648d18f5ed10c0e179abe43255af" +checksum = "d660770404473ccd7bc9f8b28494a811bc18542b915c0855c51e8f419d5223ce" dependencies = [ "futures-core", "pin-project-lite", @@ -1253,10 +1284,11 @@ checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" [[package]] name = "unicode-linebreak" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f" +checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137" dependencies = [ + "hashbrown", "regex", ] diff --git a/README.md b/README.md index ff0699c6..d09bbad5 100644 --- a/README.md +++ b/README.md @@ -41,20 +41,41 @@ cd helix cargo install --path helix-term ``` -This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars. -If you want to customize your `languages.toml` config, -tree-sitter grammars may be manually fetched and built with `hx --grammar fetch` and `hx --grammar build`. +This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`. -Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the +Helix needs its runtime files so make sure to copy/symlink the `runtime/` directory into the config directory (for example `~/.config/helix/runtime` on Linux/macOS, or `%AppData%/helix/runtime` on Windows). -| OS | Command | -| -------------------- | -------------------------------------------- | -| Windows (cmd.exe) | `xcopy /e /i runtime %AppData%\helix\runtime` | -| Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` | -| Linux/macOS | `ln -s $PWD/runtime ~/.config/helix/runtime` | +| OS | Command | +| -------------------- | ------------------------------------------------ | +| Windows (Cmd) | `xcopy /e /i runtime %AppData%\helix\runtime` | +| Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` | +| Linux / MacOS | `ln -s $PWD/runtime ~/.config/helix/runtime` | + +Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires +elevated priviliges - i.e. PowerShell or Cmd must be run as administrator. + +**PowerShell:** + +```powershell +New-Item -ItemType SymbolicLink -Target "runtime" -Path "$Env:AppData\helix\runtime" +``` + +**Cmd:** + +```cmd +cd %appdata%\helix +mklink /D runtime "\runtime" +``` + +The runtime location can be overridden via the `HELIX_RUNTIME` environment variable. + +> NOTE: if `HELIX_RUNTIME` is set prior to calling `cargo install --path helix-term`, +> tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`. -This location can be overridden via the `HELIX_RUNTIME` environment variable. +If you plan on keeping the repo locally, an alternative to copying/symlinking +runtime files is to set `HELIX_RUNTIME=/path/to/helix/runtime` +(`HELIX_RUNTIME=$PWD/runtime` if you're in the helix repo directory). Packages already solve this for you by wrapping the `hx` binary with a wrapper that sets the variable to the install dir. @@ -62,12 +83,32 @@ that sets the variable to the install dir. > NOTE: running via cargo also doesn't require setting explicit `HELIX_RUNTIME` path, it will automatically > detect the `runtime` directory in the project root. +If you want to customize your `languages.toml` config, +tree-sitter grammars may be manually fetched and built with `hx --grammar fetch` and `hx --grammar build`. + In order to use LSP features like auto-complete, you will need to [install the appropriate Language Server](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers) for a language. [![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions) +## Adding Helix to your desktop environment + +If installing from source, to use Helix in desktop environments that supports [XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html), including Gnome and KDE, copy the provided `.desktop` file to the correct folder: + +```bash +cp contrib/Helix.desktop ~/.local/share/applications +``` + +To use another terminal than the default, you will need to modify the `.desktop` file. For example, to use `kitty`: + +```bash +sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop +sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop +``` + +Please note: there is no icon for Helix yet, so the system default will be used. + ## MacOS Helix can be installed on MacOS through homebrew: diff --git a/book/src/configuration.md b/book/src/configuration.md index 3fd1bd17..bb02a935 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -45,9 +45,11 @@ on unix operating systems. | `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` | | `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` | | `cursorline` | Highlight all lines with a cursor. | `false` | +| `cursorcolumn` | Highlight all columns with a cursor. | `false` | | `gutters` | Gutters to display: Available are `diagnostics` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "line-numbers"]` | | `auto-completion` | Enable automatic pop up of auto-completion. | `true` | | `auto-format` | Enable automatic formatting on save. | `true` | +| `auto-save` | Enable automatic saving on focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal. | `false` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `auto-info` | Whether to display infoboxes | `true` | @@ -251,5 +253,6 @@ Example: render = true character = "╎" rainbow = "normal" +character = "╎" # Some characters that work well: "▏", "┆", "┊", "⸽" skip-levels = 1 ``` diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index 5c64d097..caf5d525 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -15,6 +15,7 @@ | cpp | ✓ | ✓ | ✓ | `clangd` | | css | ✓ | | | `vscode-css-language-server` | | cue | ✓ | | | `cuelsp` | +| d | ✓ | ✓ | ✓ | `serve-d` | | dart | ✓ | | ✓ | `dart` | | devicetree | ✓ | | | | | diff | ✓ | | | | @@ -66,7 +67,7 @@ | llvm | ✓ | ✓ | ✓ | | | llvm-mir | ✓ | ✓ | ✓ | | | llvm-mir-yaml | ✓ | | ✓ | | -| lua | ✓ | | ✓ | `lua-language-server` | +| lua | ✓ | ✓ | ✓ | `lua-language-server` | | make | ✓ | | | | | markdown | ✓ | | | `marksman` | | markdown.inline | ✓ | | | | @@ -86,7 +87,8 @@ | prisma | ✓ | | | `prisma-language-server` | | prolog | | | | `swipl` | | protobuf | ✓ | | ✓ | | -| python | ✓ | ✓ | | `pylsp` | +| purescript | ✓ | | | `purescript-language-server` | +| python | ✓ | ✓ | ✓ | `pylsp` | | r | ✓ | | | `R` | | racket | | | | `racket` | | regex | ✓ | | | | @@ -124,4 +126,4 @@ | wgsl | ✓ | | | `wgsl_analyzer` | | xit | ✓ | | | | | yaml | ✓ | | ✓ | `yaml-language-server` | -| zig | ✓ | | ✓ | `zls` | +| zig | ✓ | ✓ | ✓ | `zls` | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index 4cbff306..f858ba72 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -28,7 +28,7 @@ | `:quit-all!`, `:qa!` | Force close all views ignoring unsaved changes. | | `:cquit`, `:cq` | Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2). | | `:cquit!`, `:cq!` | Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2). | -| `:theme` | Change the editor theme. | +| `:theme` | Change the editor theme (show current theme if no name specified). | | `:clipboard-yank` | Yank main selection into system clipboard. | | `:clipboard-yank-join` | Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline. | | `:primary-clipboard-yank` | Yank main selection into system primary clipboard. | @@ -44,6 +44,7 @@ | `:show-directory`, `:pwd` | Show the current working directory. | | `:encoding` | Set encoding. Based on `https://encoding.spec.whatwg.org`. | | `:reload` | Discard changes and reload from the source file. | +| `:update` | Write changes only if the file has been modified. | | `:lsp-restart` | Restarts the Language Server that is in use by the current doc | | `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. | | `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. | @@ -65,7 +66,7 @@ | `:config-reload` | Refresh user config. | | `:config-open` | Open the user config.toml file. | | `:log-open` | Open the helix log file. | -| `:insert-output` | Run shell command, inserting output after each selection. | +| `:insert-output` | Run shell command, inserting output before each selection. | | `:append-output` | Run shell command, appending output after each selection. | | `:pipe` | Pipe each selection to the shell command. | | `:run-shell-command`, `:sh` | Run a shell command | diff --git a/book/src/guides/indent.md b/book/src/guides/indent.md index f4d916b2..0e259289 100644 --- a/book/src/guides/indent.md +++ b/book/src/guides/indent.md @@ -46,6 +46,20 @@ capture on the same line, the indent level isn't changed at all. - `@outdent` (default scope `all`): Decrease the indent level by 1. The same rules as for `@indent` apply. +- `@extend`: +Extend the range of this node to the end of the line and to lines that +are indented more than the line that this node starts on. This is useful +for languages like Python, where for the purpose of indentation some nodes +(like functions or classes) should also contain indented lines that follow them. + +- `@extend.prevent-once`: +Prevents the first extension of an ancestor of this node. For example, in Python +a return expression always ends the block that it is in. Note that this only stops the +extension of the next `@extend` capture. If multiple ancestors are captured, +only the extension of the innermost one is prevented. All other ancestors are unaffected +(regardless of whether the innermost ancestor would actually have been extended). + + ## Predicates In some cases, an S-expression cannot express exactly what pattern should be matched. diff --git a/book/src/install.md b/book/src/install.md index d7a51ac2..6e2a1f3d 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -50,6 +50,23 @@ sudo dnf install helix sudo xbps-install helix ``` +## Windows + +Helix can be installed using [Scoop](https://scoop.sh/) or [Chocolatey](https://chocolatey.org/). + +**Scoop:** + +``` +scoop install helix +``` + +**Chocolatey:** + +``` +choco install helix +``` + + ## Build from source ``` @@ -58,26 +75,67 @@ cd helix cargo install --path helix-term ``` -This will install the `hx` binary to `$HOME/.cargo/bin`. +This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars in `./runtime/grammars`. -Helix also needs it's runtime files so make sure to copy/symlink the `runtime/` directory into the +Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overridden via the `HELIX_RUNTIME` environment variable. -| OS | command | -|-------------------|-----------| -|windows(cmd.exe) |`xcopy /e /i runtime %AppData%/helix/runtime` | -|windows(powershell)|`xcopy /e /i runtime $Env:AppData\helix\runtime` | -|linux/macos |`ln -s $PWD/runtime ~/.config/helix/runtime`| +| OS | Command | +| -------------------- | ------------------------------------------------ | +| Windows (Cmd) | `xcopy /e /i runtime %AppData%\helix\runtime` | +| Windows (PowerShell) | `xcopy /e /i runtime $Env:AppData\helix\runtime` | +| Linux / MacOS | `ln -s $PWD/runtime ~/.config/helix/runtime` | + +Starting with Windows Vista you can also create symbolic links on Windows. Note that this requires +elevated priviliges - i.e. PowerShell or Cmd must be run as administrator. + +**PowerShell:** + +```powershell +New-Item -ItemType SymbolicLink -Target "runtime" -Path "$Env:AppData\helix\runtime" +``` + +**Cmd:** + +```cmd +cd %appdata%\helix +mklink /D runtime "\runtime" +``` + +The runtime location can be overridden via the `HELIX_RUNTIME` environment variable. + +> NOTE: if `HELIX_RUNTIME` is set prior to calling `cargo install --path helix-term`, +> tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`. + +If you plan on keeping the repo locally, an alternative to copying/symlinking +runtime files is to set `HELIX_RUNTIME=/path/to/helix/runtime` +(`HELIX_RUNTIME=$PWD/runtime` if you're in the helix repo directory). + +To use Helix in desktop environments that supports [XDG desktop menu](https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html), including Gnome and KDE, copy the provided `.desktop` file to the correct folder: + +```bash +cp contrib/Helix.desktop ~/.local/share/applications +``` + +To use another terminal than the default, you will need to modify the `.desktop` file. For example, to use `kitty`: + +```bash +sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop +sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop +``` + +Please note: there is no icon for Helix yet, so the system default will be used. + +## Finishing up the installation -## Finishing up the installation +To make sure everything is set up as expected you should finally run the helix healthcheck via -To make sure everything is set up as expected you should finally run the helix healthcheck via ``` hx --health ``` -For more information on the information displayed in the healthcheck results refer to [Healthcheck](https://github.com/helix-editor/helix/wiki/Healthcheck). +For more information on the information displayed in the health check results refer to [Healthcheck](https://github.com/helix-editor/helix/wiki/Healthcheck). ### Building tree-sitter grammars diff --git a/book/src/keymap.md b/book/src/keymap.md index 85110634..6523b09f 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -129,7 +129,7 @@ | `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` | | `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` | | `J` | Join lines inside selection | `join_selections` | -| `A-J` | Join lines inside selection and select space | `join_selections_space` | +| `Alt-J` | Join lines inside selection and select space | `join_selections_space` | | `K` | Keep selections matching the regex | `keep_selections` | | `Alt-K` | Remove selections matching the regex | `remove_selections` | | `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` | @@ -167,10 +167,13 @@ These sub-modes are accessible from normal mode and typically switch back to nor #### View mode +Accessed by typing `z` in [normal mode](#normal-mode). + View mode is intended for scrolling and manipulating the view without changing -the selection. The "sticky" variant of this mode is persistent; use the Escape -key to return to normal mode after usage (useful when you're simply looking -over text and not actively editing it). +the selection. The "sticky" variant of this mode (accessed by typing `Z` in +normal mode) is persistent; use the Escape key to return to normal mode after +usage (useful when you're simply looking over text and not actively editing +it). | Key | Description | Command | @@ -188,6 +191,8 @@ over text and not actively editing it). #### Goto mode +Accessed by typing `g` in [normal mode](#normal-mode). + Jumps to various locations. | Key | Description | Command | @@ -213,9 +218,10 @@ Jumps to various locations. #### Match mode -Enter this mode using `m` from normal mode. See the relevant section -in [Usage](./usage.md) for an explanation about [surround](./usage.md#surround) -and [textobject](./usage.md#textobject) usage. +Accessed by typing `m` in [normal mode](#normal-mode). + +See the relevant section in [Usage](./usage.md) for an explanation about +[surround](./usage.md#surround) and [textobject](./usage.md#textobjects) usage. | Key | Description | Command | | ----- | ----------- | ------- | @@ -230,6 +236,8 @@ TODO: Mappings for selecting syntax nodes (a superset of `[`). #### Window mode +Accessed by typing `Ctrl-w` in [normal mode](#normal-mode). + This layer is similar to Vim keybindings as Kakoune does not support window. | Key | Description | Command | @@ -252,8 +260,9 @@ This layer is similar to Vim keybindings as Kakoune does not support window. #### Space mode -This layer is a kludge of mappings, mostly pickers. +Accessed by typing `Space` in [normal mode](#normal-mode). +This layer is a kludge of mappings, mostly pickers. | Key | Description | Command | | ----- | ----------- | ------- | @@ -264,8 +273,8 @@ This layer is a kludge of mappings, mostly pickers. | `k` | Show documentation for item under cursor in a [popup](#popup) (**LSP**) | `hover` | | `s` | Open document symbol picker (**LSP**) | `symbol_picker` | | `S` | Open workspace symbol picker (**LSP**) | `workspace_symbol_picker` | -| `g` | Open document diagnostics picker (**LSP**) | `diagnostics_picker` | -| `G` | Open workspace diagnostics picker (**LSP**) | `workspace_diagnostics_picker` +| `d` | Open document diagnostics picker (**LSP**) | `diagnostics_picker` | +| `D` | Open workspace diagnostics picker (**LSP**) | `workspace_diagnostics_picker` | | `r` | Rename symbol (**LSP**) | `rename_symbol` | | `a` | Apply code action (**LSP**) | `code_action` | | `'` | Open last fuzzy picker | `last_picker` | @@ -278,7 +287,7 @@ This layer is a kludge of mappings, mostly pickers. | `/` | Global search in workspace folder | `global_search` | | `?` | Open command palette | `command_palette` | -> TIP: Global search displays results in a fuzzy picker, use `space + '` to bring it back up after opening a file. +> TIP: Global search displays results in a fuzzy picker, use `Space + '` to bring it back up after opening a file. ##### Popup @@ -311,8 +320,8 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire | `]t` | Go to previous test (**TS**) | `goto_prev_test` | | `]p` | Go to next paragraph | `goto_next_paragraph` | | `[p` | Go to previous paragraph | `goto_prev_paragraph` | -| `[space` | Add newline above | `add_newline_above` | -| `]space` | Add newline below | `add_newline_below` | +| `[Space` | Add newline above | `add_newline_above` | +| `]Space` | Add newline below | `add_newline_below` | ## Insert mode @@ -391,7 +400,6 @@ Keys to use within picker. Remapping currently not supported. | `PageDown`, `Ctrl-d` | Page down | | `Home` | Go to first entry | | `End` | Go to last entry | -| `Ctrl-space` | Filter options | | `Enter` | Open selected | | `Ctrl-s` | Open horizontally | | `Ctrl-v` | Open vertically | @@ -415,8 +423,8 @@ Keys to use within prompt, Remapping currently not supported. | `Alt-d`, `Alt-Delete`, `Ctrl-Delete` | Delete next word | | `Ctrl-u` | Delete to start of line | | `Ctrl-k` | Delete to end of line | -| `backspace`, `Ctrl-h` | Delete previous char | -| `delete`, `Ctrl-d` | Delete next char | +| `Backspace`, `Ctrl-h` | Delete previous char | +| `Delete`, `Ctrl-d` | Delete next char | | `Ctrl-s` | Insert a word under doc cursor, may be changed to Ctrl-r Ctrl-w later | | `Ctrl-p`, `Up` | Select previous history | | `Ctrl-n`, `Down` | Select next history | diff --git a/book/src/languages.md b/book/src/languages.md index 73c81213..133e6447 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -50,7 +50,7 @@ These configuration keys are available: | `name` | The name of the language | | `scope` | A string like `source.js` that identifies the language. Currently, we strive to match the scope names used by popular TextMate grammars and by the Linguist library. Usually `source.` or `text.` in case of markup languages | | `injection-regex` | regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. | -| `file-types` | The filetypes of the language, for example `["yml", "yaml"]`. Extensions and full file names are supported. | +| `file-types` | The filetypes of the language, for example `["yml", "yaml"]`. See the file-type detection section below. | | `shebangs` | The interpreters from the shebang line, for example `["sh", "bash"]` | | `roots` | A set of marker files to look for when trying to find the workspace root. For example `Cargo.lock`, `yarn.lock` | | `auto-format` | Whether to autoformat this language when saving | @@ -63,6 +63,32 @@ These configuration keys are available: | `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout | | `max-line-length` | Maximum line length. Used for the `:reflow` command | +### File-type detection and the `file-types` key + +Helix determines which language configuration to use with the `file-types` key +from the above section. `file-types` is a list of strings or tables, for +example: + +```toml +file-types = ["Makefile", "toml", { suffix = ".git/config" }] +``` + +When determining a language configuration to use, Helix searches the file-types +with the following priorities: + +1. Exact match: if the filename of a file is an exact match of a string in a + `file-types` list, that language wins. In the example above, `"Makefile"` + will match against `Makefile` files. +2. Extension: if there are no exact matches, any `file-types` string that + matches the file extension of a given file wins. In the example above, the + `"toml"` matches files like `Cargo.toml` or `languages.toml`. +3. Suffix: if there are still no matches, any values in `suffix` tables + are checked against the full path of the given file. In the example above, + the `{ suffix = ".git/config" }` would match against any `config` files + in `.git` directories. Note: `/` is used as the directory separator but is + replaced at runtime with the appropriate path separator for the operating + system, so this rule would match against `.git\config` files on Windows. + ### Language Server configuration The `language-server` field takes the following keys: diff --git a/book/src/remapping.md b/book/src/remapping.md index bd4ac7f8..e89c6611 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -11,11 +11,11 @@ this: ```toml # At most one section each of 'keys.normal', 'keys.insert' and 'keys.select' [keys.normal] -C-s = ":w" # Maps the Control-s to the typable command :w which is an alias for :write (save file) -C-o = ":open ~/.config/helix/config.toml" # Maps the Control-o to opening of the helix config file +C-s = ":w" # Maps the Ctrl-s to the typable command :w which is an alias for :write (save file) +C-o = ":open ~/.config/helix/config.toml" # Maps the Ctrl-o to opening of the helix config file a = "move_char_left" # Maps the 'a' key to the move_char_left command w = "move_line_up" # Maps the 'w' key move_line_up -"C-S-esc" = "extend_line" # Maps Control-Shift-Escape to extend_line +"C-S-esc" = "extend_line" # Maps Ctrl-Shift-Escape to extend_line g = { a = "code_action" } # Maps `ga` to show possible code actions "ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode @@ -25,7 +25,7 @@ j = { k = "normal_mode" } # Maps `jk` to exit insert mode ``` > NOTE: Typable commands can also be remapped, remember to keep the `:` prefix to indicate it's a typable command. -Control, Shift and Alt modifiers are encoded respectively with the prefixes +Ctrl, Shift and Alt modifiers are encoded respectively with the prefixes `C-`, `S-` and `A-`. Special keys are encoded as follows: | Key name | Representation | diff --git a/book/src/themes.md b/book/src/themes.md index 1c2c50bc..35a5f40b 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -13,10 +13,10 @@ The default theme.toml can be found [here](https://github.com/helix-editor/helix Each line in the theme file is specified as below: ```toml -key = { fg = "#ffffff", bg = "#000000", modifiers = ["bold", "italic"] } +key = { fg = "#ffffff", bg = "#000000", underline = { color = "#ff0000", style = "curl"}, modifiers = ["bold", "italic"] } ``` -where `key` represents what you want to style, `fg` specifies the foreground color, `bg` the background color, and `modifiers` is a list of style modifiers. `bg` and `modifiers` can be omitted to defer to the defaults. +where `key` represents what you want to style, `fg` specifies the foreground color, `bg` the background color, `underline` the underline `style`/`color`, and `modifiers` is a list of style modifiers. `bg`, `underline` and `modifiers` can be omitted to defer to the defaults. To specify only the foreground color: @@ -77,17 +77,35 @@ The following values may be used as modifiers. Less common modifiers might not be supported by your terminal emulator. +| Modifier | +| --- | +| `bold` | +| `dim` | +| `italic` | +| `underlined` | +| `slow_blink` | +| `rapid_blink` | +| `reversed` | +| `hidden` | +| `crossed_out` | + +> Note: The `underlined` modifier is deprecated and only available for backwards compatibility. +> Its behavior is equivalent to setting `underline.style="line"`. + +### Underline Style + +One of the following values may be used as a value for `underline.style`. + +Some styles might not be supported by your terminal emulator. + | Modifier | | --- | -| `bold` | -| `dim` | -| `italic` | -| `underlined` | -| `slow_blink` | -| `rapid_blink` | -| `reversed` | -| `hidden` | -| `crossed_out` | +| `line` | +| `curl` | +| `dashed` | +| `dot` | +| `double_line` | + ### Rainbow @@ -236,51 +254,53 @@ These scopes are used for theming the editor interface. - `hover` - for hover popup ui -| Key | Notes | -| --- | --- | -| `ui.background` | | -| `ui.background.separator` | Picker separator below input line | -| `ui.cursor` | | -| `ui.cursor.insert` | | -| `ui.cursor.select` | | -| `ui.cursor.match` | Matching bracket etc. | -| `ui.cursor.primary` | Cursor with primary selection | -| `ui.gutter` | Gutter | -| `ui.gutter.selected` | Gutter for the line the cursor is on | -| `ui.linenr` | Line numbers | -| `ui.linenr.selected` | Line number for the line the cursor is on | -| `ui.statusline` | Statusline | -| `ui.statusline.inactive` | Statusline (unfocused document) | -| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) | -| `ui.statusline.separator` | Separator character in statusline | -| `ui.popup` | Documentation popups (e.g space-k) | -| `ui.popup.info` | Prompt for multiple key options | -| `ui.window` | Border lines separating splits | -| `ui.help` | Description box for commands | -| `ui.text` | Command prompts, popup text, etc. | -| `ui.text.focus` | | -| `ui.text.info` | The key: command text in `ui.popup.info` boxes | -| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section])| -| `ui.virtual.whitespace` | Visible white-space characters | -| `ui.virtual.indent-guide` | Vertical indent width guides | -| `ui.menu` | Code and command completion menus | -| `ui.menu.selected` | Selected autocomplete item | -| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar | -| `ui.selection` | For selections in the editing area | -| `ui.selection.primary` | | -| `ui.cursorline.primary` | The line of the primary cursor | -| `ui.cursorline.secondary` | The lines of any other cursors | -| `warning` | Diagnostics warning (gutter) | -| `error` | Diagnostics error (gutter) | -| `info` | Diagnostics info (gutter) | -| `hint` | Diagnostics hint (gutter) | -| `diagnostic` | Diagnostics fallback style (editing area) | -| `diagnostic.hint` | Diagnostics hint (editing area) | -| `diagnostic.info` | Diagnostics info (editing area) | -| `diagnostic.warning` | Diagnostics warning (editing area) | -| `diagnostic.error` | Diagnostics error (editing area) | +| Key | Notes | +| --- | --- | +| `ui.background` | | +| `ui.background.separator` | Picker separator below input line | +| `ui.cursor` | | +| `ui.cursor.insert` | | +| `ui.cursor.select` | | +| `ui.cursor.match` | Matching bracket etc. | +| `ui.cursor.primary` | Cursor with primary selection | +| `ui.gutter` | Gutter | +| `ui.gutter.selected` | Gutter for the line the cursor is on | +| `ui.linenr` | Line numbers | +| `ui.linenr.selected` | Line number for the line the cursor is on | +| `ui.statusline` | Statusline | +| `ui.statusline.inactive` | Statusline (unfocused document) | +| `ui.statusline.normal` | Statusline mode during normal mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.insert` | Statusline mode during insert mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.select` | Statusline mode during select mode ([only if `editor.color-modes` is enabled][editor-section]) | +| `ui.statusline.separator` | Separator character in statusline | +| `ui.popup` | Documentation popups (e.g Space + k) | +| `ui.popup.info` | Prompt for multiple key options | +| `ui.window` | Border lines separating splits | +| `ui.help` | Description box for commands | +| `ui.text` | Command prompts, popup text, etc. | +| `ui.text.focus` | | +| `ui.text.info` | The key: command text in `ui.popup.info` boxes | +| `ui.virtual.ruler` | Ruler columns (see the [`editor.rulers` config][editor-section]) | +| `ui.virtual.whitespace` | Visible whitespace characters | +| `ui.virtual.indent-guide` | Vertical indent width guides | +| `ui.menu` | Code and command completion menus | +| `ui.menu.selected` | Selected autocomplete item | +| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar | +| `ui.selection` | For selections in the editing area | +| `ui.selection.primary` | | +| `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) | +| `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) | +| `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) | +| `ui.cursorcolumn.secondary` | The columns of any other cursors ([if cursorcolumn is enabled][editor-section]) | +| `warning` | Diagnostics warning (gutter) | +| `error` | Diagnostics error (gutter) | +| `info` | Diagnostics info (gutter) | +| `hint` | Diagnostics hint (gutter) | +| `diagnostic` | Diagnostics fallback style (editing area) | +| `diagnostic.hint` | Diagnostics hint (editing area) | +| `diagnostic.info` | Diagnostics info (editing area) | +| `diagnostic.warning` | Diagnostics warning (editing area) | +| `diagnostic.error` | Diagnostics error (editing area) | You can check compliance to spec with diff --git a/book/src/usage.md b/book/src/usage.md index fc3a83ee..646bf926 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -53,7 +53,7 @@ Multiple characters are currently not supported, but planned. ## Syntax-tree Motions -`A-p`, `A-o`, `A-i`, and `A-n` (or `Alt` and arrow keys) move the primary +`Alt-p`, `Alt-o`, `Alt-i`, and `Alt-n` (or `Alt` and arrow keys) move the primary selection according to the selection's place in the syntax tree. Let's walk through an example to get familiar with them. Many languages have a syntax like so for function calls: @@ -100,13 +100,13 @@ in the tree above. func([arg1], arg2, arg3) ``` -Using `A-n` would select the next sibling in the syntax tree: `arg2`. +Using `Alt-n` would select the next sibling in the syntax tree: `arg2`. ``` func(arg1, [arg2], arg3) ``` -While `A-o` would expand the selection to the parent node. In the tree above we +While `Alt-o` would expand the selection to the parent node. In the tree above we can see that we would select the `arguments` node. ``` @@ -114,10 +114,10 @@ func[(arg1, arg2, arg3)] ``` There is also some nuanced behavior that prevents you from getting stuck on a -node with no sibling. If we have a selection on `arg1`, `A-p` would bring us +node with no sibling. If we have a selection on `arg1`, `Alt-p` would bring us to the previous child node. Since `arg1` doesn't have a sibling to its left, -though, we climb the syntax tree and then take the previous selection. So `A-p` -will move the selection over to the "func" `identifier`. +though, we climb the syntax tree and then take the previous selection. So +`Alt-p` will move the selection over to the "func" `identifier`. ``` [func](arg1, arg2, arg3) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 353cb4fd..491cd424 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -35,7 +35,8 @@ to `cargo install` anything either). Integration tests for helix-term can be run with `cargo integration-test`. Code contributors are strongly encouraged to write integration tests for their code. Existing tests can be used as examples. Helpers can be found in -[helpers.rs][helpers.rs] +[helpers.rs][helpers.rs]. The log level can be set with the `HELIX_LOG_LEVEL` +environment variable, e.g. `HELIX_LOG_LEVEL=debug cargo integration-test`. ## Minimum Stable Rust Version (MSRV) Policy diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index d7ab89ba..7585c347 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -29,6 +29,7 @@ tree-sitter = "0.20" once_cell = "1.15" arc-swap = "1" regex = "1" +bitflags = "1.3" log = "0.4" serde = { version = "1.0", features = ["derive"] } @@ -42,7 +43,7 @@ encoding_rs = "0.8" chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] } etcetera = "0.4" -textwrap = "0.15.1" +textwrap = "0.16.0" [dev-dependencies] quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index ff680a77..072c93d0 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -7,7 +7,6 @@ use std::collections::HashMap; use smallvec::SmallVec; // Heavily based on https://github.com/codemirror/closebrackets/ - pub const DEFAULT_PAIRS: &[(char, char)] = &[ ('(', ')'), ('{', '}'), @@ -147,13 +146,7 @@ fn prev_char(doc: &Rope, pos: usize) -> Option { } /// calculate what the resulting range should be for an auto pair insertion -fn get_next_range( - doc: &Rope, - start_range: &Range, - offset: usize, - typed_char: char, - len_inserted: usize, -) -> Range { +fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: usize) -> Range { // When the character under the cursor changes due to complete pair // insertion, we must look backward a grapheme and then add the length // of the insertion to put the resulting cursor in the right place, e.g. @@ -173,8 +166,8 @@ fn get_next_range( // inserting at the very end of the document after the last newline if start_range.head == doc.len_chars() && start_range.anchor == doc.len_chars() { return Range::new( - start_range.anchor + offset + typed_char.len_utf8(), - start_range.head + offset + typed_char.len_utf8(), + start_range.anchor + offset + 1, + start_range.head + offset + 1, ); } @@ -204,21 +197,18 @@ fn get_next_range( // trivial case: only inserted a single-char opener, just move the selection if len_inserted == 1 { let end_anchor = if single_grapheme || start_range.direction() == Direction::Backward { - start_range.anchor + offset + typed_char.len_utf8() + start_range.anchor + offset + 1 } else { start_range.anchor + offset }; - return Range::new( - end_anchor, - start_range.head + offset + typed_char.len_utf8(), - ); + return Range::new(end_anchor, start_range.head + offset + 1); } // If the head = 0, then we must be in insert mode with a backward // cursor, which implies the head will just move let end_head = if start_range.head == 0 || start_range.direction() == Direction::Backward { - start_range.head + offset + typed_char.len_utf8() + start_range.head + offset + 1 } else { // We must have a forward cursor, which means we must move to the // other end of the grapheme to get to where the new characters @@ -244,8 +234,7 @@ fn get_next_range( (_, Direction::Forward) => { if single_grapheme { - graphemes::prev_grapheme_boundary(doc.slice(..), start_range.head) - + typed_char.len_utf8() + graphemes::prev_grapheme_boundary(doc.slice(..), start_range.head) + 1 // if we are appending, the anchor stays where it is; only offset // for multiple range insertions @@ -259,7 +248,9 @@ fn get_next_range( // if we're backward, then the head is at the first char // of the typed char, so we need to add the length of // the closing char - graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + len_inserted + graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + + len_inserted + + offset } else { // when we are inserting in front of a selection, we need to move // the anchor over by however many characters were inserted overall @@ -280,9 +271,12 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let next_char = doc.get_char(cursor); let len_inserted; + // Since auto pairs are currently limited to single chars, we're either + // inserting exactly one or two chars. When arbitrary length pairs are + // added, these will need to be changed. let change = match next_char { Some(_) if !pair.should_close(doc, start_range) => { - len_inserted = pair.open.len_utf8(); + len_inserted = 1; let mut tendril = Tendril::new(); tendril.push(pair.open); (cursor, cursor, Some(tendril)) @@ -290,12 +284,12 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { _ => { // insert open & close let pair_str = Tendril::from_iter([pair.open, pair.close]); - len_inserted = pair.open.len_utf8() + pair.close.len_utf8(); + len_inserted = 2; (cursor, cursor, Some(pair_str)) } }; - let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); + let next_range = get_next_range(doc, start_range, offs, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -309,7 +303,6 @@ fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let mut end_ranges = SmallVec::with_capacity(selection.len()); - let mut offs = 0; let transaction = Transaction::change_by_selection(doc, selection, |start_range| { @@ -321,13 +314,13 @@ fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { // return transaction that moves past close (cursor, cursor, None) // no-op } else { - len_inserted += pair.close.len_utf8(); + len_inserted = 1; let mut tendril = Tendril::new(); tendril.push(pair.close); (cursor, cursor, Some(tendril)) }; - let next_range = get_next_range(doc, start_range, offs, pair.close, len_inserted); + let next_range = get_next_range(doc, start_range, offs, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -363,11 +356,11 @@ fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { pair_str.push(pair.close); } - len_inserted += pair_str.len(); + len_inserted += pair_str.chars().count(); (cursor, cursor, Some(pair_str)) }; - let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); + let next_range = get_next_range(doc, start_range, offs, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -378,551 +371,3 @@ fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { log::debug!("auto pair transaction: {:#?}", t); t } - -#[cfg(test)] -mod test { - use super::*; - use smallvec::smallvec; - - const LINE_END: &str = crate::DEFAULT_LINE_ENDING.as_str(); - - fn differing_pairs() -> impl Iterator { - DEFAULT_PAIRS.iter().filter(|(open, close)| open != close) - } - - fn matching_pairs() -> impl Iterator { - DEFAULT_PAIRS.iter().filter(|(open, close)| open == close) - } - - fn test_hooks( - in_doc: &Rope, - in_sel: &Selection, - ch: char, - pairs: &[(char, char)], - expected_doc: &Rope, - expected_sel: &Selection, - ) { - let pairs = AutoPairs::new(pairs.iter()); - let trans = hook(in_doc, in_sel, ch, &pairs).unwrap(); - let mut actual_doc = in_doc.clone(); - assert!(trans.apply(&mut actual_doc)); - assert_eq!(expected_doc, &actual_doc); - assert_eq!(expected_sel, trans.selection().unwrap()); - } - - fn test_hooks_with_pairs( - in_doc: &Rope, - in_sel: &Selection, - test_pairs: I, - pairs: &[(char, char)], - get_expected_doc: F, - actual_sel: &Selection, - ) where - I: IntoIterator, - F: Fn(char, char) -> R, - R: Into, - Rope: From, - { - test_pairs.into_iter().for_each(|(open, close)| { - test_hooks( - in_doc, - in_sel, - *open, - pairs, - &Rope::from(get_expected_doc(*open, *close)), - actual_sel, - ) - }); - } - - // [] indicates range - - /// [] -> insert ( -> ([]) - #[test] - fn test_insert_blank() { - test_hooks_with_pairs( - &Rope::from(LINE_END), - &Selection::single(1, 0), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| format!("{}{}{}", open, close, LINE_END), - &Selection::single(2, 1), - ); - - let empty_doc = Rope::from(format!("{line_end}{line_end}", line_end = LINE_END)); - - test_hooks_with_pairs( - &empty_doc, - &Selection::single(empty_doc.len_chars(), LINE_END.len()), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| { - format!( - "{line_end}{open}{close}{line_end}", - open = open, - close = close, - line_end = LINE_END - ) - }, - &Selection::single(LINE_END.len() + 2, LINE_END.len() + 1), - ); - } - - #[test] - fn test_insert_before_multi_code_point_graphemes() { - for (_, close) in differing_pairs() { - test_hooks( - &Rope::from(format!("hello 👨‍👩‍👧‍👦 goodbye{}", LINE_END)), - &Selection::single(13, 6), - *close, - DEFAULT_PAIRS, - &Rope::from(format!("hello {}👨‍👩‍👧‍👦 goodbye{}", close, LINE_END)), - &Selection::single(14, 7), - ); - } - } - - #[test] - fn test_insert_at_end_of_document() { - test_hooks_with_pairs( - &Rope::from(LINE_END), - &Selection::single(LINE_END.len(), LINE_END.len()), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| format!("{}{}{}", LINE_END, open, close), - &Selection::single(LINE_END.len() + 1, LINE_END.len() + 1), - ); - - test_hooks_with_pairs( - &Rope::from(format!("foo{}", LINE_END)), - &Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| format!("foo{}{}{}", LINE_END, open, close), - &Selection::single(LINE_END.len() + 4, LINE_END.len() + 4), - ); - } - - /// [] -> append ( -> ([]) - #[test] - fn test_append_blank() { - test_hooks_with_pairs( - // this is what happens when you have a totally blank document and then append - &Rope::from(format!("{line_end}{line_end}", line_end = LINE_END)), - // before inserting the pair, the cursor covers all of both empty lines - &Selection::single(0, LINE_END.len() * 2), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| { - format!( - "{line_end}{open}{close}{line_end}", - line_end = LINE_END, - open = open, - close = close - ) - }, - // after inserting pair, the cursor covers the first new line and the open char - &Selection::single(0, LINE_END.len() + 2), - ); - } - - /// [] ([]) - /// [] -> insert -> ([]) - /// [] ([]) - #[test] - fn test_insert_blank_multi_cursor() { - test_hooks_with_pairs( - &Rope::from("\n\n\n"), - &Selection::new( - smallvec!(Range::new(1, 0), Range::new(2, 1), Range::new(3, 2),), - 0, - ), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, close| { - format!( - "{open}{close}\n{open}{close}\n{open}{close}\n", - open = open, - close = close - ) - }, - &Selection::new( - smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),), - 0, - ), - ); - } - - /// fo[o] -> append ( -> fo[o(]) - #[test] - fn test_append() { - test_hooks_with_pairs( - &Rope::from("foo\n"), - &Selection::single(2, 4), - differing_pairs(), - DEFAULT_PAIRS, - |open, close| format!("foo{}{}\n", open, close), - &Selection::single(2, 5), - ); - } - - /// foo[] -> append to end of line ( -> foo([]) - #[test] - fn test_append_single_cursor() { - test_hooks_with_pairs( - &Rope::from(format!("foo{}", LINE_END)), - &Selection::single(3, 3 + LINE_END.len()), - differing_pairs(), - DEFAULT_PAIRS, - |open, close| format!("foo{}{}{}", open, close, LINE_END), - &Selection::single(4, 5), - ); - } - - /// fo[o] fo[o(]) - /// fo[o] -> append ( -> fo[o(]) - /// fo[o] fo[o(]) - #[test] - fn test_append_multi() { - test_hooks_with_pairs( - &Rope::from("foo\nfoo\nfoo\n"), - &Selection::new( - smallvec!(Range::new(2, 4), Range::new(6, 8), Range::new(10, 12)), - 0, - ), - differing_pairs(), - DEFAULT_PAIRS, - |open, close| { - format!( - "foo{open}{close}\nfoo{open}{close}\nfoo{open}{close}\n", - open = open, - close = close - ) - }, - &Selection::new( - smallvec!(Range::new(2, 5), Range::new(8, 11), Range::new(14, 17)), - 0, - ), - ); - } - - /// ([)] -> insert ) -> ()[] - #[test] - fn test_insert_close_inside_pair() { - for (open, close) in DEFAULT_PAIRS { - let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); - - test_hooks( - &doc, - &Selection::single(2, 1), - *close, - DEFAULT_PAIRS, - &doc, - &Selection::single(2 + LINE_END.len(), 2), - ); - } - } - - /// [(]) -> append ) -> [()] - #[test] - fn test_append_close_inside_pair() { - for (open, close) in DEFAULT_PAIRS { - let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); - - test_hooks( - &doc, - &Selection::single(0, 2), - *close, - DEFAULT_PAIRS, - &doc, - &Selection::single(0, 2 + LINE_END.len()), - ); - } - } - - /// ([]) ()[] - /// ([]) -> insert ) -> ()[] - /// ([]) ()[] - #[test] - fn test_insert_close_inside_pair_multi_cursor() { - let sel = Selection::new( - smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),), - 0, - ); - - let expected_sel = Selection::new( - smallvec!(Range::new(3, 2), Range::new(6, 5), Range::new(9, 8),), - 0, - ); - - for (open, close) in DEFAULT_PAIRS { - let doc = Rope::from(format!( - "{open}{close}\n{open}{close}\n{open}{close}\n", - open = open, - close = close - )); - - test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel); - } - } - - /// [(]) [()] - /// [(]) -> append ) -> [()] - /// [(]) [()] - #[test] - fn test_append_close_inside_pair_multi_cursor() { - let sel = Selection::new( - smallvec!(Range::new(0, 2), Range::new(3, 5), Range::new(6, 8),), - 0, - ); - - let expected_sel = Selection::new( - smallvec!(Range::new(0, 3), Range::new(3, 6), Range::new(6, 9),), - 0, - ); - - for (open, close) in DEFAULT_PAIRS { - let doc = Rope::from(format!( - "{open}{close}\n{open}{close}\n{open}{close}\n", - open = open, - close = close - )); - - test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel); - } - } - - /// ([]) -> insert ( -> (([])) - #[test] - fn test_insert_open_inside_pair() { - let sel = Selection::single(2, 1); - let expected_sel = Selection::single(3, 2); - - for (open, close) in differing_pairs() { - let doc = Rope::from(format!("{}{}", open, close)); - let expected_doc = Rope::from(format!( - "{open}{open}{close}{close}", - open = open, - close = close - )); - - test_hooks( - &doc, - &sel, - *open, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - - /// [word(]) -> append ( -> [word((])) - #[test] - fn test_append_open_inside_pair() { - let sel = Selection::single(0, 6); - let expected_sel = Selection::single(0, 7); - - for (open, close) in differing_pairs() { - let doc = Rope::from(format!("word{}{}", open, close)); - let expected_doc = Rope::from(format!( - "word{open}{open}{close}{close}", - open = open, - close = close - )); - - test_hooks( - &doc, - &sel, - *open, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - - /// ([]) -> insert " -> ("[]") - #[test] - fn test_insert_nested_open_inside_pair() { - let sel = Selection::single(2, 1); - let expected_sel = Selection::single(3, 2); - - for (outer_open, outer_close) in differing_pairs() { - let doc = Rope::from(format!("{}{}", outer_open, outer_close,)); - - for (inner_open, inner_close) in matching_pairs() { - let expected_doc = Rope::from(format!( - "{}{}{}{}", - outer_open, inner_open, inner_close, outer_close - )); - - test_hooks( - &doc, - &sel, - *inner_open, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - } - - /// [(]) -> append " -> [("]") - #[test] - fn test_append_nested_open_inside_pair() { - let sel = Selection::single(0, 2); - let expected_sel = Selection::single(0, 3); - - for (outer_open, outer_close) in differing_pairs() { - let doc = Rope::from(format!("{}{}", outer_open, outer_close,)); - - for (inner_open, inner_close) in matching_pairs() { - let expected_doc = Rope::from(format!( - "{}{}{}{}", - outer_open, inner_open, inner_close, outer_close - )); - - test_hooks( - &doc, - &sel, - *inner_open, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - } - - /// []word -> insert ( -> ([]word - #[test] - fn test_insert_open_before_non_pair() { - test_hooks_with_pairs( - &Rope::from("word"), - &Selection::single(1, 0), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, _| format!("{}word", open), - &Selection::single(2, 1), - ) - } - - /// [wor]d -> insert ( -> ([wor]d - #[test] - fn test_insert_open_with_selection() { - test_hooks_with_pairs( - &Rope::from("word"), - &Selection::single(3, 0), - DEFAULT_PAIRS, - DEFAULT_PAIRS, - |open, _| format!("{}word", open), - &Selection::single(4, 1), - ) - } - - /// [wor]d -> append ) -> [wor)]d - #[test] - fn test_append_close_inside_non_pair_with_selection() { - let sel = Selection::single(0, 4); - let expected_sel = Selection::single(0, 5); - - for (_, close) in DEFAULT_PAIRS { - let doc = Rope::from("word"); - let expected_doc = Rope::from(format!("wor{}d", close)); - test_hooks( - &doc, - &sel, - *close, - DEFAULT_PAIRS, - &expected_doc, - &expected_sel, - ); - } - } - - /// foo[ wor]d -> insert ( -> foo([) wor]d - #[test] - fn test_insert_open_trailing_word_with_selection() { - test_hooks_with_pairs( - &Rope::from("foo word"), - &Selection::single(7, 3), - differing_pairs(), - DEFAULT_PAIRS, - |open, close| format!("foo{}{} word", open, close), - &Selection::single(9, 4), - ) - } - - /// foo([) wor]d -> insert ) -> foo()[ wor]d - #[test] - fn test_insert_close_inside_pair_trailing_word_with_selection() { - for (open, close) in differing_pairs() { - test_hooks( - &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), - &Selection::single(9, 4), - *close, - DEFAULT_PAIRS, - &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), - &Selection::single(9, 5), - ) - } - } - - /// we want pairs that are *not* the same char to be inserted after - /// a non-pair char, for cases like functions, but for pairs that are - /// the same char, we want to *not* insert a pair to handle cases like "I'm" - /// - /// word[] -> insert ( -> word([]) - /// word[] -> insert ' -> word'[] - #[test] - fn test_insert_open_after_non_pair() { - let doc = Rope::from(format!("word{}", LINE_END)); - let sel = Selection::single(5, 4); - let expected_sel = Selection::single(6, 5); - - test_hooks_with_pairs( - &doc, - &sel, - differing_pairs(), - DEFAULT_PAIRS, - |open, close| format!("word{}{}{}", open, close, LINE_END), - &expected_sel, - ); - - test_hooks_with_pairs( - &doc, - &sel, - matching_pairs(), - DEFAULT_PAIRS, - |open, _| format!("word{}{}", open, LINE_END), - &expected_sel, - ); - } - - #[test] - fn test_configured_pairs() { - let test_pairs = &[('`', ':'), ('+', '-')]; - - test_hooks_with_pairs( - &Rope::from(LINE_END), - &Selection::single(1, 0), - test_pairs, - test_pairs, - |open, close| format!("{}{}{}", open, close, LINE_END), - &Selection::single(2, 1), - ); - - let doc = Rope::from(format!("foo`: word{}", LINE_END)); - - test_hooks( - &doc, - &Selection::single(9, 4), - ':', - test_pairs, - &doc, - &Selection::single(9, 5), - ) - } -} diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index ad079c25..9526fc8a 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -192,13 +192,15 @@ pub fn indent_level_for_line(line: RopeSlice, tab_width: usize) -> usize { /// Computes for node and all ancestors whether they are the first node on their line. /// The first entry in the return value represents the root node, the last one the node itself -fn get_first_in_line(mut node: Node, byte_pos: usize, new_line: bool) -> Vec { +fn get_first_in_line(mut node: Node, new_line_byte_pos: Option) -> Vec { let mut first_in_line = Vec::new(); loop { if let Some(prev) = node.prev_sibling() { // If we insert a new line, the first node at/after the cursor is considered to be the first in its line let first = prev.end_position().row != node.start_position().row - || (new_line && node.start_byte() >= byte_pos && prev.start_byte() < byte_pos); + || new_line_byte_pos.map_or(false, |byte_pos| { + node.start_byte() >= byte_pos && prev.start_byte() < byte_pos + }); first_in_line.push(Some(first)); } else { // Nodes that have no previous siblings are first in their line if and only if their parent is @@ -298,8 +300,21 @@ enum IndentScope { Tail, } -/// Execute the indent query. -/// Returns for each node (identified by its id) a list of indent captures for that node. +/// A capture from the indent query which does not define an indent but extends +/// the range of a node. This is used before the indent is calculated. +enum ExtendCapture { + Extend, + PreventOnce, +} + +/// The result of running a tree-sitter indent query. This stores for +/// each node (identified by its ID) the relevant captures (already filtered +/// by predicates). +struct IndentQueryResult { + indent_captures: HashMap>, + extend_captures: HashMap>, +} + fn query_indents( query: &Query, syntax: &Syntax, @@ -309,8 +324,9 @@ fn query_indents( // Position of the (optional) newly inserted line break. // Given as (line, byte_pos) new_line_break: Option<(usize, usize)>, -) -> HashMap> { +) -> IndentQueryResult { let mut indent_captures: HashMap> = HashMap::new(); + let mut extend_captures: HashMap> = HashMap::new(); cursor.set_byte_range(range); // Iterate over all captures from the query for m in cursor.matches(query, syntax.tree().root_node(), RopeProvider(text)) { @@ -374,10 +390,24 @@ fn query_indents( continue; } for capture in m.captures { - let capture_type = query.capture_names()[capture.index as usize].as_str(); - let capture_type = match capture_type { + let capture_name = query.capture_names()[capture.index as usize].as_str(); + let capture_type = match capture_name { "indent" => IndentCaptureType::Indent, "outdent" => IndentCaptureType::Outdent, + "extend" => { + extend_captures + .entry(capture.node.id()) + .or_insert_with(|| Vec::with_capacity(1)) + .push(ExtendCapture::Extend); + continue; + } + "extend.prevent-once" => { + extend_captures + .entry(capture.node.id()) + .or_insert_with(|| Vec::with_capacity(1)) + .push(ExtendCapture::PreventOnce); + continue; + } _ => { // Ignore any unknown captures (these may be needed for predicates such as #match?) continue; @@ -420,7 +450,72 @@ fn query_indents( .push(indent_capture); } } - indent_captures + IndentQueryResult { + indent_captures, + extend_captures, + } +} + +/// Handle extend queries. deepest_preceding is the deepest descendant of node that directly precedes the cursor position. +/// Any ancestor of deepest_preceding which is also a descendant of node may be "extended". In that case, node will be updated, +/// so that the indent computation starts with the correct syntax node. +fn extend_nodes<'a>( + node: &mut Node<'a>, + deepest_preceding: Option>, + extend_captures: &HashMap>, + text: RopeSlice, + line: usize, + tab_width: usize, +) { + if let Some(mut deepest_preceding) = deepest_preceding { + let mut stop_extend = false; + while deepest_preceding != *node { + let mut extend_node = false; + // This will be set to true if this node is captured, regardless of whether + // it actually will be extended (e.g. because the cursor isn't indented + // more than the node). + let mut node_captured = false; + if let Some(captures) = extend_captures.get(&deepest_preceding.id()) { + for capture in captures { + match capture { + ExtendCapture::PreventOnce => { + stop_extend = true; + } + ExtendCapture::Extend => { + node_captured = true; + // We extend the node if + // - the cursor is on the same line as the end of the node OR + // - the line that the cursor is on is more indented than the + // first line of the node + if deepest_preceding.end_position().row == line { + extend_node = true; + } else { + let cursor_indent = + indent_level_for_line(text.line(line), tab_width); + let node_indent = indent_level_for_line( + text.line(deepest_preceding.start_position().row), + tab_width, + ); + if cursor_indent > node_indent { + extend_node = true; + } + } + } + } + } + } + // If we encountered some `StopExtend` capture before, we don't + // extend the node even if we otherwise would + if node_captured && stop_extend { + stop_extend = false; + } else if extend_node && !stop_extend { + *node = deepest_preceding; + break; + } + // This parent always exists since node is an ancestor of deepest_preceding + deepest_preceding = deepest_preceding.parent().unwrap(); + } + } } /// Use the syntax tree to determine the indentation for a given position. @@ -459,40 +554,73 @@ fn query_indents( /// }, /// ); /// ``` +#[allow(clippy::too_many_arguments)] pub fn treesitter_indent_for_pos( query: &Query, syntax: &Syntax, indent_style: &IndentStyle, + tab_width: usize, text: RopeSlice, line: usize, pos: usize, new_line: bool, ) -> Option { let byte_pos = text.char_to_byte(pos); + // The innermost tree-sitter node which is considered for the indent + // computation. It may change if some predeceding node is extended let mut node = syntax .tree() .root_node() .descendant_for_byte_range(byte_pos, byte_pos)?; - let mut first_in_line = get_first_in_line(node, byte_pos, new_line); - let new_line_break = if new_line { - Some((line, byte_pos)) - } else { - None + let (query_result, deepest_preceding) = { + // The query range should intersect with all nodes directly preceding + // the position of the indent query in case one of them is extended. + let mut deepest_preceding = None; // The deepest node preceding the indent query position + let mut tree_cursor = node.walk(); + for child in node.children(&mut tree_cursor) { + if child.byte_range().end <= byte_pos { + deepest_preceding = Some(child); + } + } + deepest_preceding = deepest_preceding.map(|mut prec| { + // Get the deepest directly preceding node + while prec.child_count() > 0 { + prec = prec.child(prec.child_count() - 1).unwrap(); + } + prec + }); + let query_range = deepest_preceding + .map(|prec| prec.byte_range().end - 1..byte_pos + 1) + .unwrap_or(byte_pos..byte_pos + 1); + + crate::syntax::PARSER.with(|ts_parser| { + let mut ts_parser = ts_parser.borrow_mut(); + let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); + let query_result = query_indents( + query, + syntax, + &mut cursor, + text, + query_range, + new_line.then(|| (line, byte_pos)), + ); + ts_parser.cursors.push(cursor); + (query_result, deepest_preceding) + }) }; - let query_result = crate::syntax::PARSER.with(|ts_parser| { - let mut ts_parser = ts_parser.borrow_mut(); - let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); - let query_result = query_indents( - query, - syntax, - &mut cursor, - text, - byte_pos..byte_pos + 1, - new_line_break, - ); - ts_parser.cursors.push(cursor); - query_result - }); + let indent_captures = query_result.indent_captures; + let extend_captures = query_result.extend_captures; + + // Check for extend captures, potentially changing the node that the indent calculation starts with + extend_nodes( + &mut node, + deepest_preceding, + &extend_captures, + text, + line, + tab_width, + ); + let mut first_in_line = get_first_in_line(node, new_line.then(|| byte_pos)); let mut result = Indentation::default(); // We always keep track of all the indent changes on one line, in order to only indent once @@ -504,7 +632,7 @@ pub fn treesitter_indent_for_pos( // one entry for each ancestor of the node (which is what we iterate over) let is_first = *first_in_line.last().unwrap(); // Apply all indent definitions for this node - if let Some(definitions) = query_result.get(&node.id()) { + if let Some(definitions) = indent_captures.get(&node.id()) { for definition in definitions { match definition.scope { IndentScope::All => { @@ -550,7 +678,13 @@ pub fn treesitter_indent_for_pos( node = parent; first_in_line.pop(); } else { - result.add_line(&indent_for_line_below); + // Only add the indentation for the line below if that line + // is not after the line that the indentation is calculated for. + if (node.start_position().row < line) + || (new_line && node.start_position().row == line && node.start_byte() < byte_pos) + { + result.add_line(&indent_for_line_below); + } result.add_line(&indent_for_line); break; } @@ -579,6 +713,7 @@ pub fn indent_for_newline( query, syntax, indent_style, + tab_width, text, line_before, line_before_end_pos, diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs index 4323039a..afc83496 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -3,8 +3,9 @@ use std::borrow::Cow; /// Get the vec of escaped / quoted / doublequoted filenames from the input str pub fn shellwords(input: &str) -> Vec> { enum State { - Normal, - NormalEscaped, + OnWhitespace, + Unquoted, + UnquotedEscaped, Quoted, QuoteEscaped, Dquoted, @@ -13,7 +14,7 @@ pub fn shellwords(input: &str) -> Vec> { use State::*; - let mut state = Normal; + let mut state = Unquoted; let mut args: Vec> = Vec::new(); let mut escaped = String::with_capacity(input.len()); @@ -22,31 +23,47 @@ pub fn shellwords(input: &str) -> Vec> { for (i, c) in input.char_indices() { state = match state { - Normal => match c { + OnWhitespace => match c { + '"' => { + end = i; + Dquoted + } + '\'' => { + end = i; + Quoted + } '\\' => { if cfg!(unix) { escaped.push_str(&input[start..i]); start = i + 1; - NormalEscaped + UnquotedEscaped } else { - Normal + OnWhitespace } } - '"' => { + c if c.is_ascii_whitespace() => { end = i; - Dquoted + OnWhitespace } - '\'' => { - end = i; - Quoted + _ => Unquoted, + }, + Unquoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[start..i]); + start = i + 1; + UnquotedEscaped + } else { + Unquoted + } } c if c.is_ascii_whitespace() => { end = i; - Normal + OnWhitespace } - _ => Normal, + _ => Unquoted, }, - NormalEscaped => Normal, + UnquotedEscaped => Unquoted, Quoted => match c { '\\' => { if cfg!(unix) { @@ -59,7 +76,7 @@ pub fn shellwords(input: &str) -> Vec> { } '\'' => { end = i; - Normal + OnWhitespace } _ => Quoted, }, @@ -76,7 +93,7 @@ pub fn shellwords(input: &str) -> Vec> { } '"' => { end = i; - Normal + OnWhitespace } _ => Dquoted, }, @@ -195,4 +212,18 @@ mod test { ]; assert_eq!(expected, result); } + + #[test] + fn test_lists() { + let input = + r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "qoutes"]'"#; + let result = shellwords(input); + let expected = vec![ + Cow::from(":set"), + Cow::from("statusline.center"), + Cow::from(r#"["file-type","file-encoding"]"#), + Cow::from(r#"["list", "in", "qoutes"]"#), + ]; + assert_eq!(expected, result); + } } diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index e0a984d2..c17655a9 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -8,13 +8,15 @@ use crate::{ }; use arc_swap::{ArcSwap, Guard}; +use bitflags::bitflags; use slotmap::{DefaultKey as LayerId, HopSlotMap}; use std::{ borrow::Cow, cell::RefCell, - collections::{HashMap, HashSet, VecDeque}, + collections::{HashMap, VecDeque}, fmt, + mem::replace, path::Path, str::FromStr, sync::Arc, @@ -59,17 +61,23 @@ pub struct Configuration { pub language: Vec, } +impl Default for Configuration { + fn default() -> Self { + crate::config::default_syntax_loader() + } +} + // largely based on tree-sitter/cli/src/loader.rs #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct LanguageConfiguration { #[serde(rename = "name")] pub language_id: String, // c-sharp, rust - pub scope: String, // source.rust - pub file_types: Vec, // filename ends_with? + pub scope: String, // source.rust + pub file_types: Vec, // filename extension or ends_with? #[serde(default)] pub shebangs: Vec, // interpreter(s) associated with language - pub roots: Vec, // these indicate project roots <.git, Cargo.toml> + pub roots: Vec, // these indicate project roots <.git, Cargo.toml> pub comment_token: Option, pub max_line_length: Option, @@ -117,6 +125,78 @@ pub struct LanguageConfiguration { pub rulers: Option>, // if set, override editor's rulers } +#[derive(Debug, PartialEq, Eq, Hash)] +pub enum FileType { + /// The extension of the file, either the `Path::extension` or the full + /// filename if the file does not have an extension. + Extension(String), + /// The suffix of a file. This is compared to a given file's absolute + /// path, so it can be used to detect files based on their directories. + Suffix(String), +} + +impl Serialize for FileType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + match self { + FileType::Extension(extension) => serializer.serialize_str(extension), + FileType::Suffix(suffix) => { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("suffix", &suffix.replace(std::path::MAIN_SEPARATOR, "/"))?; + map.end() + } + } + } +} + +impl<'de> Deserialize<'de> for FileType { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct FileTypeVisitor; + + impl<'de> serde::de::Visitor<'de> for FileTypeVisitor { + type Value = FileType; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("string or table") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(FileType::Extension(value.to_string())) + } + + fn visit_map(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + match map.next_entry::()? { + Some((key, suffix)) if key == "suffix" => Ok(FileType::Suffix( + suffix.replace('/', &std::path::MAIN_SEPARATOR.to_string()), + )), + Some((key, _value)) => Err(serde::de::Error::custom(format!( + "unknown key in `file-types` list: {}", + key + ))), + None => Err(serde::de::Error::custom( + "expected a `suffix` key in the `file-types` entry", + )), + } + } + } + + deserializer.deserialize_any(FileTypeVisitor) + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct LanguageServerConfiguration { @@ -353,20 +433,24 @@ pub fn read_query(language: &str, filename: &str) -> String { impl LanguageConfiguration { fn initialize_highlight(&self, scopes: &[String]) -> Option> { - let language = self.language_id.to_ascii_lowercase(); - - let highlights_query = read_query(&language, "highlights.scm"); + let highlights_query = read_query(&self.language_id, "highlights.scm"); // always highlight syntax errors // highlights_query += "\n(ERROR) @error"; - let injections_query = read_query(&language, "injections.scm"); - let locals_query = read_query(&language, "locals.scm"); + let injections_query = read_query(&self.language_id, "injections.scm"); + let locals_query = read_query(&self.language_id, "locals.scm"); if highlights_query.is_empty() { None } else { let language = get_language(self.grammar.as_deref().unwrap_or(&self.language_id)) - .map_err(|e| log::info!("{}", e)) + .map_err(|err| { + log::error!( + "Failed to load tree-sitter parser for language {:?}: {}", + self.language_id, + err + ) + }) .ok()?; let config = HighlightConfiguration::new( language, @@ -418,14 +502,20 @@ impl LanguageConfiguration { } fn load_query(&self, kind: &str) -> Option { - let lang_name = self.language_id.to_ascii_lowercase(); - let query_text = read_query(&lang_name, kind); + let query_text = read_query(&self.language_id, kind); if query_text.is_empty() { return None; } let lang = self.highlight_config.get()?.as_ref()?.language; Query::new(lang, &query_text) - .map_err(|e| log::error!("Failed to parse {} queries for {}: {}", kind, lang_name, e)) + .map_err(|e| { + log::error!( + "Failed to parse {} queries for {}: {}", + kind, + self.language_id, + e + ) + }) .ok() } } @@ -436,7 +526,8 @@ impl LanguageConfiguration { pub struct Loader { // highlight_names ? language_configs: Vec>, - language_config_ids_by_file_type: HashMap, // Vec + language_config_ids_by_extension: HashMap, // Vec + language_config_ids_by_suffix: HashMap, language_config_ids_by_shebang: HashMap, scopes: ArcSwap>, @@ -446,7 +537,8 @@ impl Loader { pub fn new(config: Configuration) -> Self { let mut loader = Self { language_configs: Vec::new(), - language_config_ids_by_file_type: HashMap::new(), + language_config_ids_by_extension: HashMap::new(), + language_config_ids_by_suffix: HashMap::new(), language_config_ids_by_shebang: HashMap::new(), scopes: ArcSwap::from_pointee(Vec::new()), }; @@ -457,9 +549,14 @@ impl Loader { for file_type in &config.file_types { // entry().or_insert(Vec::new).push(language_id); - loader - .language_config_ids_by_file_type - .insert(file_type.clone(), language_id); + match file_type { + FileType::Extension(extension) => loader + .language_config_ids_by_extension + .insert(extension.clone(), language_id), + FileType::Suffix(suffix) => loader + .language_config_ids_by_suffix + .insert(suffix.clone(), language_id), + }; } for shebang in &config.shebangs { loader @@ -479,11 +576,22 @@ impl Loader { let configuration_id = path .file_name() .and_then(|n| n.to_str()) - .and_then(|file_name| self.language_config_ids_by_file_type.get(file_name)) + .and_then(|file_name| self.language_config_ids_by_extension.get(file_name)) .or_else(|| { path.extension() .and_then(|extension| extension.to_str()) - .and_then(|extension| self.language_config_ids_by_file_type.get(extension)) + .and_then(|extension| self.language_config_ids_by_extension.get(extension)) + }) + .or_else(|| { + self.language_config_ids_by_suffix + .iter() + .find_map(|(file_type, id)| { + if path.to_str()?.ends_with(file_type) { + Some(id) + } else { + None + } + }) }); configuration_id.and_then(|&id| self.language_configs.get(id).cloned()) @@ -594,6 +702,7 @@ impl Syntax { tree: None, config, depth: 0, + flags: LayerUpdateFlags::empty(), ranges: vec![Range { start_byte: 0, end_byte: usize::MAX, @@ -656,9 +765,10 @@ impl Syntax { } } - for layer in &mut self.layers.values_mut() { + for layer in self.layers.values_mut() { // The root layer always covers the whole range (0..usize::MAX) if layer.depth == 0 { + layer.flags = LayerUpdateFlags::MODIFIED; continue; } @@ -689,6 +799,8 @@ impl Syntax { edit.new_end_position, point_sub(range.end_point, edit.old_end_position), ); + + layer.flags |= LayerUpdateFlags::MOVED; } // if the edit starts in the space before and extends into the range else if edit.start_byte < range.start_byte { @@ -703,11 +815,13 @@ impl Syntax { edit.new_end_position, point_sub(range.end_point, edit.old_end_position), ); + layer.flags = LayerUpdateFlags::MODIFIED; } // If the edit is an insertion at the start of the tree, shift else if edit.start_byte == range.start_byte && is_pure_insertion { range.start_byte = edit.new_end_byte; range.start_point = edit.new_end_position; + layer.flags |= LayerUpdateFlags::MOVED; } else { range.end_byte = range .end_byte @@ -717,6 +831,7 @@ impl Syntax { edit.new_end_position, point_sub(range.end_point, edit.old_end_position), ); + layer.flags = LayerUpdateFlags::MODIFIED; } } } @@ -731,27 +846,33 @@ impl Syntax { let source_slice = source.slice(..); - let mut touched = HashSet::new(); - - // TODO: we should be able to avoid editing & parsing layers with ranges earlier in the document before the edit - while let Some(layer_id) = queue.pop_front() { - // Mark the layer as touched - touched.insert(layer_id); - let layer = &mut self.layers[layer_id]; + // Mark the layer as touched + layer.flags |= LayerUpdateFlags::TOUCHED; + // If a tree already exists, notify it of changes. if let Some(tree) = &mut layer.tree { - for edit in edits.iter().rev() { - // Apply the edits in reverse. - // If we applied them in order then edit 1 would disrupt the positioning of edit 2. - tree.edit(edit); + if layer + .flags + .intersects(LayerUpdateFlags::MODIFIED | LayerUpdateFlags::MOVED) + { + for edit in edits.iter().rev() { + // Apply the edits in reverse. + // If we applied them in order then edit 1 would disrupt the positioning of edit 2. + tree.edit(edit); + } } - } - // Re-parse the tree. - layer.parse(&mut ts_parser.parser, source)?; + if layer.flags.contains(LayerUpdateFlags::MODIFIED) { + // Re-parse the tree. + layer.parse(&mut ts_parser.parser, source)?; + } + } else { + // always parse if this layer has never been parsed before + layer.parse(&mut ts_parser.parser, source)?; + } // Switch to an immutable borrow. let layer = &self.layers[layer_id]; @@ -855,6 +976,8 @@ impl Syntax { config, depth, ranges, + // set the modified flag to ensure the layer is parsed + flags: LayerUpdateFlags::empty(), }) }); @@ -868,8 +991,11 @@ impl Syntax { // Return the cursor back in the pool. ts_parser.cursors.push(cursor); - // Remove all untouched layers - self.layers.retain(|id, _| touched.contains(&id)); + // Reset all `LayerUpdateFlags` and remove all untouched layers + self.layers.retain(|_, layer| { + replace(&mut layer.flags, LayerUpdateFlags::empty()) + .contains(LayerUpdateFlags::TOUCHED) + }); Ok(()) }) @@ -968,6 +1094,16 @@ impl Syntax { // TODO: Folding } +bitflags! { + /// Flags that track the status of a layer + /// in the `Sytaxn::update` function + struct LayerUpdateFlags : u32{ + const MODIFIED = 0b001; + const MOVED = 0b010; + const TOUCHED = 0b100; + } +} + #[derive(Debug)] pub struct LanguageLayer { // mode @@ -975,7 +1111,8 @@ pub struct LanguageLayer { pub config: Arc, pub(crate) tree: Option, pub ranges: Vec, - pub depth: usize, + pub depth: u32, + flags: LayerUpdateFlags, } impl LanguageLayer { @@ -1191,7 +1328,7 @@ struct HighlightIter<'a> { layers: Vec>, iter_count: usize, next_event: Option, - last_highlight_range: Option<(usize, usize, usize)>, + last_highlight_range: Option<(usize, usize, u32)>, } // Adapter to convert rope chunks to bytes @@ -1224,7 +1361,7 @@ struct HighlightIterLayer<'a> { config: &'a HighlightConfiguration, highlight_end_stack: Vec, scope_stack: Vec>, - depth: usize, + depth: u32, ranges: &'a [Range], } @@ -1993,6 +2130,57 @@ impl> Iterator for Merge { } } +pub fn pretty_print_tree(fmt: &mut W, node: Node) -> fmt::Result { + pretty_print_tree_impl(fmt, node, true, None, 0) +} + +fn pretty_print_tree_impl( + fmt: &mut W, + node: Node, + is_root: bool, + field_name: Option<&str>, + depth: usize, +) -> fmt::Result { + fn is_visible(node: Node) -> bool { + node.is_missing() + || (node.is_named() && node.language().node_kind_is_visible(node.kind_id())) + } + + if is_visible(node) { + let indentation_columns = depth * 2; + write!(fmt, "{:indentation_columns$}", "")?; + + if let Some(field_name) = field_name { + write!(fmt, "{}: ", field_name)?; + } + + write!(fmt, "({}", node.kind())?; + } else if is_root { + write!(fmt, "(\"{}\")", node.kind())?; + } + + for child_idx in 0..node.child_count() { + if let Some(child) = node.child(child_idx) { + if is_visible(child) { + fmt.write_char('\n')?; + } + + pretty_print_tree_impl( + fmt, + child, + false, + node.field_name_for_child(child_idx as u32), + depth + 1, + )?; + } + } + + if is_visible(node) { + write!(fmt, ")")?; + } + + Ok(()) +} #[cfg(test)] mod test { use super::*; @@ -2013,7 +2201,7 @@ mod test { ); let loader = Loader::new(Configuration { language: vec![] }); - let language = get_language("Rust").unwrap(); + let language = get_language("rust").unwrap(); let query = Query::new(language, query_str).unwrap(); let textobject = TextObjectQuery { query }; @@ -2073,7 +2261,7 @@ mod test { let loader = Loader::new(Configuration { language: vec![] }); - let language = get_language("Rust").unwrap(); + let language = get_language("rust").unwrap(); let config = HighlightConfiguration::new( language, &std::fs::read_to_string("../runtime/grammars/sources/rust/queries/highlights.scm") @@ -2164,6 +2352,63 @@ mod test { ); } + #[track_caller] + fn assert_pretty_print(source: &str, expected: &str, start: usize, end: usize) { + let source = Rope::from_str(source); + + let loader = Loader::new(Configuration { language: vec![] }); + let language = get_language("rust").unwrap(); + + let config = HighlightConfiguration::new(language, "", "", "").unwrap(); + let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)); + + let root = syntax + .tree() + .root_node() + .descendant_for_byte_range(start, end) + .unwrap(); + + let mut output = String::new(); + pretty_print_tree(&mut output, root).unwrap(); + + assert_eq!(expected, output); + } + + #[test] + fn test_pretty_print() { + let source = r#"/// Hello"#; + assert_pretty_print(source, "(line_comment)", 0, source.len()); + + // A large tree should be indented with fields: + let source = r#"fn main() { + println!("Hello, World!"); + }"#; + assert_pretty_print( + source, + concat!( + "(function_item\n", + " name: (identifier)\n", + " parameters: (parameters)\n", + " body: (block\n", + " (expression_statement\n", + " (macro_invocation\n", + " macro: (identifier)\n", + " (token_tree\n", + " (string_literal))))))", + ), + 0, + source.len(), + ); + + // Selecting a token should print just that token: + let source = r#"fn main() {}"#; + assert_pretty_print(source, r#"("fn")"#, 0, 1); + + // Error nodes are printed as errors: + let source = r#"}{"#; + assert_pretty_print(source, "(ERROR)", 0, source.len()); + } + #[test] fn test_load_runtime_file() { // Test to make sure we can load some data from the runtime directory. diff --git a/helix-core/src/test.rs b/helix-core/src/test.rs index 45503107..3e54d2c2 100644 --- a/helix-core/src/test.rs +++ b/helix-core/src/test.rs @@ -34,7 +34,7 @@ pub fn print(s: &str) -> (String, Selection) { let mut left = String::with_capacity(s.len()); 'outer: while let Some(c) = iter.next() { - let start = left.len(); + let start = left.chars().count(); if c != '#' { left.push(c); @@ -63,6 +63,7 @@ pub fn print(s: &str) -> (String, Selection) { left.push(c); continue; } + if !head_at_beg { let prev = left.pop().unwrap(); if prev != '|' { @@ -71,15 +72,18 @@ pub fn print(s: &str) -> (String, Selection) { continue; } } + iter.next(); // skip "#" if is_primary { primary_idx = Some(ranges.len()); } + let (anchor, head) = match head_at_beg { - true => (left.len(), start), - false => (start, left.len()), + true => (left.chars().count(), start), + false => (start, left.chars().count()), }; + ranges.push(Range::new(anchor, head)); continue 'outer; } @@ -95,6 +99,7 @@ pub fn print(s: &str) -> (String, Selection) { Some(i) => i, None => panic!("missing primary `#[|]#` {:?}", s), }; + let selection = Selection::new(ranges, primary); (left, selection) } @@ -141,3 +146,119 @@ pub fn plain(s: &str, selection: Selection) -> String { } out } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn print_single() { + assert_eq!( + (String::from("hello"), Selection::single(1, 0)), + print("#[|h]#ello") + ); + assert_eq!( + (String::from("hello"), Selection::single(0, 1)), + print("#[h|]#ello") + ); + assert_eq!( + (String::from("hello"), Selection::single(4, 0)), + print("#[|hell]#o") + ); + assert_eq!( + (String::from("hello"), Selection::single(0, 4)), + print("#[hell|]#o") + ); + assert_eq!( + (String::from("hello"), Selection::single(5, 0)), + print("#[|hello]#") + ); + assert_eq!( + (String::from("hello"), Selection::single(0, 5)), + print("#[hello|]#") + ); + } + + #[test] + fn print_multi() { + assert_eq!( + ( + String::from("hello"), + Selection::new( + SmallVec::from_slice(&[Range::new(1, 0), Range::new(5, 4)]), + 0 + ) + ), + print("#[|h]#ell#(|o)#") + ); + assert_eq!( + ( + String::from("hello"), + Selection::new( + SmallVec::from_slice(&[Range::new(0, 1), Range::new(4, 5)]), + 0 + ) + ), + print("#[h|]#ell#(o|)#") + ); + assert_eq!( + ( + String::from("hello"), + Selection::new( + SmallVec::from_slice(&[Range::new(2, 0), Range::new(5, 3)]), + 0 + ) + ), + print("#[|he]#l#(|lo)#") + ); + assert_eq!( + ( + String::from("hello\r\nhello\r\nhello\r\n"), + Selection::new( + SmallVec::from_slice(&[ + Range::new(7, 5), + Range::new(21, 19), + Range::new(14, 12) + ]), + 0 + ) + ), + print("hello#[|\r\n]#hello#(|\r\n)#hello#(|\r\n)#") + ); + } + + #[test] + fn print_multi_byte_code_point() { + assert_eq!( + (String::from("„“"), Selection::single(1, 0)), + print("#[|„]#“") + ); + assert_eq!( + (String::from("„“"), Selection::single(2, 1)), + print("„#[|“]#") + ); + assert_eq!( + (String::from("„“"), Selection::single(0, 1)), + print("#[„|]#“") + ); + assert_eq!( + (String::from("„“"), Selection::single(1, 2)), + print("„#[“|]#") + ); + assert_eq!( + (String::from("they said „hello“"), Selection::single(11, 10)), + print("they said #[|„]#hello“") + ); + } + + #[test] + fn print_multi_code_point_grapheme() { + assert_eq!( + ( + String::from("hello 👨‍👩‍👧‍👦 goodbye"), + Selection::single(13, 6) + ), + print("hello #[|👨‍👩‍👧‍👦]# goodbye") + ); + } +} diff --git a/helix-core/tests/indent.rs b/helix-core/tests/indent.rs index ff04d05f..e1114f4a 100644 --- a/helix-core/tests/indent.rs +++ b/helix-core/tests/indent.rs @@ -50,6 +50,7 @@ fn test_treesitter_indent(file_name: &str, lang_scope: &str) { indent_query, &syntax, &IndentStyle::Spaces(4), + 4, text, i, text.line_to_char(i) + pos, diff --git a/helix-dap/src/types.rs b/helix-dap/src/types.rs index 45f45cca..51ecfe1b 100644 --- a/helix-dap/src/types.rs +++ b/helix-dap/src/types.rs @@ -726,7 +726,7 @@ pub mod events { #[serde(tag = "event", content = "body")] // seq is omitted as unused and is not sent by some implementations pub enum Event { - Initialized, + Initialized(Option), Stopped(Stopped), Continued(Continued), Exited(Exited), diff --git a/helix-loader/src/grammar.rs b/helix-loader/src/grammar.rs index eb1895a5..a92cadb6 100644 --- a/helix-loader/src/grammar.rs +++ b/helix-loader/src/grammar.rs @@ -67,7 +67,6 @@ pub fn get_language(name: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] pub fn get_language(name: &str) -> Result { use libloading::{Library, Symbol}; - let name = name.to_ascii_lowercase(); let mut library_path = crate::runtime_dir().join("grammars").join(&name); library_path.set_extension(DYLIB_EXTENSION); diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index 536a6ba6..ad432d96 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -23,5 +23,5 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" tokio = { version = "1.21", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } -tokio-stream = "0.1.10" +tokio-stream = "0.1.11" which = "4.2" diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index c7d98fce..173c5d49 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,12 +1,19 @@ use arc_swap::{access::Map, ArcSwap}; use futures_util::Stream; use helix_core::{ - config::{default_syntax_loader, user_syntax_loader}, diagnostic::{DiagnosticTag, NumberOrString}, + path::get_relative_path, pos_at_coords, syntax, Selection, }; use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; -use helix_view::{align_view, editor::ConfigEvent, theme, tree::Layout, Align, Editor}; +use helix_view::{ + align_view, + document::DocumentSavedEventResult, + editor::{ConfigEvent, EditorEvent}, + theme, + tree::Layout, + Align, Editor, +}; use serde_json::json; use crate::{ @@ -19,7 +26,7 @@ use crate::{ ui::{self, overlay::overlayed}, }; -use log::{error, warn}; +use log::{debug, error, warn}; use std::{ io::{stdin, stdout, Write}, sync::Arc, @@ -30,8 +37,8 @@ use anyhow::{Context, Error}; use crossterm::{ event::{ - DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event as CrosstermEvent, + DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, Event as CrosstermEvent, }, execute, terminal, tty::IsTty, @@ -95,6 +102,7 @@ fn restore_term() -> Result<(), Error> { execute!( stdout, DisableBracketedPaste, + DisableFocusChange, terminal::LeaveAlternateScreen )?; terminal::disable_raw_mode()?; @@ -102,7 +110,11 @@ fn restore_term() -> Result<(), Error> { } impl Application { - pub fn new(args: Args, config: Config) -> Result { + pub fn new( + args: Args, + config: Config, + syn_loader_conf: syntax::Configuration, + ) -> Result { #[cfg(feature = "integration")] setup_integration_logging(); @@ -129,14 +141,6 @@ impl Application { }) .unwrap_or_else(|| theme_loader.default_theme(true_color)); - let syn_loader_conf = user_syntax_loader().unwrap_or_else(|err| { - eprintln!("Bad language config: {}", err); - eprintln!("Press to continue with default language config"); - use std::io::Read; - // This waits for an enter press. - let _ = std::io::stdin().read(&mut []); - default_syntax_loader() - }); let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); let mut compositor = Compositor::new().context("build compositor")?; @@ -245,6 +249,10 @@ impl Application { Ok(app) } + #[cfg(feature = "integration")] + fn render(&mut self) {} + + #[cfg(not(feature = "integration"))] fn render(&mut self) { let compositor = &mut self.compositor; @@ -275,9 +283,6 @@ impl Application { where S: Stream> + Unpin, { - #[cfg(feature = "integration")] - let mut idle_handled = false; - loop { if self.editor.should_close() { return false; @@ -294,26 +299,6 @@ impl Application { Some(signal) = self.signals.next() => { self.handle_signals(signal).await; } - Some((id, call)) = self.editor.language_servers.incoming.next() => { - self.handle_language_server_message(call, id).await; - // limit render calls for fast language server messages - let last = self.editor.language_servers.incoming.is_empty(); - - if last || self.last_render.elapsed() > LSP_DEADLINE { - self.render(); - self.last_render = Instant::now(); - } - } - Some(payload) = self.editor.debugger_events.next() => { - let needs_render = self.editor.handle_debugger_message(payload).await; - if needs_render { - self.render(); - } - } - Some(config_event) = self.editor.config_events.1.recv() => { - self.handle_config_events(config_event); - self.render(); - } Some(callback) = self.jobs.futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.render(); @@ -322,26 +307,22 @@ impl Application { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.render(); } - _ = &mut self.editor.idle_timer => { - // idle timeout - self.editor.clear_idle_timer(); - self.handle_idle_timeout(); + event = self.editor.wait_event() => { + let _idle_handled = self.handle_editor_event(event).await; #[cfg(feature = "integration")] { - idle_handled = true; + if _idle_handled { + return true; + } } } } // for integration tests only, reset the idle timer after every - // event to make a signal when test events are done processing + // event to signal when test events are done processing #[cfg(feature = "integration")] { - if idle_handled { - return true; - } - self.editor.reset_idle_timer(); } } @@ -435,22 +416,122 @@ impl Application { } pub fn handle_idle_timeout(&mut self) { - use crate::compositor::EventResult; - let editor_view = self - .compositor - .find::() - .expect("expected at least one EditorView"); - let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; - if let EventResult::Consumed(_) = editor_view.handle_idle_timeout(&mut cx) { + let should_render = self.compositor.handle_event(&Event::IdleTimeout, &mut cx); + if should_render { self.render(); } } + pub fn handle_document_write(&mut self, doc_save_event: DocumentSavedEventResult) { + let doc_save_event = match doc_save_event { + Ok(event) => event, + Err(err) => { + self.editor.set_error(err.to_string()); + return; + } + }; + + let doc = match self.editor.document_mut(doc_save_event.doc_id) { + None => { + warn!( + "received document saved event for non-existent doc id: {}", + doc_save_event.doc_id + ); + + return; + } + Some(doc) => doc, + }; + + debug!( + "document {:?} saved with revision {}", + doc.path(), + doc_save_event.revision + ); + + doc.set_last_saved_revision(doc_save_event.revision); + + let lines = doc_save_event.text.len_lines(); + let bytes = doc_save_event.text.len_bytes(); + + if doc.path() != Some(&doc_save_event.path) { + if let Err(err) = doc.set_path(Some(&doc_save_event.path)) { + log::error!( + "error setting path for doc '{:?}': {}", + doc.path(), + err.to_string(), + ); + + self.editor.set_error(err.to_string()); + return; + } + + let loader = self.editor.syn_loader.clone(); + + // borrowing the same doc again to get around the borrow checker + let doc = doc_mut!(self.editor, &doc_save_event.doc_id); + let id = doc.id(); + doc.detect_language(loader); + let _ = self.editor.refresh_language_server(id); + } + + // TODO: fix being overwritten by lsp + self.editor.set_status(format!( + "'{}' written, {}L {}B", + get_relative_path(&doc_save_event.path).to_string_lossy(), + lines, + bytes + )); + } + + #[inline(always)] + pub async fn handle_editor_event(&mut self, event: EditorEvent) -> bool { + log::debug!("received editor event: {:?}", event); + + match event { + EditorEvent::DocumentSaved(event) => { + self.handle_document_write(event); + self.render(); + } + EditorEvent::ConfigEvent(event) => { + self.handle_config_events(event); + self.render(); + } + EditorEvent::LanguageServerMessage((id, call)) => { + self.handle_language_server_message(call, id).await; + // limit render calls for fast language server messages + let last = self.editor.language_servers.incoming.is_empty(); + + if last || self.last_render.elapsed() > LSP_DEADLINE { + self.render(); + self.last_render = Instant::now(); + } + } + EditorEvent::DebuggerEvent(payload) => { + let needs_render = self.editor.handle_debugger_message(payload).await; + if needs_render { + self.render(); + } + } + EditorEvent::IdleTimer => { + self.editor.clear_idle_timer(); + self.handle_idle_timeout(); + + #[cfg(feature = "integration")] + { + return true; + } + } + } + + false + } + pub fn handle_terminal_events(&mut self, event: Result) { let mut cx = crate::compositor::Context { editor: &mut self.editor, @@ -516,14 +597,14 @@ impl Application { // trigger textDocument/didOpen for docs that are already open for doc in docs { - let language_id = - doc.language_id().map(ToOwned::to_owned).unwrap_or_default(); - let url = match doc.url() { Some(url) => url, None => continue, // skip documents with no path }; + let language_id = + doc.language_id().map(ToOwned::to_owned).unwrap_or_default(); + tokio::spawn(language_server.text_document_did_open( url, doc.version(), @@ -845,7 +926,12 @@ impl Application { async fn claim_term(&mut self) -> Result<(), Error> { terminal::enable_raw_mode()?; let mut stdout = stdout(); - execute!(stdout, terminal::EnterAlternateScreen, EnableBracketedPaste)?; + execute!( + stdout, + terminal::EnterAlternateScreen, + EnableBracketedPaste, + EnableFocusChange + )?; execute!(stdout, terminal::Clear(terminal::ClearType::All))?; if self.config.load().editor.mouse { execute!(stdout, EnableMouseCapture)?; @@ -871,11 +957,10 @@ impl Application { self.event_loop(input_stream).await; - let err = self.close().await.err(); - + let close_errs = self.close().await; restore_term()?; - if let Some(err) = err { + for err in close_errs { self.editor.exit_code = 1; eprintln!("Error: {}", err); } @@ -883,13 +968,33 @@ impl Application { Ok(self.editor.exit_code) } - pub async fn close(&mut self) -> anyhow::Result<()> { - self.jobs.finish().await?; + pub async fn close(&mut self) -> Vec { + // [NOTE] we intentionally do not return early for errors because we + // want to try to run as much cleanup as we can, regardless of + // errors along the way + let mut errs = Vec::new(); + + if let Err(err) = self + .jobs + .finish(&mut self.editor, Some(&mut self.compositor)) + .await + { + log::error!("Error executing job: {}", err); + errs.push(err); + }; + + if let Err(err) = self.editor.flush_writes().await { + log::error!("Error writing: {}", err); + errs.push(err); + } if self.editor.close_language_servers(None).await.is_err() { log::error!("Timed out waiting for language servers to shutdown"); - }; + errs.push(anyhow::format_err!( + "Timed out waiting for language servers to shutdown" + )); + } - Ok(()) + errs } } diff --git a/helix-term/src/args.rs b/helix-term/src/args.rs index 48c86633..dd787f1f 100644 --- a/helix-term/src/args.rs +++ b/helix-term/src/args.rs @@ -32,8 +32,14 @@ impl Args { "--version" => args.display_version = true, "--help" => args.display_help = true, "--tutor" => args.load_tutor = true, - "--vsplit" => args.split = Some(Layout::Vertical), - "--hsplit" => args.split = Some(Layout::Horizontal), + "--vsplit" => match args.split { + Some(_) => anyhow::bail!("can only set a split once of a specific type"), + None => args.split = Some(Layout::Vertical), + }, + "--hsplit" => match args.split { + Some(_) => anyhow::bail!("can only set a split once of a specific type"), + None => args.split = Some(Layout::Horizontal), + }, "--health" => { args.health = true; args.health_arg = argv.next_if(|opt| !opt.starts_with('-')); diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2db5bfcf..69870a27 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -27,6 +27,7 @@ use helix_core::{ SmallVec, Tendril, Transaction, }; use helix_view::{ + apply_transaction, clipboard::ClipboardType, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, editor::{Action, Motion}, @@ -46,13 +47,14 @@ use movement::Movement; use crate::{ args, compositor::{self, Component, Compositor}, + job::Callback, keymap::ReverseKeymap, ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent}, }; -use crate::job::{self, Job, Jobs}; -use futures_util::{FutureExt, StreamExt}; -use std::{collections::HashMap, fmt, future::Future}; +use crate::job::{self, Jobs}; +use futures_util::StreamExt; +use std::{collections::HashMap, fmt, fmt::Write, future::Future}; use std::{collections::HashSet, num::NonZeroUsize}; use std::{ @@ -106,10 +108,11 @@ impl<'a> Context<'a> { let callback = Box::pin(async move { let json = call.await?; let response = serde_json::from_value(json)?; - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { callback(editor, compositor, response) - }); + }, + )); Ok(call) }); self.jobs.callback(callback); @@ -859,7 +862,7 @@ fn align_selections(cx: &mut Context) { changes.sort_unstable_by_key(|(from, _, _)| *from); let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn goto_window(cx: &mut Context, align: Align) { @@ -1289,7 +1292,7 @@ fn replace(cx: &mut Context) { } }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } }) } @@ -1306,7 +1309,7 @@ where (range.from(), range.to(), Some(text)) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn switch_case(cx: &mut Context) { @@ -1864,10 +1867,15 @@ fn global_search(cx: &mut Context) { .hidden(file_picker_config.hidden) .parents(file_picker_config.parents) .ignore(file_picker_config.ignore) + .follow_links(file_picker_config.follow_symlinks) .git_ignore(file_picker_config.git_ignore) .git_global(file_picker_config.git_global) .git_exclude(file_picker_config.git_exclude) .max_depth(file_picker_config.max_depth) + // We always want to ignore the .git directory, otherwise if + // `ignore` is turned off above, we end up with a lot of noise + // in our picker. + .filter_entry(|entry| entry.file_name() != ".git") .build_parallel() .run(|| { let mut searcher = searcher.clone(); @@ -1919,8 +1927,8 @@ fn global_search(cx: &mut Context) { let show_picker = async move { let all_matches: Vec = UnboundedReceiverStream::new(all_matches_rx).collect().await; - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { if all_matches.is_empty() { editor.set_status("No matches found"); return; @@ -1956,7 +1964,8 @@ fn global_search(cx: &mut Context) { }, ); compositor.push(Box::new(overlayed(picker))); - }); + }, + )); Ok(call) }; cx.jobs.callback(show_picker); @@ -2112,7 +2121,7 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) { let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { (range.from(), range.to(), None) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); match op { Operation::Delete => { @@ -2126,14 +2135,11 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) { } #[inline] -fn delete_selection_insert_mode(doc: &mut Document, view: &View, selection: &Selection) { - let view_id = view.id; - - // then delete +fn delete_selection_insert_mode(doc: &mut Document, view: &mut View, selection: &Selection) { let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { (range.from(), range.to(), None) }); - doc.apply(&transaction, view_id); + apply_transaction(&transaction, doc, view); } fn delete_selection(cx: &mut Context) { @@ -2224,12 +2230,12 @@ fn append_mode(cx: &mut Context) { .iter() .last() .expect("selection should always have at least one range"); - if !last_range.is_empty() && last_range.head == end { + if !last_range.is_empty() && last_range.to() == end { let transaction = Transaction::change( doc.text(), [(end, end, Some(doc.line_ending.as_str().into()))].into_iter(), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } let selection = doc.selection(view.id).clone().transform(|range| { @@ -2410,29 +2416,28 @@ impl ui::menu::Item for MappableCommand { type Data = ReverseKeymap; fn label(&self, keymap: &Self::Data) -> Spans { - // formats key bindings, multiple bindings are comma separated, - // individual key presses are joined with `+` let fmt_binding = |bindings: &Vec>| -> String { - bindings - .iter() - .map(|bind| { - bind.iter() - .map(|key| key.to_string()) - .collect::>() - .join("+") - }) - .collect::>() - .join(", ") + bindings.iter().fold(String::new(), |mut acc, bind| { + if !acc.is_empty() { + acc.push_str(", "); + } + bind.iter().fold(false, |needs_plus, key| { + write!(&mut acc, "{}{}", if needs_plus { "+" } else { "" }, key) + .expect("Writing to a string can only fail on an Out-Of-Memory error"); + true + }); + acc + }) }; match self { MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) { Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), - None => doc.as_str().into(), + None => format!("{} [{}]", doc, name).into(), }, MappableCommand::Static { doc, name, .. } => match keymap.get(*name) { Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), - None => (*doc).into(), + None => format!("{} [{}]", doc, name).into(), }, } } @@ -2472,12 +2477,12 @@ 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: &mut Compositor, _| { + cx.callback = Some(Box::new(|compositor, cx| { if let Some(picker) = compositor.last_picker.take() { compositor.push(picker); + } else { + cx.editor.set_error("no last picker") } - // XXX: figure out how to show error when no last picker lifetime - // cx.editor.set_error("no last picker") })); } @@ -2501,13 +2506,6 @@ fn insert_at_line_end(cx: &mut Context) { doc.set_selection(view.id, selection); } -/// Sometimes when applying formatting changes we want to mark the buffer as unmodified, for -/// example because we just applied the same changes while saving. -enum Modified { - SetUnmodified, - LeaveModified, -} - // Creates an LspCallback that waits for formatting changes to be computed. When they're done, // it applies them, but only if the doc hasn't changed. // @@ -2516,34 +2514,44 @@ enum Modified { async fn make_format_callback( doc_id: DocumentId, doc_version: i32, - modified: Modified, + view_id: ViewId, format: impl Future> + Send + 'static, + write: Option<(Option, bool)>, ) -> anyhow::Result { - let format = format.await?; - let call: job::Callback = Box::new(move |editor, _compositor| { - if !editor.documents.contains_key(&doc_id) { + let format = format.await; + + let call: job::Callback = Callback::Editor(Box::new(move |editor| { + if !editor.documents.contains_key(&doc_id) || !editor.tree.contains(view_id) { return; } let scrolloff = editor.config().scrolloff; let doc = doc_mut!(editor, &doc_id); - let view = view_mut!(editor); - if doc.version() == doc_version { - doc.apply(&format, view.id); - doc.append_changes_to_history(view.id); - doc.detect_indent_and_line_ending(); - view.ensure_cursor_in_view(doc, scrolloff); - if let Modified::SetUnmodified = modified { - doc.reset_modified(); + let view = view_mut!(editor, view_id); + + if let Ok(format) = format { + if doc.version() == doc_version { + apply_transaction(&format, doc, view); + doc.append_changes_to_history(view.id); + doc.detect_indent_and_line_ending(); + view.ensure_cursor_in_view(doc, scrolloff); + } else { + log::info!("discarded formatting changes because the document changed"); } - } else { - log::info!("discarded formatting changes because the document changed"); } - }); + + if let Some((path, force)) = write { + let id = doc.id(); + if let Err(err) = editor.save(id, path, force) { + editor.set_error(format!("Error saving: {}", err)); + } + } + })); + Ok(call) } -#[derive(PartialEq)] +#[derive(PartialEq, Eq)] pub enum Open { Below, Above, @@ -2616,7 +2624,7 @@ fn open(cx: &mut Context, open: Open) { transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } // o inserts a new line after each line with a selection @@ -2637,7 +2645,7 @@ fn normal_mode(cx: &mut Context) { cx.editor.mode = Mode::Normal; let (view, doc) = current!(cx.editor); - try_restore_indent(doc, view.id); + try_restore_indent(doc, view); // if leaving append mode, move cursor back by 1 if doc.restore_cursor { @@ -2654,7 +2662,7 @@ fn normal_mode(cx: &mut Context) { } } -fn try_restore_indent(doc: &mut Document, view_id: ViewId) { +fn try_restore_indent(doc: &mut Document, view: &mut View) { use helix_core::chars::char_is_whitespace; use helix_core::Operation; @@ -2673,18 +2681,18 @@ fn try_restore_indent(doc: &mut Document, view_id: ViewId) { let doc_changes = doc.changes().changes(); let text = doc.text().slice(..); - let range = doc.selection(view_id).primary(); + let range = doc.selection(view.id).primary(); let pos = range.cursor(text); let line_end_pos = line_end_char_index(&text, range.cursor_line(text)); if inserted_a_new_blank_line(doc_changes, pos, line_end_pos) { // Removes tailing whitespaces. let transaction = - Transaction::change_by_selection(doc.text(), doc.selection(view_id), |range| { + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { let line_start_pos = text.line_to_char(range.cursor_line(text)); (line_start_pos, pos, None) }); - doc.apply(&transaction, view_id); + apply_transaction(&transaction, doc, view); } } @@ -2882,7 +2890,7 @@ pub mod insert { /// Exclude the cursor in range. fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range { - if range.to() == cursor.to() { + if range.to() == cursor.to() && text.len_chars() != cursor.to() { Range::new( range.from(), graphemes::prev_grapheme_boundary(text, cursor.to()), @@ -2998,7 +3006,7 @@ pub mod insert { let (view, doc) = current!(cx.editor); if let Some(t) = transaction { - doc.apply(&t, view.id); + apply_transaction(&t, doc, view); } // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc) @@ -3014,13 +3022,13 @@ pub mod insert { // TODO: round out to nearest indentation level (for example a line with 3 spaces should // indent by one to reach 4 spaces). - let indent = Tendril::from(doc.indent_unit()); + let indent = Tendril::from(doc.indent_style.as_str()); let transaction = Transaction::insert( doc.text(), &doc.selection(view.id).clone().cursors(doc.text().slice(..)), indent, ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } pub fn insert_newline(cx: &mut Context) { @@ -3107,14 +3115,14 @@ pub mod insert { transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); let (view, doc) = current!(cx.editor); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } pub fn delete_char_backward(cx: &mut Context) { let count = cx.count(); let (view, doc) = current_ref!(cx.editor); let text = doc.text().slice(..); - let indent_unit = doc.indent_unit(); + let indent_unit = doc.indent_style.as_str(); let tab_size = doc.tab_width(); let auto_pairs = doc.auto_pairs(cx.editor); @@ -3201,7 +3209,7 @@ pub mod insert { } }); let (view, doc) = current!(cx.editor); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } @@ -3219,7 +3227,7 @@ pub mod insert { None, ) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } @@ -3230,8 +3238,8 @@ pub mod insert { let text = doc.text().slice(..); let selection = doc.selection(view.id).clone().transform(|range| { - let cursor = Range::point(range.cursor(text)); - let next = movement::move_prev_word_start(text, cursor, count); + let anchor = movement::move_prev_word_start(text, range, count).from(); + let next = Range::new(anchor, range.cursor(text)); exclude_cursor(text, next, range) }); delete_selection_insert_mode(doc, view, &selection); @@ -3244,10 +3252,11 @@ pub mod insert { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); - let selection = doc - .selection(view.id) - .clone() - .transform(|range| movement::move_next_word_start(text, range, count)); + let selection = doc.selection(view.id).clone().transform(|range| { + let head = movement::move_next_word_end(text, range, count).to(); + Range::new(range.cursor(text), head) + }); + delete_selection_insert_mode(doc, view, &selection); lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); @@ -3260,7 +3269,7 @@ fn undo(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); for _ in 0..count { - if !doc.undo(view.id) { + if !doc.undo(view) { cx.editor.set_status("Already at oldest change"); break; } @@ -3271,7 +3280,7 @@ fn redo(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); for _ in 0..count { - if !doc.redo(view.id) { + if !doc.redo(view) { cx.editor.set_status("Already at newest change"); break; } @@ -3283,7 +3292,7 @@ fn earlier(cx: &mut Context) { let (view, doc) = current!(cx.editor); for _ in 0..count { // rather than doing in batch we do this so get error halfway - if !doc.earlier(view.id, UndoKind::Steps(1)) { + if !doc.earlier(view, UndoKind::Steps(1)) { cx.editor.set_status("Already at oldest change"); break; } @@ -3295,7 +3304,7 @@ fn later(cx: &mut Context) { let (view, doc) = current!(cx.editor); for _ in 0..count { // rather than doing in batch we do this so get error halfway - if !doc.later(view.id, UndoKind::Steps(1)) { + if !doc.later(view, UndoKind::Steps(1)) { cx.editor.set_status("Already at newest change"); break; } @@ -3347,9 +3356,15 @@ fn yank_joined_to_clipboard_impl( .map(Cow::into_owned) .collect(); + let clipboard_text = match clipboard_type { + ClipboardType::Clipboard => "system clipboard", + ClipboardType::Selection => "primary clipboard", + }; + let msg = format!( - "joined and yanked {} selection(s) to system clipboard", + "joined and yanked {} selection(s) to {}", values.len(), + clipboard_text, ); let joined = values.join(separator); @@ -3378,6 +3393,11 @@ fn yank_main_selection_to_clipboard_impl( let (view, doc) = current!(editor); let text = doc.text().slice(..); + let message_text = match clipboard_type { + ClipboardType::Clipboard => "yanked main selection to system clipboard", + ClipboardType::Selection => "yanked main selection to primary clipboard", + }; + let value = doc.selection(view.id).primary().fragment(text); if let Err(e) = editor @@ -3387,7 +3407,7 @@ fn yank_main_selection_to_clipboard_impl( bail!("Couldn't set system clipboard content: {}", e); } - editor.set_status("yanked main selection to system clipboard"); + editor.set_status(message_text); Ok(()) } @@ -3413,7 +3433,7 @@ enum Paste { Cursor, } -fn paste_impl(values: &[String], doc: &mut Document, view: &View, action: Paste, count: usize) { +fn paste_impl(values: &[String], doc: &mut Document, view: &mut View, action: Paste, count: usize) { let repeat = std::iter::repeat( values .last() @@ -3456,7 +3476,7 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &View, action: Paste, }; (pos, pos, values.next()) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } pub(crate) fn paste_bracketed_value(cx: &mut Context, contents: String) { @@ -3548,7 +3568,7 @@ fn replace_with_yanked(cx: &mut Context) { } }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } } } @@ -3571,7 +3591,7 @@ fn replace_selections_with_clipboard_impl( ) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); Ok(()) } @@ -3628,7 +3648,7 @@ fn indent(cx: &mut Context) { let lines = get_lines(doc, view.id); // Indent by one level - let indent = Tendril::from(doc.indent_unit().repeat(count)); + let indent = Tendril::from(doc.indent_style.as_str().repeat(count)); let transaction = Transaction::change( doc.text(), @@ -3641,7 +3661,7 @@ fn indent(cx: &mut Context) { Some((pos, pos, Some(indent.clone()))) }), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn unindent(cx: &mut Context) { @@ -3680,7 +3700,7 @@ fn unindent(cx: &mut Context) { let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn format_selections(cx: &mut Context) { @@ -3727,7 +3747,7 @@ fn format_selections(cx: &mut Context) { // language_server.offset_encoding(), // ); - // doc.apply(&transaction, view.id); + // apply_transaction(&transaction, doc, view); } } @@ -3782,7 +3802,7 @@ fn join_selections_inner(cx: &mut Context, select_space: bool) { Transaction::change(doc.text(), changes.into_iter()) }; - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { @@ -3935,7 +3955,7 @@ fn toggle_comments(cx: &mut Context) { .map(|tc| tc.as_ref()); let transaction = comment::toggle_line_comments(doc.text(), doc.selection(view.id), token); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); exit_select_mode(cx); } @@ -3991,7 +4011,7 @@ fn rotate_selection_contents(cx: &mut Context, direction: Direction) { .map(|(range, fragment)| (range.from(), range.to(), Some(fragment))), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn rotate_selection_contents_forward(cx: &mut Context) { @@ -4457,13 +4477,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { (" ", "... or any character acting as a pair"), ]; - cx.editor.autoinfo = Some(Info::new( - title, - help_text - .into_iter() - .map(|(col1, col2)| (col1.to_string(), col2.to_string())) - .collect(), - )); + cx.editor.autoinfo = Some(Info::new(title, &help_text)); } fn surround_add(cx: &mut Context) { @@ -4487,7 +4501,7 @@ fn surround_add(cx: &mut Context) { } let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); }) } @@ -4526,7 +4540,7 @@ fn surround_replace(cx: &mut Context) { (pos, pos + 1, Some(t)) }), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); }); }) } @@ -4553,7 +4567,7 @@ fn surround_delete(cx: &mut Context) { let transaction = Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); }) } @@ -4651,6 +4665,8 @@ fn shell_impl( if input.is_some() || cfg!(windows) { process.stdin(Stdio::piped()); + } else { + process.stdin(Stdio::null()); } let mut process = match process.spawn() { @@ -4728,7 +4744,7 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { if behavior != &ShellBehavior::Ignore { let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); } @@ -4791,7 +4807,7 @@ fn add_newline_impl(cx: &mut Context, open: Open) { }); let transaction = Transaction::change(text, changes); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } /// Increment object under cursor by count. @@ -4884,7 +4900,7 @@ fn increment_impl(cx: &mut Context, amount: i64) { let transaction = Transaction::change(doc.text(), changes); let transaction = transaction.with_selection(selection.clone()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } } diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index 12a3fbc7..c27417e3 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -118,11 +118,14 @@ fn dap_callback( let callback = Box::pin(async move { let json = call.await?; let response = serde_json::from_value(json)?; - let call: Callback = Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { - callback(editor, compositor, response) - }); + let call: Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + callback(editor, compositor, response) + }, + )); Ok(call) }); + jobs.callback(callback); } @@ -274,10 +277,11 @@ pub fn dap_launch(cx: &mut Context) { let completions = template.completion.clone(); let name = template.name.clone(); let callback = Box::pin(async move { - let call: Callback = Box::new(move |_editor, compositor| { - let prompt = debug_parameter_prompt(completions, name, Vec::new()); - compositor.push(Box::new(prompt)); - }); + let call: Callback = + Callback::EditorCompositor(Box::new(move |_editor, compositor| { + let prompt = debug_parameter_prompt(completions, name, Vec::new()); + compositor.push(Box::new(prompt)); + })); Ok(call) }); cx.jobs.callback(callback); @@ -332,10 +336,11 @@ fn debug_parameter_prompt( let config_name = config_name.clone(); let params = params.clone(); let callback = Box::pin(async move { - let call: Callback = Box::new(move |_editor, compositor| { - let prompt = debug_parameter_prompt(completions, config_name, params); - compositor.push(Box::new(prompt)); - }); + let call: Callback = + Callback::EditorCompositor(Box::new(move |_editor, compositor| { + let prompt = debug_parameter_prompt(completions, config_name, params); + compositor.push(Box::new(prompt)); + })); Ok(call) }); cx.jobs.callback(callback); @@ -582,7 +587,7 @@ pub fn dap_edit_condition(cx: &mut Context) { None => return, }; let callback = Box::pin(async move { - let call: Callback = Box::new(move |editor, compositor| { + let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| { let mut prompt = Prompt::new( "condition:".into(), None, @@ -610,7 +615,7 @@ pub fn dap_edit_condition(cx: &mut Context) { prompt.insert_str(&condition, editor) } compositor.push(Box::new(prompt)); - }); + })); Ok(call) }); cx.jobs.callback(callback); @@ -624,7 +629,7 @@ pub fn dap_edit_log(cx: &mut Context) { None => return, }; let callback = Box::pin(async move { - let call: Callback = Box::new(move |editor, compositor| { + let call: Callback = Callback::EditorCompositor(Box::new(move |editor, compositor| { let mut prompt = Prompt::new( "log-message:".into(), None, @@ -651,7 +656,7 @@ pub fn dap_edit_log(cx: &mut Context) { prompt.insert_str(&log_message, editor); } compositor.push(Box::new(prompt)); - }); + })); Ok(call) }); cx.jobs.callback(callback); diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 1113b44e..3c72cd2a 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,6 +1,6 @@ use helix_lsp::{ block_on, - lsp::{self, DiagnosticSeverity, NumberOrString}, + lsp::{self, CodeAction, CodeActionOrCommand, DiagnosticSeverity, NumberOrString}, util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, OffsetEncoding, }; @@ -9,7 +9,7 @@ use tui::text::{Span, Spans}; use super::{align_view, push_jump, Align, Context, Editor, Open}; use helix_core::{path, Selection}; -use helix_view::{editor::Action, theme::Style}; +use helix_view::{apply_transaction, editor::Action, theme::Style}; use crate::{ compositor::{self, Compositor}, @@ -18,7 +18,9 @@ use crate::{ }, }; -use std::{borrow::Cow, collections::BTreeMap, path::PathBuf, sync::Arc}; +use std::{ + borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt::Write, path::PathBuf, sync::Arc, +}; /// Gets the language server that is attached to a document, and /// if it's not active displays a status message. Using this macro @@ -43,23 +45,32 @@ impl ui::menu::Item for lsp::Location { type Data = PathBuf; fn label(&self, cwdir: &Self::Data) -> Spans { - let file: Cow<'_, str> = (self.uri.scheme() == "file") - .then(|| { - self.uri - .to_file_path() - .map(|path| { - // strip root prefix - path.strip_prefix(&cwdir) - .map(|path| path.to_path_buf()) - .unwrap_or(path) - }) - .map(|path| Cow::from(path.to_string_lossy().into_owned())) - .ok() - }) - .flatten() - .unwrap_or_else(|| self.uri.as_str().into()); - let line = self.range.start.line; - format!("{}:{}", file, line).into() + // The preallocation here will overallocate a few characters since it will account for the + // URL's scheme, which is not used most of the time since that scheme will be "file://". + // Those extra chars will be used to avoid allocating when writing the line number (in the + // common case where it has 5 digits or less, which should be enough for a cast majority + // of usages). + let mut res = String::with_capacity(self.uri.as_str().len()); + + if self.uri.scheme() == "file" { + // With the preallocation above and UTF-8 paths already, this closure will do one (1) + // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`. + let mut write_path_to_res = || -> Option<()> { + let path = self.uri.to_file_path().ok()?; + res.push_str(&path.strip_prefix(&cwdir).unwrap_or(&path).to_string_lossy()); + Some(()) + }; + write_path_to_res(); + } else { + // Never allocates since we declared the string with this capacity already. + res.push_str(self.uri.as_str()); + } + + // Most commonly, this will not allocate, especially on Unix systems where the root prefix + // is a simple `/` and not `C:\` (with whatever drive letter) + write!(&mut res, ":{}", self.range.start.line) + .expect("Will only failed if allocating fail"); + res.into() } } @@ -73,10 +84,8 @@ impl ui::menu::Item for lsp::SymbolInformation { } else { match self.location.uri.to_file_path() { Ok(path) => { - let relative_path = helix_core::path::get_relative_path(path.as_path()) - .to_string_lossy() - .into_owned(); - format!("{} ({})", &self.name, relative_path).into() + let get_relative_path = path::get_relative_path(path.as_path()); + format!("{} ({})", &self.name, get_relative_path.to_string_lossy()).into() } Err(_) => format!("{} ({})", &self.name, &self.location.uri).into(), } @@ -115,24 +124,21 @@ impl ui::menu::Item for PickerDiagnostic { // remove background as it is distracting in the picker list style.bg = None; - let code = self + let code: Cow<'_, str> = self .diag .code .as_ref() .map(|c| match c { - NumberOrString::Number(n) => n.to_string(), - NumberOrString::String(s) => s.to_string(), + NumberOrString::Number(n) => n.to_string().into(), + NumberOrString::String(s) => s.as_str().into(), }) - .map(|code| format!(" ({})", code)) .unwrap_or_default(); let path = match format { DiagnosticsFormat::HideSourcePath => String::new(), DiagnosticsFormat::ShowSourcePath => { - let path = path::get_truncated_path(self.url.path()) - .to_string_lossy() - .into_owned(); - format!("{}: ", path) + let path = path::get_truncated_path(self.url.path()); + format!("{}: ", path.to_string_lossy()) } }; @@ -211,7 +217,6 @@ fn sym_picker( Ok(path) => path, Err(_) => { let err = format!("unable to convert URI to filepath: {}", uri); - log::error!("{}", err); cx.editor.set_error(err); return; } @@ -421,6 +426,63 @@ impl ui::menu::Item for lsp::CodeActionOrCommand { } } +/// Determines the category of the `CodeAction` using the `CodeAction::kind` field. +/// Returns a number that represent these categories. +/// Categories with a lower number should be displayed first. +/// +/// +/// While the `kind` field is defined as open ended in the LSP spec (any value may be used) +/// in practice a closed set of common values (mostly suggested in the LSP spec) are used. +/// VSCode displays each of these categories seperatly (seperated by a heading in the codeactions picker) +/// to make them easier to navigate. Helix does not display these headings to the user. +/// However it does sort code actions by their categories to achieve the same order as the VScode picker, +/// just without the headings. +/// +/// The order used here is modeled after the [vscode sourcecode](https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeActionWidget.ts>) +fn action_category(action: &CodeActionOrCommand) -> u32 { + if let CodeActionOrCommand::CodeAction(CodeAction { + kind: Some(kind), .. + }) = action + { + let mut components = kind.as_str().split('.'); + match components.next() { + Some("quickfix") => 0, + Some("refactor") => match components.next() { + Some("extract") => 1, + Some("inline") => 2, + Some("rewrite") => 3, + Some("move") => 4, + Some("surround") => 5, + _ => 7, + }, + Some("source") => 6, + _ => 7, + } + } else { + 7 + } +} + +fn action_prefered(action: &CodeActionOrCommand) -> bool { + matches!( + action, + CodeActionOrCommand::CodeAction(CodeAction { + is_preferred: Some(true), + .. + }) + ) +} + +fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool { + matches!( + action, + CodeActionOrCommand::CodeAction(CodeAction { + diagnostics: Some(diagnostics), + .. + }) if !diagnostics.is_empty() + ) +} + pub fn code_action(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -452,15 +514,60 @@ pub fn code_action(cx: &mut Context) { cx.callback( future, move |editor, compositor, response: Option| { - let actions = match response { + let mut actions = match response { Some(a) => a, None => return, }; + + // remove disabled code actions + actions.retain(|action| { + matches!( + action, + CodeActionOrCommand::Command(_) + | CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. }) + ) + }); + if actions.is_empty() { editor.set_status("No code actions available"); return; } + // Sort codeactions into a useful order. This behaviour is only partially described in the LSP spec. + // Many details are modeled after vscode because langauge servers are usually tested against it. + // VScode sorts the codeaction two times: + // + // First the codeactions that fix some diagnostics are moved to the front. + // If both codeactions fix some diagnostics (or both fix none) the codeaction + // that is marked with `is_preffered` is shown first. The codeactions are then shown in seperate + // submenus that only contain a certain category (see `action_category`) of actions. + // + // Below this done in in a single sorting step + actions.sort_by(|action1, action2| { + // sort actions by category + let order = action_category(action1).cmp(&action_category(action2)); + if order != Ordering::Equal { + return order; + } + // within the categories sort by relevancy. + // Modeled after the `codeActionsComparator` function in vscode: + // https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeAction.ts + + // if one code action fixes a diagnostic but the other one doesn't show it first + let order = action_fixes_diagnostics(action1) + .cmp(&action_fixes_diagnostics(action2)) + .reverse(); + if order != Ordering::Equal { + return order; + } + + // if one of the codeactions is marked as prefered show it first + // otherwise keep the original LSP sorting + action_prefered(action1) + .cmp(&action_prefered(action2)) + .reverse() + }); + let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| { if event != PromptEvent::Validate { return; @@ -596,9 +703,7 @@ pub fn apply_workspace_edit( } }; - let doc = editor - .document_mut(doc_id) - .expect("Document for document_changes not found"); + let doc = doc_mut!(editor, &doc_id); // Need to determine a view for apply/append_changes_to_history let selections = doc.selections(); @@ -619,7 +724,7 @@ pub fn apply_workspace_edit( text_edits, offset_encoding, ); - doc.apply(&transaction, view_id); + apply_transaction(&transaction, doc, view_mut!(editor, view_id)); doc.append_changes_to_history(view_id); }; diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 96ff75c5..0cf75ada 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1,8 +1,13 @@ use std::ops::Deref; +use crate::job::Job; + use super::*; -use helix_view::editor::{Action, CloseError, ConfigEvent}; +use helix_view::{ + apply_transaction, + editor::{Action, CloseError, ConfigEvent}, +}; use ui::completers::{self, Completer}; #[derive(Clone)] @@ -16,6 +21,8 @@ pub struct TypableCommand { } fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { + log::debug!("quitting..."); + if event != PromptEvent::Validate { return Ok(()); } @@ -27,6 +34,7 @@ fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> buffers_remaining_impl(cx.editor)? } + cx.block_try_flush_writes()?; cx.editor.close(view!(cx.editor).id); Ok(()) @@ -43,6 +51,7 @@ fn force_quit( ensure!(args.is_empty(), ":quit! takes no arguments"); + cx.block_try_flush_writes()?; cx.editor.close(view!(cx.editor).id); Ok(()) @@ -67,14 +76,16 @@ fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> } fn buffer_close_by_ids_impl( - editor: &mut Editor, + cx: &mut compositor::Context, doc_ids: &[DocumentId], force: bool, ) -> anyhow::Result<()> { + cx.block_try_flush_writes()?; + let (modified_ids, modified_names): (Vec<_>, Vec<_>) = doc_ids .iter() .filter_map(|&doc_id| { - if let Err(CloseError::BufferModified(name)) = editor.close_document(doc_id, force) { + if let Err(CloseError::BufferModified(name)) = cx.editor.close_document(doc_id, force) { Some((doc_id, name)) } else { None @@ -83,11 +94,11 @@ fn buffer_close_by_ids_impl( .unzip(); if let Some(first) = modified_ids.first() { - let current = doc!(editor); + let current = doc!(cx.editor); // If the current document is unmodified, and there are modified // documents, switch focus to the first modified doc. if !modified_ids.contains(¤t.id()) { - editor.switch(*first, Action::Replace); + cx.editor.switch(*first, Action::Replace); } bail!( "{} unsaved buffer(s) remaining: {:?}", @@ -146,7 +157,7 @@ fn buffer_close( } let document_ids = buffer_gather_paths_impl(cx.editor, args); - buffer_close_by_ids_impl(cx.editor, &document_ids, false) + buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_buffer_close( @@ -159,7 +170,7 @@ fn force_buffer_close( } let document_ids = buffer_gather_paths_impl(cx.editor, args); - buffer_close_by_ids_impl(cx.editor, &document_ids, true) + buffer_close_by_ids_impl(cx, &document_ids, true) } fn buffer_gather_others_impl(editor: &mut Editor) -> Vec { @@ -181,7 +192,7 @@ fn buffer_close_others( } let document_ids = buffer_gather_others_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, false) + buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_buffer_close_others( @@ -194,7 +205,7 @@ fn force_buffer_close_others( } let document_ids = buffer_gather_others_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, true) + buffer_close_by_ids_impl(cx, &document_ids, true) } fn buffer_gather_all_impl(editor: &mut Editor) -> Vec { @@ -211,7 +222,7 @@ fn buffer_close_all( } let document_ids = buffer_gather_all_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, false) + buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_buffer_close_all( @@ -224,7 +235,7 @@ fn force_buffer_close_all( } let document_ids = buffer_gather_all_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, true) + buffer_close_by_ids_impl(cx, &document_ids, true) } fn buffer_next( @@ -258,39 +269,30 @@ fn write_impl( path: Option<&Cow>, force: bool, ) -> anyhow::Result<()> { - let auto_format = cx.editor.config().auto_format; + let editor_auto_fmt = cx.editor.config().auto_format; let jobs = &mut cx.jobs; - let doc = doc_mut!(cx.editor); + let (view, doc) = current!(cx.editor); + let path = path.map(AsRef::as_ref); - if let Some(ref path) = path { - doc.set_path(Some(path.as_ref().as_ref())) - .context("invalid filepath")?; - } - if doc.path().is_none() { - bail!("cannot write a buffer without a filename"); - } - let fmt = if auto_format { + let fmt = if editor_auto_fmt { doc.auto_format().map(|fmt| { - let shared = fmt.shared(); let callback = make_format_callback( doc.id(), doc.version(), - Modified::SetUnmodified, - shared.clone(), + view.id, + fmt, + Some((path.map(Into::into), force)), ); - jobs.callback(callback); - shared + + jobs.add(Job::with_callback(callback).wait_before_exiting()); }) } else { None }; - let future = doc.format_and_save(fmt, force); - cx.jobs.add(Job::new(future).wait_before_exiting()); - if path.is_some() { + if fmt.is_none() { let id = doc.id(); - doc.detect_language(cx.editor.syn_loader.clone()); - let _ = cx.editor.refresh_language_server(id); + cx.editor.save(id, path, force)?; } Ok(()) @@ -343,10 +345,9 @@ fn format( return Ok(()); } - let doc = doc!(cx.editor); + let (view, doc) = current!(cx.editor); if let Some(format) = doc.format() { - let callback = - make_format_callback(doc.id(), doc.version(), Modified::LeaveModified, format); + let callback = make_format_callback(doc.id(), doc.version(), view.id, format, None); cx.jobs.callback(callback); } @@ -462,7 +463,7 @@ fn set_line_ending( } }), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); Ok(()) @@ -480,7 +481,7 @@ fn earlier( let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - let success = doc.earlier(view.id, uk); + let success = doc.earlier(view, uk); if !success { cx.editor.set_status("Already at oldest change"); } @@ -499,7 +500,7 @@ fn later( let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - let success = doc.later(view.id, uk); + let success = doc.later(view, uk); if !success { cx.editor.set_status("Already at newest change"); } @@ -517,7 +518,7 @@ fn write_quit( } write_impl(cx, args.first(), false)?; - helix_lsp::block_on(cx.jobs.finish())?; + cx.block_try_flush_writes()?; quit(cx, &[], event) } @@ -531,6 +532,7 @@ fn force_write_quit( } write_impl(cx, args.first(), true)?; + cx.block_try_flush_writes()?; force_quit(cx, &[], event) } @@ -559,110 +561,128 @@ pub(super) fn buffers_remaining_impl(editor: &mut Editor) -> anyhow::Result<()> Ok(()) } -fn write_all_impl( +pub fn write_all_impl( cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, - quit: bool, force: bool, + write_scratch: bool, ) -> anyhow::Result<()> { - if event != PromptEvent::Validate { - return Ok(()); - } - - let mut errors = String::new(); + let mut errors: Vec<&'static str> = Vec::new(); let auto_format = cx.editor.config().auto_format; let jobs = &mut cx.jobs; + let current_view = view!(cx.editor); + // save all documents - for doc in &mut cx.editor.documents.values_mut() { - if doc.path().is_none() { - errors.push_str("cannot write a buffer without a filename\n"); - continue; - } + let saves: Vec<_> = cx + .editor + .documents + .values_mut() + .filter_map(|doc| { + if !doc.is_modified() { + return None; + } + if doc.path().is_none() { + if write_scratch { + errors.push("cannot write a buffer without a filename\n"); + } + return None; + } - if !doc.is_modified() { - continue; - } + // 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(¤t_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 + }; + + let fmt = if auto_format { + doc.auto_format().map(|fmt| { + let callback = make_format_callback( + doc.id(), + doc.version(), + target_view, + fmt, + Some((None, force)), + ); + jobs.add(Job::with_callback(callback).wait_before_exiting()); + }) + } else { + None + }; + + if fmt.is_none() { + return Some(doc.id()); + } - let fmt = if auto_format { - doc.auto_format().map(|fmt| { - let shared = fmt.shared(); - let callback = make_format_callback( - doc.id(), - doc.version(), - Modified::SetUnmodified, - shared.clone(), - ); - jobs.callback(callback); - shared - }) - } else { None - }; - let future = doc.format_and_save(fmt, force); - jobs.add(Job::new(future).wait_before_exiting()); - } + }) + .collect(); - if quit { - if !force { - buffers_remaining_impl(cx.editor)?; - } + // manually call save for the rest of docs that don't have a formatter + for id in saves { + cx.editor.save::(id, None, force)?; + } - // close all views - let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); - for view_id in views { - cx.editor.close(view_id); - } + if !errors.is_empty() && !force { + bail!("{:?}", errors); } - bail!(errors) + Ok(()) } fn write_all( cx: &mut compositor::Context, - args: &[Cow], + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_all_impl(cx, args, event, false, false) + write_all_impl(cx, false, true) } fn write_all_quit( cx: &mut compositor::Context, - args: &[Cow], + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - - write_all_impl(cx, args, event, true, false) + write_all_impl(cx, false, true)?; + quit_all_impl(cx, false) } fn force_write_all_quit( cx: &mut compositor::Context, - args: &[Cow], + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - - write_all_impl(cx, args, event, true, true) + let _ = write_all_impl(cx, true, true); + quit_all_impl(cx, true) } -fn quit_all_impl(editor: &mut Editor, force: bool) -> anyhow::Result<()> { +fn quit_all_impl(cx: &mut compositor::Context, force: bool) -> anyhow::Result<()> { + cx.block_try_flush_writes()?; if !force { - buffers_remaining_impl(editor)?; + buffers_remaining_impl(cx.editor)?; } // close all views - let views: Vec<_> = editor.tree.views().map(|(view, _)| view.id).collect(); + let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); for view_id in views { - editor.close(view_id); + cx.editor.close(view_id); } Ok(()) @@ -677,7 +697,7 @@ fn quit_all( return Ok(()); } - quit_all_impl(cx.editor, false) + quit_all_impl(cx, false) } fn force_quit_all( @@ -689,7 +709,7 @@ fn force_quit_all( return Ok(()); } - quit_all_impl(cx.editor, true) + quit_all_impl(cx, true) } fn cquit( @@ -705,9 +725,9 @@ fn cquit( .first() .and_then(|code| code.parse::().ok()) .unwrap_or(1); - cx.editor.exit_code = exit_code; - quit_all_impl(cx.editor, false) + cx.editor.exit_code = exit_code; + quit_all_impl(cx, false) } fn force_cquit( @@ -725,7 +745,7 @@ fn force_cquit( .unwrap_or(1); cx.editor.exit_code = exit_code; - quit_all_impl(cx.editor, true) + quit_all_impl(cx, true) } fn theme( @@ -752,16 +772,21 @@ fn theme( }; } PromptEvent::Validate => { - let theme_name = args.first().with_context(|| "Theme name not provided")?; - let theme = cx - .editor - .theme_loader - .load(theme_name) - .with_context(|| "Theme does not exist")?; - if !(true_color || theme.is_16_color()) { - bail!("Unsupported theme: theme requires true color support"); + if let Some(theme_name) = args.first() { + let theme = cx + .editor + .theme_loader + .load(theme_name) + .with_context(|| "Theme does not exist")?; + if !(true_color || theme.is_16_color()) { + bail!("Unsupported theme: theme requires true color support"); + } + cx.editor.set_theme(theme); + } else { + let name = cx.editor.theme.name().to_string(); + + cx.editor.set_status(name); } - cx.editor.set_theme(theme); } }; @@ -883,7 +908,7 @@ fn replace_selections_with_clipboard_impl( (range.from(), range.to(), Some(contents.as_str().into())) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); Ok(()) } @@ -1004,11 +1029,29 @@ fn reload( let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); - doc.reload(view.id).map(|_| { + doc.reload(view).map(|_| { view.ensure_cursor_in_view(doc, scrolloff); }) } +/// Update the [`Document`] if it has been modified. +fn update( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let (_view, doc) = current!(cx.editor); + if doc.is_modified() { + write(cx, args, event) + } else { + Ok(()) + } +} + fn lsp_restart( cx: &mut compositor::Context, _args: &[Cow], @@ -1057,7 +1100,22 @@ fn tree_sitter_scopes( let pos = doc.selection(view.id).primary().cursor(text); let scopes = indent::get_scopes(doc.syntax(), text, pos); - cx.editor.set_status(format!("scopes: {:?}", &scopes)); + + let contents = format!("```json\n{:?}\n````", scopes); + + let callback = async move { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { + let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let popup = Popup::new("hover", contents).auto_close(true); + compositor.replace_or_push("hover", popup); + }, + )); + Ok(call) + }; + + cx.jobs.callback(callback); + Ok(()) } @@ -1398,7 +1456,7 @@ fn sort_impl( .map(|(s, fragment)| (s.from(), s.to(), Some(fragment))), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); Ok(()) @@ -1442,7 +1500,7 @@ fn reflow( (range.from(), range.to(), Some(reflowed_text)) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); view.ensure_cursor_in_view(doc, scrolloff); @@ -1470,15 +1528,18 @@ fn tree_sitter_subtree( .root_node() .descendant_for_byte_range(from, to) { - let contents = format!("```tsq\n{}\n```", selected_node.to_sexp()); + let mut contents = String::from("```tsq\n"); + helix_core::syntax::pretty_print_tree(&mut contents, selected_node)?; + contents.push_str("\n```"); let callback = async move { - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); let popup = Popup::new("hover", contents).auto_close(true); compositor.replace_or_push("hover", popup); - }); + }, + )); Ok(call) }; @@ -1586,8 +1647,8 @@ fn run_shell_command( if !output.is_empty() { let callback = async move { - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + let call: job::Callback = Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, compositor: &mut Compositor| { let contents = ui::Markdown::new( format!("```sh\n{}\n```", output), editor.syn_loader.clone(), @@ -1596,7 +1657,8 @@ fn run_shell_command( helix_core::Position::new(editor.cursor().0.unwrap_or_default().row, 2), )); compositor.replace_or_push("shell", popup); - }); + }, + )); Ok(call) }; @@ -1809,7 +1871,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "theme", aliases: &[], - doc: "Change the editor theme.", + doc: "Change the editor theme (show current theme if no name specified).", fun: theme, completer: Some(completers::theme), }, @@ -1918,6 +1980,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: reload, completer: None, }, + TypableCommand { + name: "update", + aliases: &[], + doc: "Write changes only if the file has been modified.", + fun: update, + completer: None, + }, TypableCommand { name: "lsp-restart", aliases: &[], @@ -2068,7 +2137,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "insert-output", aliases: &[], - doc: "Run shell command, inserting output after each selection.", + doc: "Run shell command, inserting output before each selection.", fun: insert_output, completer: None, }, @@ -2106,7 +2175,7 @@ pub static TYPABLE_COMMAND_MAP: Lazy { pub jobs: &'a mut Jobs, } +impl<'a> Context<'a> { + /// Waits on all pending jobs, and then tries to flush all pending write + /// operations for all documents. + pub fn block_try_flush_writes(&mut self) -> anyhow::Result<()> { + tokio::task::block_in_place(|| helix_lsp::block_on(self.jobs.finish(self.editor, None)))?; + tokio::task::block_in_place(|| helix_lsp::block_on(self.editor.flush_writes()))?; + Ok(()) + } +} + pub trait Component: Any + AnyComponent { /// Process input events, return true if handled. fn handle_event(&mut self, _event: &Event, _ctx: &mut Context) -> EventResult { diff --git a/helix-term/src/job.rs b/helix-term/src/job.rs index e5147992..2888b6eb 100644 --- a/helix-term/src/job.rs +++ b/helix-term/src/job.rs @@ -5,7 +5,11 @@ use crate::compositor::Compositor; use futures_util::future::{BoxFuture, Future, FutureExt}; use futures_util::stream::{FuturesUnordered, StreamExt}; -pub type Callback = Box; +pub enum Callback { + EditorCompositor(Box), + Editor(Box), +} + pub type JobFuture = BoxFuture<'static, anyhow::Result>>; pub struct Job { @@ -68,9 +72,10 @@ impl Jobs { ) { match call { Ok(None) => {} - Ok(Some(call)) => { - call(editor, compositor); - } + Ok(Some(call)) => match call { + Callback::EditorCompositor(call) => call(editor, compositor), + Callback::Editor(call) => call(editor), + }, Err(e) => { editor.set_error(format!("Async job failed: {}", e)); } @@ -93,13 +98,32 @@ impl Jobs { } /// Blocks until all the jobs that need to be waited on are done. - pub async fn finish(&mut self) -> anyhow::Result<()> { + pub async fn finish( + &mut self, + editor: &mut Editor, + mut compositor: Option<&mut Compositor>, + ) -> anyhow::Result<()> { log::debug!("waiting on jobs..."); let mut wait_futures = std::mem::take(&mut self.wait_futures); + while let (Some(job), tail) = wait_futures.into_future().await { match job { - Ok(_) => { + Ok(callback) => { wait_futures = tail; + + if let Some(callback) = callback { + // clippy doesn't realize this is an error without the derefs + #[allow(clippy::needless_option_as_deref)] + match callback { + Callback::EditorCompositor(call) if compositor.is_some() => { + call(editor, compositor.as_deref_mut().unwrap()) + } + Callback::Editor(call) => call(editor), + + // skip callbacks for which we don't have the necessary references + _ => (), + } + } } Err(e) => { self.wait_futures = tail; diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 6c327ee6..118764d9 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -209,11 +209,11 @@ pub fn default() -> HashMap { "j" => jumplist_picker, "s" => symbol_picker, "S" => workspace_symbol_picker, - "g" => diagnostics_picker, - "G" => workspace_diagnostics_picker, + "d" => diagnostics_picker, + "D" => workspace_diagnostics_picker, "a" => code_action, "'" => last_picker, - "d" => { "Debug (experimental)" sticky=true + "g" => { "Debug (experimental)" sticky=true "l" => dap_launch, "b" => dap_toggle_breakpoint, "c" => dap_continue, diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index 726bf9e3..96b695c6 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -139,8 +139,18 @@ FLAGS: Err(err) => return Err(Error::new(err)), }; + let syn_loader_conf = helix_core::config::user_syntax_loader().unwrap_or_else(|err| { + eprintln!("Bad language config: {}", err); + eprintln!("Press to continue with default language config"); + use std::io::Read; + // This waits for an enter press. + let _ = std::io::stdin().read(&mut []); + helix_core::config::default_syntax_loader() + }); + // TODO: use the thread local executor to spawn the application task separately from the work pool - let mut app = Application::new(args, config).context("unable to create new application")?; + let mut app = Application::new(args, config, syn_loader_conf) + .context("unable to create new application")?; let exit_code = app.run(&mut EventStream::new()).await?; diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 2d7d4f92..de7c3232 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,5 +1,5 @@ use crate::compositor::{Component, Context, Event, EventResult}; -use helix_view::editor::CompleteAction; +use helix_view::{apply_transaction, editor::CompleteAction}; use tui::buffer::Buffer as Surface; use tui::text::Spans; @@ -92,11 +92,15 @@ impl Completion { pub fn new( editor: &Editor, - items: Vec, + mut items: Vec, offset_encoding: helix_lsp::OffsetEncoding, start_offset: usize, trigger_offset: usize, ) -> Self { + // Sort completion items according to their preselect status (given by the LSP server) + items.sort_by_key(|item| !item.preselect.unwrap_or(false)); + + // Then create the menu let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| { fn item_to_transaction( doc: &Document, @@ -143,11 +147,11 @@ impl Completion { let (view, doc) = current!(editor); // if more text was entered, remove it - doc.restore(view.id); + doc.restore(view); match event { PromptEvent::Abort => { - doc.restore(view.id); + doc.restore(view); editor.last_completion = None; } PromptEvent::Update => { @@ -164,7 +168,7 @@ impl Completion { // initialize a savepoint doc.savepoint(); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); editor.last_completion = Some(CompleteAction { trigger_offset, @@ -183,7 +187,7 @@ impl Completion { trigger_offset, ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); editor.last_completion = Some(CompleteAction { trigger_offset, @@ -213,7 +217,7 @@ impl Completion { additional_edits.clone(), offset_encoding, // TODO: should probably transcode in Client ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } } } @@ -295,6 +299,27 @@ impl Completion { pub fn is_empty(&self) -> bool { self.popup.contents().is_empty() } + + pub fn ensure_item_resolved(&mut self, cx: &mut commands::Context) -> bool { + // > If computing full completion items is expensive, servers can additionally provide a + // > handler for the completion item resolve request. ... + // > A typical use case is for example: the `textDocument/completion` request doesn't fill + // > in the `documentation` property for returned completion items since it is expensive + // > to compute. When the item is selected in the user interface then a + // > 'completionItem/resolve' request is sent with the selected completion item as a parameter. + // > The returned completion item should have the documentation property filled in. + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion + match self.popup.contents_mut().selection_mut() { + Some(item) if item.documentation.is_none() => { + let doc = doc!(cx.editor); + if let Some(resolved_item) = Self::resolve_completion_item(doc, item.clone()) { + *item = resolved_item; + } + true + } + _ => false, + } + } } impl Component for Completion { diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index ebc00c5b..c76cab2a 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1,7 +1,8 @@ use crate::{ commands, compositor::{Component, Context, Event, EventResult}, - job, key, + job::{self, Callback}, + key, keymap::{KeymapResult, Keymaps}, ui::{Completion, ProgressSpinners}, }; @@ -13,9 +14,10 @@ use helix_core::{ movement::Direction, syntax::{self, HighlightEvent}, unicode::width::UnicodeWidthStr, - LineEnding, Position, Range, Selection, Transaction, + visual_coords_at_pos, LineEnding, Position, Range, Selection, Transaction, }; use helix_view::{ + apply_transaction, document::{Mode, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig, RainbowIndentOptions}, graphics::{Color, CursorKind, Modifier, Rect, Style}, @@ -23,7 +25,7 @@ use helix_view::{ keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; -use std::{borrow::Cow, path::PathBuf}; +use std::{borrow::Cow, cmp::min, num::NonZeroUsize, path::PathBuf}; use tui::buffer::Buffer as Surface; @@ -118,9 +120,19 @@ impl EditorView { if is_focused && editor.config().cursorline { Self::highlight_cursorline(doc, view, surface, theme); } + if is_focused && editor.config().cursorcolumn { + Self::highlight_cursorcolumn(doc, view, surface, theme); + } - let highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); - let highlights = syntax::merge(highlights, Self::doc_diagnostics_highlights(doc, theme)); + let mut highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); + for diagnostic in Self::doc_diagnostics_highlights(doc, theme) { + // Most of the `diagnostic` Vecs are empty most of the time. Skipping + // a merge for any empty Vec saves a significant amount of work. + if diagnostic.is_empty() { + continue; + } + highlights = Box::new(syntax::merge(highlights, diagnostic)); + } let highlights: Box> = if is_focused { Box::new(syntax::merge( highlights, @@ -264,7 +276,7 @@ impl EditorView { pub fn doc_diagnostics_highlights( doc: &Document, theme: &Theme, - ) -> Vec<(usize, std::ops::Range)> { + ) -> [Vec<(usize, std::ops::Range)>; 5] { use helix_core::diagnostic::Severity; let get_scope_of = |scope| { theme @@ -285,22 +297,38 @@ impl EditorView { let error = get_scope_of("diagnostic.error"); let r#default = get_scope_of("diagnostic"); // this is a bit redundant but should be fine - doc.diagnostics() - .iter() - .map(|diagnostic| { - let diagnostic_scope = match diagnostic.severity { - Some(Severity::Info) => info, - Some(Severity::Hint) => hint, - Some(Severity::Warning) => warning, - Some(Severity::Error) => error, - _ => r#default, - }; - ( - diagnostic_scope, - diagnostic.range.start..diagnostic.range.end, - ) - }) - .collect() + let mut default_vec: Vec<(usize, std::ops::Range)> = Vec::new(); + let mut info_vec = Vec::new(); + let mut hint_vec = Vec::new(); + let mut warning_vec = Vec::new(); + let mut error_vec = Vec::new(); + + for diagnostic in doc.diagnostics() { + // Separate diagnostics into different Vecs by severity. + let (vec, scope) = match diagnostic.severity { + Some(Severity::Info) => (&mut info_vec, info), + Some(Severity::Hint) => (&mut hint_vec, hint), + Some(Severity::Warning) => (&mut warning_vec, warning), + Some(Severity::Error) => (&mut error_vec, error), + _ => (&mut default_vec, r#default), + }; + + // If any diagnostic overlaps ranges with the prior diagnostic, + // merge the two together. Otherwise push a new span. + match vec.last_mut() { + Some((_, range)) if diagnostic.range.start <= range.end => { + // This branch merges overlapping diagnostics, assuming that the current + // diagnostic starts on range.start or later. If this assertion fails, + // we will discard some part of `diagnostic`. This implies that + // `doc.diagnostics()` is not sorted by `diagnostic.range`. + debug_assert!(range.start <= diagnostic.range.start); + range.end = diagnostic.range.end.max(range.end) + } + _ => vec.push((scope, diagnostic.range.start..diagnostic.range.end)), + } + } + + [default_vec, info_vec, hint_vec, warning_vec, error_vec] } /// Get highlight spans for selections in a document view. @@ -401,7 +429,7 @@ impl EditorView { let characters = &whitespace.characters; let mut spans = Vec::new(); - let mut visual_x = 0u16; + let mut visual_x = 0usize; let mut line = 0u16; let tab_width = doc.tab_width(); let tab = if whitespace.render.tab() == WhitespaceRenderValue::All { @@ -439,9 +467,7 @@ impl EditorView { } let starting_indent = - (offset.col / tab_width) as u16 + config.indent_guides.skip_levels; - // TODO: limit to a max indent level too. It doesn't cause visual artifacts but it would avoid some - // extra loops if the code is deeply nested. + (offset.col / tab_width) + config.indent_guides.skip_levels as usize; let modifier = if config.indent_guides.rainbow == RainbowIndentOptions::Dim { Modifier::DIM @@ -505,14 +531,14 @@ impl EditorView { use helix_core::graphemes::{grapheme_width, RopeGraphemes}; for grapheme in RopeGraphemes::new(text) { - let out_of_bounds = visual_x < offset.col as u16 - || visual_x >= viewport.width + offset.col as u16; + let out_of_bounds = offset.col > (visual_x as usize) + || (visual_x as usize) >= viewport.width as usize + offset.col; if LineEnding::from_rope_slice(&grapheme).is_some() { if !out_of_bounds { // we still want to render an empty cell with the style surface.set_string( - viewport.x + visual_x - offset.col as u16, + (viewport.x as usize + visual_x - offset.col) as u16, viewport.y + line, &newline, style.patch(whitespace_style), @@ -560,7 +586,7 @@ impl EditorView { if !out_of_bounds { // if we're offscreen just keep going until we hit a new line surface.set_string( - viewport.x + visual_x - offset.col as u16, + (viewport.x as usize + visual_x - offset.col) as u16, viewport.y + line, display_grapheme, if is_whitespace { @@ -593,7 +619,7 @@ impl EditorView { last_line_indent_level = visual_x; } - visual_x = visual_x.saturating_add(width as u16); + visual_x = visual_x.saturating_add(width); } } } @@ -844,6 +870,53 @@ impl EditorView { } } + /// Apply the highlighting on the columns where a cursor is active + pub fn highlight_cursorcolumn( + doc: &Document, + view: &View, + surface: &mut Surface, + theme: &Theme, + ) { + let text = doc.text().slice(..); + + // Manual fallback behaviour: + // ui.cursorcolumn.{p/s} -> ui.cursorcolumn -> ui.cursorline.{p/s} + let primary_style = theme + .try_get_exact("ui.cursorcolumn.primary") + .or_else(|| theme.try_get_exact("ui.cursorcolumn")) + .unwrap_or_else(|| theme.get("ui.cursorline.primary")); + let secondary_style = theme + .try_get_exact("ui.cursorcolumn.secondary") + .or_else(|| theme.try_get_exact("ui.cursorcolumn")) + .unwrap_or_else(|| theme.get("ui.cursorline.secondary")); + + let inner_area = view.inner_area(); + let offset = view.offset.col; + + let selection = doc.selection(view.id); + let primary = selection.primary(); + for range in selection.iter() { + let is_primary = primary == *range; + + let Position { row: _, col } = + visual_coords_at_pos(text, range.cursor(text), doc.tab_width()); + // if the cursor is horizontally in the view + if col >= offset && inner_area.width > (col - offset) as u16 { + let area = Rect::new( + inner_area.x + (col - offset) as u16, + view.area.y, + 1, + view.area.height, + ); + if is_primary { + surface.set_style(area, primary_style) + } else { + surface.set_style(area, secondary_style) + } + } + } + } + /// Handle events by looking them up in `self.keymaps`. Returns None /// if event was handled (a command was executed or a subkeymap was /// activated). Only KeymapResult::{NotFound, Cancelled} is returned @@ -880,9 +953,10 @@ impl EditorView { // TODO: Use an on_mode_change hook to remove signature help cxt.jobs.callback(async { - let call: job::Callback = Box::new(|_editor, compositor| { - compositor.remove(SignatureHelp::ID); - }); + let call: job::Callback = + Callback::EditorCompositor(Box::new(|_editor, compositor| { + compositor.remove(SignatureHelp::ID); + })); Ok(call) }); } @@ -943,37 +1017,40 @@ impl EditorView { } // special handling for repeat operator (key!('.'), _) if self.keymaps.pending().is_empty() => { - // first execute whatever put us into insert mode - self.last_insert.0.execute(cxt); - // then replay the inputs - for key in self.last_insert.1.clone() { - match key { - InsertEvent::Key(key) => self.insert_mode(cxt, key), - InsertEvent::CompletionApply(compl) => { - let (view, doc) = current!(cxt.editor); - - doc.restore(view.id); - - let text = doc.text().slice(..); - let cursor = doc.selection(view.id).primary().cursor(text); - - let shift_position = - |pos: usize| -> usize { pos + cursor - compl.trigger_offset }; - - let tx = Transaction::change( - doc.text(), - compl.changes.iter().cloned().map(|(start, end, t)| { - (shift_position(start), shift_position(end), t) - }), - ); - doc.apply(&tx, view.id); - } - InsertEvent::TriggerCompletion => { - let (_, doc) = current!(cxt.editor); - doc.savepoint(); + for _ in 0..cxt.editor.count.map_or(1, NonZeroUsize::into) { + // first execute whatever put us into insert mode + self.last_insert.0.execute(cxt); + // then replay the inputs + for key in self.last_insert.1.clone() { + match key { + InsertEvent::Key(key) => self.insert_mode(cxt, key), + InsertEvent::CompletionApply(compl) => { + let (view, doc) = current!(cxt.editor); + + doc.restore(view); + + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + + let shift_position = + |pos: usize| -> usize { pos + cursor - compl.trigger_offset }; + + let tx = Transaction::change( + doc.text(), + compl.changes.iter().cloned().map(|(start, end, t)| { + (shift_position(start), shift_position(end), t) + }), + ); + apply_transaction(&tx, doc, view); + } + InsertEvent::TriggerCompletion => { + let (_, doc) = current!(cxt.editor); + doc.savepoint(); + } } } } + cxt.editor.count = None; } _ => { // set the count @@ -1030,23 +1107,20 @@ impl EditorView { editor.clear_idle_timer(); // don't retrigger } - pub fn handle_idle_timeout(&mut self, cx: &mut crate::compositor::Context) -> EventResult { - if self.completion.is_some() - || cx.editor.mode != Mode::Insert - || !cx.editor.config().auto_completion - { + pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult { + if let Some(completion) = &mut self.completion { + return if completion.ensure_item_resolved(cx) { + EventResult::Consumed(None) + } else { + EventResult::Ignored(None) + }; + } + + if cx.editor.mode != Mode::Insert || !cx.editor.config().auto_completion { return EventResult::Ignored(None); } - let mut cx = commands::Context { - register: None, - editor: cx.editor, - jobs: cx.jobs, - count: None, - callback: None, - on_next_key_callback: None, - }; - crate::commands::insert::idle_completion(&mut cx); + crate::commands::insert::idle_completion(cx); EventResult::Consumed(None) } @@ -1367,7 +1441,16 @@ impl Component for EditorView { } Event::Mouse(event) => self.handle_mouse_event(event, &mut cx), - Event::FocusGained | Event::FocusLost => EventResult::Ignored(None), + Event::IdleTimeout => self.handle_idle_timeout(&mut cx), + Event::FocusGained => EventResult::Ignored(None), + Event::FocusLost => { + if context.editor.config().auto_save { + if let Err(e) = commands::typed::write_all_impl(context, false, false) { + context.editor.set_error(format!("{}", e)); + } + } + EventResult::Consumed(None) + } } } diff --git a/helix-term/src/ui/fuzzy_match.rs b/helix-term/src/ui/fuzzy_match.rs new file mode 100644 index 00000000..e25d7328 --- /dev/null +++ b/helix-term/src/ui/fuzzy_match.rs @@ -0,0 +1,74 @@ +use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; +use fuzzy_matcher::FuzzyMatcher; + +#[cfg(test)] +mod test; + +pub struct FuzzyQuery { + queries: Vec, +} + +impl FuzzyQuery { + pub fn new(query: &str) -> FuzzyQuery { + let mut saw_backslash = false; + let queries = query + .split(|c| { + saw_backslash = match c { + ' ' if !saw_backslash => return true, + '\\' => true, + _ => false, + }; + false + }) + .filter_map(|query| { + if query.is_empty() { + None + } else { + Some(query.replace("\\ ", " ")) + } + }) + .collect(); + FuzzyQuery { queries } + } + + pub fn fuzzy_match(&self, item: &str, matcher: &Matcher) -> Option { + // use the rank of the first query for the rank, because merging ranks is not really possible + // this behaviour matches fzf and skim + let score = matcher.fuzzy_match(item, self.queries.get(0)?)?; + if self + .queries + .iter() + .any(|query| matcher.fuzzy_match(item, query).is_none()) + { + return None; + } + Some(score) + } + + pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec)> { + if self.queries.len() == 1 { + return matcher.fuzzy_indices(item, &self.queries[0]); + } + + // use the rank of the first query for the rank, because merging ranks is not really possible + // this behaviour matches fzf and skim + let (score, mut indicies) = matcher.fuzzy_indices(item, self.queries.get(0)?)?; + + // fast path for the common case of not using a space + // during matching this branch should be free thanks to branch prediction + if self.queries.len() == 1 { + return Some((score, indicies)); + } + + for query in &self.queries[1..] { + let (_, matched_indicies) = matcher.fuzzy_indices(item, query)?; + indicies.extend_from_slice(&matched_indicies); + } + + // deadup and remove duplicate matches + indicies.sort_unstable(); + indicies.dedup(); + + Some((score, indicies)) + } +} diff --git a/helix-term/src/ui/fuzzy_match/test.rs b/helix-term/src/ui/fuzzy_match/test.rs new file mode 100644 index 00000000..3f90ef68 --- /dev/null +++ b/helix-term/src/ui/fuzzy_match/test.rs @@ -0,0 +1,47 @@ +use crate::ui::fuzzy_match::FuzzyQuery; +use crate::ui::fuzzy_match::Matcher; + +fn run_test<'a>(query: &str, items: &'a [&'a str]) -> Vec { + let query = FuzzyQuery::new(query); + let matcher = Matcher::default(); + items + .iter() + .filter_map(|item| { + let (_, indicies) = query.fuzzy_indicies(item, &matcher)?; + let matched_string = indicies + .iter() + .map(|&pos| item.chars().nth(pos).unwrap()) + .collect(); + Some(matched_string) + }) + .collect() +} + +#[test] +fn match_single_value() { + let matches = run_test("foo", &["foobar", "foo", "bar"]); + assert_eq!(matches, &["foo", "foo"]) +} + +#[test] +fn match_multiple_values() { + let matches = run_test( + "foo bar", + &["foo bar", "foo bar", "bar foo", "bar", "foo"], + ); + assert_eq!(matches, &["foobar", "foobar", "barfoo"]) +} + +#[test] +fn space_escape() { + let matches = run_test(r"foo\ bar", &["bar foo", "foo bar", "foobar"]); + assert_eq!(matches, &["foo bar"]) +} + +#[test] +fn trim() { + let matches = run_test(r" foo bar ", &["bar foo", "foo bar", "foobar"]); + assert_eq!(matches, &["barfoo", "foobar", "foobar"]); + let matches = run_test(r" foo bar\ ", &["bar foo", "foo bar", "foobar"]); + assert_eq!(matches, &["bar foo"]) +} diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 1d247b1a..99c2473d 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -77,11 +77,12 @@ impl Menu { editor_data: ::Data, callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static, ) -> Self { - let mut menu = Self { + let matches = (0..options.len()).map(|i| (i, 0)).collect(); + Self { options, editor_data, matcher: Box::new(Matcher::default()), - matches: Vec::new(), + matches, cursor: None, widths: Vec::new(), callback_fn: Box::new(callback_fn), @@ -89,12 +90,7 @@ impl Menu { size: (0, 0), viewport: (0, 0), recalculate: true, - }; - - // TODO: scoring on empty input should just use a fastpath - menu.score(""); - - menu + } } pub fn score(&mut self, pattern: &str) { @@ -105,17 +101,15 @@ impl Menu { .iter() .enumerate() .filter_map(|(index, option)| { - let text: String = option.filter_text(&self.editor_data).into(); + let text = option.filter_text(&self.editor_data); // TODO: using fuzzy_indices could give us the char idx for match highlighting self.matcher .fuzzy_match(&text, pattern) .map(|score| (index, score)) }), ); - // matches.sort_unstable_by_key(|(_, score)| -score); - self.matches.sort_unstable_by_key(|(index, _score)| { - self.options[*index].sort_text(&self.editor_data) - }); + // Order of equal elements needs to be preserved as LSP preselected items come in order of high to low priority + self.matches.sort_by_key(|(_, score)| -score); // reset cursor position self.cursor = None; @@ -213,6 +207,14 @@ impl Menu { }) } + pub fn selection_mut(&mut self) -> Option<&mut T> { + self.cursor.and_then(|cursor| { + self.matches + .get(cursor) + .map(|(index, _score)| &mut self.options[*index]) + }) + } + pub fn is_empty(&self) -> bool { self.matches.is_empty() } diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 8ab15bff..f99dea0b 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,5 +1,6 @@ mod completion; pub(crate) mod editor; +mod fuzzy_match; mod info; pub mod lsp; mod markdown; @@ -13,7 +14,7 @@ mod statusline; mod text; use crate::compositor::{Component, Compositor}; -use crate::job; +use crate::job::{self, Callback}; pub use completion::Completion; pub use editor::EditorView; pub use markdown::Markdown; @@ -120,7 +121,7 @@ pub fn regex_prompt( if event == PromptEvent::Validate { let callback = async move { - let call: job::Callback = Box::new( + let call: job::Callback = Callback::EditorCompositor(Box::new( move |_editor: &mut Editor, compositor: &mut Compositor| { let contents = Text::new(format!("{}", err)); let size = compositor.size(); @@ -134,7 +135,7 @@ pub fn regex_prompt( compositor.replace_or_push("invalid-regex", popup); }, - ); + )); Ok(call) }; diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index a56455d7..2505f219 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,7 +1,7 @@ use crate::{ compositor::{Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, - ui::{self, EditorView}, + ui::{self, fuzzy_match::FuzzyQuery, EditorView}, }; use tui::{ buffer::Buffer as Surface, @@ -9,7 +9,6 @@ use tui::{ }; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; -use fuzzy_matcher::FuzzyMatcher; use tui::widgets::Widget; use std::time::Instant; @@ -161,6 +160,27 @@ impl FilePicker { self.preview_cache.insert(path.to_owned(), preview); Preview::Cached(&self.preview_cache[path]) } + + fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult { + // Try to find a document in the cache + let doc = self + .current_file(cx.editor) + .and_then(|(path, _range)| self.preview_cache.get_mut(&path)) + .and_then(|cache| match cache { + CachedPreview::Document(doc) => Some(doc), + _ => None, + }); + + // Then attempt to highlight it if it has no language set + if let Some(doc) = doc { + if doc.language_config().is_none() { + let loader = cx.editor.syn_loader.clone(); + doc.detect_language(loader); + } + } + + EventResult::Consumed(None) + } } impl Component for FilePicker { @@ -228,8 +248,14 @@ impl Component for FilePicker { let offset = Position::new(first_line, 0); - let highlights = + let mut highlights = EditorView::doc_syntax_highlights(doc, offset, area.height, &cx.editor.theme); + for spans in EditorView::doc_diagnostics_highlights(doc, &cx.editor.theme) { + if spans.is_empty() { + continue; + } + highlights = Box::new(helix_core::syntax::merge(highlights, spans)); + } EditorView::render_text_highlights( doc, offset, @@ -261,6 +287,9 @@ impl Component for FilePicker { } fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult { + if let Event::IdleTimeout = event { + return self.handle_idle_timeout(ctx); + } // TODO: keybinds for scrolling preview self.picker.handle_event(event, ctx) } @@ -287,8 +316,6 @@ pub struct Picker { matcher: Box, /// (index, score) matches: Vec<(usize, i64)>, - /// Filter over original options. - filters: Vec, // could be optimized into bit but not worth it now /// Current height of the completions box completion_height: u16, @@ -323,7 +350,6 @@ impl Picker { editor_data, matcher: Box::new(Matcher::default()), matches: Vec::new(), - filters: Vec::new(), cursor: 0, prompt, previous_pattern: String::new(), @@ -365,13 +391,14 @@ impl Picker { .map(|(index, _option)| (index, 0)), ); } else if pattern.starts_with(&self.previous_pattern) { + let query = FuzzyQuery::new(pattern); // optimization: if the pattern is a more specific version of the previous one // then we can score the filtered set. self.matches.retain_mut(|(index, score)| { let option = &self.options[*index]; let text = option.sort_text(&self.editor_data); - match self.matcher.fuzzy_match(&text, pattern) { + match query.fuzzy_match(&text, &self.matcher) { Some(s) => { // Update the score *score = s; @@ -384,23 +411,17 @@ impl Picker { self.matches .sort_unstable_by_key(|(_, score)| Reverse(*score)); } else { + let query = FuzzyQuery::new(pattern); self.matches.clear(); self.matches.extend( self.options .iter() .enumerate() .filter_map(|(index, option)| { - // filter options first before matching - if !self.filters.is_empty() { - // TODO: this filters functionality seems inefficient, - // instead store and operate on filters if any - self.filters.binary_search(&index).ok()?; - } - let text = option.filter_text(&self.editor_data); - self.matcher - .fuzzy_match(&text, pattern) + query + .fuzzy_match(&text, &self.matcher) .map(|score| (index, score)) }), ); @@ -460,14 +481,6 @@ impl Picker { .map(|(index, _score)| &self.options[*index]) } - pub fn save_filter(&mut self, cx: &Context) { - self.filters.clear(); - self.filters - .extend(self.matches.iter().map(|(index, _)| *index)); - self.filters.sort_unstable(); // used for binary search later - self.prompt.clear(cx.editor); - } - pub fn toggle_preview(&mut self) { self.show_preview = !self.show_preview; } @@ -505,6 +518,9 @@ impl Component for Picker { compositor.last_picker = compositor.pop(); }))); + // So that idle timeout retriggers + cx.editor.reset_idle_timer(); + match key_event { shift!(Tab) | key!(Up) | ctrl!('p') => { self.move_by(1, Direction::Backward); @@ -545,9 +561,6 @@ impl Component for Picker { } return close_fn; } - ctrl!(' ') => { - self.save_filter(cx); - } ctrl!('t') => { self.toggle_preview(); } @@ -630,9 +643,8 @@ impl Component for Picker { } let spans = option.label(&self.editor_data); - let (_score, highlights) = self - .matcher - .fuzzy_indices(&String::from(&spans), self.prompt.line()) + let (_score, highlights) = FuzzyQuery::new(self.prompt.line()) + .fuzzy_indicies(&String::from(&spans), &self.matcher) .unwrap_or_default(); spans.0.into_iter().fold(inner, |pos, span| { diff --git a/helix-term/tests/integration.rs b/helix-term/tests/integration.rs index 8969e976..a378af7a 100644 --- a/helix-term/tests/integration.rs +++ b/helix-term/tests/integration.rs @@ -11,7 +11,7 @@ mod test { use self::helpers::*; - #[tokio::test] + #[tokio::test(flavor = "multi_thread")] async fn hello_world() -> anyhow::Result<()> { test(("#[\n|]#", "ihello world", "hello world#[|\n]#")).await?; Ok(()) @@ -22,5 +22,6 @@ mod test { mod commands; mod movement; mod prompt; + mod splits; mod write; } diff --git a/helix-term/tests/test/auto_indent.rs b/helix-term/tests/test/auto_indent.rs index 2f638893..d5c220b7 100644 --- a/helix-term/tests/test/auto_indent.rs +++ b/helix-term/tests/test/auto_indent.rs @@ -1,6 +1,6 @@ use super::*; -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn auto_indent_c() -> anyhow::Result<()> { test_with_config( Args { @@ -8,6 +8,7 @@ async fn auto_indent_c() -> anyhow::Result<()> { ..Default::default() }, Config::default(), + helpers::test_syntax_conf(None), // switches to append mode? ( helpers::platform_line("void foo() {#[|}]#").as_ref(), diff --git a/helix-term/tests/test/auto_pairs.rs b/helix-term/tests/test/auto_pairs.rs index ec47a5b4..e18c7119 100644 --- a/helix-term/tests/test/auto_pairs.rs +++ b/helix-term/tests/test/auto_pairs.rs @@ -1,21 +1,547 @@ +use helix_core::{auto_pairs::DEFAULT_PAIRS, hashmap}; + use super::*; -#[tokio::test] -async fn auto_pairs_basic() -> anyhow::Result<()> { - test(("#[\n|]#", "i(", "(#[|)]#\n")).await?; +const LINE_END: &str = helix_core::DEFAULT_LINE_ENDING.as_str(); - test_with_config( - Args::default(), - Config { - editor: helix_view::editor::Config { - auto_pairs: AutoPairConfig::Enable(false), - ..Default::default() - }, +fn differing_pairs() -> impl Iterator { + DEFAULT_PAIRS.iter().filter(|(open, close)| open != close) +} + +fn matching_pairs() -> impl Iterator { + DEFAULT_PAIRS.iter().filter(|(open, close)| open == close) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_basic() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("#[{}|]#", LINE_END), + format!("i{}", pair.0), + format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_configured_multi_byte_chars() -> anyhow::Result<()> { + // NOTE: these are multi-byte Unicode characters + let pairs = hashmap!('„' => '“', '‚' => '‘', '「' => '」'); + + let config = Config { + editor: helix_view::editor::Config { + auto_pairs: AutoPairConfig::Pairs(pairs.clone()), ..Default::default() }, - ("#[\n|]#", "i(", "(#[|\n]#"), - ) - .await?; + ..Default::default() + }; + + for (open, close) in pairs.iter() { + test_with_config( + Args::default(), + config.clone(), + helpers::test_syntax_conf(None), + ( + format!("#[{}|]#", LINE_END), + format!("i{}", open), + format!("{}#[|{}]#{}", open, close, LINE_END), + ), + ) + .await?; + + test_with_config( + Args::default(), + config.clone(), + helpers::test_syntax_conf(None), + ( + format!("{}#[{}|]#{}", open, close, LINE_END), + format!("i{}", close), + format!("{}{}#[|{}]#", open, close, LINE_END), + ), + ) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_after_word() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!("foo#[{}|]#", LINE_END), + format!("i{}", pair.0), + format!("foo{}#[|{}]#{}", pair.0, pair.1, LINE_END), + )) + .await?; + } + + for pair in matching_pairs() { + test(( + format!("foo#[{}|]#", LINE_END), + format!("i{}", pair.0), + format!("foo{}#[|{}]#", pair.0, LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_before_word() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("#[f|]#oo{}", LINE_END), + format!("i{}", pair.0), + format!("{}#[|f]#oo{}", pair.0, LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_before_word_selection() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("#[foo|]#{}", LINE_END), + format!("i{}", pair.0), + format!("{}#[|foo]#{}", pair.0, LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_before_word_selection_trailing_word() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!("foo#[ wor|]#{}", LINE_END), + format!("i{}", pair.0), + format!("foo{}#[|{} wor]#{}", pair.0, pair.1, LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_closer_selection_trailing_word() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!("foo{}#[|{} wor]#{}", pair.0, pair.1, LINE_END), + format!("i{}", pair.1), + format!("foo{}{}#[| wor]#{}", pair.0, pair.1, LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_before_eol() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("{0}#[{0}|]#", LINE_END), + format!("i{}", pair.0), + format!( + "{eol}{open}#[|{close}]#{eol}", + eol = LINE_END, + open = pair.0, + close = pair.1 + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_auto_pairs_disabled() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test_with_config( + Args::default(), + Config { + editor: helix_view::editor::Config { + auto_pairs: AutoPairConfig::Enable(false), + ..Default::default() + }, + ..Default::default() + }, + helpers::test_syntax_conf(None), + ( + format!("#[{}|]#", LINE_END), + format!("i{}", pair.0), + format!("{}#[|{}]#", pair.0, LINE_END), + ), + ) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_multi_range() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("#[{eol}|]##({eol}|)##({eol}|)#", eol = LINE_END), + format!("i{}", pair.0), + format!( + "{open}#[|{close}]#{eol}{open}#(|{close})#{eol}{open}#(|{close})#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_before_multi_code_point_graphemes() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!("hello #[👨‍👩‍👧‍👦|]# goodbye{}", LINE_END), + format!("i{}", pair.1), + format!("hello {}#[|👨‍👩‍👧‍👦]# goodbye{}", pair.1, LINE_END), + )) + .await?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_at_end_of_document() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(TestCase { + in_text: String::from(LINE_END), + in_selection: Selection::single(LINE_END.len(), LINE_END.len()), + in_keys: format!("i{}", pair.0), + out_text: format!("{}{}{}", LINE_END, pair.0, pair.1), + out_selection: Selection::single(LINE_END.len() + 1, LINE_END.len() + 2), + }) + .await?; + + test(TestCase { + in_text: format!("foo{}", LINE_END), + in_selection: Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()), + in_keys: format!("i{}", pair.0), + out_text: format!("foo{}{}{}", LINE_END, pair.0, pair.1), + out_selection: Selection::single(LINE_END.len() + 4, LINE_END.len() + 5), + }) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_close_inside_pair() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "{open}#[{close}|]#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + format!("i{}", pair.1), + format!( + "{open}{close}#[|{eol}]#", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_close_inside_pair_multi() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "{open}#[{close}|]#{eol}{open}#({close}|)#{eol}{open}#({close}|)#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + format!("i{}", pair.1), + format!( + "{open}{close}#[|{eol}]#{open}{close}#(|{eol})#{open}{close}#(|{eol})#", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_nested_open_inside_pair() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!( + "{open}#[{close}|]#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + format!("i{}", pair.0), + format!( + "{open}{open}#[|{close}]#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_nested_open_inside_pair_multi() -> anyhow::Result<()> { + for outer_pair in DEFAULT_PAIRS { + for inner_pair in DEFAULT_PAIRS { + if inner_pair.0 == outer_pair.0 { + continue; + } + + test(( + format!( + "{outer_open}#[{outer_close}|]#{eol}{outer_open}#({outer_close}|)#{eol}{outer_open}#({outer_close}|)#{eol}", + outer_open = outer_pair.0, + outer_close = outer_pair.1, + eol = LINE_END + ), + format!("i{}", inner_pair.0), + format!( + "{outer_open}{inner_open}#[|{inner_close}]#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}", + outer_open = outer_pair.0, + outer_close = outer_pair.1, + inner_open = inner_pair.0, + inner_close = inner_pair.1, + eol = LINE_END + ), + )) + .await?; + } + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_basic() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("#[{}|]#", LINE_END), + format!("a{}", pair.0), + format!( + "#[{eol}{open}{close}|]#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_multi_range() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("#[ |]#{eol}#( |)#{eol}#( |)#{eol}", eol = LINE_END), + format!("a{}", pair.0), + format!( + "#[ {open}{close}|]#{eol}#( {open}{close}|)#{eol}#( {open}{close}|)#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_close_inside_pair() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "#[{open}|]#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + format!("a{}", pair.1), + format!( + "#[{open}{close}{eol}|]#", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_close_inside_pair_multi() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "#[{open}|]#{close}{eol}#({open}|)#{close}{eol}#({open}|)#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + format!("a{}", pair.1), + format!( + "#[{open}{close}{eol}|]##({open}{close}{eol}|)##({open}{close}{eol}|)#", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_end_of_word() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!("fo#[o|]#{}", LINE_END), + format!("a{}", pair.0), + format!( + "fo#[o{open}{close}|]#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_middle_of_word() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!("#[wo|]#rd{}", LINE_END), + format!("a{}", pair.1), + format!("#[wo{}r|]#d{}", pair.1, LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_end_of_word_multi() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!("fo#[o|]#{eol}fo#(o|)#{eol}fo#(o|)#{eol}", eol = LINE_END), + format!("a{}", pair.0), + format!( + "fo#[o{open}{close}|]#{eol}fo#(o{open}{close}|)#{eol}fo#(o{open}{close}|)#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_inside_nested_pair() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!( + "f#[oo{open}|]#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + format!("a{}", pair.0), + format!( + "f#[oo{open}{open}{close}|]#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_inside_nested_pair_multi() -> anyhow::Result<()> { + for outer_pair in DEFAULT_PAIRS { + for inner_pair in DEFAULT_PAIRS { + if inner_pair.0 == outer_pair.0 { + continue; + } + + test(( + format!( + "f#[oo{outer_open}|]#{outer_close}{eol}f#(oo{outer_open}|)#{outer_close}{eol}f#(oo{outer_open}|)#{outer_close}{eol}", + outer_open = outer_pair.0, + outer_close = outer_pair.1, + eol = LINE_END + ), + format!("a{}", inner_pair.0), + format!( + "f#[oo{outer_open}{inner_open}{inner_close}|]#{outer_close}{eol}f#(oo{outer_open}{inner_open}{inner_close}|)#{outer_close}{eol}f#(oo{outer_open}{inner_open}{inner_close}|)#{outer_close}{eol}", + outer_open = outer_pair.0, + outer_close = outer_pair.1, + inner_open = inner_pair.0, + inner_close = inner_pair.1, + eol = LINE_END + ), + )) + .await?; + } + } Ok(()) } diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index f7ce9af0..e24ee3e0 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -1,21 +1,25 @@ -use std::{ - io::{Read, Write}, - ops::RangeInclusive, -}; +use std::ops::RangeInclusive; use helix_core::diagnostic::Severity; -use helix_term::application::Application; use super::*; -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn test_write_quit_fail() -> anyhow::Result<()> { let file = helpers::new_readonly_tempfile()?; + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .build()?; test_key_sequence( - &mut helpers::app_with_file(file.path())?, + &mut app, Some("ihello:wq"), Some(&|app| { + let mut docs: Vec<_> = app.editor.documents().collect(); + assert_eq!(1, docs.len()); + + let doc = docs.pop().unwrap(); + assert_eq!(Some(file.path()), doc.path().map(PathBuf::as_path)); assert_eq!(&Severity::Error, app.editor.get_status().unwrap().1); }), false, @@ -25,11 +29,10 @@ async fn test_write_quit_fail() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] -#[ignore] +#[tokio::test(flavor = "multi_thread")] async fn test_buffer_close_concurrent() -> anyhow::Result<()> { test_key_sequences( - &mut Application::new(Args::default(), Config::default())?, + &mut helpers::AppBuilder::new().build()?, vec![ ( None, @@ -69,8 +72,12 @@ async fn test_buffer_close_concurrent() -> anyhow::Result<()> { command.push_str(":bufferclose"); + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .build()?; + test_key_sequence( - &mut helpers::app_with_file(file.path())?, + &mut app, Some(&command), Some(&|app| { assert!(!app.editor.is_err(), "error: {:?}", app.editor.get_status()); @@ -82,17 +89,12 @@ async fn test_buffer_close_concurrent() -> anyhow::Result<()> { ) .await?; - file.as_file_mut().flush()?; - file.as_file_mut().sync_all()?; - - let mut file_content = String::new(); - file.as_file_mut().read_to_string(&mut file_content)?; - assert_eq!(RANGE.end().to_string(), file_content); + helpers::assert_file_has_content(file.as_file_mut(), &RANGE.end().to_string())?; Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn test_selection_duplication() -> anyhow::Result<()> { // Forward test(( diff --git a/helix-term/tests/test/helpers.rs b/helix-term/tests/test/helpers.rs index 8f2501e6..2c5043d6 100644 --- a/helix-term/tests/test/helpers.rs +++ b/helix-term/tests/test/helpers.rs @@ -1,10 +1,15 @@ -use std::{io::Write, path::PathBuf, time::Duration}; +use std::{ + fs::File, + io::{Read, Write}, + path::PathBuf, + time::Duration, +}; use anyhow::bail; use crossterm::event::{Event, KeyEvent}; -use helix_core::{test, Selection, Transaction}; +use helix_core::{diagnostic::Severity, test, Selection, Transaction}; use helix_term::{application::Application, args::Args, config::Config}; -use helix_view::{doc, input::parse_macro}; +use helix_view::{doc, input::parse_macro, Editor}; use tempfile::NamedTempFile; use tokio_stream::wrappers::UnboundedReceiverStream; @@ -56,7 +61,9 @@ pub async fn test_key_sequences( for (i, (in_keys, test_fn)) in inputs.into_iter().enumerate() { if let Some(in_keys) = in_keys { for key_event in parse_macro(in_keys)?.into_iter() { - tx.send(Ok(Event::Key(KeyEvent::from(key_event))))?; + let key = Event::Key(KeyEvent::from(key_event)); + log::trace!("sending key: {:?}", key); + tx.send(Ok(key))?; } } @@ -70,7 +77,7 @@ pub async fn test_key_sequences( // verify if it exited on the last iteration if it should have and // the inverse if i == num_inputs - 1 && app_exited != should_exit { - bail!("expected app to exit: {} != {}", app_exited, should_exit); + bail!("expected app to exit: {} != {}", should_exit, app_exited); } if let Some(test) = test_fn { @@ -87,7 +94,17 @@ pub async fn test_key_sequences( tokio::time::timeout(TIMEOUT, event_loop).await?; } - app.close().await?; + let errs = app.close().await; + + if !errs.is_empty() { + log::error!("Errors closing app"); + + for err in errs { + log::error!("{}", err); + } + + bail!("Error closing app"); + } Ok(()) } @@ -101,20 +118,19 @@ pub async fn test_key_sequence_with_input_text>( let test_case = test_case.into(); let mut app = match app { Some(app) => app, - None => Application::new(Args::default(), Config::default())?, + None => Application::new(Args::default(), Config::default(), test_syntax_conf(None))?, }; let (view, doc) = helix_view::current!(app.editor); let sel = doc.selection(view.id).clone(); // replace the initial text with the input text - doc.apply( - &Transaction::change_by_selection(doc.text(), &sel, |_| { - (0, doc.text().len_chars(), Some((&test_case.in_text).into())) - }) - .with_selection(test_case.in_selection.clone()), - view.id, - ); + let transaction = Transaction::change_by_selection(doc.text(), &sel, |_| { + (0, doc.text().len_chars(), Some((&test_case.in_text).into())) + }) + .with_selection(test_case.in_selection.clone()); + + helix_view::apply_transaction(&transaction, doc, view); test_key_sequence( &mut app, @@ -125,16 +141,48 @@ pub async fn test_key_sequence_with_input_text>( .await } +/// Generates language configs that merge in overrides, like a user language +/// config. The argument string must be a raw TOML document. +/// +/// By default, language server configuration is dropped from the languages.toml +/// document. If a language-server is necessary for a test, it must be explicitly +/// added in `overrides`. +pub fn test_syntax_conf(overrides: Option) -> helix_core::syntax::Configuration { + let mut lang = helix_loader::config::default_lang_config(); + + for lang_config in lang + .as_table_mut() + .expect("Expected languages.toml to be a table") + .get_mut("language") + .expect("Expected languages.toml to have \"language\" keys") + .as_array_mut() + .expect("Expected an array of language configurations") + { + lang_config + .as_table_mut() + .expect("Expected language config to be a TOML table") + .remove("language-server"); + } + + if let Some(overrides) = overrides { + let override_toml = toml::from_str(&overrides).unwrap(); + lang = helix_loader::merge_toml_values(lang, override_toml, 3); + } + + lang.try_into().unwrap() +} + /// Use this for very simple test cases where there is one input /// document, selection, and sequence of key presses, and you just /// want to verify the resulting document and selection. pub async fn test_with_config>( args: Args, config: Config, + syn_conf: helix_core::syntax::Configuration, test_case: T, ) -> anyhow::Result<()> { let test_case = test_case.into(); - let app = Application::new(args, config)?; + let app = Application::new(args, config, syn_conf)?; test_key_sequence_with_input_text( Some(app), @@ -155,7 +203,13 @@ pub async fn test_with_config>( } pub async fn test>(test_case: T) -> anyhow::Result<()> { - test_with_config(Args::default(), Config::default(), test_case).await + test_with_config( + Args::default(), + Config::default(), + test_syntax_conf(None), + test_case, + ) + .await } pub fn temp_file_with_contents>( @@ -200,14 +254,87 @@ pub fn new_readonly_tempfile() -> anyhow::Result { Ok(file) } -/// Creates a new Application with default config that opens the given file -/// path -pub fn app_with_file>(path: P) -> anyhow::Result { - Application::new( - Args { - files: vec![(path.into(), helix_core::Position::default())], - ..Default::default() - }, - Config::default(), - ) +pub struct AppBuilder { + args: Args, + config: Config, + syn_conf: helix_core::syntax::Configuration, + input: Option<(String, Selection)>, +} + +impl Default for AppBuilder { + fn default() -> Self { + Self { + args: Args::default(), + config: Config::default(), + syn_conf: test_syntax_conf(None), + input: None, + } + } +} + +impl AppBuilder { + pub fn new() -> Self { + AppBuilder::default() + } + + pub fn with_file>( + mut self, + path: P, + pos: Option, + ) -> Self { + self.args.files.push((path.into(), pos.unwrap_or_default())); + self + } + + // Remove this attribute once `with_config` is used in a test: + #[allow(dead_code)] + pub fn with_config(mut self, config: Config) -> Self { + self.config = config; + self + } + + pub fn with_input_text>(mut self, input_text: S) -> Self { + self.input = Some(test::print(&input_text.into())); + self + } + + pub fn with_lang_config(mut self, syn_conf: helix_core::syntax::Configuration) -> Self { + self.syn_conf = syn_conf; + self + } + + pub fn build(self) -> anyhow::Result { + let mut app = Application::new(self.args, self.config, self.syn_conf)?; + + if let Some((text, selection)) = self.input { + let (view, doc) = helix_view::current!(app.editor); + let sel = doc.selection(view.id).clone(); + let trans = Transaction::change_by_selection(doc.text(), &sel, |_| { + (0, doc.text().len_chars(), Some((text.clone()).into())) + }) + .with_selection(selection); + + // replace the initial text with the input text + helix_view::apply_transaction(&trans, doc, view); + } + + Ok(app) + } +} + +pub fn assert_file_has_content(file: &mut File, content: &str) -> anyhow::Result<()> { + file.flush()?; + file.sync_all()?; + + let mut file_content = String::new(); + file.read_to_string(&mut file_content)?; + assert_eq!(content, file_content); + + Ok(()) +} + +pub fn assert_status_not_error(editor: &Editor) { + if let Some((_, sev)) = editor.get_status() { + assert_ne!(&Severity::Error, sev); + } } diff --git a/helix-term/tests/test/movement.rs b/helix-term/tests/test/movement.rs index e5abb0b0..fedf4b0e 100644 --- a/helix-term/tests/test/movement.rs +++ b/helix-term/tests/test/movement.rs @@ -1,6 +1,6 @@ use super::*; -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn insert_mode_cursor_position() -> anyhow::Result<()> { test(TestCase { in_text: String::new(), @@ -19,7 +19,7 @@ async fn insert_mode_cursor_position() -> anyhow::Result<()> { } /// Range direction is preserved when escaping insert mode to normal -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn insert_to_normal_mode_cursor_position() -> anyhow::Result<()> { test(("#[f|]#oo\n", "vll", "#[|foo]#\n")).await?; test(( @@ -66,11 +66,13 @@ async fn insert_to_normal_mode_cursor_position() -> anyhow::Result<()> { /// Ensure the very initial cursor in an opened file is the width of /// the first grapheme -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn cursor_position_newly_opened_file() -> anyhow::Result<()> { let test = |content: &str, expected_sel: Selection| -> anyhow::Result<()> { let file = helpers::temp_file_with_contents(content)?; - let mut app = helpers::app_with_file(file.path())?; + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .build()?; let (view, doc) = helix_view::current!(app.editor); let sel = doc.selection(view.id).clone(); @@ -86,7 +88,28 @@ async fn cursor_position_newly_opened_file() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] +async fn cursor_position_append_eof() -> anyhow::Result<()> { + // Selection is fowards + test(( + "#[foo|]#", + "abar", + helpers::platform_line("#[foobar|]#\n").as_ref(), + )) + .await?; + + // Selection is backwards + test(( + "#[|foo]#", + "abar", + helpers::platform_line("#[foobar|]#\n").as_ref(), + )) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] async fn select_mode_tree_sitter_next_function_is_union_of_objects() -> anyhow::Result<()> { test_with_config( Args { @@ -94,6 +117,7 @@ async fn select_mode_tree_sitter_next_function_is_union_of_objects() -> anyhow:: ..Default::default() }, Config::default(), + helpers::test_syntax_conf(None), ( helpers::platform_line(indoc! {"\ #[/|]#// Increments @@ -117,7 +141,7 @@ async fn select_mode_tree_sitter_next_function_is_union_of_objects() -> anyhow:: Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn select_mode_tree_sitter_prev_function_unselects_object() -> anyhow::Result<()> { test_with_config( Args { @@ -125,6 +149,7 @@ async fn select_mode_tree_sitter_prev_function_unselects_object() -> anyhow::Res ..Default::default() }, Config::default(), + helpers::test_syntax_conf(None), ( helpers::platform_line(indoc! {"\ /// Increments @@ -148,7 +173,7 @@ async fn select_mode_tree_sitter_prev_function_unselects_object() -> anyhow::Res Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn select_mode_tree_sitter_prev_function_goes_backwards_to_object() -> anyhow::Result<()> { // Note: the anchor stays put and the head moves back. test_with_config( @@ -157,6 +182,7 @@ async fn select_mode_tree_sitter_prev_function_goes_backwards_to_object() -> any ..Default::default() }, Config::default(), + helpers::test_syntax_conf(None), ( helpers::platform_line(indoc! {"\ /// Increments @@ -187,6 +213,7 @@ async fn select_mode_tree_sitter_prev_function_goes_backwards_to_object() -> any ..Default::default() }, Config::default(), + helpers::test_syntax_conf(None), ( helpers::platform_line(indoc! {"\ /// Increments diff --git a/helix-term/tests/test/prompt.rs b/helix-term/tests/test/prompt.rs index 2ab9604c..4f3bf763 100644 --- a/helix-term/tests/test/prompt.rs +++ b/helix-term/tests/test/prompt.rs @@ -1,11 +1,9 @@ use super::*; -use helix_term::application::Application; - -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn test_history_completion() -> anyhow::Result<()> { test_key_sequence( - &mut Application::new(Args::default(), Config::default())?, + &mut AppBuilder::new().build()?, Some(":asdf:theme d"), Some(&|app| { assert!(!app.editor.is_err()); diff --git a/helix-term/tests/test/splits.rs b/helix-term/tests/test/splits.rs new file mode 100644 index 00000000..5807413a --- /dev/null +++ b/helix-term/tests/test/splits.rs @@ -0,0 +1,129 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread")] +async fn test_split_write_quit_all() -> anyhow::Result<()> { + let mut file1 = tempfile::NamedTempFile::new()?; + let mut file2 = tempfile::NamedTempFile::new()?; + let mut file3 = tempfile::NamedTempFile::new()?; + + let mut app = helpers::AppBuilder::new() + .with_file(file1.path(), None) + .build()?; + + test_key_sequences( + &mut app, + vec![ + ( + Some(&format!( + "ihello1:sp:o {}ihello2:sp:o {}ihello3", + file2.path().to_string_lossy(), + file3.path().to_string_lossy() + )), + Some(&|app| { + let docs: Vec<_> = app.editor.documents().collect(); + assert_eq!(3, docs.len()); + + let doc1 = docs + .iter() + .find(|doc| doc.path().unwrap() == file1.path()) + .unwrap(); + + assert_eq!("hello1", doc1.text().to_string()); + + let doc2 = docs + .iter() + .find(|doc| doc.path().unwrap() == file2.path()) + .unwrap(); + + assert_eq!("hello2", doc2.text().to_string()); + + let doc3 = docs + .iter() + .find(|doc| doc.path().unwrap() == file3.path()) + .unwrap(); + + assert_eq!("hello3", doc3.text().to_string()); + + helpers::assert_status_not_error(&app.editor); + assert_eq!(3, app.editor.tree.views().count()); + }), + ), + ( + Some(":wqa"), + Some(&|app| { + helpers::assert_status_not_error(&app.editor); + assert_eq!(0, app.editor.tree.views().count()); + }), + ), + ], + true, + ) + .await?; + + helpers::assert_file_has_content(file1.as_file_mut(), "hello1")?; + helpers::assert_file_has_content(file2.as_file_mut(), "hello2")?; + helpers::assert_file_has_content(file3.as_file_mut(), "hello3")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_split_write_quit_same_file() -> anyhow::Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .build()?; + + test_key_sequences( + &mut app, + vec![ + ( + Some("Oihello:spogoodbye"), + Some(&|app| { + assert_eq!(2, app.editor.tree.views().count()); + helpers::assert_status_not_error(&app.editor); + + let mut docs: Vec<_> = app.editor.documents().collect(); + assert_eq!(1, docs.len()); + + let doc = docs.pop().unwrap(); + + assert_eq!( + helpers::platform_line("hello\ngoodbye"), + doc.text().to_string() + ); + + assert!(doc.is_modified()); + }), + ), + ( + Some(":wq"), + Some(&|app| { + helpers::assert_status_not_error(&app.editor); + assert_eq!(1, app.editor.tree.views().count()); + + let mut docs: Vec<_> = app.editor.documents().collect(); + assert_eq!(1, docs.len()); + + let doc = docs.pop().unwrap(); + + assert_eq!( + helpers::platform_line("hello\ngoodbye"), + doc.text().to_string() + ); + + assert!(!doc.is_modified()); + }), + ), + ], + false, + ) + .await?; + + helpers::assert_file_has_content( + file.as_file_mut(), + &helpers::platform_line("hello\ngoodbye"), + )?; + + Ok(()) +} diff --git a/helix-term/tests/test/write.rs b/helix-term/tests/test/write.rs index 8869d881..d0128edc 100644 --- a/helix-term/tests/test/write.rs +++ b/helix-term/tests/test/write.rs @@ -4,17 +4,19 @@ use std::{ }; use helix_core::diagnostic::Severity; -use helix_term::application::Application; use helix_view::doc; use super::*; -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn test_write() -> anyhow::Result<()> { let mut file = tempfile::NamedTempFile::new()?; + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .build()?; test_key_sequence( - &mut helpers::app_with_file(file.path())?, + &mut app, Some("ithe gostak distims the doshes:w"), None, false, @@ -35,12 +37,15 @@ async fn test_write() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn test_write_quit() -> anyhow::Result<()> { let mut file = tempfile::NamedTempFile::new()?; + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .build()?; test_key_sequence( - &mut helpers::app_with_file(file.path())?, + &mut app, Some("ithe gostak distims the doshes:wq"), None, true, @@ -61,25 +66,21 @@ async fn test_write_quit() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] -#[ignore] +#[tokio::test(flavor = "multi_thread")] async fn test_write_concurrent() -> anyhow::Result<()> { let mut file = tempfile::NamedTempFile::new()?; let mut command = String::new(); const RANGE: RangeInclusive = 1..=5000; + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .build()?; for i in RANGE { let cmd = format!("%c{}:w", i); command.push_str(&cmd); } - test_key_sequence( - &mut helpers::app_with_file(file.path())?, - Some(&command), - None, - false, - ) - .await?; + test_key_sequence(&mut app, Some(&command), None, false).await?; file.as_file_mut().flush()?; file.as_file_mut().sync_all()?; @@ -91,13 +92,15 @@ async fn test_write_concurrent() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] -#[ignore] +#[tokio::test(flavor = "multi_thread")] async fn test_write_fail_mod_flag() -> anyhow::Result<()> { let file = helpers::new_readonly_tempfile()?; + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .build()?; test_key_sequences( - &mut helpers::app_with_file(file.path())?, + &mut app, vec![ ( None, @@ -130,13 +133,128 @@ async fn test_write_fail_mod_flag() -> anyhow::Result<()> { Ok(()) } -#[tokio::test] -#[ignore] +#[tokio::test(flavor = "multi_thread")] +async fn test_write_scratch_to_new_path() -> anyhow::Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + + test_key_sequence( + &mut AppBuilder::new().build()?, + Some(format!("ihello:w {}", file.path().to_string_lossy()).as_ref()), + Some(&|app| { + assert!(!app.editor.is_err()); + + let mut docs: Vec<_> = app.editor.documents().collect(); + assert_eq!(1, docs.len()); + + let doc = docs.pop().unwrap(); + assert_eq!(Some(&file.path().to_path_buf()), doc.path()); + }), + false, + ) + .await?; + + helpers::assert_file_has_content(file.as_file_mut(), &helpers::platform_line("hello"))?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_write_scratch_no_path_fails() -> anyhow::Result<()> { + helpers::test_key_sequence_with_input_text( + None, + ("#[\n|]#", "ihello:w", "hello#[\n|]#"), + &|app| { + assert!(app.editor.is_err()); + + let mut docs: Vec<_> = app.editor.documents().collect(); + assert_eq!(1, docs.len()); + + let doc = docs.pop().unwrap(); + assert_eq!(None, doc.path()); + }, + false, + ) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_write_auto_format_fails_still_writes() -> anyhow::Result<()> { + let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?; + + let lang_conf = indoc! {r#" + [[language]] + name = "rust" + formatter = { command = "bash", args = [ "-c", "exit 1" ] } + "#}; + + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .with_input_text("#[l|]#et foo = 0;\n") + .with_lang_config(helpers::test_syntax_conf(Some(lang_conf.into()))) + .build()?; + + test_key_sequences(&mut app, vec![(Some(":w"), None)], false).await?; + + // file still saves + helpers::assert_file_has_content(file.as_file_mut(), "let foo = 0;\n")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_write_new_path() -> anyhow::Result<()> { + let mut file1 = tempfile::NamedTempFile::new().unwrap(); + let mut file2 = tempfile::NamedTempFile::new().unwrap(); + let mut app = helpers::AppBuilder::new() + .with_file(file1.path(), None) + .build()?; + + test_key_sequences( + &mut app, + vec![ + ( + Some("ii can eat glass, it will not hurt me:w"), + Some(&|app| { + let doc = doc!(app.editor); + assert!(!app.editor.is_err()); + assert_eq!(file1.path(), doc.path().unwrap()); + }), + ), + ( + Some(&format!(":w {}", file2.path().to_string_lossy())), + Some(&|app| { + let doc = doc!(app.editor); + assert!(!app.editor.is_err()); + assert_eq!(file2.path(), doc.path().unwrap()); + assert!(app.editor.document_by_path(file1.path()).is_none()); + }), + ), + ], + false, + ) + .await?; + + helpers::assert_file_has_content( + file1.as_file_mut(), + &helpers::platform_line("i can eat glass, it will not hurt me\n"), + )?; + + helpers::assert_file_has_content( + file2.as_file_mut(), + &helpers::platform_line("i can eat glass, it will not hurt me\n"), + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] async fn test_write_fail_new_path() -> anyhow::Result<()> { let file = helpers::new_readonly_tempfile()?; test_key_sequences( - &mut Application::new(Args::default(), Config::default())?, + &mut AppBuilder::new().build()?, vec![ ( None, diff --git a/helix-tui/Cargo.toml b/helix-tui/Cargo.toml index b220c64f..a4a1c389 100644 --- a/helix-tui/Cargo.toml +++ b/helix-tui/Cargo.toml @@ -20,6 +20,7 @@ bitflags = "1.3" cassowary = "0.3" unicode-segmentation = "1.10" crossterm = { version = "0.25", optional = true } +termini = "0.1" serde = { version = "1", "optional" = true, features = ["derive"]} helix-view = { version = "0.6", path = "../helix-view", features = ["term"] } helix-core = { version = "0.6", path = "../helix-core" } diff --git a/helix-tui/src/backend/crossterm.rs b/helix-tui/src/backend/crossterm.rs index eff098b3..7c7250fa 100644 --- a/helix-tui/src/backend/crossterm.rs +++ b/helix-tui/src/backend/crossterm.rs @@ -7,12 +7,45 @@ use crossterm::{ SetForegroundColor, }, terminal::{self, Clear, ClearType}, + Command, }; -use helix_view::graphics::{Color, CursorKind, Modifier, Rect}; -use std::io::{self, Write}; +use helix_view::graphics::{Color, CursorKind, Modifier, Rect, UnderlineStyle}; +use std::{ + fmt, + io::{self, Write}, +}; +fn vte_version() -> Option { + std::env::var("VTE_VERSION").ok()?.parse().ok() +} + +/// Describes terminal capabilities like extended underline, truecolor, etc. +#[derive(Copy, Clone, Debug, Default)] +struct Capabilities { + /// Support for undercurled, underdashed, etc. + has_extended_underlines: bool, +} + +impl Capabilities { + /// Detect capabilities from the terminfo database located based + /// on the $TERM environment variable. If detection fails, returns + /// a default value where no capability is supported. + pub fn from_env_or_default() -> Self { + match termini::TermInfo::from_env() { + Err(_) => Capabilities::default(), + Ok(t) => Capabilities { + // Smulx, VTE: https://unix.stackexchange.com/a/696253/246284 + // Su (used by kitty): https://sw.kovidgoyal.net/kitty/underlines + has_extended_underlines: t.extended_cap("Smulx").is_some() + || t.extended_cap("Su").is_some() + || vte_version() >= Some(5102), + }, + } + } +} pub struct CrosstermBackend { buffer: W, + capabilities: Capabilities, } impl CrosstermBackend @@ -20,7 +53,10 @@ where W: Write, { pub fn new(buffer: W) -> CrosstermBackend { - CrosstermBackend { buffer } + CrosstermBackend { + buffer, + capabilities: Capabilities::from_env_or_default(), + } } } @@ -47,6 +83,8 @@ where { let mut fg = Color::Reset; let mut bg = Color::Reset; + let mut underline_color = Color::Reset; + let mut underline_style = UnderlineStyle::Reset; let mut modifier = Modifier::empty(); let mut last_pos: Option<(u16, u16)> = None; for (x, y, cell) in content { @@ -74,11 +112,32 @@ where bg = cell.bg; } + let mut new_underline_style = cell.underline_style; + if self.capabilities.has_extended_underlines { + if cell.underline_color != underline_color { + let color = CColor::from(cell.underline_color); + map_error(queue!(self.buffer, SetUnderlineColor(color)))?; + underline_color = cell.underline_color; + } + } else { + match new_underline_style { + UnderlineStyle::Reset | UnderlineStyle::Line => (), + _ => new_underline_style = UnderlineStyle::Line, + } + } + + if new_underline_style != underline_style { + let attr = CAttribute::from(new_underline_style); + map_error(queue!(self.buffer, SetAttribute(attr)))?; + underline_style = new_underline_style; + } + map_error(queue!(self.buffer, Print(&cell.symbol)))?; } map_error(queue!( self.buffer, + SetUnderlineColor(CColor::Reset), SetForegroundColor(CColor::Reset), SetBackgroundColor(CColor::Reset), SetAttribute(CAttribute::Reset) @@ -153,9 +212,6 @@ impl ModifierDiff { if removed.contains(Modifier::ITALIC) { map_error(queue!(w, SetAttribute(CAttribute::NoItalic)))?; } - if removed.contains(Modifier::UNDERLINED) { - map_error(queue!(w, SetAttribute(CAttribute::NoUnderline)))?; - } if removed.contains(Modifier::DIM) { map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; } @@ -176,9 +232,6 @@ impl ModifierDiff { if added.contains(Modifier::ITALIC) { map_error(queue!(w, SetAttribute(CAttribute::Italic)))?; } - if added.contains(Modifier::UNDERLINED) { - map_error(queue!(w, SetAttribute(CAttribute::Underlined)))?; - } if added.contains(Modifier::DIM) { map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; } @@ -195,3 +248,58 @@ impl ModifierDiff { Ok(()) } } + +/// Crossterm uses semicolon as a seperator for colors +/// this is actually not spec compliant (altough commonly supported) +/// However the correct approach is to use colons as a seperator. +/// This usually doesn't make a difference for emulators that do support colored underlines. +/// However terminals that do not support colored underlines will ignore underlines colors with colons +/// while escape sequences with semicolons are always processed which leads to weird visual artifacts. +/// See [this nvim issue](https://github.com/neovim/neovim/issues/9270) for details +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetUnderlineColor(pub CColor); + +impl Command for SetUnderlineColor { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + let color = self.0; + + if color == CColor::Reset { + write!(f, "\x1b[59m")?; + return Ok(()); + } + f.write_str("\x1b[58:")?; + + let res = match color { + CColor::Black => f.write_str("5:0"), + CColor::DarkGrey => f.write_str("5:8"), + CColor::Red => f.write_str("5:9"), + CColor::DarkRed => f.write_str("5:1"), + CColor::Green => f.write_str("5:10"), + CColor::DarkGreen => f.write_str("5:2"), + CColor::Yellow => f.write_str("5:11"), + CColor::DarkYellow => f.write_str("5:3"), + CColor::Blue => f.write_str("5:12"), + CColor::DarkBlue => f.write_str("5:4"), + CColor::Magenta => f.write_str("5:13"), + CColor::DarkMagenta => f.write_str("5:5"), + CColor::Cyan => f.write_str("5:14"), + CColor::DarkCyan => f.write_str("5:6"), + CColor::White => f.write_str("5:15"), + CColor::Grey => f.write_str("5:7"), + CColor::Rgb { r, g, b } => write!(f, "2::{}:{}:{}", r, g, b), + CColor::AnsiValue(val) => write!(f, "5:{}", val), + _ => Ok(()), + }; + res?; + write!(f, "m")?; + Ok(()) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> crossterm::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "SetUnderlineColor not supported by winapi.", + )) + } +} diff --git a/helix-tui/src/buffer.rs b/helix-tui/src/buffer.rs index 21c53aad..23ba43f1 100644 --- a/helix-tui/src/buffer.rs +++ b/helix-tui/src/buffer.rs @@ -3,7 +3,7 @@ use helix_core::unicode::width::UnicodeWidthStr; use std::cmp::min; use unicode_segmentation::UnicodeSegmentation; -use helix_view::graphics::{Color, Modifier, Rect, Style}; +use helix_view::graphics::{Color, Modifier, Rect, Style, UnderlineStyle}; /// A buffer cell #[derive(Debug, Clone, PartialEq)] @@ -11,6 +11,8 @@ pub struct Cell { pub symbol: String, pub fg: Color, pub bg: Color, + pub underline_color: Color, + pub underline_style: UnderlineStyle, pub modifier: Modifier, } @@ -44,6 +46,13 @@ impl Cell { if let Some(c) = style.bg { self.bg = c; } + if let Some(c) = style.underline_color { + self.underline_color = c; + } + if let Some(style) = style.underline_style { + self.underline_style = style; + } + self.modifier.insert(style.add_modifier); self.modifier.remove(style.sub_modifier); self @@ -53,6 +62,8 @@ impl Cell { Style::default() .fg(self.fg) .bg(self.bg) + .underline_color(self.underline_color) + .underline_style(self.underline_style) .add_modifier(self.modifier) } @@ -61,6 +72,8 @@ impl Cell { self.symbol.push(' '); self.fg = Color::Reset; self.bg = Color::Reset; + self.underline_color = Color::Reset; + self.underline_style = UnderlineStyle::Reset; self.modifier = Modifier::empty(); } } @@ -71,6 +84,8 @@ impl Default for Cell { symbol: " ".into(), fg: Color::Reset, bg: Color::Reset, + underline_color: Color::Reset, + underline_style: UnderlineStyle::Reset, modifier: Modifier::empty(), } } @@ -87,7 +102,7 @@ impl Default for Cell { /// /// ``` /// use helix_tui::buffer::{Buffer, Cell}; -/// use helix_view::graphics::{Rect, Color, Style, Modifier}; +/// use helix_view::graphics::{Rect, Color, UnderlineStyle, Style, Modifier}; /// /// let mut buf = Buffer::empty(Rect{x: 0, y: 0, width: 10, height: 5}); /// buf[(0, 2)].set_symbol("x"); @@ -97,7 +112,9 @@ impl Default for Cell { /// symbol: String::from("r"), /// fg: Color::Red, /// bg: Color::White, -/// modifier: Modifier::empty() +/// underline_color: Color::Reset, +/// underline_style: UnderlineStyle::Reset, +/// modifier: Modifier::empty(), /// }); /// buf[(5, 0)].set_char('x'); /// assert_eq!(buf[(5, 0)].symbol, "x"); @@ -120,7 +137,7 @@ impl Buffer { /// Returns a Buffer with all cells initialized with the attributes of the given Cell pub fn filled(area: Rect, cell: &Cell) -> Buffer { - let size = area.area() as usize; + let size = area.area(); let mut content = Vec::with_capacity(size); for _ in 0..size { content.push(cell.clone()); @@ -222,7 +239,7 @@ impl Buffer { y, self.area ); - ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize + ((y - self.area.y) as usize) * (self.area.width as usize) + ((x - self.area.x) as usize) } /// Returns the index in the Vec for the given global (x, y) coordinates, @@ -261,8 +278,8 @@ impl Buffer { self.content.len() ); ( - self.area.x + i as u16 % self.area.width, - self.area.y + i as u16 / self.area.width, + (self.area.x as usize + (i % self.area.width as usize)) as u16, + (self.area.y as usize + (i / self.area.width as usize)) as u16, ) } @@ -463,7 +480,7 @@ impl Buffer { /// Resize the buffer so that the mapped area matches the given area and that the buffer /// length is equal to area.width * area.height pub fn resize(&mut self, area: Rect) { - let length = area.area() as usize; + let length = area.area(); if self.content.len() > length { self.content.truncate(length); } else { @@ -570,8 +587,8 @@ impl Buffer { let mut to_skip: usize = 0; for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { if (current != previous || invalidated > 0) && to_skip == 0 { - let x = i as u16 % width; - let y = i as u16 / width; + let x = (i % width as usize) as u16; + let y = (i / width as usize) as u16; updates.push((x, y, &next_buffer[i])); } diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs index 602090e5..1bfe5ee1 100644 --- a/helix-tui/src/text.rs +++ b/helix-tui/src/text.rs @@ -134,6 +134,8 @@ impl<'a> Span<'a> { /// style: Style { /// fg: Some(Color::Yellow), /// bg: Some(Color::Black), + /// underline_color: None, + /// underline_style: None, /// add_modifier: Modifier::empty(), /// sub_modifier: Modifier::empty(), /// }, @@ -143,6 +145,8 @@ impl<'a> Span<'a> { /// style: Style { /// fg: Some(Color::Yellow), /// bg: Some(Color::Black), + /// underline_color: None, + /// underline_style: None, /// add_modifier: Modifier::empty(), /// sub_modifier: Modifier::empty(), /// }, @@ -152,6 +156,8 @@ impl<'a> Span<'a> { /// style: Style { /// fg: Some(Color::Yellow), /// bg: Some(Color::Black), + /// underline_color: None, + /// underline_style: None, /// add_modifier: Modifier::empty(), /// sub_modifier: Modifier::empty(), /// }, @@ -161,6 +167,8 @@ impl<'a> Span<'a> { /// style: Style { /// fg: Some(Color::Yellow), /// bg: Some(Color::Black), + /// underline_color: None, + /// underline_style: None, /// add_modifier: Modifier::empty(), /// sub_modifier: Modifier::empty(), /// }, diff --git a/helix-tui/tests/terminal.rs b/helix-tui/tests/terminal.rs index 3dd3b0b0..2824c9f2 100644 --- a/helix-tui/tests/terminal.rs +++ b/helix-tui/tests/terminal.rs @@ -4,12 +4,12 @@ use helix_tui::{ }; #[test] -fn terminal_buffer_size_should_be_limited() { +fn terminal_buffer_size_should_not_be_limited() { let backend = TestBackend::new(400, 400); let terminal = Terminal::new(backend).unwrap(); let size = terminal.backend().size().unwrap(); - assert_eq!(size.width, 255); - assert_eq!(size.height, 255); + assert_eq!(size.width, 400); + assert_eq!(size.height, 400); } // #[test] diff --git a/helix-view/src/clipboard.rs b/helix-view/src/clipboard.rs index f3d94734..ad6f621a 100644 --- a/helix-view/src/clipboard.rs +++ b/helix-view/src/clipboard.rs @@ -17,7 +17,7 @@ pub trait ClipboardProvider: std::fmt::Debug { #[cfg(not(windows))] macro_rules! command_provider { (paste => $get_prg:literal $( , $get_arg:literal )* ; copy => $set_prg:literal $( , $set_arg:literal )* ; ) => {{ - log::info!( + log::debug!( "Using {} to interact with the system clipboard", if $set_prg != $get_prg { format!("{}+{}", $set_prg, $get_prg)} else { $set_prg.to_string() } ); diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index b6b2f664..9a7febd2 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -24,7 +24,7 @@ use helix_core::{ DEFAULT_LINE_ENDING, }; -use crate::{DocumentId, Editor, ViewId}; +use crate::{apply_transaction, DocumentId, Editor, View, ViewId}; /// 8kB of buffer space for encoding and decoding `Rope`s. const BUF_SIZE: usize = 8192; @@ -83,6 +83,18 @@ impl Serialize for Mode { } } +/// A snapshot of the text of a document that we want to write out to disk +#[derive(Debug, Clone)] +pub struct DocumentSavedEvent { + pub revision: usize, + pub doc_id: DocumentId, + pub path: PathBuf, + pub text: Rope, +} + +pub type DocumentSavedEventResult = Result; +pub type DocumentSavedEventFuture = BoxFuture<'static, DocumentSavedEventResult>; + pub struct Document { pub(crate) id: DocumentId, text: Rope, @@ -492,45 +504,61 @@ impl Document { Some(fut.boxed()) } - pub fn save(&mut self, force: bool) -> impl Future> { - self.save_impl::>(None, force) - } - - pub fn format_and_save( + pub fn save>( &mut self, - formatting: Option>>, + path: Option

, force: bool, - ) -> impl Future> { - self.save_impl(formatting, force) + ) -> Result< + impl Future> + 'static + Send, + anyhow::Error, + > { + let path = path.map(|path| path.into()); + self.save_impl(path, force) + + // futures_util::future::Ready<_>, } - // TODO: do we need some way of ensuring two save operations on the same doc can't run at once? - // or is that handled by the OS/async layer /// The `Document`'s text is encoded according to its encoding and written to the file located /// at its `path()`. - /// - /// If `formatting` is present, it supplies some changes that we apply to the text before saving. - fn save_impl>>( + fn save_impl( &mut self, - formatting: Option, + path: Option, force: bool, - ) -> impl Future> { + ) -> Result< + impl Future> + 'static + Send, + anyhow::Error, + > { + log::debug!( + "submitting save of doc '{:?}'", + self.path().map(|path| path.to_string_lossy()) + ); + // we clone and move text + path into the future so that we asynchronously save the current // state without blocking any further edits. + let text = self.text().clone(); - let mut text = self.text().clone(); - let path = self.path.clone().expect("Can't save with no path set!"); - let identifier = self.identifier(); + let path = match path { + Some(path) => helix_core::path::get_canonicalized_path(&path)?, + None => { + if self.path.is_none() { + bail!("Can't save with no path set!"); + } + self.path.as_ref().unwrap().clone() + } + }; + + let identifier = self.path().map(|_| self.identifier()); let language_server = self.language_server.clone(); // mark changes up to now as saved - self.reset_modified(); + let current_rev = self.get_current_revision(); + let doc_id = self.id(); let encoding = self.encoding; // We encode the file according to the `Document`'s encoding. - async move { + let future = async move { use tokio::fs::File; if let Some(parent) = path.parent() { // TODO: display a prompt asking the user if the directories should be created @@ -543,39 +571,34 @@ impl Document { } } - if let Some(fmt) = formatting { - match fmt.await { - Ok(transaction) => { - let success = transaction.changes().apply(&mut text); - if !success { - // This shouldn't happen, because the transaction changes were generated - // from the same text we're saving. - log::error!("failed to apply format changes before saving"); - } - } - Err(err) => { - // formatting failed: report error, and save file without modifications - log::error!("{}", err); - } - } - } - - let mut file = File::create(path).await?; + let mut file = File::create(&path).await?; to_writer(&mut file, encoding, &text).await?; + let event = DocumentSavedEvent { + revision: current_rev, + doc_id, + path, + text: text.clone(), + }; + if let Some(language_server) = language_server { if !language_server.is_initialized() { - return Ok(()); + return Ok(event); } - if let Some(notification) = - language_server.text_document_did_save(identifier, &text) - { - notification.await?; + + if let Some(identifier) = identifier { + if let Some(notification) = + language_server.text_document_did_save(identifier, &text) + { + notification.await?; + } } } - Ok(()) - } + Ok(event) + }; + + Ok(future) } /// Detect the programming language based on the file type. @@ -601,7 +624,7 @@ impl Document { } /// Reload the document from its path. - pub fn reload(&mut self, view_id: ViewId) -> Result<(), Error> { + pub fn reload(&mut self, view: &mut View) -> Result<(), Error> { let encoding = &self.encoding; let path = self.path().filter(|path| path.exists()); @@ -617,8 +640,8 @@ impl Document { // This is not considered a modification of the contents of the file regardless // of the encoding. let transaction = helix_core::diff::compare_ropes(self.text(), &rope); - self.apply(&transaction, view_id); - self.append_changes_to_history(view_id); + apply_transaction(&transaction, self, view); + self.append_changes_to_history(view.id); self.reset_modified(); self.detect_indent_and_line_ending(); @@ -810,6 +833,9 @@ impl Document { } /// Apply a [`Transaction`] to the [`Document`] to change its text. + /// Instead of calling this function directly, use [crate::apply_transaction] + /// to ensure that the transaction is applied to the appropriate [`View`] as + /// well. pub fn apply(&mut self, transaction: &Transaction, view_id: ViewId) -> bool { // store the state just before any changes are made. This allows us to undo to the // state just before a transaction was applied. @@ -831,11 +857,11 @@ impl Document { success } - fn undo_redo_impl(&mut self, view_id: ViewId, undo: bool) -> bool { + fn undo_redo_impl(&mut self, view: &mut View, undo: bool) -> bool { let mut history = self.history.take(); let txn = if undo { history.undo() } else { history.redo() }; let success = if let Some(txn) = txn { - self.apply_impl(txn, view_id) + self.apply_impl(txn, view.id) && view.apply(txn, self) } else { false }; @@ -849,26 +875,26 @@ impl Document { } /// Undo the last modification to the [`Document`]. Returns whether the undo was successful. - pub fn undo(&mut self, view_id: ViewId) -> bool { - self.undo_redo_impl(view_id, true) + pub fn undo(&mut self, view: &mut View) -> bool { + self.undo_redo_impl(view, true) } /// Redo the last modification to the [`Document`]. Returns whether the redo was successful. - pub fn redo(&mut self, view_id: ViewId) -> bool { - self.undo_redo_impl(view_id, false) + pub fn redo(&mut self, view: &mut View) -> bool { + self.undo_redo_impl(view, false) } pub fn savepoint(&mut self) { self.savepoint = Some(Transaction::new(self.text())); } - pub fn restore(&mut self, view_id: ViewId) { + pub fn restore(&mut self, view: &mut View) { if let Some(revert) = self.savepoint.take() { - self.apply(&revert, view_id); + apply_transaction(&revert, self, view); } } - fn earlier_later_impl(&mut self, view_id: ViewId, uk: UndoKind, earlier: bool) -> bool { + fn earlier_later_impl(&mut self, view: &mut View, uk: UndoKind, earlier: bool) -> bool { let txns = if earlier { self.history.get_mut().earlier(uk) } else { @@ -876,7 +902,7 @@ impl Document { }; let mut success = false; for txn in txns { - if self.apply_impl(&txn, view_id) { + if self.apply_impl(&txn, view.id) && view.apply(&txn, self) { success = true; } } @@ -888,13 +914,13 @@ impl Document { } /// Undo modifications to the [`Document`] according to `uk`. - pub fn earlier(&mut self, view_id: ViewId, uk: UndoKind) -> bool { - self.earlier_later_impl(view_id, uk, true) + pub fn earlier(&mut self, view: &mut View, uk: UndoKind) -> bool { + self.earlier_later_impl(view, uk, true) } /// Redo modifications to the [`Document`] according to `uk`. - pub fn later(&mut self, view_id: ViewId, uk: UndoKind) -> bool { - self.earlier_later_impl(view_id, uk, false) + pub fn later(&mut self, view: &mut View, uk: UndoKind) -> bool { + self.earlier_later_impl(view, uk, false) } /// Commit pending changes to history @@ -927,6 +953,12 @@ impl Document { let history = self.history.take(); let current_revision = history.current_revision(); self.history.set(history); + log::debug!( + "id {} modified - last saved: {}, current: {}", + self.id, + self.last_saved_revision, + current_revision + ); current_revision != self.last_saved_revision || !self.changes.is_empty() } @@ -938,6 +970,30 @@ impl Document { self.last_saved_revision = current_revision; } + /// Set the document's latest saved revision to the given one. + pub fn set_last_saved_revision(&mut self, rev: usize) { + log::debug!( + "doc {} revision updated {} -> {}", + self.id, + self.last_saved_revision, + rev + ); + self.last_saved_revision = rev; + } + + /// Get the document's latest saved revision. + pub fn get_last_saved_revision(&mut self) -> usize { + self.last_saved_revision + } + + /// Get the current revision number + pub fn get_current_revision(&mut self) -> usize { + let history = self.history.take(); + let current_revision = history.current_revision(); + self.history.set(history); + current_revision + } + /// Corresponding language scope name. Usually `source.`. pub fn language_scope(&self) -> Option<&str> { self.language @@ -996,14 +1052,6 @@ impl Document { .map_or(4, |config| config.tab_width) // fallback to 4 columns } - /// Returns a string containing a single level of indentation. - /// - /// TODO: we might not need this function anymore, since the information - /// is conveniently available in `Document::indent_style` now. - pub fn indent_unit(&self) -> &'static str { - self.indent_style.as_str() - } - pub fn changes(&self) -> &ChangeSet { &self.changes } @@ -1306,84 +1354,66 @@ mod test { ); } - macro_rules! test_decode { - ($label:expr, $label_override:expr) => { - let encoding = encoding::Encoding::for_label($label_override.as_bytes()).unwrap(); - let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/encoding"); - let path = base_path.join(format!("{}_in.txt", $label)); - let ref_path = base_path.join(format!("{}_in_ref.txt", $label)); - assert!(path.exists()); - assert!(ref_path.exists()); - - let mut file = std::fs::File::open(path).unwrap(); - let text = from_reader(&mut file, Some(encoding)) - .unwrap() - .0 - .to_string(); - let expectation = std::fs::read_to_string(ref_path).unwrap(); - assert_eq!(text[..], expectation[..]); - }; - } - - macro_rules! test_encode { - ($label:expr, $label_override:expr) => { - let encoding = encoding::Encoding::for_label($label_override.as_bytes()).unwrap(); - let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/encoding"); - let path = base_path.join(format!("{}_out.txt", $label)); - let ref_path = base_path.join(format!("{}_out_ref.txt", $label)); - assert!(path.exists()); - assert!(ref_path.exists()); - - let text = Rope::from_str(&std::fs::read_to_string(path).unwrap()); - let mut buf: Vec = Vec::new(); - helix_lsp::block_on(to_writer(&mut buf, encoding, &text)).unwrap(); - - let expectation = std::fs::read(ref_path).unwrap(); - assert_eq!(buf, expectation); - }; - } - - macro_rules! test_decode_fn { + macro_rules! decode { ($name:ident, $label:expr, $label_override:expr) => { #[test] fn $name() { - test_decode!($label, $label_override); + let encoding = encoding::Encoding::for_label($label_override.as_bytes()).unwrap(); + let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/encoding"); + let path = base_path.join(format!("{}_in.txt", $label)); + let ref_path = base_path.join(format!("{}_in_ref.txt", $label)); + assert!(path.exists()); + assert!(ref_path.exists()); + + let mut file = std::fs::File::open(path).unwrap(); + let text = from_reader(&mut file, Some(encoding)) + .unwrap() + .0 + .to_string(); + let expectation = std::fs::read_to_string(ref_path).unwrap(); + assert_eq!(text[..], expectation[..]); } }; ($name:ident, $label:expr) => { - #[test] - fn $name() { - test_decode!($label, $label); - } + decode!($name, $label, $label); }; } - macro_rules! test_encode_fn { + macro_rules! encode { ($name:ident, $label:expr, $label_override:expr) => { #[test] fn $name() { - test_encode!($label, $label_override); + let encoding = encoding::Encoding::for_label($label_override.as_bytes()).unwrap(); + let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/encoding"); + let path = base_path.join(format!("{}_out.txt", $label)); + let ref_path = base_path.join(format!("{}_out_ref.txt", $label)); + assert!(path.exists()); + assert!(ref_path.exists()); + + let text = Rope::from_str(&std::fs::read_to_string(path).unwrap()); + let mut buf: Vec = Vec::new(); + helix_lsp::block_on(to_writer(&mut buf, encoding, &text)).unwrap(); + + let expectation = std::fs::read(ref_path).unwrap(); + assert_eq!(buf, expectation); } }; ($name:ident, $label:expr) => { - #[test] - fn $name() { - test_encode!($label, $label); - } + encode!($name, $label, $label); }; } - test_decode_fn!(test_big5_decode, "big5"); - test_encode_fn!(test_big5_encode, "big5"); - test_decode_fn!(test_euc_kr_decode, "euc_kr", "EUC-KR"); - test_encode_fn!(test_euc_kr_encode, "euc_kr", "EUC-KR"); - test_decode_fn!(test_gb18030_decode, "gb18030"); - test_encode_fn!(test_gb18030_encode, "gb18030"); - test_decode_fn!(test_iso_2022_jp_decode, "iso_2022_jp", "ISO-2022-JP"); - test_encode_fn!(test_iso_2022_jp_encode, "iso_2022_jp", "ISO-2022-JP"); - test_decode_fn!(test_jis0208_decode, "jis0208", "EUC-JP"); - test_encode_fn!(test_jis0208_encode, "jis0208", "EUC-JP"); - test_decode_fn!(test_jis0212_decode, "jis0212", "EUC-JP"); - test_decode_fn!(test_shift_jis_decode, "shift_jis"); - test_encode_fn!(test_shift_jis_encode, "shift_jis"); + decode!(big5_decode, "big5"); + encode!(big5_encode, "big5"); + decode!(euc_kr_decode, "euc_kr", "EUC-KR"); + encode!(euc_kr_encode, "euc_kr", "EUC-KR"); + decode!(gb18030_decode, "gb18030"); + encode!(gb18030_encode, "gb18030"); + decode!(iso_2022_jp_decode, "iso_2022_jp", "ISO-2022-JP"); + encode!(iso_2022_jp_encode, "iso_2022_jp", "ISO-2022-JP"); + decode!(jis0208_decode, "jis0208", "EUC-JP"); + encode!(jis0208_encode, "jis0208", "EUC-JP"); + decode!(jis0212_decode, "jis0212", "EUC-JP"); + decode!(shift_jis_decode, "shift_jis"); + encode!(shift_jis_encode, "shift_jis"); } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index cbaa7d10..3903322a 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,16 +1,18 @@ use crate::{ + align_view, clipboard::{get_clipboard_provider, ClipboardProvider}, - document::Mode, + document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode}, graphics::{CursorKind, Rect}, info::Info, input::KeyEvent, theme::{self, Theme}, tree::{self, Tree}, - Document, DocumentId, View, ViewId, + Align, Document, DocumentId, View, ViewId, }; -use futures_util::future; use futures_util::stream::select_all::SelectAll; +use futures_util::{future, StreamExt}; +use helix_lsp::Call; use tokio_stream::wrappers::UnboundedReceiverStream; use std::{ @@ -28,7 +30,7 @@ use tokio::{ time::{sleep, Duration, Instant, Sleep}, }; -use anyhow::Error; +use anyhow::{anyhow, bail, Error}; pub use helix_core::diagnostic::Severity; pub use helix_core::register::Registers; @@ -65,7 +67,7 @@ where ) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct FilePickerConfig { /// IgnoreOptions @@ -124,6 +126,8 @@ pub struct Config { pub line_number: LineNumber, /// Highlight the lines cursors are currently on. Defaults to false. pub cursorline: bool, + /// Highlight the columns cursors are currently on. Defaults to false. + pub cursorcolumn: bool, /// Gutters. Default ["diagnostics", "line-numbers"] pub gutters: Vec, /// Middle click paste support. Defaults to true. @@ -136,6 +140,8 @@ pub struct Config { pub auto_completion: bool, /// Automatic formatting on save. Defaults to true. pub auto_format: bool, + /// Automatic save on focus lost. Defaults to false. + pub auto_save: bool, /// Time in milliseconds since last keypress before idle timers trigger. /// Used for autocompletion, set to 0 for instant. Defaults to 400ms. #[serde( @@ -170,7 +176,7 @@ pub struct Config { pub color_modes: bool, } -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] pub struct TerminalConfig { pub command: String, @@ -223,7 +229,7 @@ pub fn get_terminal_provider() -> Option { None } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] pub struct LspConfig { /// Display LSP progress messages below statusline @@ -244,7 +250,7 @@ impl Default for LspConfig { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct SearchConfig { /// Smart case: Case insensitive searching unless pattern contains upper case characters. Defaults to true. @@ -253,7 +259,7 @@ pub struct SearchConfig { pub wrap_around: bool, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct StatusLineConfig { pub left: Vec, @@ -277,7 +283,7 @@ impl Default for StatusLineConfig { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct ModeConfig { pub normal: String, @@ -456,7 +462,7 @@ impl std::str::FromStr for GutterType { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct WhitespaceConfig { pub render: WhitespaceRender, @@ -566,6 +572,7 @@ pub struct IndentGuidesConfig { pub character: char, pub rainbow: RainbowIndentOptions, pub skip_levels: u16, + pub skip_levels: u8, } impl Default for IndentGuidesConfig { @@ -592,11 +599,13 @@ impl Default for Config { }, line_number: LineNumber::Absolute, cursorline: false, + cursorcolumn: false, gutters: vec![GutterType::Diagnostics, GutterType::LineNumbers], middle_click_paste: true, auto_pairs: AutoPairConfig::default(), auto_completion: true, auto_format: true, + auto_save: false, idle_timeout: Duration::from_millis(400), completion_trigger_len: 2, auto_info: true, @@ -650,12 +659,21 @@ pub struct Breakpoint { pub log_message: Option, } +use futures_util::stream::{Flatten, Once}; + pub struct Editor { /// Current editing mode. pub mode: Mode, pub tree: Tree, pub next_document_id: DocumentId, pub documents: BTreeMap, + + // We Flatten<> to resolve the inner DocumentSavedEventFuture. For that we need a stream of streams, hence the Once<>. + // https://stackoverflow.com/a/66875668 + pub saves: HashMap>>, + pub save_queue: SelectAll>>>, + pub write_count: usize, + pub count: Option, pub selected_register: Option, pub registers: Registers, @@ -695,6 +713,15 @@ pub struct Editor { pub config_events: (UnboundedSender, UnboundedReceiver), } +#[derive(Debug)] +pub enum EditorEvent { + DocumentSaved(DocumentSavedEventResult), + ConfigEvent(ConfigEvent), + LanguageServerMessage((usize, Call)), + DebuggerEvent(dap::Payload), + IdleTimer, +} + #[derive(Debug, Clone)] pub enum ConfigEvent { Refresh, @@ -726,6 +753,8 @@ pub enum CloseError { DoesNotExist, /// Buffer is modified BufferModified(String), + /// Document failed to save + SaveError(anyhow::Error), } impl Editor { @@ -746,6 +775,9 @@ impl Editor { tree: Tree::new(area), next_document_id: DocumentId::default(), documents: BTreeMap::new(), + saves: HashMap::new(), + save_queue: SelectAll::new(), + write_count: 0, count: None, selected_register: None, macro_recording: None, @@ -811,12 +843,16 @@ impl Editor { #[inline] pub fn set_status>>(&mut self, status: T) { - self.status_msg = Some((status.into(), Severity::Info)); + let status = status.into(); + log::debug!("editor status: {}", status); + self.status_msg = Some((status, Severity::Info)); } #[inline] pub fn set_error>>(&mut self, error: T) { - self.status_msg = Some((error.into(), Severity::Error)); + let error = error.into(); + log::error!("editor error: {}", error); + self.status_msg = Some((error, Severity::Error)); } #[inline] @@ -936,13 +972,7 @@ impl Editor { let doc = doc_mut!(self, &doc_id); doc.ensure_view_init(view.id); - // TODO: reuse align_view - let pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - let line = doc.text().char_to_line(pos); - view.offset.row = line.saturating_sub(view.inner_area().height as usize / 2); + align_view(doc, view, Align::Center); } pub fn switch(&mut self, id: DocumentId, action: Action) { @@ -1041,6 +1071,13 @@ impl Editor { DocumentId(unsafe { NonZeroUsize::new_unchecked(self.next_document_id.0.get() + 1) }); doc.id = id; self.documents.insert(id, doc); + + let (save_sender, save_receiver) = tokio::sync::mpsc::unbounded_channel(); + self.saves.insert(id, save_sender); + + let stream = UnboundedReceiverStream::new(save_receiver).flatten(); + self.save_queue.push(stream); + id } @@ -1087,16 +1124,19 @@ impl Editor { } pub fn close_document(&mut self, doc_id: DocumentId, force: bool) -> Result<(), CloseError> { - let doc = match self.documents.get(&doc_id) { + let doc = match self.documents.get_mut(&doc_id) { Some(doc) => doc, None => return Err(CloseError::DoesNotExist), }; - if !force && doc.is_modified() { return Err(CloseError::BufferModified(doc.display_name().into_owned())); } + // This will also disallow any follow-up writes + self.saves.remove(&doc_id); + if let Some(language_server) = doc.language_server() { + // TODO: track error tokio::spawn(language_server.text_document_did_close(doc.identifier())); } @@ -1159,6 +1199,32 @@ impl Editor { Ok(()) } + pub fn save>( + &mut self, + doc_id: DocumentId, + path: Option

, + force: bool, + ) -> anyhow::Result<()> { + // convert a channel of futures to pipe into main queue one by one + // via stream.then() ? then push into main future + + let path = path.map(|path| path.into()); + let doc = doc_mut!(self, &doc_id); + let future = doc.save(path, force)?; + + use futures_util::stream; + + self.saves + .get(&doc_id) + .ok_or_else(|| anyhow::format_err!("saves are closed for this document!"))? + .send(stream::once(Box::pin(future))) + .map_err(|err| anyhow!("failed to send save event: {}", err))?; + + self.write_count += 1; + + Ok(()) + } + pub fn resize(&mut self, area: Rect) { if self.tree.resize(area) { self._refresh(); @@ -1259,14 +1325,14 @@ impl Editor { } } - /// Closes language servers with timeout. The default timeout is 500 ms, use + /// Closes language servers with timeout. The default timeout is 10000 ms, use /// `timeout` parameter to override this. pub async fn close_language_servers( &self, timeout: Option, ) -> Result<(), tokio::time::error::Elapsed> { tokio::time::timeout( - Duration::from_millis(timeout.unwrap_or(500)), + Duration::from_millis(timeout.unwrap_or(3000)), future::join_all( self.language_servers .iter_clients() @@ -1276,4 +1342,48 @@ impl Editor { .await .map(|_| ()) } + + pub async fn wait_event(&mut self) -> EditorEvent { + tokio::select! { + biased; + + Some(event) = self.save_queue.next() => { + self.write_count -= 1; + EditorEvent::DocumentSaved(event) + } + Some(config_event) = self.config_events.1.recv() => { + EditorEvent::ConfigEvent(config_event) + } + Some(message) = self.language_servers.incoming.next() => { + EditorEvent::LanguageServerMessage(message) + } + Some(event) = self.debugger_events.next() => { + EditorEvent::DebuggerEvent(event) + } + _ = &mut self.idle_timer => { + EditorEvent::IdleTimer + } + } + } + + pub async fn flush_writes(&mut self) -> anyhow::Result<()> { + while self.write_count > 0 { + if let Some(save_event) = self.save_queue.next().await { + self.write_count -= 1; + + let save_event = match save_event { + Ok(event) => event, + Err(err) => { + self.set_error(err.to_string()); + bail!(err); + } + }; + + let doc = doc_mut!(self, &save_event.doc_id); + doc.set_last_saved_revision(save_event.revision); + } + } + + Ok(()) + } } diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs index fb3c8b3f..cbae873a 100644 --- a/helix-view/src/graphics.rs +++ b/helix-view/src/graphics.rs @@ -95,31 +95,19 @@ pub struct Rect { } impl Rect { - /// Creates a new rect, with width and height limited to keep the area under max u16. - /// If clipped, aspect ratio will be preserved. + /// Creates a new rect, with width and height pub fn new(x: u16, y: u16, width: u16, height: u16) -> Rect { - let max_area = u16::max_value(); - let (clipped_width, clipped_height) = - if u32::from(width) * u32::from(height) > u32::from(max_area) { - let aspect_ratio = f64::from(width) / f64::from(height); - let max_area_f = f64::from(max_area); - let height_f = (max_area_f / aspect_ratio).sqrt(); - let width_f = height_f * aspect_ratio; - (width_f as u16, height_f as u16) - } else { - (width, height) - }; Rect { x, y, - width: clipped_width, - height: clipped_height, + width, + height, } } #[inline] - pub fn area(self) -> u16 { - self.width * self.height + pub fn area(self) -> usize { + (self.width as usize) * (self.height as usize) } #[inline] @@ -315,6 +303,44 @@ impl From for crossterm::style::Color { } } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum UnderlineStyle { + Reset, + Line, + Curl, + Dotted, + Dashed, + DoubleLine, +} + +impl FromStr for UnderlineStyle { + type Err = &'static str; + + fn from_str(modifier: &str) -> Result { + match modifier { + "line" => Ok(Self::Line), + "curl" => Ok(Self::Curl), + "dotted" => Ok(Self::Dotted), + "dashed" => Ok(Self::Dashed), + "double_line" => Ok(Self::DoubleLine), + _ => Err("Invalid underline style"), + } + } +} + +impl From for crossterm::style::Attribute { + fn from(style: UnderlineStyle) -> Self { + match style { + UnderlineStyle::Line => crossterm::style::Attribute::Underlined, + UnderlineStyle::Curl => crossterm::style::Attribute::Undercurled, + UnderlineStyle::Dotted => crossterm::style::Attribute::Underdotted, + UnderlineStyle::Dashed => crossterm::style::Attribute::Underdashed, + UnderlineStyle::DoubleLine => crossterm::style::Attribute::DoubleUnderlined, + UnderlineStyle::Reset => crossterm::style::Attribute::NoUnderline, + } + } +} + bitflags! { /// Modifier changes the way a piece of text is displayed. /// @@ -332,7 +358,6 @@ bitflags! { const BOLD = 0b0000_0000_0001; const DIM = 0b0000_0000_0010; const ITALIC = 0b0000_0000_0100; - const UNDERLINED = 0b0000_0000_1000; const SLOW_BLINK = 0b0000_0001_0000; const RAPID_BLINK = 0b0000_0010_0000; const REVERSED = 0b0000_0100_0000; @@ -349,7 +374,6 @@ impl FromStr for Modifier { "bold" => Ok(Self::BOLD), "dim" => Ok(Self::DIM), "italic" => Ok(Self::ITALIC), - "underlined" => Ok(Self::UNDERLINED), "slow_blink" => Ok(Self::SLOW_BLINK), "rapid_blink" => Ok(Self::RAPID_BLINK), "reversed" => Ok(Self::REVERSED), @@ -375,7 +399,7 @@ impl FromStr for Modifier { /// just S3. /// /// ```rust -/// # use helix_view::graphics::{Rect, Color, Modifier, Style}; +/// # use helix_view::graphics::{Rect, Color, UnderlineStyle, Modifier, Style}; /// # use helix_tui::buffer::Buffer; /// let styles = [ /// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC), @@ -391,6 +415,8 @@ impl FromStr for Modifier { /// fg: Some(Color::Yellow), /// bg: Some(Color::Red), /// add_modifier: Modifier::BOLD, +/// underline_color: Some(Color::Reset), +/// underline_style: Some(UnderlineStyle::Reset), /// sub_modifier: Modifier::empty(), /// }, /// buffer[(0, 0)].style(), @@ -401,7 +427,7 @@ impl FromStr for Modifier { /// reset all properties until that point use [`Style::reset`]. /// /// ``` -/// # use helix_view::graphics::{Rect, Color, Modifier, Style}; +/// # use helix_view::graphics::{Rect, Color, UnderlineStyle, Modifier, Style}; /// # use helix_tui::buffer::Buffer; /// let styles = [ /// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC), @@ -415,6 +441,8 @@ impl FromStr for Modifier { /// Style { /// fg: Some(Color::Yellow), /// bg: Some(Color::Reset), +/// underline_color: Some(Color::Reset), +/// underline_style: Some(UnderlineStyle::Reset), /// add_modifier: Modifier::empty(), /// sub_modifier: Modifier::empty(), /// }, @@ -426,6 +454,8 @@ impl FromStr for Modifier { pub struct Style { pub fg: Option, pub bg: Option, + pub underline_color: Option, + pub underline_style: Option, pub add_modifier: Modifier, pub sub_modifier: Modifier, } @@ -435,6 +465,8 @@ impl Default for Style { Style { fg: None, bg: None, + underline_color: None, + underline_style: None, add_modifier: Modifier::empty(), sub_modifier: Modifier::empty(), } @@ -447,6 +479,8 @@ impl Style { Style { fg: Some(Color::Reset), bg: Some(Color::Reset), + underline_color: None, + underline_style: None, add_modifier: Modifier::empty(), sub_modifier: Modifier::all(), } @@ -482,6 +516,36 @@ impl Style { self } + /// Changes the underline color. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_view::graphics::{Color, Style}; + /// let style = Style::default().underline_color(Color::Blue); + /// let diff = Style::default().underline_color(Color::Red); + /// assert_eq!(style.patch(diff), Style::default().underline_color(Color::Red)); + /// ``` + pub fn underline_color(mut self, color: Color) -> Style { + self.underline_color = Some(color); + self + } + + /// Changes the underline style. + /// + /// ## Examples + /// + /// ```rust + /// # use helix_view::graphics::{UnderlineStyle, Style}; + /// let style = Style::default().underline_style(UnderlineStyle::Line); + /// let diff = Style::default().underline_style(UnderlineStyle::Curl); + /// assert_eq!(style.patch(diff), Style::default().underline_style(UnderlineStyle::Curl)); + /// ``` + pub fn underline_style(mut self, style: UnderlineStyle) -> Style { + self.underline_style = Some(style); + self + } + /// Changes the text emphasis. /// /// When applied, it adds the given modifier to the `Style` modifiers. @@ -538,6 +602,8 @@ impl Style { pub fn patch(mut self, other: Style) -> Style { self.fg = other.fg.or(self.fg); self.bg = other.bg.or(self.bg); + self.underline_color = other.underline_color.or(self.underline_color); + self.underline_style = other.underline_style.or(self.underline_style); self.add_modifier.remove(other.sub_modifier); self.add_modifier.insert(other.add_modifier); @@ -552,33 +618,6 @@ impl Style { mod tests { use super::*; - #[test] - fn test_rect_size_truncation() { - for width in 256u16..300u16 { - for height in 256u16..300u16 { - let rect = Rect::new(0, 0, width, height); - rect.area(); // Should not panic. - assert!(rect.width < width || rect.height < height); - // The target dimensions are rounded down so the math will not be too precise - // but let's make sure the ratios don't diverge crazily. - assert!( - (f64::from(rect.width) / f64::from(rect.height) - - f64::from(width) / f64::from(height)) - .abs() - < 1.0 - ) - } - } - - // One dimension below 255, one above. Area above max u16. - let width = 900; - let height = 100; - let rect = Rect::new(0, 0, width, height); - assert_ne!(rect.width, 900); - assert_ne!(rect.height, 100); - assert!(rect.width < width || rect.height < height); - } - #[test] fn test_rect_size_preservation() { for width in 0..256u16 { diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index ab0e2986..2c207d27 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use crate::{ - graphics::{Color, Modifier, Style}, + graphics::{Color, Style, UnderlineStyle}, Document, Editor, Theme, View, }; @@ -147,7 +147,7 @@ pub fn breakpoints<'doc>( .find(|breakpoint| breakpoint.line == line)?; let mut style = if breakpoint.condition.is_some() && breakpoint.log_message.is_some() { - error.add_modifier(Modifier::UNDERLINED) + error.underline_style(UnderlineStyle::Line) } else if breakpoint.condition.is_some() { error } else if breakpoint.log_message.is_some() { diff --git a/helix-view/src/handlers/dap.rs b/helix-view/src/handlers/dap.rs index e39584c3..2e86871b 100644 --- a/helix-view/src/handlers/dap.rs +++ b/helix-view/src/handlers/dap.rs @@ -262,7 +262,7 @@ impl Editor { log::info!("{}", output); self.set_status(format!("{} {}", prefix, output)); } - Event::Initialized => { + Event::Initialized(_) => { // send existing breakpoints for (path, breakpoints) in &mut self.breakpoints { // TODO: call futures in parallel, await all diff --git a/helix-view/src/info.rs b/helix-view/src/info.rs index 5ad6a60c..3080cf8e 100644 --- a/helix-view/src/info.rs +++ b/helix-view/src/info.rs @@ -16,7 +16,11 @@ pub struct Info { } impl Info { - pub fn new(title: &str, body: Vec<(String, String)>) -> Self { + pub fn new(title: &str, body: &[(T, U)]) -> Self + where + T: AsRef, + U: AsRef, + { if body.is_empty() { return Self { title: title.to_string(), @@ -26,11 +30,21 @@ impl Info { }; } - let item_width = body.iter().map(|(item, _)| item.width()).max().unwrap(); + let item_width = body + .iter() + .map(|(item, _)| item.as_ref().width()) + .max() + .unwrap(); let mut text = String::new(); - for (item, desc) in &body { - let _ = writeln!(text, "{:width$} {}", item, desc, width = item_width); + for (item, desc) in body { + let _ = writeln!( + text, + "{:width$} {}", + item.as_ref(), + desc.as_ref(), + width = item_width + ); } Self { @@ -42,19 +56,19 @@ impl Info { } pub fn from_keymap(title: &str, body: Vec<(&str, BTreeSet)>) -> Self { - let body = body + let body: Vec<_> = body .into_iter() .map(|(desc, events)| { let events = events.iter().map(ToString::to_string).collect::>(); - (events.join(", "), desc.to_string()) + (events.join(", "), desc) }) .collect(); - Self::new(title, body) + Self::new(title, &body) } pub fn from_registers(registers: &Registers) -> Self { - let body = registers + let body: Vec<_> = registers .inner() .iter() .map(|(ch, reg)| { @@ -62,13 +76,12 @@ impl Info { .read() .get(0) .and_then(|s| s.lines().next()) - .map(String::from) .unwrap_or_default(); (ch.to_string(), content) }) .collect(); - let mut infobox = Self::new("Registers", body); + let mut infobox = Self::new("Registers", &body); infobox.width = 30; // copied content could be very long infobox } diff --git a/helix-view/src/input.rs b/helix-view/src/input.rs index 083a1e08..30fa72c4 100644 --- a/helix-view/src/input.rs +++ b/helix-view/src/input.rs @@ -14,6 +14,7 @@ pub enum Event { Mouse(MouseEvent), Paste(String), Resize(u16, u16), + IdleTimeout, } #[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)] diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index 788304bc..276be441 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -53,17 +53,30 @@ pub fn align_view(doc: &Document, view: &mut View, align: Align) { .cursor(doc.text().slice(..)); let line = doc.text().char_to_line(pos); - let height = view.inner_area().height as usize; + let last_line_height = view.inner_area().height.saturating_sub(1) as usize; let relative = match align { - Align::Center => height / 2, + Align::Center => last_line_height / 2, Align::Top => 0, - Align::Bottom => height, + Align::Bottom => last_line_height, }; view.offset.row = line.saturating_sub(relative); } +/// Applies a [`helix_core::Transaction`] to the given [`Document`] +/// and [`View`]. +pub fn apply_transaction( + transaction: &helix_core::Transaction, + doc: &mut Document, + view: &mut View, +) -> bool { + // This is a short function but it's easy to call `Document::apply` + // without calling `View::apply` or in the wrong order. The transaction + // must be applied to the document before the view. + doc.apply(transaction, view.id) && view.apply(transaction, doc) +} + pub use document::Document; pub use editor::Editor; pub use theme::Theme; diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index 514d7839..ed8b09e2 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -11,21 +11,17 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Deserializer}; use toml::{map::Map, Value}; +use crate::graphics::UnderlineStyle; pub use crate::graphics::{Color, Modifier, Style}; -pub static DEFAULT_THEME: Lazy = Lazy::new(|| { - // let raw_theme: Value = toml::from_slice(include_bytes!("../../theme.toml")) - // .expect("Failed to parse default theme"); - // Theme::from(raw_theme) - - toml::from_slice(include_bytes!("../../theme.toml")).expect("Failed to parse default theme") +pub static DEFAULT_THEME: Lazy = Lazy::new(|| Theme { + name: "default".into(), + ..toml::from_slice(include_bytes!("../../theme.toml")).expect("Failed to parse default theme") }); -pub static BASE16_DEFAULT_THEME: Lazy = Lazy::new(|| { - // let raw_theme: Value = toml::from_slice(include_bytes!("../../base16_theme.toml")) - // .expect("Failed to parse base 16 default theme"); - // Theme::from(raw_theme) - toml::from_slice(include_bytes!("../../base16_theme.toml")) +pub static BASE16_DEFAULT_THEME: Lazy = Lazy::new(|| Theme { + name: "base16_theme".into(), + ..toml::from_slice(include_bytes!("../../base16_theme.toml")) .expect("Failed to parse base 16 default theme") }); @@ -52,7 +48,12 @@ impl Loader { return Ok(self.base16_default()); } - self.load_theme(name, name, false).map(Theme::from) + let theme = self.load_theme(name, name, false).map(Theme::from)?; + + Ok(Theme { + name: name.into(), + ..theme + }) } // load the theme and its parent recursively and merge them @@ -179,8 +180,10 @@ impl Loader { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct Theme { + name: String, + // UI styles are stored in a HashMap styles: HashMap, // tree-sitter highlight styles are stored in a Vec to optimize lookups @@ -201,6 +204,7 @@ impl From for Theme { scopes, rainbow_length, highlights, + ..Default::default() } } } @@ -269,6 +273,7 @@ impl<'de> Deserialize<'de> for Theme { scopes, rainbow_length, highlights, + ..Default::default() }) } } @@ -340,6 +345,10 @@ impl Theme { self.highlights[index] } + pub fn name(&self) -> &str { + &self.name + } + pub fn get(&self, scope: &str) -> Style { self.try_get(scope).unwrap_or_default() } @@ -352,6 +361,13 @@ impl Theme { .find_map(|s| self.styles.get(s).copied()) } + /// Get the style of a scope, without falling back to dot separated broader + /// scopes. For example if `ui.text.focus` is not defined in the theme, it + /// will return `None`, even if `ui.text` is. + pub fn try_get_exact(&self, scope: &str) -> Option