Merge branch 'master' into sticky-context

pull/6118/merge^2
SoraTenshi 1 year ago
commit 641006df12
No known key found for this signature in database

@ -85,6 +85,7 @@ jobs:
rust: stable
target: x86_64-pc-windows-msvc
cross: false
# 23.03: build issues
- build: aarch64-macos
os: macos-latest
rust: stable
@ -113,6 +114,12 @@ jobs:
mkdir -p runtime/grammars/sources
tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources
# The rust-toolchain action ignores rust-toolchain.toml files.
# Removing this before building with cargo ensures that the rust-toolchain
# is considered the same between installation and usage.
- name: Remove the rust-toolchain.toml file
run: rm rust-toolchain.toml
- name: Install ${{ matrix.rust }} toolchain
uses: dtolnay/rust-toolchain@master
with:
@ -155,6 +162,10 @@ jobs:
shell: bash
if: matrix.build == 'aarch64-linux' || matrix.build == 'x86_64-linux'
run: |
# Required as of 22.x https://github.com/AppImage/AppImageKit/wiki/FUSE
sudo add-apt-repository universe
sudo apt install libfuse2
mkdir dist
name=dev
@ -244,7 +255,7 @@ jobs:
exe=".exe"
fi
pkgname=helix-$GITHUB_REF_NAME-$platform
mkdir $pkgname
mkdir -p $pkgname
cp $source/LICENSE $source/README.md $pkgname
mkdir $pkgname/contrib
cp -r $source/contrib/completion $pkgname/contrib

@ -1,3 +1,393 @@
# 23.05 (2023-05-18)
23.05 is a smaller release focusing on fixes. There were 88 contributors in this release. Thank you all!
Features:
- Add a config option to exclude declaration from LSP references request ([#6886](https://github.com/helix-editor/helix/pull/6886))
- Enable injecting languages based on their file extension and shebang ([#3970](https://github.com/helix-editor/helix/pull/3970))
- Sort the buffer picker by most recent access ([#2980](https://github.com/helix-editor/helix/pull/2980))
- Perform syntax highlighting in the picker asynchronously ([#7028](https://github.com/helix-editor/helix/pull/7028))
Commands:
- `:update` is now aliased as `:u` ([#6835](https://github.com/helix-editor/helix/pull/6835))
- Add `extend_to_first_nonwhitespace` which acts the same as `goto_first_nonwhitespace` but always extends ([#6837](https://github.com/helix-editor/helix/pull/6837))
- Add `:clear-register` for clearing the given register or all registers ([#5695](https://github.com/helix-editor/helix/pull/5695))
- Add `:write-buffer-close` and `:write-buffer-close!` ([#6947](https://github.com/helix-editor/helix/pull/6947))
Fixes:
- Normalize LSP workspace paths ([#6517](https://github.com/helix-editor/helix/pull/6517))
- Robustly handle invalid LSP ranges ([#6512](https://github.com/helix-editor/helix/pull/6512))
- Fix line number display for LSP goto pickers ([#6559](https://github.com/helix-editor/helix/pull/6559))
- Fix toggling of `soft-wrap.enable` option ([#6656](https://github.com/helix-editor/helix/pull/6656), [58e457a](https://github.com/helix-editor/helix/commit/58e457a), [#6742](https://github.com/helix-editor/helix/pull/6742))
- Handle `workspace/configuration` requests from stopped language servers ([#6693](https://github.com/helix-editor/helix/pull/6693))
- Fix possible crash from opening the jumplist picker ([#6672](https://github.com/helix-editor/helix/pull/6672))
- Fix theme preview returning to current theme on line and word deletions ([#6694](https://github.com/helix-editor/helix/pull/6694))
- Re-run crate build scripts on changes to revision and grammar repositories ([#6743](https://github.com/helix-editor/helix/pull/6743))
- Fix crash on opening from suspended state ([#6764](https://github.com/helix-editor/helix/pull/6764))
- Fix unwrap bug in DAP ([#6786](https://github.com/helix-editor/helix/pull/6786))
- Always build tree-sitter parsers with C++14 and C11 ([#6792](https://github.com/helix-editor/helix/pull/6792), [#6834](https://github.com/helix-editor/helix/pull/6834), [#6845](https://github.com/helix-editor/helix/pull/6845))
- Exit with a non-zero statuscode when tree-sitter parser builds fail ([#6795](https://github.com/helix-editor/helix/pull/6795))
- Flip symbol range in LSP goto commands ([#6794](https://github.com/helix-editor/helix/pull/6794))
- Fix runtime toggling of the `mouse` option ([#6675](https://github.com/helix-editor/helix/pull/6675))
- Fix panic in inlay hint computation when view anchor is out of bounds ([#6883](https://github.com/helix-editor/helix/pull/6883))
- Significantly improve performance of git discovery on slow file systems ([#6890](https://github.com/helix-editor/helix/pull/6890))
- Downgrade gix log level to info ([#6915](https://github.com/helix-editor/helix/pull/6915))
- Conserve BOM and properly support saving UTF16 files ([#6497](https://github.com/helix-editor/helix/pull/6497))
- Correctly handle completion re-request ([#6594](https://github.com/helix-editor/helix/pull/6594))
- Fix offset encoding in LSP `didChange` notifications ([#6921](https://github.com/helix-editor/helix/pull/6921))
- Change `gix` logging level to info ([#6915](https://github.com/helix-editor/helix/pull/6915))
- Improve error message when writes fail because parent directories do not exist ([#7014](https://github.com/helix-editor/helix/pull/7014))
- Replace DAP variables popup instead of pushing more popups ([#7034](https://github.com/helix-editor/helix/pull/7034))
- Disable tree-sitter for files after parsing for 500ms ([#7028](https://github.com/helix-editor/helix/pull/7028))
- Fix crash when deleting with multiple cursors ([#6024](https://github.com/helix-editor/helix/pull/6024))
- Fix selection sliding when deleting forwards in append mode ([#6024](https://github.com/helix-editor/helix/pull/6024))
- Fix completion on paths containing spaces ([#6779](https://github.com/helix-editor/helix/pull/6779))
Themes:
- Style inlay hints in `dracula` theme ([#6515](https://github.com/helix-editor/helix/pull/6515))
- Style inlay hints in `onedark` theme ([#6503](https://github.com/helix-editor/helix/pull/6503))
- Style inlay hints and the soft-wrap indicator in `varua` ([#6568](https://github.com/helix-editor/helix/pull/6568), [#6589](https://github.com/helix-editor/helix/pull/6589))
- Style inlay hints in `emacs` theme ([#6569](https://github.com/helix-editor/helix/pull/6569))
- Update `base16_transparent` and `dark_high_contrast` themes ([#6577](https://github.com/helix-editor/helix/pull/6577))
- Style inlay hints for `mellow` and `rasmus` themes ([#6583](https://github.com/helix-editor/helix/pull/6583))
- Dim pane divider for `base16_transparent` theme ([#6534](https://github.com/helix-editor/helix/pull/6534))
- Style inlay hints in `zenburn` theme ([#6593](https://github.com/helix-editor/helix/pull/6593))
- Style inlay hints in `boo_berry` theme ([#6625](https://github.com/helix-editor/helix/pull/6625))
- Add `ferra` theme ([#6619](https://github.com/helix-editor/helix/pull/6619), [#6776](https://github.com/helix-editor/helix/pull/6776))
- Style inlay hints in `nightfox` theme ([#6655](https://github.com/helix-editor/helix/pull/6655))
- Fix `ayu` theme family markup code block background ([#6538](https://github.com/helix-editor/helix/pull/6538))
- Improve whitespace and search match colors in `rose_pine` theme ([#6679](https://github.com/helix-editor/helix/pull/6679))
- Highlight selected items in `base16_transparent` theme ([#6716](https://github.com/helix-editor/helix/pull/6716))
- Adjust everforest to resemble original more closely ([#5866](https://github.com/helix-editor/helix/pull/5866))
- Refactor `dracula` theme ([#6552](https://github.com/helix-editor/helix/pull/6552), [#6767](https://github.com/helix-editor/helix/pull/6767), [#6855](https://github.com/helix-editor/helix/pull/6855), [#6987](https://github.com/helix-editor/helix/pull/6987))
- Style inlay hints in `darcula` theme ([#6732](https://github.com/helix-editor/helix/pull/6732))
- Style inlay hints in `kanagawa` theme ([#6773](https://github.com/helix-editor/helix/pull/6773))
- Improve `ayu_dark` theme ([#6622](https://github.com/helix-editor/helix/pull/6622))
- Refactor `noctis` theme multiple cursor highlighting ([96720e7](https://github.com/helix-editor/helix/commit/96720e7))
- Refactor `noctis` theme whitespace rendering and indent guides ([f2ccc03](https://github.com/helix-editor/helix/commit/f2ccc03))
- Add `amberwood` theme ([#6924](https://github.com/helix-editor/helix/pull/6924))
- Update `nightfox` theme ([#7061](https://github.com/helix-editor/helix/pull/7061))
Language support:
- R language server: use the `--no-echo` flag to silence output ([#6570](https://github.com/helix-editor/helix/pull/6570))
- Recognize CUDA files as C++ ([#6521](https://github.com/helix-editor/helix/pull/6521))
- Add support for Hurl ([#6450](https://github.com/helix-editor/helix/pull/6450))
- Add textobject queries for Julia ([#6588](https://github.com/helix-editor/helix/pull/6588))
- Update Ruby highlight queries ([#6587](https://github.com/helix-editor/helix/pull/6587))
- Add xsd to XML file-types ([#6631](https://github.com/helix-editor/helix/pull/6631))
- Support Robot Framework ([#6611](https://github.com/helix-editor/helix/pull/6611))
- Update Gleam tree-sitter parser ([#6641](https://github.com/helix-editor/helix/pull/6641))
- Update git-commit tree-sitter parser ([#6692](https://github.com/helix-editor/helix/pull/6692))
- Update Haskell tree-sitter parser ([#6317](https://github.com/helix-editor/helix/pull/6317))
- Add injection queries for Haskell quasiquotes ([#6474](https://github.com/helix-editor/helix/pull/6474))
- Highlight C/C++ escape sequences ([#6724](https://github.com/helix-editor/helix/pull/6724))
- Support Markdoc ([#6432](https://github.com/helix-editor/helix/pull/6432))
- Support OpenCL ([#6473](https://github.com/helix-editor/helix/pull/6473))
- Support DTD ([#6644](https://github.com/helix-editor/helix/pull/6644))
- Fix constant highlighting in Python queries ([#6751](https://github.com/helix-editor/helix/pull/6751))
- Support Just ([#6453](https://github.com/helix-editor/helix/pull/6453))
- Fix Go locals query for `var_spec` identifiers ([#6763](https://github.com/helix-editor/helix/pull/6763))
- Update Markdown tree-sitter parser ([#6785](https://github.com/helix-editor/helix/pull/6785))
- Fix Haskell workspace root for cabal projects ([#6828](https://github.com/helix-editor/helix/pull/6828))
- Avoid extra indentation in Go switches ([#6817](https://github.com/helix-editor/helix/pull/6817))
- Fix Go workspace roots ([#6884](https://github.com/helix-editor/helix/pull/6884))
- Set PerlNavigator as the default Perl language server ([#6860](https://github.com/helix-editor/helix/pull/6860))
- Highlight more sqlx macros in Rust ([#6793](https://github.com/helix-editor/helix/pull/6793))
- Switch Odin tree-sitter grammar ([#6766](https://github.com/helix-editor/helix/pull/6766))
- Recognize `poetry.lock` as TOML ([#6928](https://github.com/helix-editor/helix/pull/6928))
- Recognize Jupyter notebooks as JSON ([#6927](https://github.com/helix-editor/helix/pull/6927))
- Add language server configuration for Crystal ([#6948](https://github.com/helix-editor/helix/pull/6948))
- Add `build.gradle.kts` to Java and Scala roots ([#6970](https://github.com/helix-editor/helix/pull/6970))
- Recognize `sty` and `cls` files as latex ([#6986](https://github.com/helix-editor/helix/pull/6986))
- Update Dockerfile tree-sitter grammar ([#6895](https://github.com/helix-editor/helix/pull/6895))
- Add comment injections for Odin ([#7027](https://github.com/helix-editor/helix/pull/7027))
- Recognize `gml` as XML ([#7055](https://github.com/helix-editor/helix/pull/7055))
- Recognize `geojson` as JSON ([#7054](https://github.com/helix-editor/helix/pull/7054))
Packaging:
- Update the Nix flake dependencies, remove a deprecated option ([#6546](https://github.com/helix-editor/helix/pull/6546))
- Fix and re-enable aarch64-macos release binary builds ([#6504](https://github.com/helix-editor/helix/pull/6504))
- The git dependency on `tree-sitter` has been replaced with a regular crates.io dependency ([#6608](https://github.com/helix-editor/helix/pull/6608))
# 23.03 (2023-03-31)
23.03 brings some long-awaited and exciting features. Thank you to everyone involved! This release saw changes from 102 contributors.
For the full log, check out the [git log](https://github.com/helix-editor/helix/compare/22.12..23.03).
Also check out the [release notes](https://helix-editor.com/news/release-23-03-highlights/) for more commentary on larger features.
Breaking changes:
- Select diagnostic range in `goto_*_diag` commands ([#4713](https://github.com/helix-editor/helix/pull/4713), [#5164](https://github.com/helix-editor/helix/pull/5164), [#6193](https://github.com/helix-editor/helix/pull/6193))
- Remove jump behavior from `increment`/`decrement` ([#4123](https://github.com/helix-editor/helix/pull/4123), [#5929](https://github.com/helix-editor/helix/pull/5929))
- Select change range in `goto_*_change` commands ([#5206](https://github.com/helix-editor/helix/pull/5206))
- Split file modification indicator from filename statusline elements ([#4731](https://github.com/helix-editor/helix/pull/4731), [#6036](https://github.com/helix-editor/helix/pull/6036))
- Jump to symbol ranges in LSP goto commands ([#5986](https://github.com/helix-editor/helix/pull/5986))
- Workspace detection now stops at the first `.helix/` directory (merging multiple `.helix/languages.toml` configurations is no longer supported) ([#5748](https://github.com/helix-editor/helix/pull/5748))
Features:
- Dynamic workspace symbol picker ([#5055](https://github.com/helix-editor/helix/pull/5055))
- Soft-wrap ([#5420](https://github.com/helix-editor/helix/pull/5420), [#5786](https://github.com/helix-editor/helix/pull/5786), [#5893](https://github.com/helix-editor/helix/pull/5893), [#6142](https://github.com/helix-editor/helix/pull/6142), [#6440](https://github.com/helix-editor/helix/pull/6440))
- Initial support for LSP snippet completions ([#5864](https://github.com/helix-editor/helix/pull/5864), [b1f7528](https://github.com/helix-editor/helix/commit/b1f7528), [#6263](https://github.com/helix-editor/helix/pull/6263), [bbf4800](https://github.com/helix-editor/helix/commit/bbf4800), [90348b8](https://github.com/helix-editor/helix/commit/90348b8), [f87299f](https://github.com/helix-editor/helix/commit/f87299f), [#6371](https://github.com/helix-editor/helix/pull/6371), [9fe3adc](https://github.com/helix-editor/helix/commit/9fe3adc))
- Add a statusline element for showing the current version control HEAD ([#5682](https://github.com/helix-editor/helix/pull/5682))
- Display LSP type hints ([#5420](https://github.com/helix-editor/helix/pull/5420), [#5934](https://github.com/helix-editor/helix/pull/5934), [#6312](https://github.com/helix-editor/helix/pull/6312))
- Enable the Kitty keyboard protocol on terminals with support ([#4939](https://github.com/helix-editor/helix/pull/4939), [#6170](https://github.com/helix-editor/helix/pull/6170), [#6194](https://github.com/helix-editor/helix/pull/6194), [#6438](https://github.com/helix-editor/helix/pull/6438))
- Add a statusline element for the basename of the current file ([#5318](https://github.com/helix-editor/helix/pull/5318))
- Add substring matching syntax for the picker ([#5658](https://github.com/helix-editor/helix/pull/5658))
- Support LSP `textDocument/prepareRename` ([#6103](https://github.com/helix-editor/helix/pull/6103))
- Allow multiple runtime directories with priorities ([#5411](https://github.com/helix-editor/helix/pull/5411))
- Allow configuring whether to insert or replace completions ([#5728](https://github.com/helix-editor/helix/pull/5728))
- Allow per-workspace config file `.helix/config.toml` ([#5748](https://github.com/helix-editor/helix/pull/5748))
- Add `workspace-lsp-roots` config option to support multiple LSP roots for use with monorepos ([#5748](https://github.com/helix-editor/helix/pull/5748))
Commands:
- `:pipe-to` which pipes selections into a shell command and ignores output ([#4931](https://github.com/helix-editor/helix/pull/4931))
- `merge_consecutive_selections` (`A-_`) combines all consecutive selections ([#5047](https://github.com/helix-editor/helix/pull/5047))
- `rotate_view_reverse` which focuses the previous view ([#5356](https://github.com/helix-editor/helix/pull/5356))
- `goto_declaration` (`gD`, requires LSP) which jumps to a symbol's declaration ([#5646](https://github.com/helix-editor/helix/pull/5646))
- `file_picker_in_current_buffer_directory` ([#4666](https://github.com/helix-editor/helix/pull/4666))
- `:character-info` which shows information about the character under the cursor ([#4000](https://github.com/helix-editor/helix/pull/4000))
- `:toggle-option` for toggling config options at runtime ([#4085](https://github.com/helix-editor/helix/pull/4085))
- `dap_restart` for restarting a debug session in DAP ([#5651](https://github.com/helix-editor/helix/pull/5651))
- `:lsp-stop` to stop the language server of the current buffer ([#5964](https://github.com/helix-editor/helix/pull/5964))
- `:reset-diff-change` for resetting a diff hunk to its original text ([#4974](https://github.com/helix-editor/helix/pull/4974))
- `:config-open-workspace` for opening the config file local to the current workspace ([#5748](https://github.com/helix-editor/helix/pull/5748))
Usability improvements:
- Remove empty detail section in completion menu when LSP doesn't send details ([#4902](https://github.com/helix-editor/helix/pull/4902))
- Pass client information on LSP initialization ([#4904](https://github.com/helix-editor/helix/pull/4904))
- Allow specifying environment variables for language servers in language config ([#4004](https://github.com/helix-editor/helix/pull/4004))
- Allow detached git worktrees to be recognized as root paths ([#5097](https://github.com/helix-editor/helix/pull/5097))
- Improve error message handling for theme loading failures ([#5073](https://github.com/helix-editor/helix/pull/5073))
- Print the names of binaries required for LSP/DAP in health-check ([#5195](https://github.com/helix-editor/helix/pull/5195))
- Improve sorting in the picker in cases of ties ([#5169](https://github.com/helix-editor/helix/pull/5169))
- Add theming for prompt suggestions ([#5104](https://github.com/helix-editor/helix/pull/5104))
- Open a file picker when using `:open` on directories ([#2707](https://github.com/helix-editor/helix/pull/2707), [#5278](https://github.com/helix-editor/helix/pull/5278))
- Reload language config with `:config-reload` ([#5239](https://github.com/helix-editor/helix/pull/5239), [#5381](https://github.com/helix-editor/helix/pull/5381), [#5431](https://github.com/helix-editor/helix/pull/5431))
- Improve indent queries for python when the tree is errored ([#5332](https://github.com/helix-editor/helix/pull/5332))
- Picker: Open files without closing the picker with `A-ret` ([#4435](https://github.com/helix-editor/helix/pull/4435))
- Allow theming cursors by primary/secondary and by mode ([#5130](https://github.com/helix-editor/helix/pull/5130))
- Allow configuration of the minimum width for the line-numbers gutter ([#4724](https://github.com/helix-editor/helix/pull/4724), [#5696](https://github.com/helix-editor/helix/pull/5696))
- Use filename completer for `:run-shell-command` command ([#5729](https://github.com/helix-editor/helix/pull/5729))
- Surround with line-endings with `ms<ret>` ([#4571](https://github.com/helix-editor/helix/pull/4571))
- Hide duplicate symlinks in file pickers ([#5658](https://github.com/helix-editor/helix/pull/5658))
- Tabulate buffer picker contents ([#5777](https://github.com/helix-editor/helix/pull/5777))
- Add an option to disable LSP ([#4425](https://github.com/helix-editor/helix/pull/4425))
- Short-circuit tree-sitter and word object motions ([#5851](https://github.com/helix-editor/helix/pull/5851))
- Add exit code to failed command message ([#5898](https://github.com/helix-editor/helix/pull/5898))
- Make `m` textobject look for pairs enclosing selections ([#3344](https://github.com/helix-editor/helix/pull/3344))
- Negotiate LSP position encoding ([#5894](https://github.com/helix-editor/helix/pull/5894), [a48d1a4](https://github.com/helix-editor/helix/commit/a48d1a4))
- Display deprecated LSP completions with strikethrough ([#5932](https://github.com/helix-editor/helix/pull/5932))
- Add JSONRPC request ID to failed LSP/DAP request log messages ([#6010](https://github.com/helix-editor/helix/pull/6010), [#6018](https://github.com/helix-editor/helix/pull/6018))
- Ignore case when filtering LSP completions ([#6008](https://github.com/helix-editor/helix/pull/6008))
- Show current language when no arguments are passed to `:set-language` ([#5895](https://github.com/helix-editor/helix/pull/5895))
- Refactor and rewrite all book documentation ([#5534](https://github.com/helix-editor/helix/pull/5534))
- Separate diagnostic picker message and code ([#6095](https://github.com/helix-editor/helix/pull/6095))
- Add a config option to bypass undercurl detection ([#6253](https://github.com/helix-editor/helix/pull/6253))
- Only complete appropriate arguments for typed commands ([#5966](https://github.com/helix-editor/helix/pull/5966))
- Discard outdated LSP diagnostics ([3c9d5d0](https://github.com/helix-editor/helix/commit/3c9d5d0))
- Discard outdated LSP workspace edits ([b6a4927](https://github.com/helix-editor/helix/commit/b6a4927))
- Run shell commands asynchronously ([#6373](https://github.com/helix-editor/helix/pull/6373))
- Show diagnostic codes in LSP diagnostic messages ([#6378](https://github.com/helix-editor/helix/pull/6378))
- Highlight the current line in a DAP debug session ([#5957](https://github.com/helix-editor/helix/pull/5957))
- Hide signature help if it overlaps with the completion menu ([#5523](https://github.com/helix-editor/helix/pull/5523), [7a69c40](https://github.com/helix-editor/helix/commit/7a69c40))
Fixes:
- Fix behavior of `auto-completion` flag for completion-on-trigger ([#5042](https://github.com/helix-editor/helix/pull/5042))
- Reset editor mode when changing buffers ([#5072](https://github.com/helix-editor/helix/pull/5072))
- Respect scrolloff settings in mouse movements ([#5255](https://github.com/helix-editor/helix/pull/5255))
- Avoid trailing `s` when only one file is opened ([#5189](https://github.com/helix-editor/helix/pull/5189))
- Fix erroneous indent between closers of auto-pairs ([#5330](https://github.com/helix-editor/helix/pull/5330))
- Expand `~` when parsing file paths in `:open` ([#5329](https://github.com/helix-editor/helix/pull/5329))
- Fix theme inheritance for default themes ([#5218](https://github.com/helix-editor/helix/pull/5218))
- Fix `extend_line` with a count when the current line(s) are selected ([#5288](https://github.com/helix-editor/helix/pull/5288))
- Prompt: Fix autocompletion for paths containing periods ([#5175](https://github.com/helix-editor/helix/pull/5175))
- Skip serializing JSONRPC params if params is null ([#5471](https://github.com/helix-editor/helix/pull/5471))
- Fix interaction with the `xclip` clipboard provider ([#5426](https://github.com/helix-editor/helix/pull/5426))
- Fix undo/redo execution from the command palette ([#5294](https://github.com/helix-editor/helix/pull/5294))
- Fix highlighting of non-block cursors ([#5575](https://github.com/helix-editor/helix/pull/5575))
- Fix panic when nooping in `join_selections` and `join_selections_space` ([#5423](https://github.com/helix-editor/helix/pull/5423))
- Fix selecting a changed file in global search ([#5639](https://github.com/helix-editor/helix/pull/5639))
- Fix initial syntax highlight layer sort order ([#5196](https://github.com/helix-editor/helix/pull/5196))
- Fix UTF-8 length handling for shellwords ([#5738](https://github.com/helix-editor/helix/pull/5738))
- Remove C-j and C-k bindings from the completion menu ([#5070](https://github.com/helix-editor/helix/pull/5070))
- Always commit to history when pasting ([#5790](https://github.com/helix-editor/helix/pull/5790))
- Properly handle LSP position encoding ([#5711](https://github.com/helix-editor/helix/pull/5711))
- Fix infinite loop in `copy_selection_on_prev_line` ([#5888](https://github.com/helix-editor/helix/pull/5888))
- Fix completion popup positioning ([#5842](https://github.com/helix-editor/helix/pull/5842))
- Fix a panic when uncommenting a line with only a comment token ([#5933](https://github.com/helix-editor/helix/pull/5933))
- Fix panic in `goto_window_center` at EOF ([#5987](https://github.com/helix-editor/helix/pull/5987))
- Ignore invalid file URIs sent by a language server ([#6000](https://github.com/helix-editor/helix/pull/6000))
- Decode LSP URIs for the workspace diagnostics picker ([#6016](https://github.com/helix-editor/helix/pull/6016))
- Fix incorrect usages of `tab_width` with `indent_width` ([#5918](https://github.com/helix-editor/helix/pull/5918))
- DAP: Send Disconnect if the Terminated event is received ([#5532](https://github.com/helix-editor/helix/pull/5532))
- DAP: Validate key and index exist when requesting variables ([#5628](https://github.com/helix-editor/helix/pull/5628))
- Check LSP renaming support before prompting for rename text ([#6257](https://github.com/helix-editor/helix/pull/6257))
- Fix indent guide rendering ([#6136](https://github.com/helix-editor/helix/pull/6136))
- Fix division by zero panic ([#6155](https://github.com/helix-editor/helix/pull/6155))
- Fix lacking space panic ([#6109](https://github.com/helix-editor/helix/pull/6109))
- Send error replies for malformed and unhandled LSP requests ([#6058](https://github.com/helix-editor/helix/pull/6058))
- Fix table column calculations for dynamic pickers ([#5920](https://github.com/helix-editor/helix/pull/5920))
- Skip adding jumplist entries for `:<n>` line number previews ([#5751](https://github.com/helix-editor/helix/pull/5751))
- Fix completion race conditions ([#6173](https://github.com/helix-editor/helix/pull/6173))
- Fix `shrink_selection` with multiple cursors ([#6093](https://github.com/helix-editor/helix/pull/6093))
- Fix indentation calculation for lines with mixed tabs/spaces ([#6278](https://github.com/helix-editor/helix/pull/6278))
- No-op `client/registerCapability` LSP requests ([#6258](https://github.com/helix-editor/helix/pull/6258))
- Send the STOP signal to all processes in the process group ([#3546](https://github.com/helix-editor/helix/pull/3546))
- Fix workspace edit client capabilities declaration ([7bf168d](https://github.com/helix-editor/helix/commit/7bf168d))
- Fix highlighting in picker results with multiple columns ([#6333](https://github.com/helix-editor/helix/pull/6333))
- Canonicalize paths before stripping the current dir as a prefix ([#6290](https://github.com/helix-editor/helix/pull/6290))
- Fix truncation behavior for long path names in the file picker ([#6410](https://github.com/helix-editor/helix/pull/6410), [67783dd](https://github.com/helix-editor/helix/commit/67783dd))
- Fix theme reloading behavior in `:config-reload` ([ab819d8](https://github.com/helix-editor/helix/commit/ab819d8))
Themes:
- Update `serika` ([#5038](https://github.com/helix-editor/helix/pull/5038), [#6344](https://github.com/helix-editor/helix/pull/6344))
- Update `flatwhite` ([#5036](https://github.com/helix-editor/helix/pull/5036), [#6323](https://github.com/helix-editor/helix/pull/6323))
- Update `autumn` ([#5051](https://github.com/helix-editor/helix/pull/5051), [#5397](https://github.com/helix-editor/helix/pull/5397), [#6280](https://github.com/helix-editor/helix/pull/6280), [#6316](https://github.com/helix-editor/helix/pull/6316))
- Update `acme` ([#5019](https://github.com/helix-editor/helix/pull/5019), [#5486](https://github.com/helix-editor/helix/pull/5486), [#5488](https://github.com/helix-editor/helix/pull/5488))
- Update `gruvbox` themes ([#5066](https://github.com/helix-editor/helix/pull/5066), [#5333](https://github.com/helix-editor/helix/pull/5333), [#5540](https://github.com/helix-editor/helix/pull/5540), [#6285](https://github.com/helix-editor/helix/pull/6285), [#6295](https://github.com/helix-editor/helix/pull/6295))
- Update `base16_transparent` ([#5105](https://github.com/helix-editor/helix/pull/5105))
- Update `dark_high_contrast` ([#5105](https://github.com/helix-editor/helix/pull/5105))
- Update `dracula` ([#5236](https://github.com/helix-editor/helix/pull/5236), [#5627](https://github.com/helix-editor/helix/pull/5627), [#6414](https://github.com/helix-editor/helix/pull/6414))
- Update `monokai_pro_spectrum` ([#5250](https://github.com/helix-editor/helix/pull/5250), [#5602](https://github.com/helix-editor/helix/pull/5602))
- Update `rose_pine` ([#5267](https://github.com/helix-editor/helix/pull/5267), [#5489](https://github.com/helix-editor/helix/pull/5489), [#6384](https://github.com/helix-editor/helix/pull/6384))
- Update `kanagawa` ([#5273](https://github.com/helix-editor/helix/pull/5273), [#5571](https://github.com/helix-editor/helix/pull/5571), [#6085](https://github.com/helix-editor/helix/pull/6085))
- Update `emacs` ([#5334](https://github.com/helix-editor/helix/pull/5334))
- Add `github` themes ([#5353](https://github.com/helix-editor/helix/pull/5353), [efeec12](https://github.com/helix-editor/helix/commit/efeec12))
- Dark themes: `github_dark`, `github_dark_colorblind`, `github_dark_dimmed`, `github_dark_high_contrast`, `github_dark_tritanopia`
- Light themes: `github_light`, `github_light_colorblind`, `github_light_dimmed`, `github_light_high_contrast`, `github_light_tritanopia`
- Update `solarized` variants ([#5445](https://github.com/helix-editor/helix/pull/5445), [#6327](https://github.com/helix-editor/helix/pull/6327))
- Update `catppuccin` variants ([#5404](https://github.com/helix-editor/helix/pull/5404), [#6107](https://github.com/helix-editor/helix/pull/6107), [#6269](https://github.com/helix-editor/helix/pull/6269), [#6464](https://github.com/helix-editor/helix/pull/6464))
- Use curly underlines in built-in themes ([#5419](https://github.com/helix-editor/helix/pull/5419))
- Update `zenburn` ([#5573](https://github.com/helix-editor/helix/pull/5573))
- Rewrite `snazzy` ([#3971](https://github.com/helix-editor/helix/pull/3971))
- Add `monokai_aqua` ([#5578](https://github.com/helix-editor/helix/pull/5578))
- Add `markup.strikethrough` to existing themes ([#5619](https://github.com/helix-editor/helix/pull/5619))
- Update `sonokai` ([#5440](https://github.com/helix-editor/helix/pull/5440))
- Update `onedark` ([#5755](https://github.com/helix-editor/helix/pull/5755))
- Add `ayu_evolve` ([#5638](https://github.com/helix-editor/helix/pull/5638), [#6028](https://github.com/helix-editor/helix/pull/6028), [#6225](https://github.com/helix-editor/helix/pull/6225))
- Add `jellybeans` ([#5719](https://github.com/helix-editor/helix/pull/5719))
- Update `fleet_dark` ([#5605](https://github.com/helix-editor/helix/pull/5605), [#6266](https://github.com/helix-editor/helix/pull/6266), [#6324](https://github.com/helix-editor/helix/pull/6324), [#6375](https://github.com/helix-editor/helix/pull/6375))
- Add `darcula-solid` ([#5778](https://github.com/helix-editor/helix/pull/5778))
- Remove text background from monokai themes ([#6009](https://github.com/helix-editor/helix/pull/6009))
- Update `pop_dark` ([#5992](https://github.com/helix-editor/helix/pull/5992), [#6208](https://github.com/helix-editor/helix/pull/6208), [#6227](https://github.com/helix-editor/helix/pull/6227), [#6292](https://github.com/helix-editor/helix/pull/6292))
- Add `everblush` ([#6086](https://github.com/helix-editor/helix/pull/6086))
- Add `adwaita-dark` ([#6042](https://github.com/helix-editor/helix/pull/6042), [#6342](https://github.com/helix-editor/helix/pull/6342))
- Update `papercolor` ([#6162](https://github.com/helix-editor/helix/pull/6162))
- Update `onelight` ([#6192](https://github.com/helix-editor/helix/pull/6192), [#6276](https://github.com/helix-editor/helix/pull/6276))
- Add `molokai` ([#6260](https://github.com/helix-editor/helix/pull/6260))
- Update `ayu` variants ([#6329](https://github.com/helix-editor/helix/pull/6329))
- Update `tokyonight` variants ([#6349](https://github.com/helix-editor/helix/pull/6349))
- Update `nord` variants ([#6376](https://github.com/helix-editor/helix/pull/6376))
New languages:
- BibTeX ([#5064](https://github.com/helix-editor/helix/pull/5064))
- Mermaid.js ([#5147](https://github.com/helix-editor/helix/pull/5147))
- Crystal ([#4993](https://github.com/helix-editor/helix/pull/4993), [#5205](https://github.com/helix-editor/helix/pull/5205))
- MATLAB/Octave ([#5192](https://github.com/helix-editor/helix/pull/5192))
- `tfvars` (uses HCL) ([#5396](https://github.com/helix-editor/helix/pull/5396))
- Ponylang ([#5416](https://github.com/helix-editor/helix/pull/5416))
- DHall ([1f6809c](https://github.com/helix-editor/helix/commit/1f6809c))
- Sagemath ([#5649](https://github.com/helix-editor/helix/pull/5649))
- MSBuild ([#5793](https://github.com/helix-editor/helix/pull/5793))
- pem ([#5797](https://github.com/helix-editor/helix/pull/5797))
- passwd ([#4959](https://github.com/helix-editor/helix/pull/4959))
- hosts ([#4950](https://github.com/helix-editor/helix/pull/4950), [#5914](https://github.com/helix-editor/helix/pull/5914))
- uxntal ([#6047](https://github.com/helix-editor/helix/pull/6047))
- Yuck ([#6064](https://github.com/helix-editor/helix/pull/6064), [#6242](https://github.com/helix-editor/helix/pull/6242))
- GNU gettext PO ([#5996](https://github.com/helix-editor/helix/pull/5996))
- Sway ([#6023](https://github.com/helix-editor/helix/pull/6023))
- NASM ([#6068](https://github.com/helix-editor/helix/pull/6068))
- PRQL ([#6126](https://github.com/helix-editor/helix/pull/6126))
- reStructuredText ([#6180](https://github.com/helix-editor/helix/pull/6180))
- Smithy ([#6370](https://github.com/helix-editor/helix/pull/6370))
- VHDL ([#5826](https://github.com/helix-editor/helix/pull/5826))
- Rego (OpenPolicy Agent) ([#6415](https://github.com/helix-editor/helix/pull/6415))
- Nim ([#6123](https://github.com/helix-editor/helix/pull/6123))
Updated languages and queries:
- Use diff syntax for patch files ([#5085](https://github.com/helix-editor/helix/pull/5085))
- Add Haskell textobjects ([#5061](https://github.com/helix-editor/helix/pull/5061))
- Fix commonlisp configuration ([#5091](https://github.com/helix-editor/helix/pull/5091))
- Update Scheme ([bae890d](https://github.com/helix-editor/helix/commit/bae890d))
- Add indent queries for Bash ([#5149](https://github.com/helix-editor/helix/pull/5149))
- Recognize `c++` as a C++ extension ([#5183](https://github.com/helix-editor/helix/pull/5183))
- Enable HTTP server in `metals` (Scala) config ([#5551](https://github.com/helix-editor/helix/pull/5551))
- Change V-lang language server to `v ls` from `vls` ([#5677](https://github.com/helix-editor/helix/pull/5677))
- Inject comment grammar into Nix ([#5208](https://github.com/helix-editor/helix/pull/5208))
- Update Rust highlights ([#5238](https://github.com/helix-editor/helix/pull/5238), [#5349](https://github.com/helix-editor/helix/pull/5349))
- Fix HTML injection within Markdown ([#5265](https://github.com/helix-editor/helix/pull/5265))
- Fix comment token for godot ([#5276](https://github.com/helix-editor/helix/pull/5276))
- Expand injections for Vue ([#5268](https://github.com/helix-editor/helix/pull/5268))
- Add `.bash_aliases` as a Bash file-type ([#5347](https://github.com/helix-editor/helix/pull/5347))
- Fix comment token for sshclientconfig ([#5351](https://github.com/helix-editor/helix/pull/5351))
- Update Prisma ([#5417](https://github.com/helix-editor/helix/pull/5417))
- Update C++ ([#5457](https://github.com/helix-editor/helix/pull/5457))
- Add more file-types for Python ([#5593](https://github.com/helix-editor/helix/pull/5593))
- Update tree-sitter-scala ([#5576](https://github.com/helix-editor/helix/pull/5576))
- Add an injection regex for Lua ([#5606](https://github.com/helix-editor/helix/pull/5606))
- Add `build.gradle` to java roots configuration ([#5641](https://github.com/helix-editor/helix/pull/5641))
- Add Hub PR files to markdown file-types ([#5634](https://github.com/helix-editor/helix/pull/5634))
- Add an external formatter configuration for Cue ([#5679](https://github.com/helix-editor/helix/pull/5679))
- Add injections for builders and writers to Nix ([#5629](https://github.com/helix-editor/helix/pull/5629))
- Update tree-sitter-xml to fix whitespace parsing ([#5685](https://github.com/helix-editor/helix/pull/5685))
- Add `Justfile` to the make file-types configuration ([#5687](https://github.com/helix-editor/helix/pull/5687))
- Update tree-sitter-sql and highlight queries ([#5683](https://github.com/helix-editor/helix/pull/5683), [#5772](https://github.com/helix-editor/helix/pull/5772))
- Use the bash grammar and queries for env language ([#5720](https://github.com/helix-editor/helix/pull/5720))
- Add podspec files to ruby file-types ([#5811](https://github.com/helix-editor/helix/pull/5811))
- Recognize `.C` and `.H` file-types as C++ ([#5808](https://github.com/helix-editor/helix/pull/5808))
- Recognize plist and mobileconfig files as XML ([#5863](https://github.com/helix-editor/helix/pull/5863))
- Fix `select` indentation in Go ([#5713](https://github.com/helix-editor/helix/pull/5713))
- Check for external file modifications when writing ([#5805](https://github.com/helix-editor/helix/pull/5805))
- Recognize containerfiles as dockerfile syntax ([#5873](https://github.com/helix-editor/helix/pull/5873))
- Update godot grammar and queries ([#5944](https://github.com/helix-editor/helix/pull/5944), [#6186](https://github.com/helix-editor/helix/pull/6186))
- Improve DHall highlights ([#5959](https://github.com/helix-editor/helix/pull/5959))
- Recognize `.env.dist` and `source.env` as env language ([#6003](https://github.com/helix-editor/helix/pull/6003))
- Update tree-sitter-git-rebase ([#6030](https://github.com/helix-editor/helix/pull/6030), [#6094](https://github.com/helix-editor/helix/pull/6094))
- Improve SQL highlights ([#6041](https://github.com/helix-editor/helix/pull/6041))
- Improve markdown highlights and inject LaTeX ([#6100](https://github.com/helix-editor/helix/pull/6100))
- Add textobject queries for Elm ([#6084](https://github.com/helix-editor/helix/pull/6084))
- Recognize graphql schema file type ([#6159](https://github.com/helix-editor/helix/pull/6159))
- Improve highlighting in comments ([#6143](https://github.com/helix-editor/helix/pull/6143))
- Improve highlighting for JavaScript/TypeScript/ECMAScript languages ([#6205](https://github.com/helix-editor/helix/pull/6205))
- Improve PHP highlights ([#6203](https://github.com/helix-editor/helix/pull/6203), [#6250](https://github.com/helix-editor/helix/pull/6250), [#6299](https://github.com/helix-editor/helix/pull/6299))
- Improve Go highlights ([#6204](https://github.com/helix-editor/helix/pull/6204))
- Highlight unchecked sqlx functions as SQL in Rust ([#6256](https://github.com/helix-editor/helix/pull/6256))
- Improve Erlang highlights ([cdd6c8d](https://github.com/helix-editor/helix/commit/cdd6c8d))
- Improve Nix highlights ([fb4d703](https://github.com/helix-editor/helix/commit/fb4d703))
- Improve gdscript highlights ([#6311](https://github.com/helix-editor/helix/pull/6311))
- Improve Vlang highlights ([#6279](https://github.com/helix-editor/helix/pull/6279))
- Improve Makefile highlights ([#6339](https://github.com/helix-editor/helix/pull/6339))
- Remove auto-pair for `'` in OCaml ([#6381](https://github.com/helix-editor/helix/pull/6381))
- Fix indents in switch statements in ECMA languages ([#6369](https://github.com/helix-editor/helix/pull/6369))
- Recognize xlb and storyboard file-types as XML ([#6407](https://github.com/helix-editor/helix/pull/6407))
- Recognize cts and mts file-types as TypeScript ([#6424](https://github.com/helix-editor/helix/pull/6424))
- Recognize SVG file-type as XML ([#6431](https://github.com/helix-editor/helix/pull/6431))
- Add theme scopes for (un)checked list item markup scopes ([#6434](https://github.com/helix-editor/helix/pull/6434))
- Update git commit grammar and add the comment textobject ([#6439](https://github.com/helix-editor/helix/pull/6439), [#6493](https://github.com/helix-editor/helix/pull/6493))
- Recognize ARB file-type as JSON ([#6452](https://github.com/helix-editor/helix/pull/6452))
- Inject markdown into markdown strings in Julia ([#6489](https://github.com/helix-editor/helix/pull/6489))
Packaging:
- Fix Nix flake devShell for darwin hosts ([#5368](https://github.com/helix-editor/helix/pull/5368))
- Add Appstream metadata file to `contrib/` ([#5643](https://github.com/helix-editor/helix/pull/5643))
- Increase the MSRV to 1.65 ([#5570](https://github.com/helix-editor/helix/pull/5570), [#6185](https://github.com/helix-editor/helix/pull/6185))
- Expose the Nix flake's `wrapper` ([#5994](https://github.com/helix-editor/helix/pull/5994))
# 22.12 (2022-12-06)
This is a great big release filled with changes from a 99 contributors. A big _thank you_ to you all!

826
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -32,6 +32,3 @@ inherits = "test"
package.helix-core.opt-level = 2
package.helix-tui.opt-level = 2
package.helix-term.opt-level = 2
[patch.crates-io]
tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "c51896d32dcc11a38e41f36e3deb1a6a9c4f4b14" }

@ -1 +1 @@
22.12
23.05

@ -16,3 +16,4 @@
- [Adding languages](./guides/adding_languages.md)
- [Adding textobject queries](./guides/textobject.md)
- [Adding indent queries](./guides/indent.md)
- [Adding injection queries](./guides/injection.md)

@ -30,6 +30,9 @@ You can use a custom configuration file by specifying it with the `-c` or
Additionally, you can reload the configuration file by sending the USR1
signal to the Helix process on Unix operating systems, such as by using the command `pkill -USR1 hx`.
Finally, you can have a `config.toml` local to a project by putting it under a `.helix` directory in your repository.
Its settings will be merged with the configuration directory `config.toml` and the built-in configuration.
## Editor
### `[editor]` Section
@ -57,7 +60,8 @@ signal to the Helix process on Unix operating systems, such as by using the comm
| `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file | `[]` |
| `bufferline` | Renders a line at the top of the editor displaying open buffers. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use) | `never` |
| `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` |
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap_at_text_width` is set | `80` |
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
### `[editor.statusline]` Section
@ -123,6 +127,8 @@ The following statusline elements can be configured:
| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` |
| `display-inlay-hints` | Display inlay hints[^2] | `false` |
| `display-signature-help-docs` | Display docs under signature help popup | `true` |
| `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` |
| `goto-reference-include-declaration` | Include declaration in the goto references popup. | `true` |
[^1]: By default, a progress spinner is shown in the statusline beside the file path.
[^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix.
@ -156,7 +162,7 @@ All git related options are only enabled in a git repository.
| Key | Description | Default |
|--|--|---------|
|`hidden` | Enables ignoring hidden files | true
|`follow-links` | Follow symlinks instead of ignoring them | true
|`follow-symlinks` | Follow symlinks instead of ignoring them | true
|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | true
|`parents` | Enables reading ignore files from parent directories | true
|`ignore` | Enables reading `.ignore` files | true
@ -305,7 +311,7 @@ Example:
min-width = 1
```
#### `[editor.gutters.diagnotics]` Section
#### `[editor.gutters.diagnostics]` Section
Currently unused

@ -2,9 +2,9 @@
Helix's editing model is strongly inspired from Vim and Kakoune, and a notable
difference from Vim (and the most striking similarity to Kakoune) is that Helix
follows the `selection → action` model. This means that the whatever you are
going to act on (a word, a paragraph, a line, etc) is selected first and the
action itself (delete, change, yank, etc) comes second. A cursor is simply a
follows the `selection → action` model. This means that whatever you are
going to act on (a word, a paragraph, a line, etc.) is selected first and the
action itself (delete, change, yank, etc.) comes second. A cursor is simply a
single width selection.
See also Kakoune's [Migrating from Vim](https://github.com/mawww/kakoune/wiki/Migrating-from-Vim) and Helix's [Migrating from Vim](https://github.com/helix-editor/helix/wiki/Migrating-from-Vim).

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

@ -12,7 +12,9 @@
| `:buffer-next`, `:bn`, `:bnext` | Goto next buffer. |
| `:buffer-previous`, `:bp`, `:bprev` | Goto previous buffer. |
| `:write`, `:w` | Write changes to disk. Accepts an optional path (:write some/path.txt) |
| `:write!`, `:w!` | Force write changes to disk creating necessary subdirectories. Accepts an optional path (:write some/path.txt) |
| `:write!`, `:w!` | Force write changes to disk creating necessary subdirectories. Accepts an optional path (:write! some/path.txt) |
| `:write-buffer-close`, `:wbc` | Write changes to disk and closes the buffer. Accepts an optional path (:write-buffer-close some/path.txt) |
| `:write-buffer-close!`, `:wbc!` | Force write changes to disk creating necessary subdirectories and closes the buffer. Accepts an optional path (:write-buffer-close! some/path.txt) |
| `:new`, `:n` | Create a new scratch buffer. |
| `:format`, `:fmt` | Format the file using the LSP formatter. |
| `:indent-style` | Set the indentation style for editing. ('t' for tabs or 1-8 for number of spaces.) |
@ -46,10 +48,10 @@
| `:character-info`, `:char` | Get info about the character under the primary cursor. |
| `:reload` | Discard changes and reload from the source file. |
| `:reload-all` | Discard changes and reload all documents from the source files. |
| `:update` | Write changes only if the file has been modified. |
| `:update`, `:u` | Write changes only if the file has been modified. |
| `:lsp-workspace-command` | Open workspace command picker |
| `:lsp-restart` | Restarts the Language Server that is in use by the current doc |
| `:lsp-stop` | Stops the Language Server that is in use by the current doc |
| `:lsp-restart` | Restarts the language servers used by the current doc |
| `:lsp-stop` | Stops the language servers that are used by the current doc |
| `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. |
| `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. |
| `:debug-remote`, `:dbg-tcp` | Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters. |
@ -70,6 +72,7 @@
| `:tree-sitter-subtree`, `:ts-subtree` | Display tree sitter subtree under cursor, primarily for debugging queries. |
| `:config-reload` | Refresh user config. |
| `:config-open` | Open the user config.toml file. |
| `:config-open-workspace` | Open the workspace config.toml file. |
| `:log-open` | Open the helix log file. |
| `:insert-output` | Run shell command, inserting output before each selection. |
| `:append-output` | Run shell command, appending output after each selection. |
@ -77,3 +80,4 @@
| `:pipe-to` | Pipe each selection to the shell command, ignoring output. |
| `:run-shell-command`, `:sh` | Run a shell command |
| `:reset-diff-change`, `:diffget`, `:diffg` | Reset the diff change at the cursor position. |
| `:clear-register` | Clear given register. If no argument is provided, clear all registers. |

@ -9,6 +9,7 @@ below.
necessary configuration for the new language. For more information on
language configuration, refer to the
[language configuration section](../languages.md) of the documentation.
A new language server can be added by extending the `[language-server]` table in the same file.
2. If you are adding a new language or updating an existing language server
configuration, run the command `cargo xtask docgen` to update the
[Language Support](../lang-support.md) documentation.

@ -0,0 +1,57 @@
# Adding Injection Queries
Writing language injection queries allows one to highlight a specific node as a different language.
In addition to the [standard](upstream-docs) language injection options used by tree-sitter, there
are a few Helix specific extensions that allow for more control.
And example of a simple query that would highlight all strings as bash in Nix:
```scm
((string_expression (string_fragment) @injection.content)
(#set! injection.language "bash"))
```
## Capture Types
- `@injection.language` (standard):
The captured node may contain the language name used to highlight the node captured by
`@injection.content`.
- `@injection.content` (standard):
Marks the content to be highlighted as the language captured with `@injection.language` _et al_.
- `@injection.filename` (extension):
The captured node may contain a filename with a file-extension known to Helix,
highlighting `@injection.content` as that language. This uses the language extensions defined in
both the default languages.toml distributed with Helix, as well as user defined languages.
- `@injection.shebang` (extension):
The captured node may contain a shebang used to choose a language to highlight as. This also uses
the shebangs defined in the default and user `languages.toml`.
## Settings
- `injection.combined` (standard):
Indicates that all the matching nodes in the tree should have their content parsed as one
nested document.
- `injection.language` (standard):
Forces the captured content to be highlighted as the given language
- `injection.include-children` (standard):
Indicates that the content nodes entire text should be re-parsed, including the text of its child
nodes. By default, child nodes text will be excluded from the injected document.
- `injection.include-unnamed-children` (extension):
Same as `injection.include-children` but only for unnamed child nodes.
## Predicates
- `#eq?` (standard):
The first argument (a capture) must be equal to the second argument
(a capture or a string).
- `#match?` (standard):
The first argument (a capture) must match the regex given in the
second argument (a string).
[upstream-docs]: http://tree-sitter.github.io/tree-sitter/syntax-highlighting#language-injection

@ -8,15 +8,20 @@
- [Fedora/RHEL](#fedorarhel)
- [Arch Linux community](#arch-linux-community)
- [NixOS](#nixos)
- [Flatpak](#flatpak)
- [AppImage](#appimage)
- [macOS](#macos)
- [Homebrew Core](#homebrew-core)
- [Windows](#windows)
- [Winget](#winget)
- [Scoop](#scoop)
- [Chocolatey](#chocolatey)
- [MSYS2](#msys2)
- [Building from source](#building-from-source)
- [Configuring Helix's runtime files](#configuring-helixs-runtime-files)
- [Linux and macOS](#linux-and-macos)
- [Windows](#windows)
- [Multiple runtime directories](#multiple-runtime-directories)
- [Validating the installation](#validating-the-installation)
- [Configure the desktop shortcut](#configure-the-desktop-shortcut)
<!--toc:end-->
@ -40,8 +45,6 @@ line.
## Linux, macOS, Windows and OpenBSD packaging status
Helix is available for Linux, macOS and Windows via the official repositories listed below.
[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions)
## Linux
@ -50,7 +53,7 @@ The following third party repositories are available:
### Ubuntu
Helix is available via [Maveonair's PPA](https://launchpad.net/~maveonair/+archive/ubuntu/helix-editor):
Add the `PPA` for Helix:
```sh
sudo add-apt-repository ppa:maveonair/helix-editor
@ -60,7 +63,7 @@ sudo apt install helix
### Fedora/RHEL
Helix is available via `copr`:
Enable the `COPR` repository for Helix:
```sh
sudo dnf copr enable varlad/helix
@ -89,10 +92,19 @@ If you are using a version of Nix without flakes enabled,
[install Cachix CLI](https://docs.cachix.org/installation) and use
`cachix use helix` to configure Nix to use cached outputs when possible.
### Flatpak
Helix is available on [Flathub](https://flathub.org/en-GB/apps/com.helix_editor.Helix):
```sh
flatpak install flathub com.helix_editor.Helix
flatpak run com.helix_editor.Helix
```
### AppImage
Install Helix using [AppImage](https://appimage.org/).
Download Helix AppImage from the [latest releases](https://github.com/helix-editor/helix/releases/latest) page.
Install Helix using the Linux [AppImage](https://appimage.org/) format.
Download the official Helix AppImage from the [latest releases](https://github.com/helix-editor/helix/releases/latest) page.
```sh
chmod +x helix-*.AppImage # change permission for executable mode
@ -109,9 +121,17 @@ brew install helix
## Windows
Install on Windows using [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/)
Install on Windows using [Winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/), [Scoop](https://scoop.sh/), [Chocolatey](https://chocolatey.org/)
or [MSYS2](https://msys2.org/).
### Winget
Windows Package Manager winget command-line tool is by default available on Windows 11 and modern versions of Windows 10 as a part of the App Installer.
You can get [App Installer from the Microsoft Store](https://www.microsoft.com/p/app-installer/9nblggh4nns1#activetab=pivot:overviewtab). If it's already installed, make sure it is updated with the latest version.
```sh
winget install Helix.Helix
```
### Scoop
```sh
@ -134,33 +154,37 @@ pacman -S mingw-w64-ucrt-x86_64-helix
## Building from source
Clone the repository:
Requirements:
- The [Rust toolchain](https://www.rust-lang.org/tools/install)
- The [Git version control system](https://git-scm.com/)
- A c++14 compatible compiler to build the tree-sitter grammars, for example GCC or Clang
If you are using the `musl-libc` standard library instead of `glibc` the following environment variable must be set during the build to ensure tree-sitter grammars can be loaded correctly:
```sh
RUSTFLAGS="-C target-feature=-crt-static"
```
1. Clone the repository:
```sh
git clone https://github.com/helix-editor/helix
cd helix
```
Compile from source:
2. Compile from source:
```sh
cargo install --path helix-term --locked
```
This command will create the `hx` executable and construct the tree-sitter
grammars in the local `runtime` folder. To build the tree-sitter grammars requires
a c++ compiler to be installed, for example `gcc-c++`.
> 💡 If you are using the musl-libc instead of glibc the following environment variable must be set during the build
> to ensure tree-sitter grammars can be loaded correctly:
>
> ```sh
> RUSTFLAGS="-C target-feature=-crt-static"
> ```
grammars in the local `runtime` folder.
> 💡 Tree-sitter grammars can be fetched and compiled if not pre-packaged. Fetch
> grammars with `hx --grammar fetch` (requires `git`) and compile them with
> `hx --grammar build` (requires a C++ compiler). This will install them in
> grammars with `hx --grammar fetch` and compile them with
> `hx --grammar build`. This will install them in
> the `runtime` directory within the user's helix config directory (more
> [details below](#multiple-runtime-directories)).

@ -15,7 +15,7 @@
- [Popup](#popup)
- [Unimpaired](#unimpaired)
- [Insert mode](#insert-mode)
- [Select / extend mode](#select-extend-mode)
- [Select / extend mode](#select--extend-mode)
- [Picker](#picker)
- [Prompt](#prompt)
@ -32,8 +32,8 @@
| Key | Description | Command |
| ----- | ----------- | ------- |
| `h`, `Left` | Move left | `move_char_left` |
| `j`, `Down` | Move down | `move_line_down` |
| `k`, `Up` | Move up | `move_line_up` |
| `j`, `Down` | Move down | `move_visual_line_down` |
| `k`, `Up` | Move up | `move_visual_line_up` |
| `l`, `Right` | Move right | `move_char_right` |
| `w` | Move next word start | `move_next_word_start` |
| `b` | Move previous word start | `move_prev_word_start` |
@ -111,7 +111,8 @@
| `s` | Select all regex matches inside selections | `select_regex` |
| `S` | Split selection into sub selections on regex matches | `split_selection` |
| `Alt-s` | Split selection on newlines | `split_selection_on_newline` |
| `Alt-_ ` | Merge consecutive selections | `merge_consecutive_selections` |
| `Alt-minus` | Merge selections | `merge_selections` |
| `Alt-_` | Merge consecutive selections | `merge_consecutive_selections` |
| `&` | Align selection in columns | `align_selections` |
| `_` | Trim whitespace from the selection | `trim_selections` |
| `;` | Collapse selection onto a single cursor | `collapse_selection` |
@ -218,6 +219,8 @@ Jumps to various locations.
| `n` | Go to next buffer | `goto_next_buffer` |
| `p` | Go to previous buffer | `goto_previous_buffer` |
| `.` | Go to last modification in current file | `goto_last_modification` |
| `j` | Move down textual (instead of visual) line | `move_line_down` |
| `k` | Move up textual (instead of visual) line | `move_line_up` |
#### Match mode

@ -18,6 +18,9 @@ There are three possible locations for a `languages.toml` file:
```toml
# in <config_dir>/helix/languages.toml
[language-server.mylang-lsp]
command = "mylang-lsp"
[[language]]
name = "rust"
auto-format = false
@ -41,8 +44,8 @@ injection-regex = "mylang"
file-types = ["mylang", "myl"]
comment-token = "#"
indent = { tab-width = 2, unit = " " }
language-server = { command = "mylang-lsp", args = ["--stdio"], environment = { "ENV1" = "value1", "ENV2" = "value2" } }
formatter = { command = "mylang-formatter" , args = ["--stdin"] }
language-servers = [ "mylang-lsp" ]
```
These configuration keys are available:
@ -50,6 +53,7 @@ These configuration keys are available:
| Key | Description |
| ---- | ----------- |
| `name` | The name of the language |
| `language-id` | The language-id for language servers, checkout the table at [TextDocumentItem](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem) for the right id |
| `scope` | A string like `source.js` that identifies the language. Currently, we strive to match the scope names used by popular TextMate grammars and by the Linguist library. Usually `source.<name>` or `text.<name>` in case of markup languages |
| `injection-regex` | regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. |
| `file-types` | The filetypes of the language, for example `["yml", "yaml"]`. See the file-type detection section below. |
@ -59,11 +63,11 @@ These configuration keys are available:
| `diagnostic-severity` | Minimal severity of diagnostic for it to be displayed. (Allowed values: `Error`, `Warning`, `Info`, `Hint`) |
| `comment-token` | The token to use as a comment-token |
| `indent` | The indent to use. Has sub keys `unit` (the text inserted into the document when indenting; usually set to N spaces or `"\t"` for tabs) and `tab-width` (the number of spaces rendered for a tab) |
| `language-server` | The Language Server to run. See the Language Server configuration section below. |
| `config` | Language Server configuration |
| `language-servers` | The Language Servers used for this language. See below for more information in the section [Configuring Language Servers for a language](#configuring-language-servers-for-a-language) |
| `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) |
| `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout |
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap_at_text_width` is set, defaults to `editor.text-width` |
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. |
### File-type detection and the `file-types` key
@ -91,31 +95,102 @@ with the following priorities:
replaced at runtime with the appropriate path separator for the operating
system, so this rule would match against `.git\config` files on Windows.
### Language Server configuration
## Language Server configuration
Language servers are configured separately in the table `language-server` in the same file as the languages `languages.toml`
The `language-server` field takes the following keys:
For example:
| Key | Description |
| --- | ----------- |
| `command` | The name of the language server binary to execute. Binaries must be in `$PATH` |
| `args` | A list of arguments to pass to the language server binary |
| `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` |
| `language-id` | The language name to pass to the language server. Some language servers support multiple languages and use this field to determine which one is being served in a buffer |
| `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` |
```toml
[language-server.mylang-lsp]
command = "mylang-lsp"
args = ["--stdio"]
config = { provideFormatter = true }
environment = { "ENV1" = "value1", "ENV2" = "value2" }
[language-server.efm-lsp-prettier]
command = "efm-langserver"
[language-server.efm-lsp-prettier.config]
documentFormatting = true
languages = { typescript = [ { formatCommand ="prettier --stdin-filepath ${INPUT}", formatStdin = true } ] }
```
The top-level `config` field is used to configure the LSP initialization options. A `format`
sub-table within `config` can be used to pass extra formatting options to
[Document Formatting Requests](https://github.com/microsoft/language-server-protocol/blob/gh-pages/_specifications/specification-3-16.md#document-formatting-request--leftwards_arrow_with_hook).
These are the available options for a language server.
| Key | Description |
| ---- | ----------- |
| `command` | The name or path of the language server binary to execute. Binaries must be in `$PATH` |
| `args` | A list of arguments to pass to the language server binary |
| `config` | LSP initialization options |
| `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` |
| `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` |
A `format` sub-table within `config` can be used to pass extra formatting options to
[Document Formatting Requests](https://github.com/microsoft/language-server-protocol/blob/gh-pages/_specifications/specification-3-17.md#document-formatting-request--leftwards_arrow_with_hook).
For example with typescript:
```toml
[[language]]
name = "typescript"
auto-format = true
[language-server.typescript-language-server]
# pass format options according to https://github.com/typescript-language-server/typescript-language-server#workspacedidchangeconfiguration omitting the "[language].format." prefix.
config = { format = { "semicolons" = "insert", "insertSpaceBeforeFunctionParenthesis" = true } }
```
### Configuring Language Servers for a language
The `language-servers` attribute in a language tells helix which language servers are used for this language.
They have to be defined in the `[language-server]` table as described in the previous section.
Different languages can use the same language server instance, e.g. `typescript-language-server` is used for javascript, jsx, tsx and typescript by default.
In case multiple language servers are specified in the `language-servers` attribute of a `language`,
it's often useful to only enable/disable certain language-server features for these language servers.
For example `efm-lsp-prettier` of the previous example is used only with a formatting command `prettier`,
so everything else should be handled by the `typescript-language-server` (which is configured by default)
The language configuration for typescript could look like this:
```toml
[[language]]
name = "typescript"
language-servers = [ { name = "efm-lsp-prettier", only-features = [ "format" ] }, "typescript-language-server" ]
```
or equivalent:
```toml
[[language]]
name = "typescript"
language-servers = [ { name = "typescript-language-server", except-features = [ "format" ] }, "efm-lsp-prettier" ]
```
Each requested LSP feature is prioritized in the order of the `language-servers` array.
For example the first `goto-definition` supported language server (in this case `typescript-language-server`) will be taken for the relevant LSP request (command `goto_definition`).
The features `diagnostics`, `code-action`, `completion`, `document-symbols` and `workspace-symbols` are an exception to that rule, as they are working for all language servers at the same time and are merged together, if enabled for the language.
If no `except-features` or `only-features` is given all features for the language server are enabled.
If a language server itself doesn't support a feature the next language server array entry will be tried (and so on).
The list of supported features is:
- `format`
- `goto-definition`
- `goto-declaration`
- `goto-type-definition`
- `goto-reference`
- `goto-implementation`
- `signature-help`
- `hover`
- `document-highlight`
- `completion`
- `code-action`
- `workspace-command`
- `document-symbols`
- `workspace-symbols`
- `diagnostics`
- `rename-symbol`
- `inlay-hints`
## Tree-sitter grammar configuration
The source for a language's tree-sitter grammar is specified in a `[[grammar]]`

@ -228,6 +228,8 @@ We use a similar set of scopes as
- `list`
- `unnumbered`
- `numbered`
- `checked`
- `unchecked`
- `bold`
- `italic`
- `strikethrough`
@ -276,8 +278,11 @@ These scopes are used for theming the editor interface:
| `ui.cursor.primary.normal` | |
| `ui.cursor.primary.insert` | |
| `ui.cursor.primary.select` | |
| `ui.debug.breakpoint` | Breakpoint indicator, found in the gutter |
| `ui.debug.active` | Indicator for the line at which debugging execution is paused at, found in the gutter |
| `ui.gutter` | Gutter |
| `ui.gutter.selected` | Gutter for the line the cursor is on |
| `ui.highlight.frameline` | Line at which debugging execution is paused at |
| `ui.linenr` | Line numbers |
| `ui.linenr.selected` | Line number for the line the cursor is on |
| `ui.statusline` | Statusline |

@ -36,6 +36,12 @@
<content_rating type="oars-1.1" />
<releases>
<release version="23.05" date="2023-05-18">
<url>https://github.com/helix-editor/helix/releases/tag/23.05</url>
</release>
<release version="23.03" date="2023-03-31">
<url>https://helix-editor.com/news/release-23-03-highlights/</url>
</release>
<release version="22.12" date="2022-12-6">
<url>https://helix-editor.com/news/release-22-12-highlights/</url>
</release>

@ -3,24 +3,22 @@
"crane": {
"flake": false,
"locked": {
"lastModified": 1670900067,
"narHash": "sha256-VXVa+KBfukhmWizaiGiHRVX/fuk66P8dgSFfkVN4/MY=",
"lastModified": 1681175776,
"narHash": "sha256-7SsUy9114fryHAZ8p1L6G6YSu7jjz55FddEwa2U8XZc=",
"owner": "ipetkov",
"repo": "crane",
"rev": "59b31b41a589c0a65e4a1f86b0e5eac68081468b",
"rev": "445a3d222947632b5593112bb817850e8a9cf737",
"type": "github"
},
"original": {
"owner": "ipetkov",
"ref": "v0.12.1",
"repo": "crane",
"type": "github"
}
},
"dream2nix": {
"inputs": {
"alejandra": [
"nci"
],
"all-cabal-json": [
"nci"
],
@ -28,6 +26,8 @@
"devshell": [
"nci"
],
"drv-parts": "drv-parts",
"flake-compat": "flake-compat",
"flake-parts": [
"nci",
"parts"
@ -51,6 +51,7 @@
"nci",
"nixpkgs"
],
"nixpkgsV1": "nixpkgsV1",
"poetry2nix": [
"nci"
],
@ -62,11 +63,11 @@
]
},
"locked": {
"lastModified": 1677289985,
"narHash": "sha256-lUp06cTTlWubeBGMZqPl9jODM99LpWMcwxRiscFAUJg=",
"lastModified": 1683212002,
"narHash": "sha256-EObtqyQsv9v+inieRY5cvyCMCUI5zuU5qu+1axlJCPM=",
"owner": "nix-community",
"repo": "dream2nix",
"rev": "28b973a8d4c30cc1cbb3377ea2023a76bc3fb889",
"rev": "fbfb09d2ab5ff761d822dd40b4a1def81651d096",
"type": "github"
},
"original": {
@ -75,13 +76,64 @@
"type": "github"
}
},
"drv-parts": {
"inputs": {
"flake-compat": [
"nci",
"dream2nix",
"flake-compat"
],
"flake-parts": [
"nci",
"dream2nix",
"flake-parts"
],
"nixpkgs": [
"nci",
"dream2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1680698112,
"narHash": "sha256-FgnobN/DvCjEsc0UAZEAdPLkL4IZi2ZMnu2K2bUaElc=",
"owner": "davhau",
"repo": "drv-parts",
"rev": "e8c2ec1157dc1edb002989669a0dbd935f430201",
"type": "github"
},
"original": {
"owner": "davhau",
"repo": "drv-parts",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
@ -93,11 +145,11 @@
"mk-naked-shell": {
"flake": false,
"locked": {
"lastModified": 1676572903,
"narHash": "sha256-oQoDHHUTxNVSURfkFcYLuAK+btjs30T4rbEUtCUyKy8=",
"lastModified": 1681286841,
"narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=",
"owner": "yusdacra",
"repo": "mk-naked-shell",
"rev": "aeca9f8aa592f5e8f71f407d081cb26fd30c5a57",
"rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd",
"type": "github"
},
"original": {
@ -119,11 +171,11 @@
]
},
"locked": {
"lastModified": 1677297103,
"narHash": "sha256-ArlJIbp9NGV9yvhZdV0SOUFfRlI/kHeKoCk30NbSiLc=",
"lastModified": 1683699050,
"narHash": "sha256-UWKQpzVcSshB+sU2O8CCHjOSTQrNS7Kk9V3+UeBsJpg=",
"owner": "yusdacra",
"repo": "nix-cargo-integration",
"rev": "a79272a2cb0942392bb3a5bf9a3ec6bc568795b2",
"rev": "ed27173cd1b223f598343ea3c15aacb1d140feac",
"type": "github"
},
"original": {
@ -134,11 +186,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1677063315,
"narHash": "sha256-qiB4ajTeAOVnVSAwCNEEkoybrAlA+cpeiBxLobHndE8=",
"lastModified": 1683408522,
"narHash": "sha256-9kcPh6Uxo17a3kK3XCHhcWiV1Yu1kYj22RHiymUhMkU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "988cc958c57ce4350ec248d2d53087777f9e1949",
"rev": "897876e4c484f1e8f92009fd11b7d988a121a4e7",
"type": "github"
},
"original": {
@ -151,11 +203,11 @@
"nixpkgs-lib": {
"locked": {
"dir": "lib",
"lastModified": 1675183161,
"narHash": "sha256-Zq8sNgAxDckpn7tJo7V1afRSk2eoVbu3OjI1QklGLNg=",
"lastModified": 1682879489,
"narHash": "sha256-sASwo8gBt7JDnOOstnps90K1wxmVfyhsTPPNTGBPjjg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e1e1b192c1a5aab2960bf0a0bd53a2e8124fa18e",
"rev": "da45bf6ec7bbcc5d1e14d3795c025199f28e0de0",
"type": "github"
},
"original": {
@ -166,6 +218,21 @@
"type": "github"
}
},
"nixpkgsV1": {
"locked": {
"lastModified": 1678500271,
"narHash": "sha256-tRBLElf6f02HJGG0ZR7znMNFv/Uf7b2fFInpTHiHaSE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5eb98948b66de29f899c7fe27ae112a47964baf8",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-22.11",
"type": "indirect"
}
},
"parts": {
"inputs": {
"nixpkgs-lib": [
@ -174,11 +241,11 @@
]
},
"locked": {
"lastModified": 1675933616,
"narHash": "sha256-/rczJkJHtx16IFxMmAWu5nNYcSXNg1YYXTHoGjLrLUA=",
"lastModified": 1683560683,
"narHash": "sha256-XAygPMN5Xnk/W2c1aW0jyEa6lfMDZWlQgiNtmHXytPc=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "47478a4a003e745402acf63be7f9a092d51b83d7",
"rev": "006c75898cf814ef9497252b022e91c946ba8e17",
"type": "github"
},
"original": {
@ -192,11 +259,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1675933616,
"narHash": "sha256-/rczJkJHtx16IFxMmAWu5nNYcSXNg1YYXTHoGjLrLUA=",
"lastModified": 1683560683,
"narHash": "sha256-XAygPMN5Xnk/W2c1aW0jyEa6lfMDZWlQgiNtmHXytPc=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "47478a4a003e745402acf63be7f9a092d51b83d7",
"rev": "006c75898cf814ef9497252b022e91c946ba8e17",
"type": "github"
},
"original": {
@ -221,11 +288,11 @@
]
},
"locked": {
"lastModified": 1677292251,
"narHash": "sha256-D+6q5Z2MQn3UFJtqsM5/AvVHi3NXKZTIMZt1JGq/spA=",
"lastModified": 1683771545,
"narHash": "sha256-we0GYcKTo2jRQGmUGrzQ9VH0OYAUsJMCsK8UkF+vZUA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "34cdbf6ad480ce13a6a526f57d8b9e609f3d65dc",
"rev": "c57e210faf68e5d5386f18f1b17ad8365d25e4ed",
"type": "github"
},
"original": {
@ -233,6 +300,21 @@
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

@ -64,7 +64,7 @@
};
in
inp.parts.lib.mkFlake {inputs = inp;} {
imports = [inp.nci.flakeModule];
imports = [inp.nci.flakeModule inp.parts.flakeModules.easyOverlay];
systems = [
"x86_64-linux"
"x86_64-darwin"
@ -123,8 +123,6 @@
then ''$RUSTFLAGS -C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment''
else "$RUSTFLAGS";
in {
# by default NCI adds rust-analyzer component, but helix toolchain doesn't have rust-analyzer
nci.toolchains.shell.components = ["rust-src" "rustfmt" "clippy"];
nci.projects."helix-project".relPath = "";
nci.crates."helix-term" = {
overrides = {
@ -148,6 +146,10 @@
packages.helix-dev = makeOverridableHelix config.packages.helix-unwrapped-dev {};
packages.default = config.packages.helix;
overlayAttrs = {
inherit (config.packages) helix;
};
devShells.default = config.nci.outputs."helix-project".devShell.overrideAttrs (old: {
nativeBuildInputs =
(old.nativeBuildInputs or [])

@ -29,9 +29,10 @@ tree-sitter = "0.20"
once_cell = "1.17"
arc-swap = "1"
regex = "1"
bitflags = "2.0"
bitflags = "2.3"
ahash = "0.8.3"
hashbrown = { version = "0.13.2", features = ["raw"] }
dunce = "1.0"
log = "0.4"
serde = { version = "1.0", features = ["derive"] }
@ -44,7 +45,7 @@ encoding_rs = "0.8"
chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] }
etcetera = "0.4"
etcetera = "0.8"
textwrap = "0.16.0"
[dev-dependencies]

@ -43,6 +43,7 @@ pub struct Diagnostic {
pub message: String,
pub severity: Option<Severity>,
pub code: Option<NumberOrString>,
pub language_server_id: usize,
pub tags: Vec<DiagnosticTag>,
pub source: Option<String>,
pub data: Option<serde_json::Value>,

@ -36,55 +36,12 @@ pub mod unicode {
pub use unicode_width as width;
}
pub use helix_loader::find_workspace;
pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option<usize> {
line.chars().position(|ch| !ch.is_whitespace())
}
/// Find project root.
///
/// Order of detection:
/// * Top-most folder containing a root marker in current git repository
/// * Git repository root if no marker detected
/// * Top-most folder containing a root marker if not git repository detected
/// * Current working directory as fallback
pub fn find_root(root: Option<&str>, root_markers: &[String]) -> std::path::PathBuf {
let current_dir = std::env::current_dir().expect("unable to determine current directory");
let root = match root {
Some(root) => {
let root = std::path::Path::new(root);
if root.is_absolute() {
root.to_path_buf()
} else {
current_dir.join(root)
}
}
None => current_dir.clone(),
};
let mut top_marker = None;
for ancestor in root.ancestors() {
if root_markers
.iter()
.any(|marker| ancestor.join(marker).exists())
{
top_marker = Some(ancestor);
}
if ancestor.join(".git").exists() {
// Top marker is repo root if not root marker was detected yet
if top_marker.is_none() {
top_marker = Some(ancestor);
}
// Don't go higher than repo if we're in one
break;
}
}
// Return the found top marker or the current_dir as fallback
top_marker.map_or(current_dir, |a| a.to_path_buf())
}
pub use ropey::{self, str_utils, Rope, RopeBuilder, RopeSlice};
// pub use tendril::StrTendril as Tendril;
@ -98,7 +55,7 @@ pub use {regex, tree_sitter};
pub use graphemes::RopeGraphemes;
pub use position::{
char_idx_at_visual_offset, coords_at_pos, pos_at_coords, visual_offset_from_anchor,
visual_offset_from_block, Position,
visual_offset_from_block, Position, VisualOffsetError,
};
#[allow(deprecated)]
pub use position::{pos_at_visual_coords, visual_coords_at_pos};
@ -110,4 +67,4 @@ pub use syntax::Syntax;
pub use diagnostic::Diagnostic;
pub use line_ending::{LineEnding, DEFAULT_LINE_ENDING};
pub use transaction::{Assoc, Change, ChangeSet, Operation, Transaction};
pub use transaction::{Assoc, Change, ChangeSet, Deletion, Operation, Transaction};

@ -62,7 +62,7 @@ pub fn move_vertically_visual(
annotations: &mut TextAnnotations,
) -> Range {
if !text_fmt.soft_wrap {
move_vertically(slice, range, dir, count, behaviour, text_fmt, annotations);
return move_vertically(slice, range, dir, count, behaviour, text_fmt, annotations);
}
annotations.clear_line_annotations();
let pos = range.cursor(slice);

@ -40,6 +40,21 @@ pub fn expand_tilde(path: &Path) -> PathBuf {
/// needs to improve on.
/// Copied from cargo: <https://github.com/rust-lang/cargo/blob/070e459c2d8b79c5b2ac5218064e7603329c92ae/crates/cargo-util/src/paths.rs#L81>
pub fn get_normalized_path(path: &Path) -> PathBuf {
// normalization strategy is to canonicalize first ancestor path that exists (i.e., canonicalize as much as possible),
// then run handrolled normalization on the non-existent remainder
let (base, path) = path
.ancestors()
.find_map(|base| {
let canonicalized_base = dunce::canonicalize(base).ok()?;
let remainder = path.strip_prefix(base).ok()?.into();
Some((canonicalized_base, remainder))
})
.unwrap_or_else(|| (PathBuf::new(), PathBuf::from(path)));
if path.as_os_str().is_empty() {
return base;
}
let mut components = path.components().peekable();
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
components.next();
@ -63,7 +78,7 @@ pub fn get_normalized_path(path: &Path) -> PathBuf {
}
}
}
ret
base.join(ret)
}
/// Returns the canonical, absolute form of a path with all intermediate components normalized.
@ -82,13 +97,19 @@ pub fn get_canonicalized_path(path: &Path) -> std::io::Result<PathBuf> {
}
pub fn get_relative_path(path: &Path) -> PathBuf {
let path = PathBuf::from(path);
let path = if path.is_absolute() {
let cwdir = std::env::current_dir().expect("couldn't determine current directory");
path.strip_prefix(cwdir).unwrap_or(path)
let cwdir = std::env::current_dir()
.map(|path| get_normalized_path(&path))
.expect("couldn't determine current directory");
get_normalized_path(&path)
.strip_prefix(cwdir)
.map(PathBuf::from)
.unwrap_or(path)
} else {
path
};
fold_home_dir(path)
fold_home_dir(&path)
}
/// Returns a truncated filepath where the basepart of the path is reduced to the first

@ -109,7 +109,7 @@ pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Po
/// softwrapping positions are estimated with an O(1) algorithm
/// to ensure consistent performance for large lines (currently unimplemented)
///
/// Usualy you want to use `visual_offset_from_anchor` instead but this function
/// Usually you want to use `visual_offset_from_anchor` instead but this function
/// can be useful (and faster) if
/// * You already know the visual position of the block
/// * You only care about the horizontal offset (column) and not the vertical offset (row)
@ -137,6 +137,12 @@ pub fn visual_offset_from_block(
(last_pos, block_start)
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum VisualOffsetError {
PosBeforeAnchorRow,
PosAfterMaxRow,
}
/// Returns the visual offset from the start of the visual line
/// that contains anchor.
pub fn visual_offset_from_anchor(
@ -146,28 +152,46 @@ pub fn visual_offset_from_anchor(
text_fmt: &TextFormat,
annotations: &TextAnnotations,
max_rows: usize,
) -> Option<(Position, usize)> {
) -> Result<(Position, usize), VisualOffsetError> {
let (formatter, block_start) =
DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, annotations, anchor);
let mut char_pos = block_start;
let mut anchor_line = None;
let mut found_pos = None;
let mut last_pos = Position::default();
if pos < block_start {
return Err(VisualOffsetError::PosBeforeAnchorRow);
}
for (grapheme, vpos) in formatter {
last_pos = vpos;
char_pos += grapheme.doc_chars();
if char_pos > anchor && anchor_line.is_none() {
anchor_line = Some(last_pos.row);
}
if char_pos > pos {
last_pos.row -= anchor_line.unwrap();
return Some((last_pos, block_start));
if let Some(anchor_line) = anchor_line {
last_pos.row -= anchor_line;
return Ok((last_pos, block_start));
} else {
found_pos = Some(last_pos);
}
}
if char_pos > anchor && anchor_line.is_none() {
if let Some(mut found_pos) = found_pos {
return if found_pos.row == last_pos.row {
found_pos.row = 0;
Ok((found_pos, block_start))
} else {
Err(VisualOffsetError::PosBeforeAnchorRow)
};
} else {
anchor_line = Some(last_pos.row);
}
}
if let Some(anchor_line) = anchor_line {
if vpos.row >= anchor_line + max_rows {
return None;
return Err(VisualOffsetError::PosAfterMaxRow);
}
}
}
@ -175,7 +199,7 @@ pub fn visual_offset_from_anchor(
let anchor_line = anchor_line.unwrap_or(last_pos.row);
last_pos.row -= anchor_line;
Some((last_pos, block_start))
Ok((last_pos, block_start))
}
/// Convert (line, column) coordinates to a character index.
@ -267,7 +291,7 @@ pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize)
///
/// If no (text) grapheme starts at exactly at the specified column the
/// start of the grapheme to the left is returned. If there is no grapheme
/// to the left (for example if the line starts with virtual text) then the positiong
/// to the left (for example if the line starts with virtual text) then the positioning
/// of the next grapheme to the right is returned.
///
/// If the `line` coordinate is beyond the end of the file, the EOF
@ -285,18 +309,19 @@ pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize)
/// on the visual line is returned if the visual line contains any text:
/// If the visual line at the specified offset is a virtual line generated by a `LineAnnotation`
/// the previous char_index is returned, together with the remaining vertical offset (`virtual_lines`)
pub fn char_idx_at_visual_offset<'a>(
text: RopeSlice<'a>,
pub fn char_idx_at_visual_offset(
text: RopeSlice,
mut anchor: usize,
mut row_offset: isize,
column: usize,
text_fmt: &TextFormat,
annotations: &TextAnnotations,
) -> (usize, usize) {
let mut pos = anchor;
// convert row relative to visual line containing anchor to row relative to a block containing anchor (anchor may change)
loop {
let (visual_pos_in_block, block_char_offset) =
visual_offset_from_block(text, anchor, anchor, text_fmt, annotations);
visual_offset_from_block(text, anchor, pos, text_fmt, annotations);
row_offset += visual_pos_in_block.row as isize;
anchor = block_char_offset;
if row_offset >= 0 {
@ -308,10 +333,10 @@ pub fn char_idx_at_visual_offset<'a>(
break;
}
// the row_offset is negative so we need to look at the previous block
// set the anchor to the last char before the current block
// this char index is also always a line earlier so increase the row_offset by 1
// set the anchor to the last char before the current block so that we can compute
// the distance of this block from the start of the previous block
pos = anchor;
anchor -= 1;
row_offset += 1;
}
char_idx_at_visual_block_offset(

@ -78,4 +78,12 @@ impl Registers {
pub fn inner(&self) -> &HashMap<char, Register> {
&self.inner
}
pub fn clear(&mut self) {
self.inner.clear();
}
pub fn remove(&mut self, name: char) -> Option<Register> {
self.inner.remove(&name)
}
}

@ -38,7 +38,7 @@ use std::borrow::Cow;
/// Ranges are considered to be inclusive on the left and
/// exclusive on the right, regardless of anchor-head ordering.
/// This means, for example, that non-zero-width ranges that
/// are directly adjecent, sharing an edge, do not overlap.
/// are directly adjacent, sharing an edge, do not overlap.
/// However, a zero-width range will overlap with the shared
/// left-edge of another range.
///
@ -522,7 +522,14 @@ impl Selection {
self
}
// Merges all ranges that are consecutive
/// Replaces ranges with one spanning from first to last range.
pub fn merge_ranges(self) -> Self {
let first = self.ranges.first().unwrap();
let last = self.ranges.last().unwrap();
Selection::new(smallvec![first.merge(*last)], 0)
}
/// Merges all ranges that are consecutive.
pub fn merge_consecutive_ranges(mut self) -> Self {
let mut primary = self.ranges[self.primary_index];

@ -294,14 +294,14 @@ mod test {
#[test]
fn test_lists() {
let input =
r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "qoutes"]'"#;
r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "quotes"]'"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":set"),
Cow::from("statusline.center"),
Cow::from(r#"["file-type","file-encoding"]"#),
Cow::from(r#"["list", "in", "qoutes"]"#),
Cow::from(r#"["list", "in", "quotes"]"#),
];
assert_eq!(expected, result);
}

@ -397,15 +397,10 @@ mod test {
let selections: SmallVec<[Range; 1]> = spec
.match_indices('^')
.into_iter()
.map(|(i, _)| Range::point(i))
.collect();
let expectations: Vec<usize> = spec
.match_indices('_')
.into_iter()
.map(|(i, _)| i)
.collect();
let expectations: Vec<usize> = spec.match_indices('_').map(|(i, _)| i).collect();
(rope, Selection::new(selections, 0), expectations)
}

@ -16,17 +16,17 @@ use slotmap::{DefaultKey as LayerId, HopSlotMap};
use std::{
borrow::Cow,
cell::RefCell,
collections::{HashMap, VecDeque},
fmt,
collections::{HashMap, HashSet, VecDeque},
fmt::{self, Display},
hash::{Hash, Hasher},
mem::{replace, transmute},
path::Path,
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
};
use once_cell::sync::{Lazy, OnceCell};
use serde::{Deserialize, Serialize};
use serde::{ser::SerializeSeq, Deserialize, Serialize};
use helix_loader::grammar::{get_language, load_runtime_file};
@ -60,8 +60,11 @@ fn default_timeout() -> u64 {
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Configuration {
pub language: Vec<LanguageConfiguration>,
#[serde(default)]
pub language_server: HashMap<String, LanguageServerConfiguration>,
}
impl Default for Configuration {
@ -75,7 +78,10 @@ impl Default for Configuration {
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct LanguageConfiguration {
#[serde(rename = "name")]
pub language_id: String, // c-sharp, rust
pub language_id: String, // c-sharp, rust, tsx
#[serde(rename = "language-id")]
// see the table under https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem
pub language_server_language_id: Option<String>, // csharp, rust, typescriptreact, for the language-server
pub scope: String, // source.rust
pub file_types: Vec<FileType>, // filename extension or ends_with? <Gemfile, rb, etc>
#[serde(default)]
@ -85,9 +91,6 @@ pub struct LanguageConfiguration {
pub text_width: Option<usize>,
pub soft_wrap: Option<SoftWrap>,
#[serde(default, skip_serializing, deserialize_with = "deserialize_lsp_config")]
pub config: Option<serde_json::Value>,
#[serde(default)]
pub auto_format: bool,
@ -107,8 +110,13 @@ pub struct LanguageConfiguration {
#[serde(skip)]
pub(crate) highlight_config: OnceCell<Option<Arc<HighlightConfiguration>>>,
// tags_config OnceCell<> https://github.com/tree-sitter/tree-sitter/pull/583
#[serde(skip_serializing_if = "Option::is_none")]
pub language_server: Option<LanguageServerConfiguration>,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
serialize_with = "serialize_lang_features",
deserialize_with = "deserialize_lang_features"
)]
pub language_servers: Vec<LanguageServerFeatures>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indent: Option<IndentationConfiguration>,
@ -131,6 +139,10 @@ pub struct LanguageConfiguration {
pub auto_pairs: Option<AutoPairs>,
pub rulers: Option<Vec<u16>>, // if set, override editor's rulers
/// Hardcoded LSP root directories relative to the workspace root, like `examples` or `tools/fuzz`.
/// Falling back to the current working directory if none are configured.
pub workspace_lsp_roots: Option<Vec<PathBuf>>,
}
#[derive(Debug, PartialEq, Eq, Hash)]
@ -187,9 +199,12 @@ impl<'de> Deserialize<'de> for FileType {
M: serde::de::MapAccess<'de>,
{
match map.next_entry::<String, String>()? {
Some((key, suffix)) if key == "suffix" => Ok(FileType::Suffix(
suffix.replace('/', &std::path::MAIN_SEPARATOR.to_string()),
)),
Some((key, suffix)) if key == "suffix" => Ok(FileType::Suffix({
// FIXME: use `suffix.replace('/', std::path::MAIN_SEPARATOR_STR)`
// if MSRV is updated to 1.68
let mut seperator = [0; 1];
suffix.replace('/', std::path::MAIN_SEPARATOR.encode_utf8(&mut seperator))
})),
Some((key, _value)) => Err(serde::de::Error::custom(format!(
"unknown key in `file-types` list: {}",
key
@ -205,6 +220,133 @@ impl<'de> Deserialize<'de> for FileType {
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum LanguageServerFeature {
Format,
GotoDeclaration,
GotoDefinition,
GotoTypeDefinition,
GotoReference,
GotoImplementation,
// Goto, use bitflags, combining previous Goto members?
SignatureHelp,
Hover,
DocumentHighlight,
Completion,
CodeAction,
WorkspaceCommand,
DocumentSymbols,
WorkspaceSymbols,
// Symbols, use bitflags, see above?
Diagnostics,
RenameSymbol,
InlayHints,
}
impl Display for LanguageServerFeature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use LanguageServerFeature::*;
let feature = match self {
Format => "format",
GotoDeclaration => "goto-declaration",
GotoDefinition => "goto-definition",
GotoTypeDefinition => "goto-type-definition",
GotoReference => "goto-type-definition",
GotoImplementation => "goto-implementation",
SignatureHelp => "signature-help",
Hover => "hover",
DocumentHighlight => "document-highlight",
Completion => "completion",
CodeAction => "code-action",
WorkspaceCommand => "workspace-command",
DocumentSymbols => "document-symbols",
WorkspaceSymbols => "workspace-symbols",
Diagnostics => "diagnostics",
RenameSymbol => "rename-symbol",
InlayHints => "inlay-hints",
};
write!(f, "{feature}",)
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)]
enum LanguageServerFeatureConfiguration {
#[serde(rename_all = "kebab-case")]
Features {
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
only_features: HashSet<LanguageServerFeature>,
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
except_features: HashSet<LanguageServerFeature>,
name: String,
},
Simple(String),
}
#[derive(Debug, Default)]
pub struct LanguageServerFeatures {
pub name: String,
pub only: HashSet<LanguageServerFeature>,
pub excluded: HashSet<LanguageServerFeature>,
}
impl LanguageServerFeatures {
pub fn has_feature(&self, feature: LanguageServerFeature) -> bool {
(self.only.is_empty() || self.only.contains(&feature)) && !self.excluded.contains(&feature)
}
}
fn deserialize_lang_features<'de, D>(
deserializer: D,
) -> Result<Vec<LanguageServerFeatures>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Vec<LanguageServerFeatureConfiguration> = Deserialize::deserialize(deserializer)?;
let res = raw
.into_iter()
.map(|config| match config {
LanguageServerFeatureConfiguration::Simple(name) => LanguageServerFeatures {
name,
..Default::default()
},
LanguageServerFeatureConfiguration::Features {
only_features,
except_features,
name,
} => LanguageServerFeatures {
name,
only: only_features,
excluded: except_features,
},
})
.collect();
Ok(res)
}
fn serialize_lang_features<S>(
map: &Vec<LanguageServerFeatures>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut serializer = serializer.serialize_seq(Some(map.len()))?;
for features in map {
let features = if features.only.is_empty() && features.excluded.is_empty() {
LanguageServerFeatureConfiguration::Simple(features.name.to_owned())
} else {
LanguageServerFeatureConfiguration::Features {
only_features: features.only.clone(),
except_features: features.excluded.clone(),
name: features.name.to_owned(),
}
};
serializer.serialize_element(&features)?;
}
serializer.end()
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct LanguageServerConfiguration {
@ -214,9 +356,10 @@ pub struct LanguageServerConfiguration {
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub environment: HashMap<String, String>,
#[serde(default, skip_serializing, deserialize_with = "deserialize_lsp_config")]
pub config: Option<serde_json::Value>,
#[serde(default = "default_timeout")]
pub timeout: u64,
pub language_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -569,6 +712,8 @@ impl LanguageConfiguration {
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct SoftWrap {
/// Soft wrap lines that exceed viewport width. Default to off
// NOTE: Option on purpose because the struct is shared between language config and global config.
// By default the option is None so that the language config falls back to the global config unless explicitly set.
pub enable: Option<bool>,
/// Maximum space left free at the end of the line.
/// This space is used to wrap text at word boundaries. If that is not possible within this limit
@ -603,6 +748,8 @@ pub struct Loader {
language_config_ids_by_suffix: HashMap<String, usize>,
language_config_ids_by_shebang: HashMap<String, usize>,
language_server_configs: HashMap<String, LanguageServerConfiguration>,
scopes: ArcSwap<Vec<String>>,
}
@ -610,6 +757,7 @@ impl Loader {
pub fn new(config: Configuration) -> Self {
let mut loader = Self {
language_configs: Vec::new(),
language_server_configs: config.language_server,
language_config_ids_by_extension: HashMap::new(),
language_config_ids_by_suffix: HashMap::new(),
language_config_ids_by_shebang: HashMap::new(),
@ -674,9 +822,8 @@ impl Loader {
pub fn language_config_for_shebang(&self, source: &Rope) -> Option<Arc<LanguageConfiguration>> {
let line = Cow::from(source.line(0));
static SHEBANG_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^#!\s*(?:\S*[/\\](?:env\s+(?:\-\S+\s+)*)?)?([^\s\.\d]+)").unwrap()
});
static SHEBANG_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(&["^", SHEBANG].concat()).unwrap());
let configuration_id = SHEBANG_REGEX
.captures(&line)
.and_then(|cap| self.language_config_ids_by_shebang.get(&cap[1]));
@ -698,15 +845,14 @@ impl Loader {
.cloned()
}
pub fn language_configuration_for_injection_string(
&self,
string: &str,
) -> Option<Arc<LanguageConfiguration>> {
/// Unlike language_config_for_language_id, which only returns Some for an exact id, this
/// function will perform a regex match on the given string to find the closest language match.
pub fn language_config_for_name(&self, name: &str) -> Option<Arc<LanguageConfiguration>> {
let mut best_match_length = 0;
let mut best_match_position = None;
for (i, configuration) in self.language_configs.iter().enumerate() {
if let Some(injection_regex) = &configuration.injection_regex {
if let Some(mat) = injection_regex.find(string) {
if let Some(mat) = injection_regex.find(name) {
let length = mat.end() - mat.start();
if length > best_match_length {
best_match_position = Some(i);
@ -716,17 +862,30 @@ impl Loader {
}
}
if let Some(i) = best_match_position {
let configuration = &self.language_configs[i];
return Some(configuration.clone());
best_match_position.map(|i| self.language_configs[i].clone())
}
pub fn language_configuration_for_injection_string(
&self,
capture: &InjectionLanguageMarker,
) -> Option<Arc<LanguageConfiguration>> {
match capture {
InjectionLanguageMarker::Name(string) => self.language_config_for_name(string),
InjectionLanguageMarker::Filename(file) => self.language_config_for_file_name(file),
InjectionLanguageMarker::Shebang(shebang) => {
self.language_config_for_language_id(shebang)
}
}
None
}
pub fn language_configs(&self) -> impl Iterator<Item = &Arc<LanguageConfiguration>> {
self.language_configs.iter()
}
pub fn language_server_configs(&self) -> &HashMap<String, LanguageServerConfiguration> {
&self.language_server_configs
}
pub fn set_scopes(&self, scopes: Vec<String>) {
self.scopes.store(Arc::new(scopes));
@ -770,7 +929,11 @@ fn byte_range_to_str(range: std::ops::Range<usize>, source: RopeSlice) -> Cow<st
}
impl Syntax {
pub fn new(source: &Rope, config: Arc<HighlightConfiguration>, loader: Arc<Loader>) -> Self {
pub fn new(
source: &Rope,
config: Arc<HighlightConfiguration>,
loader: Arc<Loader>,
) -> Option<Self> {
let root_layer = LanguageLayer {
tree: None,
config,
@ -795,11 +958,13 @@ impl Syntax {
loader,
};
syntax
.update(source, source, &ChangeSet::new(source))
.unwrap();
let res = syntax.update(source, source, &ChangeSet::new(source));
syntax
if res.is_err() {
log::error!("TS parser failed, disabeling TS for the current buffer: {res:?}");
return None;
}
Some(syntax)
}
pub fn update(
@ -812,7 +977,7 @@ impl Syntax {
queue.push_back(self.root);
let scopes = self.loader.scopes.load();
let injection_callback = |language: &str| {
let injection_callback = |language: &InjectionLanguageMarker| {
self.loader
.language_configuration_for_injection_string(language)
.and_then(|language_config| language_config.highlight_config(&scopes))
@ -927,6 +1092,7 @@ impl Syntax {
PARSER.with(|ts_parser| {
let ts_parser = &mut ts_parser.borrow_mut();
ts_parser.parser.set_timeout_micros(1000 * 500); // half a second is pretty generours
let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new);
// TODO: might need to set cursor range
cursor.set_byte_range(0..usize::MAX);
@ -973,12 +1139,9 @@ impl Syntax {
);
let mut injections = Vec::new();
for mat in matches {
let (language_name, content_node, included_children) = injection_for_match(
&layer.config,
&layer.config.injections_query,
&mat,
source_slice,
);
let (injection_capture, content_node, included_children) = layer
.config
.injection_for_match(&layer.config.injections_query, &mat, source_slice);
// Explicitly remove this match so that none of its other captures will remain
// in the stream of captures.
@ -986,9 +1149,10 @@ impl Syntax {
// If a language is found with the given name, then add a new language layer
// to the highlighted document.
if let (Some(language_name), Some(content_node)) = (language_name, content_node)
if let (Some(injection_capture), Some(content_node)) =
(injection_capture, content_node)
{
if let Some(config) = (injection_callback)(&language_name) {
if let Some(config) = (injection_callback)(&injection_capture) {
let ranges =
intersect_ranges(&layer.ranges, &[content_node], included_children);
@ -1013,14 +1177,11 @@ impl Syntax {
);
for mat in matches {
let entry = &mut injections_by_pattern_index[mat.pattern_index];
let (language_name, content_node, included_children) = injection_for_match(
&layer.config,
combined_injections_query,
&mat,
source_slice,
);
if language_name.is_some() {
entry.0 = language_name;
let (injection_capture, content_node, included_children) = layer
.config
.injection_for_match(combined_injections_query, &mat, source_slice);
if injection_capture.is_some() {
entry.0 = injection_capture;
}
if let Some(content_node) = content_node {
entry.1.push(content_node);
@ -1407,6 +1568,8 @@ pub struct HighlightConfiguration {
non_local_variable_patterns: Vec<bool>,
injection_content_capture_index: Option<u32>,
injection_language_capture_index: Option<u32>,
injection_filename_capture_index: Option<u32>,
injection_shebang_capture_index: Option<u32>,
local_scope_capture_index: Option<u32>,
local_def_capture_index: Option<u32>,
local_def_value_capture_index: Option<u32>,
@ -1550,6 +1713,8 @@ impl HighlightConfiguration {
// Store the numeric ids for all of the special captures.
let mut injection_content_capture_index = None;
let mut injection_language_capture_index = None;
let mut injection_filename_capture_index = None;
let mut injection_shebang_capture_index = None;
let mut local_def_capture_index = None;
let mut local_def_value_capture_index = None;
let mut local_ref_capture_index = None;
@ -1570,6 +1735,8 @@ impl HighlightConfiguration {
match name.as_str() {
"injection.content" => injection_content_capture_index = i,
"injection.language" => injection_language_capture_index = i,
"injection.filename" => injection_filename_capture_index = i,
"injection.shebang" => injection_shebang_capture_index = i,
_ => {}
}
}
@ -1585,6 +1752,8 @@ impl HighlightConfiguration {
non_local_variable_patterns,
injection_content_capture_index,
injection_language_capture_index,
injection_filename_capture_index,
injection_shebang_capture_index,
local_scope_capture_index,
local_def_capture_index,
local_def_value_capture_index,
@ -1643,6 +1812,90 @@ impl HighlightConfiguration {
self.highlight_indices.store(Arc::new(indices));
}
fn injection_pair<'a>(
&self,
query_match: &QueryMatch<'a, 'a>,
source: RopeSlice<'a>,
) -> (Option<InjectionLanguageMarker<'a>>, Option<Node<'a>>) {
let mut injection_capture = None;
let mut content_node = None;
for capture in query_match.captures {
let index = Some(capture.index);
if index == self.injection_language_capture_index {
let name = byte_range_to_str(capture.node.byte_range(), source);
injection_capture = Some(InjectionLanguageMarker::Name(name));
} else if index == self.injection_filename_capture_index {
let name = byte_range_to_str(capture.node.byte_range(), source);
let path = Path::new(name.as_ref()).to_path_buf();
injection_capture = Some(InjectionLanguageMarker::Filename(path.into()));
} else if index == self.injection_shebang_capture_index {
let node_slice = source.byte_slice(capture.node.byte_range());
// some languages allow space and newlines before the actual string content
// so a shebang could be on either the first or second line
let lines = if let Ok(end) = node_slice.try_line_to_byte(2) {
node_slice.byte_slice(..end)
} else {
node_slice
};
static SHEBANG_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(SHEBANG).unwrap());
injection_capture = SHEBANG_REGEX
.captures(&Cow::from(lines))
.map(|cap| InjectionLanguageMarker::Shebang(cap[1].to_owned()))
} else if index == self.injection_content_capture_index {
content_node = Some(capture.node);
}
}
(injection_capture, content_node)
}
fn injection_for_match<'a>(
&self,
query: &'a Query,
query_match: &QueryMatch<'a, 'a>,
source: RopeSlice<'a>,
) -> (
Option<InjectionLanguageMarker<'a>>,
Option<Node<'a>>,
IncludedChildren,
) {
let (mut injection_capture, content_node) = self.injection_pair(query_match, source);
let mut included_children = IncludedChildren::default();
for prop in query.property_settings(query_match.pattern_index) {
match prop.key.as_ref() {
// In addition to specifying the language name via the text of a
// captured node, it can also be hard-coded via a `#set!` predicate
// that sets the injection.language key.
"injection.language" if injection_capture.is_none() => {
injection_capture = prop
.value
.as_ref()
.map(|s| InjectionLanguageMarker::Name(s.as_ref().into()));
}
// By default, injections do not include the *children* of an
// `injection.content` node - only the ranges that belong to the
// node itself. This can be changed using a `#set!` predicate that
// sets the `injection.include-children` key.
"injection.include-children" => included_children = IncludedChildren::All,
// Some queries might only exclude named children but include unnamed
// children in their `injection.content` node. This can be enabled using
// a `#set!` predicate that sets the `injection.include-unnamed-children` key.
"injection.include-unnamed-children" => {
included_children = IncludedChildren::Unnamed
}
_ => {}
}
}
(injection_capture, content_node, included_children)
}
}
impl<'a> HighlightIterLayer<'a> {
@ -2054,56 +2307,15 @@ impl<'a> Iterator for HighlightIter<'a> {
}
}
fn injection_for_match<'a>(
config: &HighlightConfiguration,
query: &'a Query,
query_match: &QueryMatch<'a, 'a>,
source: RopeSlice<'a>,
) -> (Option<Cow<'a, str>>, Option<Node<'a>>, IncludedChildren) {
let content_capture_index = config.injection_content_capture_index;
let language_capture_index = config.injection_language_capture_index;
let mut language_name = None;
let mut content_node = None;
for capture in query_match.captures {
let index = Some(capture.index);
if index == language_capture_index {
let name = byte_range_to_str(capture.node.byte_range(), source);
language_name = Some(name);
} else if index == content_capture_index {
content_node = Some(capture.node);
}
}
let mut included_children = IncludedChildren::default();
for prop in query.property_settings(query_match.pattern_index) {
match prop.key.as_ref() {
// In addition to specifying the language name via the text of a
// captured node, it can also be hard-coded via a `#set!` predicate
// that sets the injection.language key.
"injection.language" => {
if language_name.is_none() {
language_name = prop.value.as_ref().map(|s| s.as_ref().into())
}
}
// By default, injections do not include the *children* of an
// `injection.content` node - only the ranges that belong to the
// node itself. This can be changed using a `#set!` predicate that
// sets the `injection.include-children` key.
"injection.include-children" => included_children = IncludedChildren::All,
// Some queries might only exclude named children but include unnamed
// children in their `injection.content` node. This can be enabled using
// a `#set!` predicate that sets the `injection.include-unnamed-children` key.
"injection.include-unnamed-children" => included_children = IncludedChildren::Unnamed,
_ => {}
}
}
(language_name, content_node, included_children)
#[derive(Debug, Clone)]
pub enum InjectionLanguageMarker<'a> {
Name(Cow<'a, str>),
Filename(Cow<'a, Path>),
Shebang(String),
}
const SHEBANG: &str = r"#!\s*(?:\S*[/\\](?:env\s+(?:\-\S+\s+)*)?)?([^\s\.\d]+)";
pub struct Merge<I> {
iter: I,
spans: Box<dyn Iterator<Item = (usize, std::ops::Range<usize>)>>,
@ -2319,7 +2531,10 @@ mod test {
"#,
);
let loader = Loader::new(Configuration { language: vec![] });
let loader = Loader::new(Configuration {
language: vec![],
language_server: HashMap::new(),
});
let language = get_language("rust").unwrap();
let query = Query::new(language, query_str).unwrap();
@ -2327,7 +2542,7 @@ mod test {
let mut cursor = QueryCursor::new();
let config = HighlightConfiguration::new(language, "", "", "").unwrap();
let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader));
let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)).unwrap();
let root = syntax.tree().root_node();
let mut test = |capture, range| {
@ -2378,7 +2593,10 @@ mod test {
.map(String::from)
.collect();
let loader = Loader::new(Configuration { language: vec![] });
let loader = Loader::new(Configuration {
language: vec![],
language_server: HashMap::new(),
});
let language = get_language("rust").unwrap();
let config = HighlightConfiguration::new(
@ -2398,7 +2616,7 @@ mod test {
fn main() {}
",
);
let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader));
let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)).unwrap();
let tree = syntax.tree();
let root = tree.root_node();
assert_eq!(root.kind(), "source_file");
@ -2481,11 +2699,14 @@ mod test {
) {
let source = Rope::from_str(source);
let loader = Loader::new(Configuration { language: vec![] });
let loader = Loader::new(Configuration {
language: vec![],
language_server: HashMap::new(),
});
let language = get_language(language_name).unwrap();
let config = HighlightConfiguration::new(language, "", "", "").unwrap();
let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader));
let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)).unwrap();
let root = syntax
.tree()

@ -1,5 +1,4 @@
use std::cell::Cell;
use std::convert::identity;
use std::ops::Range;
use std::rc::Rc;
@ -113,9 +112,7 @@ impl<A, M> Layer<A, M> {
pub fn reset_pos(&self, char_idx: usize, get_char_idx: impl Fn(&A) -> usize) {
let new_index = self
.annotations
.binary_search_by_key(&char_idx, get_char_idx)
.unwrap_or_else(identity);
.partition_point(|annot| get_char_idx(annot) < char_idx);
self.current_index.set(new_index);
}
@ -175,7 +172,7 @@ impl TextAnnotations {
for char_idx in char_range {
if let Some((_, Some(highlight))) = self.overlay_at(char_idx) {
// we don't know the number of chars the original grapheme takes
// however it doesn't matter as highlight bounderies are automatically
// however it doesn't matter as highlight boundaries are automatically
// aligned to grapheme boundaries in the rendering code
highlights.push((highlight.0, char_idx..char_idx + 1))
}
@ -206,7 +203,7 @@ impl TextAnnotations {
/// Add new grapheme overlays.
///
/// The overlayed grapheme will be rendered with `highlight`
/// The overlaid grapheme will be rendered with `highlight`
/// patched on top of `ui.text`.
///
/// The overlays **must be sorted** by their `char_idx`.

@ -5,6 +5,7 @@ use std::borrow::Cow;
/// (from, to, replacement)
pub type Change = (usize, usize, Option<Tendril>);
pub type Deletion = (usize, usize);
// TODO: pub(crate)
#[derive(Debug, Clone, PartialEq, Eq)]
@ -534,6 +535,46 @@ impl Transaction {
Self::from(changeset)
}
/// Generate a transaction from a set of potentially overlapping deletions
/// by merging overlapping deletions together.
pub fn delete<I>(doc: &Rope, deletions: I) -> Self
where
I: Iterator<Item = Deletion>,
{
let len = doc.len_chars();
let (lower, upper) = deletions.size_hint();
let size = upper.unwrap_or(lower);
let mut changeset = ChangeSet::with_capacity(2 * size + 1); // rough estimate
let mut last = 0;
for (mut from, to) in deletions {
if last > to {
continue;
}
if last > from {
from = last
}
debug_assert!(
from <= to,
"Edit end must end before it starts (should {from} <= {to})"
);
// Retain from last "to" to current "from"
changeset.retain(from - last);
changeset.delete(to - from);
last = to;
}
changeset.retain(len - last);
Self::from(changeset)
}
pub fn insert_at_eof(mut self, text: Tendril) -> Transaction {
self.changes.insert(text);
self
}
/// Generate a transaction with a change per selection range.
pub fn change_by_selection<F>(doc: &Rope, selection: &Selection, f: F) -> Self
where
@ -580,6 +621,16 @@ impl Transaction {
)
}
/// Generate a transaction with a deletion per selection range.
/// Compared to using `change_by_selection` directly these ranges may overlap.
/// In that case they are merged
pub fn delete_by_selection<F>(doc: &Rope, selection: &Selection, f: F) -> Self
where
F: FnMut(&Range) -> Deletion,
{
Self::delete(doc, selection.iter().map(f))
}
/// Insert text at each selection head.
pub fn insert(doc: &Rope, selection: &Selection, text: Tendril) -> Self {
Self::change_by_selection(doc, selection, |range| {

@ -72,7 +72,7 @@ fn test_treesitter_indent(file_name: &str, lang_scope: &str) {
let language_config = loader.language_config_for_scope(lang_scope).unwrap();
let highlight_config = language_config.highlight_config(&[]).unwrap();
let syntax = Syntax::new(&doc, highlight_config, std::sync::Arc::new(loader));
let syntax = Syntax::new(&doc, highlight_config, std::sync::Arc::new(loader)).unwrap();
let indent_query = language_config.indent_query().unwrap();
let text = doc.slice(..);

@ -62,12 +62,10 @@ impl Client {
if command.is_empty() {
return Result::Err(Error::Other(anyhow!("Command not provided")));
}
if transport == "tcp" && port_arg.is_some() {
Self::tcp_process(command, args, port_arg.unwrap(), id).await
} else if transport == "stdio" {
Self::stdio(command, args, id)
} else {
Result::Err(Error::Other(anyhow!("Incorrect transport {}", transport)))
match (transport, port_arg) {
("tcp", Some(port_arg)) => Self::tcp_process(command, args, port_arg, id).await,
("stdio", _) => Self::stdio(command, args, id),
_ => Result::Err(Error::Other(anyhow!("Incorrect transport {}", transport))),
}
}
@ -512,4 +510,10 @@ impl Client {
self.call::<requests::SetExceptionBreakpoints>(args)
}
pub fn current_stack_frame(&self) -> Option<&StackFrame> {
self.stack_frames
.get(&self.thread_id?)?
.get(self.active_frame?)
}
}

@ -230,38 +230,48 @@ impl Transport {
}
}
async fn recv(
async fn recv_inner(
transport: Arc<Self>,
mut server_stdout: Box<dyn AsyncBufRead + Unpin + Send>,
client_tx: UnboundedSender<Payload>,
) {
) -> Result<()> {
let mut recv_buffer = String::new();
loop {
match Self::recv_server_message(&mut server_stdout, &mut recv_buffer).await {
Ok(msg) => {
transport
.process_server_message(&client_tx, msg)
.await
.unwrap();
}
Err(err) => {
error!("err: <- {:?}", err);
break;
}
}
let msg = Self::recv_server_message(&mut server_stdout, &mut recv_buffer).await?;
transport.process_server_message(&client_tx, msg).await?;
}
}
async fn send(
async fn recv(
transport: Arc<Self>,
server_stdout: Box<dyn AsyncBufRead + Unpin + Send>,
client_tx: UnboundedSender<Payload>,
) {
if let Err(err) = Self::recv_inner(transport, server_stdout, client_tx).await {
error!("err: <- {:?}", err);
}
}
async fn send_inner(
transport: Arc<Self>,
mut server_stdin: Box<dyn AsyncWrite + Unpin + Send>,
mut client_rx: UnboundedReceiver<Payload>,
) {
) -> Result<()> {
while let Some(payload) = client_rx.recv().await {
transport
.send_payload_to_server(&mut server_stdin, payload)
.await
.unwrap()
.await?;
}
Ok(())
}
async fn send(
transport: Arc<Self>,
server_stdin: Box<dyn AsyncWrite + Unpin + Send>,
client_rx: UnboundedReceiver<Payload>,
) {
if let Err(err) = Self::send_inner(transport, server_stdin, client_rx).await {
error!("err: <- {:?}", err);
}
}

@ -17,7 +17,7 @@ path = "src/main.rs"
anyhow = "1"
serde = { version = "1.0", features = ["derive"] }
toml = "0.7"
etcetera = "0.4"
etcetera = "0.8"
tree-sitter = "0.20"
once_cell = "1.17"
log = "0.4"
@ -27,6 +27,7 @@ log = "0.4"
# cloning/compiling tree-sitter grammars
cc = { version = "1" }
threadpool = { version = "1.0" }
tempfile = "3.5.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
libloading = "0.7"
libloading = "0.8"

@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::path::Path;
use std::process::Command;
const VERSION: &str = include_str!("../VERSION");
@ -11,7 +12,7 @@ fn main() {
.filter(|output| output.status.success())
.and_then(|x| String::from_utf8(x.stdout).ok());
let version: Cow<_> = match git_hash {
let version: Cow<_> = match &git_hash {
Some(git_hash) => format!("{} ({})", VERSION, &git_hash[..8]).into(),
None => VERSION.into(),
};
@ -23,4 +24,40 @@ fn main() {
println!("cargo:rerun-if-changed=../VERSION");
println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", version);
if git_hash.is_none() {
return;
}
// we need to revparse because the git dir could be anywhere if you are
// using detached worktrees but there is no good way to obtain an OsString
// from command output so for now we can't accept non-utf8 paths here
// probably rare enouch where it doesn't matter tough we could use gitoxide
// here but that would be make it a hard dependency and slow compile times
let Some(git_dir): Option<String> = Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|x| String::from_utf8(x.stdout).ok())
else{ return; };
// If heads starts pointing at something else (different branch)
// we need to return
let head = Path::new(&git_dir).join("HEAD");
if head.exists() {
println!("cargo:rerun-if-changed={}", head.display());
}
// if the thing head points to (branch) itself changes
// we need to return
let Some(head_ref): Option<String> = Command::new("git")
.args(["symbolic-ref", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|x| String::from_utf8(x.stdout).ok())
else{ return; };
let head_ref = Path::new(&git_dir).join(head_ref);
if head_ref.exists() {
println!("cargo:rerun-if-changed={}", head_ref.display());
}
}

@ -9,37 +9,38 @@ pub fn default_lang_config() -> toml::Value {
/// User configured languages.toml file, merged with the default config.
pub fn user_lang_config() -> Result<toml::Value, toml::de::Error> {
let config = crate::local_config_dirs()
.into_iter()
.chain([crate::config_dir()].into_iter())
.map(|path| path.join("languages.toml"))
.filter_map(|file| {
std::fs::read_to_string(file)
.map(|config| toml::from_str(&config))
.ok()
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.chain([default_lang_config()].into_iter())
.fold(toml::Value::Table(toml::value::Table::default()), |a, b| {
// combines for example
// b:
// [[language]]
// name = "toml"
// language-server = { command = "taplo", args = ["lsp", "stdio"] }
//
// a:
// [[language]]
// language-server = { command = "/usr/bin/taplo" }
//
// into:
// [[language]]
// name = "toml"
// language-server = { command = "/usr/bin/taplo" }
//
// thus it overrides the third depth-level of b with values of a if they exist, but otherwise merges their values
crate::merge_toml_values(b, a, 3)
});
let config = [
crate::config_dir(),
crate::find_workspace().0.join(".helix"),
]
.into_iter()
.map(|path| path.join("languages.toml"))
.filter_map(|file| {
std::fs::read_to_string(file)
.map(|config| toml::from_str(&config))
.ok()
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.fold(default_lang_config(), |a, b| {
// combines for example
// b:
// [[language]]
// name = "toml"
// language-server = { command = "taplo", args = ["lsp", "stdio"] }
//
// a:
// [[language]]
// language-server = { command = "/usr/bin/taplo" }
//
// into:
// [[language]]
// name = "toml"
// language-server = { command = "/usr/bin/taplo" }
//
// thus it overrides the third depth-level of b with values of a if they exist, but otherwise merges their values
crate::merge_toml_values(a, b, 3)
});
Ok(config)
}

@ -1,4 +1,4 @@
use anyhow::{anyhow, Context, Result};
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::time::SystemTime;
@ -8,6 +8,7 @@ use std::{
process::Command,
sync::mpsc::channel,
};
use tempfile::TempPath;
use tree_sitter::Language;
#[cfg(unix)]
@ -97,15 +98,12 @@ pub fn fetch_grammars() -> Result<()> {
let mut git_up_to_date = 0;
let mut non_git = Vec::new();
for res in results {
for (grammar_id, res) in results {
match res {
Ok(FetchStatus::GitUpToDate) => git_up_to_date += 1,
Ok(FetchStatus::GitUpdated {
grammar_id,
revision,
}) => git_updated.push((grammar_id, revision)),
Ok(FetchStatus::NonGit { grammar_id }) => non_git.push(grammar_id),
Err(e) => errors.push(e),
Ok(FetchStatus::GitUpdated { revision }) => git_updated.push((grammar_id, revision)),
Ok(FetchStatus::NonGit) => non_git.push(grammar_id),
Err(e) => errors.push((grammar_id, e)),
}
}
@ -137,10 +135,10 @@ pub fn fetch_grammars() -> Result<()> {
if !errors.is_empty() {
let len = errors.len();
println!("{} grammars failed to fetch", len);
for (i, error) in errors.into_iter().enumerate() {
println!("\tFailure {}/{}: {}", i + 1, len, error);
for (i, (grammar, error)) in errors.into_iter().enumerate() {
println!("Failure {}/{len}: {grammar} {error}", i + 1);
}
bail!("{len} grammars failed to fetch");
}
Ok(())
@ -157,11 +155,11 @@ pub fn build_grammars(target: Option<String>) -> Result<()> {
let mut already_built = 0;
let mut built = Vec::new();
for res in results {
for (grammar_id, res) in results {
match res {
Ok(BuildStatus::AlreadyBuilt) => already_built += 1,
Ok(BuildStatus::Built { grammar_id }) => built.push(grammar_id),
Err(e) => errors.push(e),
Ok(BuildStatus::Built) => built.push(grammar_id),
Err(e) => errors.push((grammar_id, e)),
}
}
@ -178,10 +176,10 @@ pub fn build_grammars(target: Option<String>) -> Result<()> {
if !errors.is_empty() {
let len = errors.len();
println!("{} grammars failed to build", len);
for (i, error) in errors.into_iter().enumerate() {
println!("\tFailure {}/{}: {}", i, len, error);
for (i, (grammar_id, error)) in errors.into_iter().enumerate() {
println!("Failure {}/{len}: {grammar_id} {error}", i + 1);
}
bail!("{len} grammars failed to build");
}
Ok(())
@ -213,7 +211,7 @@ fn get_grammar_configs() -> Result<Vec<GrammarConfiguration>> {
Ok(grammars)
}
fn run_parallel<F, Res>(grammars: Vec<GrammarConfiguration>, job: F) -> Vec<Result<Res>>
fn run_parallel<F, Res>(grammars: Vec<GrammarConfiguration>, job: F) -> Vec<(String, Result<Res>)>
where
F: Fn(GrammarConfiguration) -> Result<Res> + Send + 'static + Clone,
Res: Send + 'static,
@ -228,7 +226,7 @@ where
pool.execute(move || {
// Ignore any SendErrors, if any job in another thread has encountered an
// error the Receiver will be closed causing this send to fail.
let _ = tx.send(job(grammar));
let _ = tx.send((grammar.grammar_id.clone(), job(grammar)));
});
}
@ -239,13 +237,8 @@ where
enum FetchStatus {
GitUpToDate,
GitUpdated {
grammar_id: String,
revision: String,
},
NonGit {
grammar_id: String,
},
GitUpdated { revision: String },
NonGit,
}
fn fetch_grammar(grammar: GrammarConfiguration) -> Result<FetchStatus> {
@ -286,17 +279,12 @@ fn fetch_grammar(grammar: GrammarConfiguration) -> Result<FetchStatus> {
)?;
git(&grammar_dir, ["checkout", &revision])?;
Ok(FetchStatus::GitUpdated {
grammar_id: grammar.grammar_id,
revision,
})
Ok(FetchStatus::GitUpdated { revision })
} else {
Ok(FetchStatus::GitUpToDate)
}
} else {
Ok(FetchStatus::NonGit {
grammar_id: grammar.grammar_id,
})
Ok(FetchStatus::NonGit)
}
}
@ -346,7 +334,7 @@ where
enum BuildStatus {
AlreadyBuilt,
Built { grammar_id: String },
Built,
}
fn build_grammar(grammar: GrammarConfiguration, target: Option<&str>) -> Result<BuildStatus> {
@ -413,6 +401,18 @@ fn build_tree_sitter_library(
let mut library_path = parser_lib_path.join(&grammar.grammar_id);
library_path.set_extension(DYLIB_EXTENSION);
// if we are running inside a buildscript emit cargo metadata
// to detect if we are running from a buildscript check some env variables
// that cargo only sets for build scripts
if std::env::var("OUT_DIR").is_ok() && std::env::var("CARGO").is_ok() {
if let Some(scanner_path) = scanner_path.as_ref().and_then(|path| path.to_str()) {
println!("cargo:rerun-if-changed={scanner_path}");
}
if let Some(parser_path) = parser_path.to_str() {
println!("cargo:rerun-if-changed={parser_path}");
}
}
let recompile = needs_recompile(&library_path, &parser_path, &scanner_path)
.context("Failed to compare source and binary timestamps")?;
@ -433,16 +433,53 @@ fn build_tree_sitter_library(
for (key, value) in compiler.env() {
command.env(key, value);
}
command.args(compiler.args());
// used to delay dropping the temporary object file until after the compilation is complete
let _path_guard;
if cfg!(all(windows, target_env = "msvc")) {
if compiler.is_like_msvc() {
command
.args(["/nologo", "/LD", "/I"])
.arg(header_path)
.arg("/Od")
.arg("/utf-8");
.arg("/utf-8")
.arg("/std:c11");
if let Some(scanner_path) = scanner_path.as_ref() {
command.arg(scanner_path);
if scanner_path.extension() == Some("c".as_ref()) {
command.arg(scanner_path);
} else {
let mut cpp_command = Command::new(compiler.path());
cpp_command.current_dir(src_path);
for (key, value) in compiler.env() {
cpp_command.env(key, value);
}
cpp_command.args(compiler.args());
let object_file =
library_path.with_file_name(format!("{}_scanner.obj", &grammar.grammar_id));
cpp_command
.args(["/nologo", "/LD", "/I"])
.arg(header_path)
.arg("/Od")
.arg("/utf-8")
.arg("/std:c++14")
.arg(format!("/Fo{}", object_file.display()))
.arg("/c")
.arg(scanner_path);
let output = cpp_command
.output()
.context("Failed to execute C++ compiler")?;
if !output.status.success() {
return Err(anyhow!(
"Parser compilation failed.\nStdout: {}\nStderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
}
command.arg(&object_file);
_path_guard = TempPath::from_path(object_file);
}
}
command
@ -454,20 +491,49 @@ fn build_tree_sitter_library(
.arg("-shared")
.arg("-fPIC")
.arg("-fno-exceptions")
.arg("-g")
.arg("-I")
.arg(header_path)
.arg("-o")
.arg(&library_path)
.arg("-O3");
.arg(&library_path);
if let Some(scanner_path) = scanner_path.as_ref() {
if scanner_path.extension() == Some("c".as_ref()) {
command.arg("-xc").arg("-std=c99").arg(scanner_path);
command.arg("-xc").arg("-std=c11").arg(scanner_path);
} else {
command.arg(scanner_path);
let mut cpp_command = Command::new(compiler.path());
cpp_command.current_dir(src_path);
for (key, value) in compiler.env() {
cpp_command.env(key, value);
}
cpp_command.args(compiler.args());
let object_file =
library_path.with_file_name(format!("{}_scanner.o", &grammar.grammar_id));
cpp_command
.arg("-fPIC")
.arg("-fno-exceptions")
.arg("-I")
.arg(header_path)
.arg("-o")
.arg(&object_file)
.arg("-std=c++14")
.arg("-c")
.arg(scanner_path);
let output = cpp_command
.output()
.context("Failed to execute C++ compiler")?;
if !output.status.success() {
return Err(anyhow!(
"Parser compilation failed.\nStdout: {}\nStderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
}
command.arg(&object_file);
_path_guard = TempPath::from_path(object_file);
}
}
command.arg("-xc").arg(parser_path);
command.arg("-xc").arg("-std=c11").arg(parser_path);
if cfg!(all(
unix,
not(any(target_os = "macos", target_os = "illumos"))
@ -487,9 +553,7 @@ fn build_tree_sitter_library(
));
}
Ok(BuildStatus::Built {
grammar_id: grammar.grammar_id,
})
Ok(BuildStatus::Built)
}
fn needs_recompile(

@ -42,7 +42,7 @@ fn prioritize_runtime_dirs() -> Vec<PathBuf> {
let mut rt_dirs = Vec::new();
if let Ok(dir) = std::env::var("CARGO_MANIFEST_DIR") {
// this is the directory of the crate being run by cargo, we need the workspace path so we take the parent
let path = std::path::PathBuf::from(dir).parent().unwrap().join(RT_DIR);
let path = PathBuf::from(dir).parent().unwrap().join(RT_DIR);
log::debug!("runtime dir: {}", path.to_string_lossy());
rt_dirs.push(path);
}
@ -113,15 +113,6 @@ pub fn config_dir() -> PathBuf {
path
}
pub fn local_config_dirs() -> Vec<PathBuf> {
let directories = find_local_config_dirs()
.into_iter()
.map(|path| path.join(".helix"))
.collect();
log::debug!("Located configuration folders: {:?}", directories);
directories
}
pub fn cache_dir() -> PathBuf {
// TODO: allow env var override
let strategy = choose_base_strategy().expect("Unable to find the config directory!");
@ -137,6 +128,10 @@ pub fn config_file() -> PathBuf {
.unwrap_or_else(|| config_dir().join("config.toml"))
}
pub fn workspace_config_file() -> PathBuf {
find_workspace().0.join(".helix").join("config.toml")
}
pub fn lang_config_file() -> PathBuf {
config_dir().join("languages.toml")
}
@ -145,22 +140,6 @@ pub fn log_file() -> PathBuf {
cache_dir().join("helix.log")
}
pub fn find_local_config_dirs() -> Vec<PathBuf> {
let current_dir = std::env::current_dir().expect("unable to determine current directory");
let mut directories = Vec::new();
for ancestor in current_dir.ancestors() {
if ancestor.join(".git").exists() {
directories.push(ancestor.to_path_buf());
// Don't go higher than repo if we're in one
break;
} else if ancestor.join(".helix").is_dir() {
directories.push(ancestor.to_path_buf());
}
}
directories
}
/// Merge two TOML documents, merging values from `right` onto `left`
///
/// When an array exists in both `left` and `right`, `right`'s array is
@ -230,6 +209,24 @@ pub fn merge_toml_values(left: toml::Value, right: toml::Value, merge_depth: usi
}
}
/// Finds the current workspace folder.
/// Used as a ceiling dir for LSP root resolution, the filepicker and potentially as a future filewatching root
///
/// This function starts searching the FS upward from the CWD
/// and returns the first directory that contains either `.git` or `.helix`.
/// If no workspace was found returns (CWD, true).
/// Otherwise (workspace, false) is returned
pub fn find_workspace() -> (PathBuf, bool) {
let current_dir = std::env::current_dir().expect("unable to determine current directory");
for ancestor in current_dir.ancestors() {
if ancestor.join(".git").exists() || ancestor.join(".helix").exists() {
return (ancestor.to_owned(), false);
}
}
(current_dir, true)
}
#[cfg(test)]
mod merge_toml_tests {
use std::str;

@ -24,6 +24,7 @@ lsp-types = { version = "0.94" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
tokio = { version = "1.26", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio-stream = "0.1.12"
tokio = { version = "1.28", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio-stream = "0.1.14"
which = "4.4"
parking_lot = "0.12.1"

@ -1,22 +1,26 @@
use crate::{
jsonrpc,
find_lsp_workspace, jsonrpc,
transport::{Payload, Transport},
Call, Error, OffsetEncoding, Result,
};
use helix_core::{find_root, ChangeSet, Rope};
use helix_core::{find_workspace, path, syntax::LanguageServerFeature, ChangeSet, Rope};
use helix_loader::{self, VERSION_AND_GIT_HASH};
use lsp::PositionEncodingKind;
use lsp::{
notification::DidChangeWorkspaceFolders, DidChangeWorkspaceFoldersParams, OneOf,
PositionEncodingKind, WorkspaceFolder, WorkspaceFoldersChangeEvent,
};
use lsp_types as lsp;
use parking_lot::Mutex;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::future::Future;
use std::process::Stdio;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use std::{collections::HashMap, path::PathBuf};
use tokio::{
io::{BufReader, BufWriter},
process::{Child, Command},
@ -26,9 +30,21 @@ use tokio::{
},
};
fn workspace_for_uri(uri: lsp::Url) -> WorkspaceFolder {
lsp::WorkspaceFolder {
name: uri
.path_segments()
.and_then(|segments| segments.last())
.map(|basename| basename.to_string())
.unwrap_or_default(),
uri,
}
}
#[derive(Debug)]
pub struct Client {
id: usize,
name: String,
_process: Child,
server_tx: UnboundedSender<Payload>,
request_counter: AtomicU64,
@ -36,20 +52,131 @@ pub struct Client {
config: Option<Value>,
root_path: std::path::PathBuf,
root_uri: Option<lsp::Url>,
workspace_folders: Vec<lsp::WorkspaceFolder>,
workspace_folders: Mutex<Vec<lsp::WorkspaceFolder>>,
initialize_notify: Arc<Notify>,
/// workspace folders added while the server is still initializing
req_timeout: u64,
}
impl Client {
#[allow(clippy::type_complexity)]
#[allow(clippy::too_many_arguments)]
pub fn try_add_doc(
self: &Arc<Self>,
root_markers: &[String],
manual_roots: &[PathBuf],
doc_path: Option<&std::path::PathBuf>,
may_support_workspace: bool,
) -> bool {
let (workspace, workspace_is_cwd) = find_workspace();
let workspace = path::get_normalized_path(&workspace);
let root = find_lsp_workspace(
doc_path
.and_then(|x| x.parent().and_then(|x| x.to_str()))
.unwrap_or("."),
root_markers,
manual_roots,
&workspace,
workspace_is_cwd,
);
let root_uri = root
.as_ref()
.and_then(|root| lsp::Url::from_file_path(root).ok());
if self.root_path == root.unwrap_or(workspace)
|| root_uri.as_ref().map_or(false, |root_uri| {
self.workspace_folders
.lock()
.iter()
.any(|workspace| &workspace.uri == root_uri)
})
{
// workspace URI is already registered so we can use this client
return true;
}
// this server definitely doesn't support multiple workspace, no need to check capabilities
if !may_support_workspace {
return false;
}
let Some(capabilities) = self.capabilities.get() else {
let client = Arc::clone(self);
// initialization hasn't finished yet, deal with this new root later
// TODO: In the edgecase that a **new root** is added
// for an LSP that **doesn't support workspace_folders** before initaliation is finished
// the new roots are ignored.
// That particular edgecase would require retroactively spawning new LSP
// clients and therefore also require us to retroactively update the corresponding
// documents LSP client handle. It's doable but a pretty weird edgecase so let's
// wait and see if anyone ever runs into it.
tokio::spawn(async move {
client.initialize_notify.notified().await;
if let Some(workspace_folders_caps) = client
.capabilities()
.workspace
.as_ref()
.and_then(|cap| cap.workspace_folders.as_ref())
.filter(|cap| cap.supported.unwrap_or(false))
{
client.add_workspace_folder(
root_uri,
&workspace_folders_caps.change_notifications,
);
}
});
return true;
};
if let Some(workspace_folders_caps) = capabilities
.workspace
.as_ref()
.and_then(|cap| cap.workspace_folders.as_ref())
.filter(|cap| cap.supported.unwrap_or(false))
{
self.add_workspace_folder(root_uri, &workspace_folders_caps.change_notifications);
true
} else {
// the server doesn't support multi workspaces, we need a new client
false
}
}
fn add_workspace_folder(
&self,
root_uri: Option<lsp::Url>,
change_notifications: &Option<OneOf<bool, String>>,
) {
// root_uri is None just means that there isn't really any LSP workspace
// associated with this file. For servers that support multiple workspaces
// there is just one server so we can always just use that shared instance.
// No need to add a new workspace root here as there is no logical root for this file
// let the server deal with this
let Some(root_uri) = root_uri else {
return;
};
// server supports workspace folders, let's add the new root to the list
self.workspace_folders
.lock()
.push(workspace_for_uri(root_uri.clone()));
if &Some(OneOf::Left(false)) == change_notifications {
// server specifically opted out of DidWorkspaceChange notifications
// let's assume the server will request the workspace folders itself
// and that we can therefore reuse the client (but are done now)
return;
}
tokio::spawn(self.did_change_workspace(vec![workspace_for_uri(root_uri)], Vec::new()));
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn start(
cmd: &str,
args: &[String],
config: Option<Value>,
server_environment: HashMap<String, String>,
root_markers: &[String],
manual_roots: &[PathBuf],
id: usize,
name: String,
req_timeout: u64,
doc_path: Option<&std::path::PathBuf>,
) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc<Notify>)> {
@ -74,47 +201,51 @@ impl Client {
let stderr = BufReader::new(process.stderr.take().expect("Failed to open stderr"));
let (server_rx, server_tx, initialize_notify) =
Transport::start(reader, writer, stderr, id);
let root_path = find_root(
doc_path.and_then(|x| x.parent().and_then(|x| x.to_str())),
Transport::start(reader, writer, stderr, id, name.clone());
let (workspace, workspace_is_cwd) = find_workspace();
let workspace = path::get_normalized_path(&workspace);
let root = find_lsp_workspace(
doc_path
.and_then(|x| x.parent().and_then(|x| x.to_str()))
.unwrap_or("."),
root_markers,
manual_roots,
&workspace,
workspace_is_cwd,
);
let root_uri = lsp::Url::from_file_path(root_path.clone()).ok();
// `root_uri` and `workspace_folder` can be empty in case there is no workspace
// `root_url` can not, use `workspace` as a fallback
let root_path = root.clone().unwrap_or_else(|| workspace.clone());
let root_uri = root.and_then(|root| lsp::Url::from_file_path(root).ok());
// TODO: support multiple workspace folders
let workspace_folders = root_uri
.clone()
.map(|root| {
vec![lsp::WorkspaceFolder {
name: root
.path_segments()
.and_then(|segments| segments.last())
.map(|basename| basename.to_string())
.unwrap_or_default(),
uri: root,
}]
})
.map(|root| vec![workspace_for_uri(root)])
.unwrap_or_default();
let client = Self {
id,
name,
_process: process,
server_tx,
request_counter: AtomicU64::new(0),
capabilities: OnceCell::new(),
config,
req_timeout,
root_path,
root_uri,
workspace_folders,
workspace_folders: Mutex::new(workspace_folders),
initialize_notify: initialize_notify.clone(),
};
Ok((client, server_rx, initialize_notify))
}
pub fn name(&self) -> &str {
&self.name
}
pub fn id(&self) -> usize {
self.id
}
@ -145,6 +276,87 @@ impl Client {
.expect("language server not yet initialized!")
}
/// Client has to be initialized otherwise this function panics
#[inline]
pub fn supports_feature(&self, feature: LanguageServerFeature) -> bool {
let capabilities = self.capabilities();
use lsp::*;
match feature {
LanguageServerFeature::Format => matches!(
capabilities.document_formatting_provider,
Some(OneOf::Left(true) | OneOf::Right(_))
),
LanguageServerFeature::GotoDeclaration => matches!(
capabilities.declaration_provider,
Some(
DeclarationCapability::Simple(true)
| DeclarationCapability::RegistrationOptions(_)
| DeclarationCapability::Options(_),
)
),
LanguageServerFeature::GotoDefinition => matches!(
capabilities.definition_provider,
Some(OneOf::Left(true) | OneOf::Right(_))
),
LanguageServerFeature::GotoTypeDefinition => matches!(
capabilities.type_definition_provider,
Some(
TypeDefinitionProviderCapability::Simple(true)
| TypeDefinitionProviderCapability::Options(_),
)
),
LanguageServerFeature::GotoReference => matches!(
capabilities.references_provider,
Some(OneOf::Left(true) | OneOf::Right(_))
),
LanguageServerFeature::GotoImplementation => matches!(
capabilities.implementation_provider,
Some(
ImplementationProviderCapability::Simple(true)
| ImplementationProviderCapability::Options(_),
)
),
LanguageServerFeature::SignatureHelp => capabilities.signature_help_provider.is_some(),
LanguageServerFeature::Hover => matches!(
capabilities.hover_provider,
Some(HoverProviderCapability::Simple(true) | HoverProviderCapability::Options(_),)
),
LanguageServerFeature::DocumentHighlight => matches!(
capabilities.document_highlight_provider,
Some(OneOf::Left(true) | OneOf::Right(_))
),
LanguageServerFeature::Completion => capabilities.completion_provider.is_some(),
LanguageServerFeature::CodeAction => matches!(
capabilities.code_action_provider,
Some(
CodeActionProviderCapability::Simple(true)
| CodeActionProviderCapability::Options(_),
)
),
LanguageServerFeature::WorkspaceCommand => {
capabilities.execute_command_provider.is_some()
}
LanguageServerFeature::DocumentSymbols => matches!(
capabilities.document_symbol_provider,
Some(OneOf::Left(true) | OneOf::Right(_))
),
LanguageServerFeature::WorkspaceSymbols => matches!(
capabilities.workspace_symbol_provider,
Some(OneOf::Left(true) | OneOf::Right(_))
),
LanguageServerFeature::Diagnostics => true, // there's no extra server capability
LanguageServerFeature::RenameSymbol => matches!(
capabilities.rename_provider,
Some(OneOf::Left(true)) | Some(OneOf::Right(_))
),
LanguageServerFeature::InlayHints => matches!(
capabilities.inlay_hint_provider,
Some(OneOf::Left(true) | OneOf::Right(InlayHintServerCapabilities::Options(_)))
),
}
}
pub fn offset_encoding(&self) -> OffsetEncoding {
self.capabilities()
.position_encoding
@ -154,7 +366,7 @@ impl Client {
"utf-16" => Some(OffsetEncoding::Utf16),
"utf-32" => Some(OffsetEncoding::Utf32),
encoding => {
log::error!("Server provided invalid position encording {encoding}, defaulting to utf-16");
log::error!("Server provided invalid position encoding {encoding}, defaulting to utf-16");
None
},
})
@ -165,8 +377,10 @@ impl Client {
self.config.as_ref()
}
pub fn workspace_folders(&self) -> &[lsp::WorkspaceFolder] {
&self.workspace_folders
pub async fn workspace_folders(
&self,
) -> parking_lot::MutexGuard<'_, Vec<lsp::WorkspaceFolder>> {
self.workspace_folders.lock()
}
/// Execute a RPC request on the language server.
@ -286,7 +500,7 @@ impl Client {
// General messages
// -------------------------------------------------------------------------------------------
pub(crate) async fn initialize(&self) -> Result<lsp::InitializeResult> {
pub(crate) async fn initialize(&self, enable_snippets: bool) -> Result<lsp::InitializeResult> {
if let Some(config) = &self.config {
log::info!("Using custom LSP config: {}", config);
}
@ -294,7 +508,7 @@ impl Client {
#[allow(deprecated)]
let params = lsp::InitializeParams {
process_id: Some(std::process::id()),
workspace_folders: Some(self.workspace_folders.clone()),
workspace_folders: Some(self.workspace_folders.lock().clone()),
// root_path is obsolete, but some clients like pyright still use it so we specify both.
// clients will prefer _uri if possible
root_path: self.root_path.to_str().map(|path| path.to_owned()),
@ -334,7 +548,7 @@ impl Client {
text_document: Some(lsp::TextDocumentClientCapabilities {
completion: Some(lsp::CompletionClientCapabilities {
completion_item: Some(lsp::CompletionItemCapability {
snippet_support: Some(true),
snippet_support: Some(enable_snippets),
resolve_support: Some(lsp::CompletionItemCapabilityResolveSupport {
properties: vec![
String::from("documentation"),
@ -413,8 +627,8 @@ impl Client {
}),
general: Some(lsp::GeneralClientCapabilities {
position_encodings: Some(vec![
PositionEncodingKind::UTF32,
PositionEncodingKind::UTF8,
PositionEncodingKind::UTF32,
PositionEncodingKind::UTF16,
]),
..Default::default()
@ -465,6 +679,16 @@ impl Client {
)
}
pub fn did_change_workspace(
&self,
added: Vec<WorkspaceFolder>,
removed: Vec<WorkspaceFolder>,
) -> impl Future<Output = Result<()>> {
self.notify::<DidChangeWorkspaceFolders>(DidChangeWorkspaceFoldersParams {
event: WorkspaceFoldersChangeEvent { added, removed },
})
}
// -------------------------------------------------------------------------------------------
// Text document
// -------------------------------------------------------------------------------------------
@ -508,7 +732,11 @@ impl Client {
// Calculation is therefore a bunch trickier.
use helix_core::RopeSlice;
fn traverse(pos: lsp::Position, text: RopeSlice) -> lsp::Position {
fn traverse(
pos: lsp::Position,
text: RopeSlice,
offset_encoding: OffsetEncoding,
) -> lsp::Position {
let lsp::Position {
mut line,
mut character,
@ -525,7 +753,11 @@ impl Client {
line += 1;
character = 0;
} else {
character += ch.len_utf16() as u32;
character += match offset_encoding {
OffsetEncoding::Utf8 => ch.len_utf8() as u32,
OffsetEncoding::Utf16 => ch.len_utf16() as u32,
OffsetEncoding::Utf32 => 1,
};
}
}
lsp::Position { line, character }
@ -546,7 +778,7 @@ impl Client {
}
Delete(_) => {
let start = pos_to_lsp_pos(new_text, new_pos, offset_encoding);
let end = traverse(start, old_text.slice(old_pos..old_end));
let end = traverse(start, old_text.slice(old_pos..old_end), offset_encoding);
// deletion
changes.push(lsp::TextDocumentContentChangeEvent {
@ -563,7 +795,8 @@ impl Client {
// a subsequent delete means a replace, consume it
let end = if let Some(Delete(len)) = iter.peek() {
old_end = old_pos + len;
let end = traverse(start, old_text.slice(old_pos..old_end));
let end =
traverse(start, old_text.slice(old_pos..old_end), offset_encoding);
iter.next();
@ -1030,6 +1263,7 @@ impl Client {
&self,
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
include_declaration: bool,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap();
@ -1046,7 +1280,7 @@ impl Client {
position,
},
context: lsp::ReferenceContext {
include_declaration: true,
include_declaration,
},
work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
partial_result_params: lsp::PartialResultParams {
@ -1148,21 +1382,13 @@ impl Client {
Some(self.call::<lsp::request::CodeActionRequest>(params))
}
pub fn supports_rename(&self) -> bool {
let capabilities = self.capabilities.get().unwrap();
matches!(
capabilities.rename_provider,
Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_))
)
}
pub fn rename_symbol(
&self,
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
new_name: String,
) -> Option<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
if !self.supports_rename() {
if !self.supports_feature(LanguageServerFeature::RenameSymbol) {
return None;
}

@ -10,22 +10,23 @@ pub use lsp::{Position, Url};
pub use lsp_types as lsp;
use futures_util::stream::select_all::SelectAll;
use helix_core::syntax::{LanguageConfiguration, LanguageServerConfiguration};
use helix_core::{
path,
syntax::{LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures},
};
use tokio::sync::mpsc::UnboundedReceiver;
use std::{
collections::{hash_map::Entry, HashMap},
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
};
use thiserror::Error;
use tokio_stream::wrappers::UnboundedReceiverStream;
pub type Result<T> = core::result::Result<T, Error>;
type LanguageId = String;
pub type LanguageServerName = String;
#[derive(Error, Debug)]
pub enum Error {
@ -45,7 +46,7 @@ pub enum Error {
Other(#[from] anyhow::Error),
}
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OffsetEncoding {
/// UTF-8 code units aka bytes
Utf8,
@ -128,7 +129,11 @@ pub mod util {
) -> Option<usize> {
let pos_line = pos.line as usize;
if pos_line > doc.len_lines() - 1 {
return None;
// If it extends past the end, truncate it to the end. This is because the
// way the LSP describes the range including the last newline is by
// specifying a line number after what we would call the last line.
log::warn!("LSP position {pos:?} out of range assuming EOF");
return Some(doc.len_chars());
}
// We need to be careful here to fully comply ith the LSP spec.
@ -144,10 +149,10 @@ pub mod util {
// > \n, \r\n and \r. Positions are line end character agnostic.
// > So you can not specify a position that denotes \r|\n or \n| where | represents the character offset.
//
// This means that while the line must be in bounds the `charater`
// This means that while the line must be in bounds the `character`
// must be capped to the end of the line.
// Note that the end of the line here is **before** the line terminator
// so we must use `line_end_char_index` istead of `doc.line_to_char(pos_line + 1)`
// so we must use `line_end_char_index` instead of `doc.line_to_char(pos_line + 1)`
//
// FIXME: Helix does not fully comply with the LSP spec for line terminators.
// The LSP standard requires that line terminators are ['\n', '\r\n', '\r'].
@ -238,9 +243,20 @@ pub mod util {
pub fn lsp_range_to_range(
doc: &Rope,
range: lsp::Range,
mut range: lsp::Range,
offset_encoding: OffsetEncoding,
) -> Option<Range> {
// This is sort of an edgecase. It's not clear from the spec how to deal with
// ranges where end < start. They don't make much sense but vscode simply caps start to end
// and because it's not specified quite a few LS rely on this as a result (for example the TS server)
if range.start > range.end {
log::error!(
"Invalid LSP range start {:?} > end {:?}, using an empty range at the end instead",
range.start,
range.end
);
range.start = range.end;
}
let start = lsp_pos_to_pos(doc, range.start, offset_encoding)?;
let end = lsp_pos_to_pos(doc, range.end, offset_encoding)?;
@ -605,23 +621,18 @@ impl Notification {
#[derive(Debug)]
pub struct Registry {
inner: HashMap<LanguageId, (usize, Arc<Client>)>,
counter: AtomicUsize,
inner: HashMap<LanguageServerName, Vec<Arc<Client>>>,
syn_loader: Arc<helix_core::syntax::Loader>,
counter: usize,
pub incoming: SelectAll<UnboundedReceiverStream<(usize, Call)>>,
}
impl Default for Registry {
fn default() -> Self {
Self::new()
}
}
impl Registry {
pub fn new() -> Self {
pub fn new(syn_loader: Arc<helix_core::syntax::Loader>) -> Self {
Self {
inner: HashMap::new(),
counter: AtomicUsize::new(0),
syn_loader,
counter: 0,
incoming: SelectAll::new(),
}
}
@ -629,85 +640,134 @@ impl Registry {
pub fn get_by_id(&self, id: usize) -> Option<&Client> {
self.inner
.values()
.find(|(client_id, _)| client_id == &id)
.map(|(_, client)| client.as_ref())
.flatten()
.find(|client| client.id() == id)
.map(|client| &**client)
}
pub fn remove_by_id(&mut self, id: usize) {
self.inner.retain(|_, (client_id, _)| client_id != &id)
self.inner.retain(|_, language_servers| {
language_servers.retain(|ls| id != ls.id());
!language_servers.is_empty()
});
}
fn start_client(
&mut self,
name: String,
ls_config: &LanguageConfiguration,
doc_path: Option<&std::path::PathBuf>,
root_dirs: &[PathBuf],
enable_snippets: bool,
) -> Result<Arc<Client>> {
let config = self
.syn_loader
.language_server_configs()
.get(&name)
.ok_or_else(|| anyhow::anyhow!("Language server '{name}' not defined"))?;
let id = self.counter;
self.counter += 1;
let NewClient(client, incoming) = start_client(
id,
name,
ls_config,
config,
doc_path,
root_dirs,
enable_snippets,
)?;
self.incoming.push(UnboundedReceiverStream::new(incoming));
Ok(client)
}
/// If this method is called, all documents that have a reference to language servers used by the language config have to refresh their language servers,
/// as it could be that language servers of these documents were stopped by this method.
/// See helix_view::editor::Editor::refresh_language_servers
pub fn restart(
&mut self,
language_config: &LanguageConfiguration,
doc_path: Option<&std::path::PathBuf>,
) -> Result<Option<Arc<Client>>> {
let config = match &language_config.language_server {
Some(config) => config,
None => return Ok(None),
};
let scope = language_config.scope.clone();
match self.inner.entry(scope) {
Entry::Vacant(_) => Ok(None),
Entry::Occupied(mut entry) => {
// initialize a new client
let id = self.counter.fetch_add(1, Ordering::Relaxed);
let NewClientResult(client, incoming) =
start_client(id, language_config, config, doc_path)?;
self.incoming.push(UnboundedReceiverStream::new(incoming));
let (_, old_client) = entry.insert((id, client.clone()));
root_dirs: &[PathBuf],
enable_snippets: bool,
) -> Result<Vec<Arc<Client>>> {
language_config
.language_servers
.iter()
.filter_map(|LanguageServerFeatures { name, .. }| {
if self.inner.contains_key(name) {
let client = match self.start_client(
name.clone(),
language_config,
doc_path,
root_dirs,
enable_snippets,
) {
Ok(client) => client,
error => return Some(error),
};
let old_clients = self
.inner
.insert(name.clone(), vec![client.clone()])
.unwrap();
for old_client in old_clients {
tokio::spawn(async move {
let _ = old_client.force_shutdown().await;
});
}
Some(Ok(client))
} else {
None
}
})
.collect()
}
pub fn stop(&mut self, name: &str) {
if let Some(clients) = self.inner.remove(name) {
for client in clients {
tokio::spawn(async move {
let _ = old_client.force_shutdown().await;
let _ = client.force_shutdown().await;
});
Ok(Some(client))
}
}
}
pub fn stop(&mut self, language_config: &LanguageConfiguration) {
let scope = language_config.scope.clone();
if let Some((_, client)) = self.inner.remove(&scope) {
tokio::spawn(async move {
let _ = client.force_shutdown().await;
});
}
}
pub fn get(
&mut self,
language_config: &LanguageConfiguration,
doc_path: Option<&std::path::PathBuf>,
) -> Result<Option<Arc<Client>>> {
let config = match &language_config.language_server {
Some(config) => config,
None => return Ok(None),
};
match self.inner.entry(language_config.scope.clone()) {
Entry::Occupied(entry) => Ok(Some(entry.get().1.clone())),
Entry::Vacant(entry) => {
// initialize a new client
let id = self.counter.fetch_add(1, Ordering::Relaxed);
let NewClientResult(client, incoming) =
start_client(id, language_config, config, doc_path)?;
self.incoming.push(UnboundedReceiverStream::new(incoming));
entry.insert((id, client.clone()));
Ok(Some(client))
}
}
root_dirs: &[PathBuf],
enable_snippets: bool,
) -> Result<HashMap<LanguageServerName, Arc<Client>>> {
language_config
.language_servers
.iter()
.map(|LanguageServerFeatures { name, .. }| {
if let Some(clients) = self.inner.get(name) {
if let Some((_, client)) = clients.iter().enumerate().find(|(i, client)| {
client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0)
}) {
return Ok((name.to_owned(), client.clone()));
}
}
let client = self.start_client(
name.clone(),
language_config,
doc_path,
root_dirs,
enable_snippets,
)?;
let clients = self.inner.entry(name.clone()).or_default();
clients.push(client.clone());
Ok((name.clone(), client))
})
.collect()
}
pub fn iter_clients(&self) -> impl Iterator<Item = &Arc<Client>> {
self.inner.values().map(|(_, client)| client)
self.inner.values().flatten()
}
}
@ -789,23 +849,28 @@ impl LspProgressMap {
}
}
struct NewClientResult(Arc<Client>, UnboundedReceiver<(usize, Call)>);
struct NewClient(Arc<Client>, UnboundedReceiver<(usize, Call)>);
/// start_client takes both a LanguageConfiguration and a LanguageServerConfiguration to ensure that
/// it is only called when it makes sense.
fn start_client(
id: usize,
name: String,
config: &LanguageConfiguration,
ls_config: &LanguageServerConfiguration,
doc_path: Option<&std::path::PathBuf>,
) -> Result<NewClientResult> {
root_dirs: &[PathBuf],
enable_snippets: bool,
) -> Result<NewClient> {
let (client, incoming, initialize_notify) = Client::start(
&ls_config.command,
&ls_config.args,
config.config.clone(),
ls_config.config.clone(),
ls_config.environment.clone(),
&config.roots,
config.workspace_lsp_roots.as_deref().unwrap_or(root_dirs),
id,
name,
ls_config.timeout,
doc_path,
)?;
@ -820,7 +885,7 @@ fn start_client(
.capabilities
.get_or_try_init(|| {
_client
.initialize()
.initialize(enable_snippets)
.map_ok(|response| response.capabilities)
})
.await;
@ -839,7 +904,66 @@ fn start_client(
initialize_notify.notify_one();
});
Ok(NewClientResult(client, incoming))
Ok(NewClient(client, incoming))
}
/// Find an LSP workspace of a file using the following mechanism:
/// * if the file is outside `workspace` return `None`
/// * start at `file` and search the file tree upward
/// * stop the search at the first `root_dirs` entry that contains `file`
/// * if no `root_dirs` matches `file` stop at workspace
/// * Returns the top most directory that contains a `root_marker`
/// * If no root marker and we stopped at a `root_dirs` entry, return the directory we stopped at
/// * If we stopped at `workspace` instead and `workspace_is_cwd == false` return `None`
/// * If we stopped at `workspace` instead and `workspace_is_cwd == true` return `workspace`
pub fn find_lsp_workspace(
file: &str,
root_markers: &[String],
root_dirs: &[PathBuf],
workspace: &Path,
workspace_is_cwd: bool,
) -> Option<PathBuf> {
let file = std::path::Path::new(file);
let mut file = if file.is_absolute() {
file.to_path_buf()
} else {
let current_dir = std::env::current_dir().expect("unable to determine current directory");
current_dir.join(file)
};
file = path::get_normalized_path(&file);
if !file.starts_with(workspace) {
return None;
}
let mut top_marker = None;
for ancestor in file.ancestors() {
if root_markers
.iter()
.any(|marker| ancestor.join(marker).exists())
{
top_marker = Some(ancestor);
}
if root_dirs
.iter()
.any(|root_dir| path::get_normalized_path(&workspace.join(root_dir)) == ancestor)
{
// if the worskapce is the cwd do not search any higher for workspaces
// but specify
return Some(top_marker.unwrap_or(workspace).to_owned());
}
if ancestor == workspace {
// if the workspace is the CWD, let the LSP decide what the workspace
// is
return top_marker
.or_else(|| (!workspace_is_cwd).then_some(workspace))
.map(Path::to_owned);
}
}
debug_assert!(false, "workspace must be an ancestor of <file>");
None
}
#[cfg(test)]
@ -860,16 +984,16 @@ mod tests {
test_case!("", (0, 0) => Some(0));
test_case!("", (0, 1) => Some(0));
test_case!("", (1, 0) => None);
test_case!("", (1, 0) => Some(0));
test_case!("\n\n", (0, 0) => Some(0));
test_case!("\n\n", (1, 0) => Some(1));
test_case!("\n\n", (1, 1) => Some(1));
test_case!("\n\n", (2, 0) => Some(2));
test_case!("\n\n", (3, 0) => None);
test_case!("\n\n", (3, 0) => Some(2));
test_case!("test\n\n\n\ncase", (4, 3) => Some(11));
test_case!("test\n\n\n\ncase", (4, 4) => Some(12));
test_case!("test\n\n\n\ncase", (4, 5) => Some(12));
test_case!("", (u32::MAX, u32::MAX) => None);
test_case!("", (u32::MAX, u32::MAX) => Some(0));
}
#[test]

@ -61,7 +61,7 @@ fn render_elements(
offset: &mut usize,
tabstops: &mut Vec<(usize, (usize, usize))>,
newline_with_offset: &str,
include_placeholer: bool,
include_placeholder: bool,
) {
use SnippetElement::*;
@ -89,7 +89,7 @@ fn render_elements(
offset,
tabstops,
newline_with_offset,
include_placeholer,
include_placeholder,
);
}
&Tabstop { tabstop } => {
@ -100,14 +100,14 @@ fn render_elements(
value: inner_snippet_elements,
} => {
let start_offset = *offset;
if include_placeholer {
if include_placeholder {
render_elements(
inner_snippet_elements,
insert,
offset,
tabstops,
newline_with_offset,
include_placeholer,
include_placeholder,
);
}
tabstops.push((*tabstop, (start_offset, *offset)));
@ -127,7 +127,7 @@ fn render_elements(
pub fn render(
snippet: &Snippet<'_>,
newline_with_offset: &str,
include_placeholer: bool,
include_placeholder: bool,
) -> (Tendril, Vec<SmallVec<[(usize, usize); 1]>>) {
let mut insert = Tendril::new();
let mut tabstops = Vec::new();
@ -139,7 +139,7 @@ pub fn render(
&mut offset,
&mut tabstops,
newline_with_offset,
include_placeholer,
include_placeholder,
);
// sort in ascending order (except for 0, which should always be the last one (per lsp doc))

@ -38,6 +38,7 @@ enum ServerMessage {
#[derive(Debug)]
pub struct Transport {
id: usize,
name: String,
pending_requests: Mutex<HashMap<jsonrpc::Id, Sender<Result<Value>>>>,
}
@ -47,6 +48,7 @@ impl Transport {
server_stdin: BufWriter<ChildStdin>,
server_stderr: BufReader<ChildStderr>,
id: usize,
name: String,
) -> (
UnboundedReceiver<(usize, jsonrpc::Call)>,
UnboundedSender<Payload>,
@ -58,6 +60,7 @@ impl Transport {
let transport = Self {
id,
name,
pending_requests: Mutex::new(HashMap::default()),
};
@ -83,6 +86,7 @@ impl Transport {
async fn recv_server_message(
reader: &mut (impl AsyncBufRead + Unpin + Send),
buffer: &mut String,
language_server_name: &str,
) -> Result<ServerMessage> {
let mut content_length = None;
loop {
@ -124,7 +128,7 @@ impl Transport {
reader.read_exact(&mut content).await?;
let msg = std::str::from_utf8(&content).context("invalid utf8 from server")?;
info!("<- {}", msg);
info!("{language_server_name} <- {msg}");
// try parsing as output (server response) or call (server request)
let output: serde_json::Result<ServerMessage> = serde_json::from_str(msg);
@ -135,12 +139,13 @@ impl Transport {
async fn recv_server_error(
err: &mut (impl AsyncBufRead + Unpin + Send),
buffer: &mut String,
language_server_name: &str,
) -> Result<()> {
buffer.truncate(0);
if err.read_line(buffer).await? == 0 {
return Err(Error::StreamClosed);
};
error!("err <- {:?}", buffer);
error!("{language_server_name} err <- {buffer:?}");
Ok(())
}
@ -162,15 +167,17 @@ impl Transport {
Payload::Notification(value) => serde_json::to_string(&value)?,
Payload::Response(error) => serde_json::to_string(&error)?,
};
self.send_string_to_server(server_stdin, json).await
self.send_string_to_server(server_stdin, json, &self.name)
.await
}
async fn send_string_to_server(
&self,
server_stdin: &mut BufWriter<ChildStdin>,
request: String,
language_server_name: &str,
) -> Result<()> {
info!("-> {}", request);
info!("{language_server_name} -> {request}");
// send the headers
server_stdin
@ -189,9 +196,13 @@ impl Transport {
&self,
client_tx: &UnboundedSender<(usize, jsonrpc::Call)>,
msg: ServerMessage,
language_server_name: &str,
) -> Result<()> {
match msg {
ServerMessage::Output(output) => self.process_request_response(output).await?,
ServerMessage::Output(output) => {
self.process_request_response(output, language_server_name)
.await?
}
ServerMessage::Call(call) => {
client_tx
.send((self.id, call))
@ -202,14 +213,18 @@ impl Transport {
Ok(())
}
async fn process_request_response(&self, output: jsonrpc::Output) -> Result<()> {
async fn process_request_response(
&self,
output: jsonrpc::Output,
language_server_name: &str,
) -> Result<()> {
let (id, result) = match output {
jsonrpc::Output::Success(jsonrpc::Success { id, result, .. }) => {
info!("<- {}", result);
info!("{language_server_name} <- {}", result);
(id, Ok(result))
}
jsonrpc::Output::Failure(jsonrpc::Failure { id, error, .. }) => {
error!("<- {}", error);
error!("{language_server_name} <- {error}");
(id, Err(error.into()))
}
};
@ -240,12 +255,17 @@ impl Transport {
) {
let mut recv_buffer = String::new();
loop {
match Self::recv_server_message(&mut server_stdout, &mut recv_buffer).await {
match Self::recv_server_message(&mut server_stdout, &mut recv_buffer, &transport.name)
.await
{
Ok(msg) => {
match transport.process_server_message(&client_tx, msg).await {
match transport
.process_server_message(&client_tx, msg, &transport.name)
.await
{
Ok(_) => {}
Err(err) => {
error!("err: <- {:?}", err);
error!("{} err: <- {err:?}", transport.name);
break;
}
};
@ -270,7 +290,7 @@ impl Transport {
params: jsonrpc::Params::None,
}));
match transport
.process_server_message(&client_tx, notification)
.process_server_message(&client_tx, notification, &transport.name)
.await
{
Ok(_) => {}
@ -281,20 +301,22 @@ impl Transport {
break;
}
Err(err) => {
error!("err: <- {:?}", err);
error!("{} err: <- {err:?}", transport.name);
break;
}
}
}
}
async fn err(_transport: Arc<Self>, mut server_stderr: BufReader<ChildStderr>) {
async fn err(transport: Arc<Self>, mut server_stderr: BufReader<ChildStderr>) {
let mut recv_buffer = String::new();
loop {
match Self::recv_server_error(&mut server_stderr, &mut recv_buffer).await {
match Self::recv_server_error(&mut server_stderr, &mut recv_buffer, &transport.name)
.await
{
Ok(_) => {}
Err(err) => {
error!("err: <- {:?}", err);
error!("{} err: <- {err:?}", transport.name);
break;
}
}
@ -348,10 +370,11 @@ impl Transport {
method: lsp_types::notification::Initialized::METHOD.to_string(),
params: jsonrpc::Params::None,
}));
match transport.process_server_message(&client_tx, notification).await {
let language_server_name = &transport.name;
match transport.process_server_message(&client_tx, notification, language_server_name).await {
Ok(_) => {}
Err(err) => {
error!("err: <- {:?}", err);
error!("{language_server_name} err: <- {err:?}");
}
}
@ -361,7 +384,7 @@ impl Transport {
match transport.send_payload_to_server(&mut server_stdin, msg).await {
Ok(_) => {}
Err(err) => {
error!("err: <- {:?}", err);
error!("{language_server_name} err: <- {err:?}");
}
}
}
@ -380,7 +403,7 @@ impl Transport {
match transport.send_payload_to_server(&mut server_stdin, msg).await {
Ok(_) => {}
Err(err) => {
error!("err: <- {:?}", err);
error!("{} err: <- {err:?}", transport.name);
}
}
}

@ -68,7 +68,7 @@ grep-searcher = "0.1.11"
[target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
libc = "0.2.140"
libc = "0.2.144"
[build-dependencies]
helix-loader = { version = "0.6", path = "../helix-loader" }

@ -25,11 +25,12 @@ use crate::{
config::Config,
job::Jobs,
keymap::Keymaps,
ui::{self, overlay::overlayed},
ui::{self, overlay::overlaid},
};
use log::{debug, error, warn};
use std::{
collections::btree_map::Entry,
io::{stdin, stdout},
path::Path,
sync::Arc,
@ -169,7 +170,7 @@ impl Application {
std::env::set_current_dir(first).context("set current dir")?;
editor.new_file(Action::VerticalSplit);
let picker = ui::file_picker(".".into(), &config.load().editor);
compositor.push(Box::new(overlayed(picker)));
compositor.push(Box::new(overlaid(picker)));
} else {
let nr_of_files = args.files.len();
for (i, (file, pos)) in args.files.into_iter().enumerate() {
@ -361,6 +362,9 @@ impl Application {
ConfigEvent::Update(editor_config) => {
let mut app_config = (*self.config.load().clone()).clone();
app_config.editor = *editor_config;
if let Err(err) = self.terminal.reconfigure(app_config.editor.clone().into()) {
self.editor.set_error(err.to_string());
};
self.config.store(Arc::new(app_config));
}
}
@ -393,20 +397,23 @@ impl Application {
/// Refresh theme after config change
fn refresh_theme(&mut self, config: &Config) -> Result<(), Error> {
if let Some(theme) = config.theme.clone() {
let true_color = self.true_color();
let theme = self
.theme_loader
.load(&theme)
.map_err(|err| anyhow::anyhow!("Failed to load theme `{}`: {}", theme, err))?;
if true_color || theme.is_16_color() {
self.editor.set_theme(theme);
} else {
anyhow::bail!("theme requires truecolor support, which is not available")
}
}
let true_color = config.editor.true_color || crate::true_color();
let theme = config
.theme
.as_ref()
.and_then(|theme| {
self.theme_loader
.load(theme)
.map_err(|e| {
log::warn!("failed to load theme `{}` - {}", theme, e);
e
})
.ok()
.filter(|theme| (true_color || theme.is_16_color()))
})
.unwrap_or_else(|| self.theme_loader.default_theme(true_color));
self.editor.set_theme(theme);
Ok(())
}
@ -416,6 +423,8 @@ impl Application {
.map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?;
self.refresh_language_config()?;
self.refresh_theme(&default_config)?;
self.terminal
.reconfigure(default_config.editor.clone().into())?;
// Store new config
self.config.store(Arc::new(default_config));
Ok(())
@ -431,10 +440,6 @@ impl Application {
}
}
fn true_color(&self) -> bool {
self.config.load().editor.true_color || crate::true_color()
}
#[cfg(windows)]
// no signal handling available on windows
pub async fn handle_signals(&mut self, _signal: ()) {}
@ -472,7 +477,17 @@ impl Application {
}
}
signal::SIGCONT => {
self.claim_term().await.unwrap();
// Copy/Paste from same issue from neovim:
// https://github.com/neovim/neovim/issues/12322
// https://github.com/neovim/neovim/pull/13084
for retries in 1..=10 {
match self.claim_term().await {
Ok(()) => break,
Err(err) if retries == 10 => panic!("Failed to claim terminal: {}", err),
Err(_) => continue,
}
}
// redraw the terminal
let area = self.terminal.size().expect("couldn't get terminal size");
self.compositor.resize(area);
@ -550,7 +565,7 @@ impl Application {
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);
self.editor.refresh_language_servers(id);
}
// TODO: fix being overwritten by lsp
@ -648,6 +663,18 @@ impl Application {
) {
use helix_lsp::{Call, MethodCall, Notification};
macro_rules! language_server {
() => {
match self.editor.language_server_by_id(server_id) {
Some(language_server) => language_server,
None => {
warn!("can't find language server with id `{}`", server_id);
return;
}
}
};
}
match call {
Call::Notification(helix_lsp::jsonrpc::Notification { method, params, .. }) => {
let notification = match Notification::parse(&method, params) {
@ -663,14 +690,7 @@ impl Application {
match notification {
Notification::Initialized => {
let language_server =
match self.editor.language_servers.get_by_id(server_id) {
Some(language_server) => language_server,
None => {
warn!("can't find language server with id `{}`", server_id);
return;
}
};
let language_server = language_server!();
// Trigger a workspace/didChangeConfiguration notification after initialization.
// This might not be required by the spec but Neovim does this as well, so it's
@ -679,9 +699,10 @@ impl Application {
tokio::spawn(language_server.did_change_configuration(config.clone()));
}
let docs = self.editor.documents().filter(|doc| {
doc.language_server().map(|server| server.id()) == Some(server_id)
});
let docs = self
.editor
.documents()
.filter(|doc| doc.supports_language_server(server_id));
// trigger textDocument/didOpen for docs that are already open
for doc in docs {
@ -701,7 +722,7 @@ impl Application {
));
}
}
Notification::PublishDiagnostics(mut params) => {
Notification::PublishDiagnostics(params) => {
let path = match params.uri.to_file_path() {
Ok(path) => path,
Err(_) => {
@ -709,6 +730,7 @@ impl Application {
return;
}
};
let offset_encoding = language_server!().offset_encoding();
let doc = self.editor.document_by_path_mut(&path).filter(|doc| {
if let Some(version) = params.version {
if version != doc.version() {
@ -731,18 +753,11 @@ impl Application {
use helix_core::diagnostic::{Diagnostic, Range, Severity::*};
use lsp::DiagnosticSeverity;
let language_server = if let Some(language_server) = doc.language_server() {
language_server
} else {
log::warn!("Discarding diagnostic because language server is not initialized: {:?}", diagnostic);
return None;
};
// TODO: convert inside server
let start = if let Some(start) = lsp_pos_to_pos(
text,
diagnostic.range.start,
language_server.offset_encoding(),
offset_encoding,
) {
start
} else {
@ -750,11 +765,9 @@ impl Application {
return None;
};
let end = if let Some(end) = lsp_pos_to_pos(
text,
diagnostic.range.end,
language_server.offset_encoding(),
) {
let end = if let Some(end) =
lsp_pos_to_pos(text, diagnostic.range.end, offset_encoding)
{
end
} else {
log::warn!("lsp position out of bounds - {:?}", diagnostic);
@ -793,14 +806,19 @@ impl Application {
None => None,
};
let tags = if let Some(ref tags) = diagnostic.tags {
let new_tags = tags.iter().filter_map(|tag| {
match *tag {
lsp::DiagnosticTag::DEPRECATED => Some(DiagnosticTag::Deprecated),
lsp::DiagnosticTag::UNNECESSARY => Some(DiagnosticTag::Unnecessary),
_ => None
}
}).collect();
let tags = if let Some(tags) = &diagnostic.tags {
let new_tags = tags
.iter()
.filter_map(|tag| match *tag {
lsp::DiagnosticTag::DEPRECATED => {
Some(DiagnosticTag::Deprecated)
}
lsp::DiagnosticTag::UNNECESSARY => {
Some(DiagnosticTag::Unnecessary)
}
_ => None,
})
.collect();
new_tags
} else {
@ -816,25 +834,40 @@ impl Application {
tags,
source: diagnostic.source.clone(),
data: diagnostic.data.clone(),
language_server_id: server_id,
})
})
.collect();
doc.set_diagnostics(diagnostics);
doc.replace_diagnostics(diagnostics, server_id);
}
// Sort diagnostics first by severity and then by line numbers.
// Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order
params
let mut diagnostics = params
.diagnostics
.sort_unstable_by_key(|d| (d.severity, d.range.start));
.into_iter()
.map(|d| (d, server_id))
.collect();
// Insert the original lsp::Diagnostics here because we may have no open document
// for diagnosic message and so we can't calculate the exact position.
// When using them later in the diagnostics picker, we calculate them on-demand.
self.editor
.diagnostics
.insert(params.uri, params.diagnostics);
match self.editor.diagnostics.entry(params.uri) {
Entry::Occupied(o) => {
let current_diagnostics = o.into_mut();
// there may entries of other language servers, which is why we can't overwrite the whole entry
current_diagnostics.retain(|(_, lsp_id)| *lsp_id != server_id);
current_diagnostics.append(&mut diagnostics);
// Sort diagnostics first by severity and then by line numbers.
// Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order
current_diagnostics
.sort_unstable_by_key(|(d, _)| (d.severity, d.range.start));
}
Entry::Vacant(v) => {
diagnostics
.sort_unstable_by_key(|(d, _)| (d.severity, d.range.start));
v.insert(diagnostics);
}
};
}
Notification::ShowMessage(params) => {
log::warn!("unhandled window/showMessage: {:?}", params);
@ -931,24 +964,18 @@ impl Application {
Notification::Exit => {
self.editor.set_status("Language server exited");
// Clear any diagnostics for documents with this server open.
let urls: Vec<_> = self
.editor
.documents_mut()
.filter_map(|doc| {
if doc.language_server().map(|server| server.id())
== Some(server_id)
{
doc.set_diagnostics(Vec::new());
doc.url()
} else {
None
}
})
.collect();
// LSPs may produce diagnostics for files that haven't been opened in helix,
// we need to clear those and remove the entries from the list if this leads to
// an empty diagnostic list for said files
for diags in self.editor.diagnostics.values_mut() {
diags.retain(|(_, lsp_id)| *lsp_id != server_id);
}
for url in urls {
self.editor.diagnostics.remove(&url);
self.editor.diagnostics.retain(|_, diags| !diags.is_empty());
// Clear any diagnostics for documents with this server open.
for doc in self.editor.documents_mut() {
doc.clear_diagnostics(server_id);
}
// Remove the language server from the registry.
@ -1015,32 +1042,21 @@ impl Application {
}))
}
Ok(MethodCall::WorkspaceFolders) => {
let language_server =
self.editor.language_servers.get_by_id(server_id).unwrap();
Ok(json!(language_server.workspace_folders()))
Ok(json!(&*language_server!().workspace_folders().await))
}
Ok(MethodCall::WorkspaceConfiguration(params)) => {
let language_server = language_server!();
let result: Vec<_> = params
.items
.iter()
.map(|item| {
let mut config = match &item.scope_uri {
Some(scope) => {
let path = scope.to_file_path().ok()?;
let doc = self.editor.document_by_path(path)?;
doc.language_config()?.config.as_ref()?
}
None => self
.editor
.language_servers
.get_by_id(server_id)
.unwrap()
.config()?,
};
let mut config = language_server.config()?;
if let Some(section) = item.section.as_ref() {
for part in section.split('.') {
config = config.get(part)?;
// for some reason some lsps send an empty string (observed in 'vscode-eslint-language-server')
if !section.is_empty() {
for part in section.split('.') {
config = config.get(part)?;
}
}
}
Some(config)
@ -1061,15 +1077,7 @@ impl Application {
}
};
let language_server = match self.editor.language_servers.get_by_id(server_id) {
Some(language_server) => language_server,
None => {
warn!("can't find language server with id `{}`", server_id);
return;
}
};
tokio::spawn(language_server.reply(id, reply));
tokio::spawn(language_server!().reply(id, reply));
}
Call::Invalid { id } => log::error!("LSP invalid method call id={:?}", id),
}

@ -12,7 +12,7 @@ pub use typed::*;
use helix_core::{
char_idx_at_visual_offset, comment,
doc_formatter::TextFormat,
encoding, find_first_non_whitespace_char, find_root, graphemes,
encoding, find_first_non_whitespace_char, find_workspace, graphemes,
history::UndoKind,
increment, indent,
indent::IndentStyle,
@ -23,17 +23,18 @@ use helix_core::{
regex::{self, Regex, RegexBuilder},
search::{self, CharMatcher},
selection, shellwords, surround,
syntax::LanguageServerFeature,
text_annotations::TextAnnotations,
textobject,
tree_sitter::Node,
unicode::width::UnicodeWidthChar,
visual_offset_from_block, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice,
Selection, SmallVec, Tendril, Transaction,
visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes,
RopeSlice, Selection, SmallVec, Tendril, Transaction,
};
use helix_view::{
clipboard::ClipboardType,
document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
editor::{Action, Motion},
editor::{Action, CompleteAction, Motion},
info::Info,
input::KeyEvent,
keyboard::KeyCode,
@ -54,13 +55,13 @@ use crate::{
job::Callback,
keymap::ReverseKeymap,
ui::{
self, editor::InsertEvent, overlay::overlayed, FilePicker, Picker, Popup, Prompt,
PromptEvent,
self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem,
FilePicker, Picker, Popup, Prompt, PromptEvent,
},
};
use crate::job::{self, Jobs};
use futures_util::StreamExt;
use futures_util::{stream::FuturesUnordered, StreamExt, TryStreamExt};
use std::{collections::HashMap, fmt, future::Future};
use std::{collections::HashSet, num::NonZeroUsize};
@ -97,6 +98,13 @@ impl<'a> Context<'a> {
}));
}
/// Call `replace_or_push` on the Compositor
pub fn replace_or_push_layer<T: Component>(&mut self, id: &'static str, component: T) {
self.callback = Some(Box::new(move |compositor: &mut Compositor, _| {
compositor.replace_or_push(id, component);
}));
}
#[inline]
pub fn on_next_key(
&mut self,
@ -267,6 +275,7 @@ impl MappableCommand {
select_regex, "Select all regex matches inside selections",
split_selection, "Split selections on regex matches",
split_selection_on_newline, "Split selection on newlines",
merge_selections, "Merge selections",
merge_consecutive_selections, "Merge consecutive selections",
search, "Search for regex pattern",
rsearch, "Reverse search for regex pattern",
@ -347,6 +356,7 @@ impl MappableCommand {
goto_first_nonwhitespace, "Goto first non-blank in line",
trim_selections, "Trim whitespace from selections",
extend_to_line_start, "Extend to line start",
extend_to_first_nonwhitespace, "Extend to first non-blank in line",
extend_to_line_end, "Extend to line end",
extend_to_line_end_newline, "Extend to line end",
signature_help, "Show signature help",
@ -501,7 +511,7 @@ impl std::str::FromStr for MappableCommand {
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(suffix) = s.strip_prefix(':') {
let mut typable_command = suffix.split(' ').into_iter().map(|arg| arg.trim());
let mut typable_command = suffix.split(' ').map(|arg| arg.trim());
let name = typable_command
.next()
.ok_or_else(|| anyhow!("Expected typable command name"))?;
@ -787,58 +797,72 @@ fn extend_to_line_start(cx: &mut Context) {
}
fn kill_to_line_start(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
let selection = doc.selection(view.id).clone().transform(|range| {
let line = range.cursor_line(text);
let first_char = text.line_to_char(line);
let anchor = range.cursor(text);
let head = if anchor == first_char && line != 0 {
// select until previous line
line_end_char_index(&text, line - 1)
} else if let Some(pos) = find_first_non_whitespace_char(text.line(line)) {
if first_char + pos < anchor {
// select until first non-blank in line if cursor is after it
first_char + pos
delete_by_selection_insert_mode(
cx,
move |text, range| {
let line = range.cursor_line(text);
let first_char = text.line_to_char(line);
let anchor = range.cursor(text);
let head = if anchor == first_char && line != 0 {
// select until previous line
line_end_char_index(&text, line - 1)
} else if let Some(pos) = find_first_non_whitespace_char(text.line(line)) {
if first_char + pos < anchor {
// select until first non-blank in line if cursor is after it
first_char + pos
} else {
// select until start of line
first_char
}
} else {
// select until start of line
first_char
}
} else {
// select until start of line
first_char
};
Range::new(head, anchor)
});
delete_selection_insert_mode(doc, view, &selection);
lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
};
(head, anchor)
},
Direction::Backward,
);
}
fn kill_to_line_end(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
delete_by_selection_insert_mode(
cx,
|text, range| {
let line = range.cursor_line(text);
let line_end_pos = line_end_char_index(&text, line);
let pos = range.cursor(text);
let selection = doc.selection(view.id).clone().transform(|range| {
let line = range.cursor_line(text);
let line_end_pos = line_end_char_index(&text, line);
let pos = range.cursor(text);
// if the cursor is on the newline char delete that
if pos == line_end_pos {
(pos, text.line_to_char(line + 1))
} else {
(pos, line_end_pos)
}
},
Direction::Forward,
);
}
let mut new_range = range.put_cursor(text, line_end_pos, true);
// don't want to remove the line separator itself if the cursor doesn't reach the end of line.
if pos != line_end_pos {
new_range.head = line_end_pos;
}
new_range
});
delete_selection_insert_mode(doc, view, &selection);
fn goto_first_nonwhitespace(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
goto_first_nonwhitespace_impl(
view,
doc,
if cx.editor.mode == Mode::Select {
Movement::Extend
} else {
Movement::Move
},
)
}
fn goto_first_nonwhitespace(cx: &mut Context) {
fn extend_to_first_nonwhitespace(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
goto_first_nonwhitespace_impl(view, doc, Movement::Extend)
}
fn goto_first_nonwhitespace_impl(view: &mut View, doc: &mut Document, movement: Movement) {
let text = doc.text().slice(..);
let selection = doc.selection(view.id).clone().transform(|range| {
@ -846,7 +870,7 @@ fn goto_first_nonwhitespace(cx: &mut Context) {
if let Some(pos) = find_first_non_whitespace_char(text.line(line)) {
let pos = pos + text.line_to_char(line);
range.put_cursor(text, pos, cx.editor.mode == Mode::Select)
range.put_cursor(text, pos, movement == Movement::Extend)
} else {
range
}
@ -1470,7 +1494,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) {
let cursor = range.cursor(text);
let height = view.inner_height();
let scrolloff = config.scrolloff.min(height / 2);
let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2);
let offset = match direction {
Forward => offset as isize,
Backward => -(offset as isize),
@ -1489,18 +1513,19 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) {
&annotations,
);
let head;
let mut head;
match direction {
Forward => {
head = char_idx_at_visual_offset(
let off;
(head, off) = char_idx_at_visual_offset(
doc_text,
view.offset.anchor,
(view.offset.vertical_offset + scrolloff) as isize,
0,
&text_fmt,
&annotations,
)
.0;
);
head += (off != 0) as usize;
if head <= cursor {
return;
}
@ -1509,7 +1534,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) {
head = char_idx_at_visual_offset(
doc_text,
view.offset.anchor,
(view.offset.vertical_offset + height - scrolloff) as isize,
(view.offset.vertical_offset + height - scrolloff - 1) as isize,
0,
&text_fmt,
&annotations,
@ -1560,7 +1585,7 @@ fn half_page_down(cx: &mut Context) {
}
#[allow(deprecated)]
// currently uses the deprected `visual_coords_at_pos`/`pos_at_visual_coords` functions
// currently uses the deprecated `visual_coords_at_pos`/`pos_at_visual_coords` functions
// as this function ignores softwrapping (and virtual text) and instead only cares
// about "text visual position"
//
@ -1713,6 +1738,12 @@ fn split_selection_on_newline(cx: &mut Context) {
doc.set_selection(view.id, selection);
}
fn merge_selections(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let selection = doc.selection(view.id).clone().merge_ranges();
doc.set_selection(view.id, selection);
}
fn merge_consecutive_selections(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let selection = doc.selection(view.id).clone().merge_consecutive_ranges();
@ -2146,7 +2177,7 @@ fn global_search(cx: &mut Context) {
Some((path.clone().into(), Some((*line_num, *line_num))))
},
);
compositor.push(Box::new(overlayed(picker)));
compositor.push(Box::new(overlaid(picker)));
},
));
Ok(call)
@ -2288,9 +2319,8 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) {
};
// then delete
let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
(range.from(), range.to(), None)
});
let transaction =
Transaction::delete_by_selection(doc.text(), selection, |range| (range.from(), range.to()));
doc.apply(&transaction, view.id);
match op {
@ -2305,11 +2335,49 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) {
}
#[inline]
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)
});
fn delete_by_selection_insert_mode(
cx: &mut Context,
mut f: impl FnMut(RopeSlice, &Range) -> Deletion,
direction: Direction,
) {
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
let mut selection = SmallVec::new();
let mut insert_newline = false;
let text_len = text.len_chars();
let mut transaction =
Transaction::delete_by_selection(doc.text(), doc.selection(view.id), |range| {
let (start, end) = f(text, range);
if direction == Direction::Forward {
let mut range = *range;
if range.head > range.anchor {
insert_newline |= end == text_len;
// move the cursor to the right so that the selection
// doesn't shrink when deleting forward (so the text appears to
// move to left)
// += 1 is enough here as the range is normalized to grapheme boundaries
// later anyway
range.head += 1;
}
selection.push(range);
}
(start, end)
});
// in case we delete the last character and the cursor would be moved to the EOF char
// insert a newline, just like when entering append mode
if insert_newline {
transaction = transaction.insert_at_eof(doc.line_ending.as_str().into());
}
if direction == Direction::Forward {
doc.set_selection(
view.id,
Selection::new(selection, doc.selection(view.id).primary_index()),
);
}
doc.apply(&transaction, view.id);
lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
}
fn delete_selection(cx: &mut Context) {
@ -2418,11 +2486,9 @@ fn append_mode(cx: &mut Context) {
}
fn file_picker(cx: &mut Context) {
// We don't specify language markers, root will be the root of the current
// git repo or the current dir if we're not in a repo
let root = find_root(None, &[]);
let root = find_workspace().0;
let picker = ui::file_picker(root, &cx.editor.config());
cx.push_layer(Box::new(overlayed(picker)));
cx.push_layer(Box::new(overlaid(picker)));
}
fn file_picker_in_current_buffer_directory(cx: &mut Context) {
@ -2439,12 +2505,12 @@ fn file_picker_in_current_buffer_directory(cx: &mut Context) {
};
let picker = ui::file_picker(path, &cx.editor.config());
cx.push_layer(Box::new(overlayed(picker)));
cx.push_layer(Box::new(overlaid(picker)));
}
fn file_picker_in_current_directory(cx: &mut Context) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("./"));
let picker = ui::file_picker(cwd, &cx.editor.config());
cx.push_layer(Box::new(overlayed(picker)));
cx.push_layer(Box::new(overlaid(picker)));
}
fn buffer_picker(cx: &mut Context) {
@ -2455,6 +2521,7 @@ fn buffer_picker(cx: &mut Context) {
path: Option<PathBuf>,
is_modified: bool,
is_current: bool,
focused_at: std::time::Instant,
}
impl ui::menu::Item for BufferMeta {
@ -2487,14 +2554,21 @@ fn buffer_picker(cx: &mut Context) {
path: doc.path().cloned(),
is_modified: doc.is_modified(),
is_current: doc.id() == current,
focused_at: doc.focused_at,
};
let mut items = cx
.editor
.documents
.values()
.map(|doc| new_meta(doc))
.collect::<Vec<BufferMeta>>();
// mru
items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at));
let picker = FilePicker::new(
cx.editor
.documents
.values()
.map(|doc| new_meta(doc))
.collect(),
items,
(),
|cx, meta, action| {
cx.editor.switch(meta.id, action);
@ -2509,7 +2583,7 @@ fn buffer_picker(cx: &mut Context) {
Some((meta.id.into(), Some((line, line))))
},
);
cx.push_layer(Box::new(overlayed(picker)));
cx.push_layer(Box::new(overlaid(picker)));
}
fn jumplist_picker(cx: &mut Context) {
@ -2548,6 +2622,13 @@ fn jumplist_picker(cx: &mut Context) {
}
}
for (view, _) in cx.editor.tree.views_mut() {
for doc_id in view.jumps.iter().map(|e| e.0).collect::<Vec<_>>().iter() {
let doc = doc_mut!(cx.editor, doc_id);
view.sync_changes(doc);
}
}
let new_meta = |view: &View, doc_id: DocumentId, selection: Selection| {
let doc = &cx.editor.documents.get(&doc_id);
let text = doc.map_or("".into(), |d| {
@ -2591,7 +2672,7 @@ fn jumplist_picker(cx: &mut Context) {
Some((meta.path.clone()?.into(), Some((line, line))))
},
);
cx.push_layer(Box::new(overlayed(picker)));
cx.push_layer(Box::new(overlaid(picker)));
}
impl ui::menu::Item for MappableCommand {
@ -2665,7 +2746,7 @@ pub fn command_palette(cx: &mut Context) {
}
}
});
compositor.push(Box::new(overlayed(picker)));
compositor.push(Box::new(overlaid(picker)));
},
));
}
@ -2956,7 +3037,7 @@ fn exit_select_mode(cx: &mut Context) {
fn goto_first_diag(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let selection = match doc.diagnostics().first() {
let selection = match doc.shown_diagnostics().next() {
Some(diag) => Selection::single(diag.range.start, diag.range.end),
None => return,
};
@ -2965,7 +3046,7 @@ fn goto_first_diag(cx: &mut Context) {
fn goto_last_diag(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let selection = match doc.diagnostics().last() {
let selection = match doc.shown_diagnostics().last() {
Some(diag) => Selection::single(diag.range.start, diag.range.end),
None => return,
};
@ -2981,10 +3062,9 @@ fn goto_next_diag(cx: &mut Context) {
.cursor(doc.text().slice(..));
let diag = doc
.diagnostics()
.iter()
.shown_diagnostics()
.find(|diag| diag.range.start > cursor_pos)
.or_else(|| doc.diagnostics().first());
.or_else(|| doc.shown_diagnostics().next());
let selection = match diag {
Some(diag) => Selection::single(diag.range.start, diag.range.end),
@ -3002,11 +3082,10 @@ fn goto_prev_diag(cx: &mut Context) {
.cursor(doc.text().slice(..));
let diag = doc
.diagnostics()
.iter()
.shown_diagnostics()
.rev()
.find(|diag| diag.range.start < cursor_pos)
.or_else(|| doc.diagnostics().last());
.or_else(|| doc.shown_diagnostics().last());
let selection = match diag {
// NOTE: the selection is reversed because we're jumping to the
@ -3161,23 +3240,19 @@ pub mod insert {
use helix_lsp::lsp;
// if ch matches completion char, trigger completion
let doc = doc_mut!(cx.editor);
let language_server = match doc.language_server() {
Some(language_server) => language_server,
None => return,
};
let capabilities = language_server.capabilities();
let trigger_completion = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.any(|ls| {
// TODO: what if trigger is multiple chars long
matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions {
trigger_characters: Some(triggers),
..
}) if triggers.iter().any(|trigger| trigger.contains(ch)))
});
if let Some(lsp::CompletionOptions {
trigger_characters: Some(triggers),
..
}) = &capabilities.completion_provider
{
// TODO: what if trigger is multiple chars long
if triggers.iter().any(|trigger| trigger.contains(ch)) {
cx.editor.clear_idle_timer();
super::completion(cx);
}
if trigger_completion {
cx.editor.clear_idle_timer();
super::completion(cx);
}
}
@ -3185,12 +3260,12 @@ pub mod insert {
use helix_lsp::lsp;
// if ch matches signature_help char, trigger
let doc = doc_mut!(cx.editor);
// The language_server!() macro is not used here since it will
// print an "LSP not active for current buffer" message on
// every keypress.
let language_server = match doc.language_server() {
Some(language_server) => language_server,
None => return,
// TODO support multiple language servers (not just the first that is found), likely by merging UI somehow
let Some(language_server) = doc
.language_servers_with_feature(LanguageServerFeature::SignatureHelp)
.next()
else {
return;
};
let capabilities = language_server.capabilities();
@ -3382,10 +3457,10 @@ pub mod insert {
let auto_pairs = doc.auto_pairs(cx.editor);
let transaction =
Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| {
Transaction::delete_by_selection(doc.text(), doc.selection(view.id), |range| {
let pos = range.cursor(text);
if pos == 0 {
return (pos, pos, None);
return (pos, pos);
}
let line_start_pos = text.line_to_char(range.cursor_line(text));
// consider to delete by indent level if all characters before `pos` are indent units.
@ -3393,11 +3468,7 @@ pub mod insert {
if !fragment.is_empty() && fragment.chars().all(|ch| ch == ' ' || ch == '\t') {
if text.get_char(pos.saturating_sub(1)) == Some('\t') {
// fast path, delete one char
(
graphemes::nth_prev_grapheme_boundary(text, pos, 1),
pos,
None,
)
(graphemes::nth_prev_grapheme_boundary(text, pos, 1), pos)
} else {
let width: usize = fragment
.chars()
@ -3424,7 +3495,7 @@ pub mod insert {
_ => break,
}
}
(start, pos, None) // delete!
(start, pos) // delete!
}
} else {
match (
@ -3442,17 +3513,12 @@ pub mod insert {
(
graphemes::nth_prev_grapheme_boundary(text, pos, count),
graphemes::nth_next_grapheme_boundary(text, pos, count),
None,
)
}
_ =>
// delete 1 char
{
(
graphemes::nth_prev_grapheme_boundary(text, pos, count),
pos,
None,
)
(graphemes::nth_prev_grapheme_boundary(text, pos, count), pos)
}
}
}
@ -3465,50 +3531,40 @@ pub mod insert {
pub fn delete_char_forward(cx: &mut Context) {
let count = cx.count();
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
let transaction =
Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| {
delete_by_selection_insert_mode(
cx,
|text, range| {
let pos = range.cursor(text);
(
pos,
graphemes::nth_next_grapheme_boundary(text, pos, count),
None,
)
});
doc.apply(&transaction, view.id);
lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
(pos, graphemes::nth_next_grapheme_boundary(text, pos, count))
},
Direction::Forward,
)
}
pub fn delete_word_backward(cx: &mut Context) {
let count = cx.count();
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
let selection = doc.selection(view.id).clone().transform(|range| {
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);
lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
delete_by_selection_insert_mode(
cx,
|text, range| {
let anchor = movement::move_prev_word_start(text, *range, count).from();
let next = Range::new(anchor, range.cursor(text));
let range = exclude_cursor(text, next, *range);
(range.from(), range.to())
},
Direction::Backward,
);
}
pub fn delete_word_forward(cx: &mut Context) {
let count = cx.count();
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
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);
delete_by_selection_insert_mode(
cx,
|text, range| {
let head = movement::move_next_word_end(text, *range, count).to();
(range.cursor(text), head)
},
Direction::Forward,
);
}
}
@ -3992,55 +4048,60 @@ fn format_selections(cx: &mut Context) {
use helix_lsp::{lsp, util::range_to_lsp_range};
let (view, doc) = current!(cx.editor);
let view_id = view.id;
// via lsp if available
// TODO: else via tree-sitter indentation calculations
let language_server = match doc.language_server() {
Some(language_server) => language_server,
None => return,
if doc.selection(view_id).len() != 1 {
cx.editor
.set_error("format_selections only supports a single selection for now");
return;
}
// TODO extra LanguageServerFeature::FormatSelections?
// maybe such that LanguageServerFeature::Format contains it as well
let Some(language_server) = doc
.language_servers_with_feature(LanguageServerFeature::Format)
.find(|ls| {
matches!(
ls.capabilities().document_range_formatting_provider,
Some(lsp::OneOf::Left(true) | lsp::OneOf::Right(_))
)
})
else {
cx.editor
.set_error("No configured language server does not support range formatting");
return;
};
let offset_encoding = language_server.offset_encoding();
let ranges: Vec<lsp::Range> = doc
.selection(view.id)
.selection(view_id)
.iter()
.map(|range| range_to_lsp_range(doc.text(), *range, language_server.offset_encoding()))
.map(|range| range_to_lsp_range(doc.text(), *range, offset_encoding))
.collect();
if ranges.len() != 1 {
cx.editor
.set_error("format_selections only supports a single selection for now");
return;
}
// TODO: handle fails
// TODO: concurrent map over all ranges
let range = ranges[0];
let request = match language_server.text_document_range_formatting(
doc.identifier(),
range,
lsp::FormattingOptions::default(),
None,
) {
Some(future) => future,
None => {
cx.editor
.set_error("Language server does not support range formatting");
return;
}
};
let future = language_server
.text_document_range_formatting(
doc.identifier(),
range,
lsp::FormattingOptions::default(),
None,
)
.unwrap();
let edits = tokio::task::block_in_place(|| helix_lsp::block_on(request)).unwrap_or_default();
let edits = tokio::task::block_in_place(|| helix_lsp::block_on(future)).unwrap_or_default();
let transaction = helix_lsp::util::generate_transaction_from_edits(
doc.text(),
edits,
language_server.offset_encoding(),
);
let transaction =
helix_lsp::util::generate_transaction_from_edits(doc.text(), edits, offset_encoding);
doc.apply(&transaction, view.id);
doc.apply(&transaction, view_id);
}
fn join_selections_impl(cx: &mut Context, select_space: bool) {
@ -4170,35 +4231,75 @@ pub fn completion(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let language_server = match doc.language_server() {
Some(language_server) => language_server,
None => return,
let savepoint = if let Some(CompleteAction::Selected { savepoint }) = &cx.editor.last_completion
{
savepoint.clone()
} else {
doc.savepoint(view)
};
let offset_encoding = language_server.offset_encoding();
let text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
let pos = pos_to_lsp_pos(doc.text(), cursor, offset_encoding);
let text = savepoint.text.clone();
let cursor = savepoint.cursor();
let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.map(|language_server| {
let language_server_id = language_server.id();
let offset_encoding = language_server.offset_encoding();
let pos = pos_to_lsp_pos(&text, cursor, offset_encoding);
let doc_id = doc.identifier();
let completion_request = language_server.completion(doc_id, pos, None).unwrap();
async move {
let json = completion_request.await?;
let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
let items = match response {
Some(lsp::CompletionResponse::Array(items)) => items,
// TODO: do something with is_incomplete
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete: _is_incomplete,
items,
})) => items,
None => Vec::new(),
}
.into_iter()
.map(|item| CompletionItem {
item,
language_server_id,
resolved: false,
})
.collect();
let future = match language_server.completion(doc.identifier(), pos, None) {
Some(future) => future,
None => return,
};
anyhow::Ok(items)
}
})
.collect();
// setup a chanel that allows the request to be canceled
// setup a channel that allows the request to be canceled
let (tx, rx) = oneshot::channel();
// set completion_request so that this request can be canceled
// by setting completion_request, the old channel stored there is dropped
// and the associated request is automatically dropped
cx.editor.completion_request_handle = Some(tx);
let future = async move {
let items_future = async move {
let mut items = Vec::new();
// TODO if one completion request errors, all other completion requests are discarded (even if they're valid)
while let Some(mut lsp_items) = futures.try_next().await? {
items.append(&mut lsp_items);
}
anyhow::Ok(items)
};
tokio::select! {
biased;
_ = rx => {
Ok(serde_json::Value::Null)
Ok(Vec::new())
}
res = future => {
res = items_future => {
res
}
}
@ -4214,7 +4315,6 @@ pub fn completion(cx: &mut Context) {
iter.reverse();
let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count();
let start_offset = cursor.saturating_sub(offset);
let savepoint = doc.savepoint(view);
let trigger_doc = doc.id();
let trigger_view = view.id;
@ -4233,46 +4333,45 @@ pub fn completion(cx: &mut Context) {
},
));
cx.callback(
future,
move |editor, compositor, response: Option<lsp::CompletionResponse>| {
cx.jobs.callback(async move {
let items = future.await?;
let call = move |editor: &mut Editor, compositor: &mut Compositor| {
let (view, doc) = current_ref!(editor);
// check if the completion request is stale.
//
// Completions are completed asynchrounsly and therefore the user could
// Completions are completed asynchronously and therefore the user could
//switch document/view or leave insert mode. In all of thoise cases the
// completion should be discarded
if editor.mode != Mode::Insert || view.id != trigger_view || doc.id() != trigger_doc {
return;
}
let items = match response {
Some(lsp::CompletionResponse::Array(items)) => items,
// TODO: do something with is_incomplete
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete: _is_incomplete,
items,
})) => items,
None => Vec::new(),
};
if items.is_empty() {
// editor.set_error("No completion available");
return;
}
let size = compositor.size();
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.set_completion(
let completion_area = ui.set_completion(
editor,
savepoint,
items,
offset_encoding,
start_offset,
trigger_offset,
size,
);
},
);
let size = compositor.size();
let signature_help_area = compositor
.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
.map(|signature_help| signature_help.area(size, editor));
// Delete the signature help popup if they intersect.
if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b))
{
compositor.remove(SignatureHelp::ID);
}
};
Ok(Callback::EditorCompositor(Box::new(call)))
});
}
// comments
@ -5069,9 +5168,10 @@ async fn shell_impl_async(
let output = if let Some(mut stdin) = process.stdin.take() {
let input_task = tokio::spawn(async move {
if let Some(input) = input {
helix_view::document::to_writer(&mut stdin, encoding::UTF_8, &input).await?;
helix_view::document::to_writer(&mut stdin, (encoding::UTF_8, false), &input)
.await?;
}
Ok::<_, anyhow::Error>(())
anyhow::Ok(())
});
let (output, _) = tokio::join! {
process.wait_with_output(),

@ -2,7 +2,7 @@ use super::{Context, Editor};
use crate::{
compositor::{self, Compositor},
job::{Callback, Jobs},
ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent, Text},
ui::{self, overlay::overlaid, FilePicker, Picker, Popup, Prompt, PromptEvent, Text},
};
use dap::{StackFrame, Thread, ThreadStates};
use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate};
@ -270,7 +270,7 @@ pub fn dap_launch(cx: &mut Context) {
let templates = config.templates.clone();
cx.push_layer(Box::new(overlayed(Picker::new(
cx.push_layer(Box::new(overlaid(Picker::new(
templates,
(),
|cx, template, _action| {
@ -580,7 +580,7 @@ pub fn dap_variables(cx: &mut Context) {
let contents = Text::from(tui::text::Text::from(variables));
let popup = Popup::new("dap-variables", contents);
cx.push_layer(Box::new(popup));
cx.replace_or_push_layer("dap-variables", popup);
}
pub fn dap_terminate(cx: &mut Context) {

File diff suppressed because it is too large Load Diff

@ -116,7 +116,7 @@ fn open(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) ->
let call: job::Callback = job::Callback::EditorCompositor(Box::new(
move |editor: &mut Editor, compositor: &mut Compositor| {
let picker = ui::file_picker(path, &editor.config());
compositor.push(Box::new(overlayed(picker)));
compositor.push(Box::new(overlaid(picker)));
},
));
Ok(call)
@ -382,6 +382,36 @@ fn force_write(
write_impl(cx, args.first(), true)
}
fn write_buffer_close(
cx: &mut compositor::Context,
args: &[Cow<str>],
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
write_impl(cx, args.first(), false)?;
let document_ids = buffer_gather_paths_impl(cx.editor, args);
buffer_close_by_ids_impl(cx, &document_ids, false)
}
fn force_write_buffer_close(
cx: &mut compositor::Context,
args: &[Cow<str>],
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
write_impl(cx, args.first(), true)?;
let document_ids = buffer_gather_paths_impl(cx.editor, args);
buffer_close_by_ids_impl(cx, &document_ids, false)
}
fn new_file(
cx: &mut compositor::Context,
_args: &[Cow<str>],
@ -1299,26 +1329,22 @@ fn lsp_workspace_command(
if event != PromptEvent::Validate {
return Ok(());
}
let (_, doc) = current!(cx.editor);
let language_server = match doc.language_server() {
Some(language_server) => language_server,
None => {
cx.editor
.set_status("Language server not active for current buffer");
return Ok(());
}
let doc = doc!(cx.editor);
let Some((language_server_id, options)) = doc
.language_servers_with_feature(LanguageServerFeature::WorkspaceCommand)
.find_map(|ls| {
ls.capabilities()
.execute_command_provider
.as_ref()
.map(|options| (ls.id(), options))
})
else {
cx.editor.set_status(
"No active language servers for this document support workspace commands",
);
return Ok(());
};
let options = match &language_server.capabilities().execute_command_provider {
Some(options) => options,
None => {
cx.editor
.set_status("Workspace commands are not supported for this language server");
return Ok(());
}
};
if args.is_empty() {
let commands = options
.commands
@ -1332,10 +1358,10 @@ fn lsp_workspace_command(
let callback = async move {
let call: job::Callback = Callback::EditorCompositor(Box::new(
move |_editor: &mut Editor, compositor: &mut Compositor| {
let picker = ui::Picker::new(commands, (), |cx, command, _action| {
execute_lsp_command(cx.editor, command.clone());
let picker = ui::Picker::new(commands, (), move |cx, command, _action| {
execute_lsp_command(cx.editor, language_server_id, command.clone());
});
compositor.push(Box::new(overlayed(picker)))
compositor.push(Box::new(overlaid(picker)))
},
));
Ok(call)
@ -1346,6 +1372,7 @@ fn lsp_workspace_command(
if options.commands.iter().any(|c| c == &command) {
execute_lsp_command(
cx.editor,
language_server_id,
helix_lsp::lsp::Command {
title: command.clone(),
arguments: None,
@ -1371,26 +1398,40 @@ fn lsp_restart(
return Ok(());
}
let editor_config = cx.editor.config.load();
let (_view, doc) = current!(cx.editor);
let config = doc
.language_config()
.context("LSP not defined for the current document")?;
let scope = config.scope.clone();
cx.editor.language_servers.restart(config, doc.path())?;
cx.editor.language_servers.restart(
config,
doc.path(),
&editor_config.workspace_lsp_roots,
editor_config.lsp.snippets,
)?;
// This collect is needed because refresh_language_server would need to re-borrow editor.
let document_ids_to_refresh: Vec<DocumentId> = cx
.editor
.documents()
.filter_map(|doc| match doc.language_config() {
Some(config) if config.scope.eq(&scope) => Some(doc.id()),
Some(config)
if config.language_servers.iter().any(|ls| {
config
.language_servers
.iter()
.any(|restarted_ls| restarted_ls.name == ls.name)
}) =>
{
Some(doc.id())
}
_ => None,
})
.collect();
for document_id in document_ids_to_refresh {
cx.editor.refresh_language_server(document_id);
cx.editor.refresh_language_servers(document_id);
}
Ok(())
@ -1405,22 +1446,18 @@ fn lsp_stop(
return Ok(());
}
let doc = doc!(cx.editor);
let ls_id = doc
.language_server()
.map(|ls| ls.id())
.context("LSP not running for the current document")?;
let ls_shutdown_names = doc!(cx.editor)
.language_servers()
.map(|ls| ls.name().to_string())
.collect::<Vec<_>>();
let config = doc
.language_config()
.context("LSP not defined for the current document")?;
cx.editor.language_servers.stop(config);
for ls_name in &ls_shutdown_names {
cx.editor.language_servers.stop(ls_name);
for doc in cx.editor.documents_mut() {
if doc.language_server().map_or(false, |ls| ls.id() == ls_id) {
doc.set_language_server(None);
doc.set_diagnostics(Default::default());
for doc in cx.editor.documents_mut() {
if let Some(client) = doc.remove_language_server_by_name(ls_name) {
doc.clear_diagnostics(client.id());
}
}
}
@ -1764,12 +1801,12 @@ fn toggle_option(
let pointer = format!("/{}", key.replace('.', "/"));
let value = config.pointer_mut(&pointer).ok_or_else(key_error)?;
if let Value::Bool(b) = *value {
*value = Value::Bool(!b);
} else {
let Value::Bool(old_value) = *value else {
anyhow::bail!("Key `{}` is not toggle-able", key)
}
};
let new_value = !old_value;
*value = Value::Bool(new_value);
// This unwrap should never fail because we only replace one boolean value
// with another, maintaining a valid json config
let config = serde_json::from_value(config).unwrap();
@ -1778,6 +1815,8 @@ fn toggle_option(
.config_events
.0
.send(ConfigEvent::Update(config))?;
cx.editor
.set_status(format!("Option `{}` is now set to `{}`", key, new_value));
Ok(())
}
@ -1812,7 +1851,7 @@ fn language(
doc.detect_indent_and_line_ending();
let id = doc.id();
cx.editor.refresh_language_server(id);
cx.editor.refresh_language_servers(id);
Ok(())
}
@ -1970,6 +2009,20 @@ fn open_config(
Ok(())
}
fn open_workspace_config(
cx: &mut compositor::Context,
_args: &[Cow<str>],
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
cx.editor
.open(&helix_loader::workspace_config_file(), Action::Replace)?;
Ok(())
}
fn open_log(
cx: &mut compositor::Context,
_args: &[Cow<str>],
@ -2105,20 +2158,16 @@ fn reset_diff_change(
let scrolloff = editor.config().scrolloff;
let (view, doc) = current!(editor);
// TODO refactor to use let..else once MSRV is raised to 1.65
let handle = match doc.diff_handle() {
Some(handle) => handle,
None => bail!("Diff is not available in the current buffer"),
let Some(handle) = doc.diff_handle() else {
bail!("Diff is not available in the current buffer")
};
let diff = handle.load();
let doc_text = doc.text().slice(..);
let line = doc.selection(view.id).primary().cursor_line(doc_text);
// TODO refactor to use let..else once MSRV is raised to 1.65
let hunk_idx = match diff.hunk_at(line as u32, true) {
Some(hunk_idx) => hunk_idx,
None => bail!("There is no change at the cursor"),
let Some(hunk_idx) = diff.hunk_at(line as u32, true) else {
bail!("There is no change at the cursor")
};
let hunk = diff.nth_hunk(hunk_idx);
let diff_base = diff.diff_base();
@ -2149,6 +2198,38 @@ fn reset_diff_change(
Ok(())
}
fn clear_register(
cx: &mut compositor::Context,
args: &[Cow<str>],
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
ensure!(args.len() <= 1, ":clear-register takes at most 1 argument");
if args.is_empty() {
cx.editor.registers.clear();
cx.editor.set_status("All registers cleared");
return Ok(());
}
ensure!(
args[0].chars().count() == 1,
format!("Invalid register {}", args[0])
);
let register = args[0].chars().next().unwrap_or_default();
match cx.editor.registers.remove(register) {
Some(_) => cx
.editor
.set_status(format!("Register {} cleared", register)),
None => cx
.editor
.set_error(format!("Register {} not found", register)),
}
Ok(())
}
pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
TypableCommand {
name: "quit",
@ -2237,10 +2318,24 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
TypableCommand {
name: "write!",
aliases: &["w!"],
doc: "Force write changes to disk creating necessary subdirectories. Accepts an optional path (:write some/path.txt)",
doc: "Force write changes to disk creating necessary subdirectories. Accepts an optional path (:write! some/path.txt)",
fun: force_write,
signature: CommandSignature::positional(&[completers::filename]),
},
TypableCommand {
name: "write-buffer-close",
aliases: &["wbc"],
doc: "Write changes to disk and closes the buffer. Accepts an optional path (:write-buffer-close some/path.txt)",
fun: write_buffer_close,
signature: CommandSignature::positional(&[completers::filename]),
},
TypableCommand {
name: "write-buffer-close!",
aliases: &["wbc!"],
doc: "Force write changes to disk creating necessary subdirectories and closes the buffer. Accepts an optional path (:write-buffer-close! some/path.txt)",
fun: force_write_buffer_close,
signature: CommandSignature::positional(&[completers::filename]),
},
TypableCommand {
name: "new",
aliases: &["n"],
@ -2479,7 +2574,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
},
TypableCommand {
name: "update",
aliases: &[],
aliases: &["u"],
doc: "Write changes only if the file has been modified.",
fun: update,
signature: CommandSignature::none(),
@ -2494,14 +2589,14 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
TypableCommand {
name: "lsp-restart",
aliases: &[],
doc: "Restarts the Language Server that is in use by the current doc",
doc: "Restarts the language servers used by the current doc",
fun: lsp_restart,
signature: CommandSignature::none(),
},
TypableCommand {
name: "lsp-stop",
aliases: &[],
doc: "Stops the Language Server that is in use by the current doc",
doc: "Stops the language servers that are used by the current doc",
fun: lsp_stop,
signature: CommandSignature::none(),
},
@ -2646,6 +2741,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
fun: open_config,
signature: CommandSignature::none(),
},
TypableCommand {
name: "config-open-workspace",
aliases: &[],
doc: "Open the workspace config.toml file.",
fun: open_workspace_config,
signature: CommandSignature::none(),
},
TypableCommand {
name: "log-open",
aliases: &[],
@ -2695,6 +2797,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
fun: reset_diff_change,
signature: CommandSignature::none(),
},
TypableCommand {
name: "clear-register",
aliases: &[],
doc: "Clear given register. If no argument is provided, clear all registers.",
fun: clear_register,
signature: CommandSignature::none(),
},
];
pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableCommand>> =
@ -2739,13 +2848,10 @@ pub(super) fn command_mode(cx: &mut Context) {
} else {
// Otherwise, use the command's completer and the last shellword
// as completion input.
let (part, part_len) = if words.len() == 1 || shellwords.ends_with_whitespace() {
let (word, word_len) = if words.len() == 1 || shellwords.ends_with_whitespace() {
(&Cow::Borrowed(""), 0)
} else {
(
words.last().unwrap(),
shellwords.parts().last().unwrap().len(),
)
(words.last().unwrap(), words.last().unwrap().len())
};
let argument_number = argument_number_of(&shellwords);
@ -2754,13 +2860,13 @@ pub(super) fn command_mode(cx: &mut Context) {
.get(&words[0] as &str)
.map(|tc| tc.completer_for_argument_number(argument_number))
{
completer(editor, part)
completer(editor, word)
.into_iter()
.map(|(range, file)| {
let file = shellwords::escape(file);
// offset ranges to input
let offset = input.len() - part_len;
let offset = input.len() - word_len;
let range = (range.start + offset)..;
(range, file)
})

@ -1,27 +1,34 @@
use crate::keymap::{default::default, merge_keys, Keymap};
use crate::keymap;
use crate::keymap::{merge_keys, Keymap};
use helix_loader::merge_toml_values;
use helix_view::document::Mode;
use serde::Deserialize;
use std::collections::HashMap;
use std::fmt::Display;
use std::fs;
use std::io::Error as IOError;
use std::path::PathBuf;
use toml::de::Error as TomlError;
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub theme: Option<String>,
#[serde(default = "default")]
pub keys: HashMap<Mode, Keymap>,
#[serde(default)]
pub editor: helix_view::editor::Config,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigRaw {
pub theme: Option<String>,
pub keys: Option<HashMap<Mode, Keymap>>,
pub editor: Option<toml::Value>,
}
impl Default for Config {
fn default() -> Config {
Config {
theme: None,
keys: default(),
keys: keymap::default(),
editor: helix_view::editor::Config::default(),
}
}
@ -33,6 +40,12 @@ pub enum ConfigLoadError {
Error(IOError),
}
impl Default for ConfigLoadError {
fn default() -> Self {
ConfigLoadError::Error(IOError::new(std::io::ErrorKind::NotFound, "place holder"))
}
}
impl Display for ConfigLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@ -43,17 +56,72 @@ impl Display for ConfigLoadError {
}
impl Config {
pub fn load(config_path: PathBuf) -> Result<Config, ConfigLoadError> {
match std::fs::read_to_string(config_path) {
Ok(config) => toml::from_str(&config)
.map(merge_keys)
.map_err(ConfigLoadError::BadConfig),
Err(err) => Err(ConfigLoadError::Error(err)),
}
pub fn load(
global: Result<String, ConfigLoadError>,
local: Result<String, ConfigLoadError>,
) -> Result<Config, ConfigLoadError> {
let global_config: Result<ConfigRaw, ConfigLoadError> =
global.and_then(|file| toml::from_str(&file).map_err(ConfigLoadError::BadConfig));
let local_config: Result<ConfigRaw, ConfigLoadError> =
local.and_then(|file| toml::from_str(&file).map_err(ConfigLoadError::BadConfig));
let res = match (global_config, local_config) {
(Ok(global), Ok(local)) => {
let mut keys = keymap::default();
if let Some(global_keys) = global.keys {
merge_keys(&mut keys, global_keys)
}
if let Some(local_keys) = local.keys {
merge_keys(&mut keys, local_keys)
}
let editor = match (global.editor, local.editor) {
(None, None) => helix_view::editor::Config::default(),
(None, Some(val)) | (Some(val), None) => {
val.try_into().map_err(ConfigLoadError::BadConfig)?
}
(Some(global), Some(local)) => merge_toml_values(global, local, 3)
.try_into()
.map_err(ConfigLoadError::BadConfig)?,
};
Config {
theme: local.theme.or(global.theme),
keys,
editor,
}
}
// if any configs are invalid return that first
(_, Err(ConfigLoadError::BadConfig(err)))
| (Err(ConfigLoadError::BadConfig(err)), _) => {
return Err(ConfigLoadError::BadConfig(err))
}
(Ok(config), Err(_)) | (Err(_), Ok(config)) => {
let mut keys = keymap::default();
if let Some(keymap) = config.keys {
merge_keys(&mut keys, keymap);
}
Config {
theme: config.theme,
keys,
editor: config.editor.map_or_else(
|| Ok(helix_view::editor::Config::default()),
|val| val.try_into().map_err(ConfigLoadError::BadConfig),
)?,
}
}
// these are just two io errors return the one for the global config
(Err(err), Err(_)) => return Err(err),
};
Ok(res)
}
pub fn load_default() -> Result<Config, ConfigLoadError> {
Config::load(helix_loader::config_file())
let global_config =
fs::read_to_string(helix_loader::config_file()).map_err(ConfigLoadError::Error);
let local_config = fs::read_to_string(helix_loader::workspace_config_file())
.map_err(ConfigLoadError::Error);
Config::load(global_config, local_config)
}
}
@ -61,6 +129,12 @@ impl Config {
mod tests {
use super::*;
impl Config {
fn load_test(config: &str) -> Config {
Config::load(Ok(config.to_owned()), Err(ConfigLoadError::default())).unwrap()
}
}
#[test]
fn parsing_keymaps_config_file() {
use crate::keymap;
@ -77,18 +151,24 @@ mod tests {
A-F12 = "move_next_word_end"
"#;
let mut keys = keymap::default();
merge_keys(
&mut keys,
hashmap! {
Mode::Insert => Keymap::new(keymap!({ "Insert mode"
"y" => move_line_down,
"S-C-a" => delete_selection,
})),
Mode::Normal => Keymap::new(keymap!({ "Normal mode"
"A-F12" => move_next_word_end,
})),
},
);
assert_eq!(
toml::from_str::<Config>(sample_keymaps).unwrap(),
Config::load_test(sample_keymaps),
Config {
keys: hashmap! {
Mode::Insert => Keymap::new(keymap!({ "Insert mode"
"y" => move_line_down,
"S-C-a" => delete_selection,
})),
Mode::Normal => Keymap::new(keymap!({ "Normal mode"
"A-F12" => move_next_word_end,
})),
},
keys,
..Default::default()
}
);
@ -97,11 +177,11 @@ mod tests {
#[test]
fn keys_resolve_to_correct_defaults() {
// From serde default
let default_keys = toml::from_str::<Config>("").unwrap().keys;
assert_eq!(default_keys, default());
let default_keys = Config::load_test("").keys;
assert_eq!(default_keys, keymap::default());
// From the Default trait
let default_keys = Config::default().keys;
assert_eq!(default_keys, default());
assert_eq!(default_keys, keymap::default());
}
}

@ -201,10 +201,14 @@ pub fn languages_all() -> std::io::Result<()> {
for lang in &syn_loader_conf.language {
column(&lang.language_id, Color::Reset);
let lsp = lang
.language_server
.as_ref()
.map(|lsp| lsp.command.to_string());
// TODO multiple language servers (check binary for each supported language server, not just the first)
let lsp = lang.language_servers.first().and_then(|ls| {
syn_loader_conf
.language_server
.get(&ls.name)
.map(|config| config.command.clone())
});
check_binary(lsp);
let dap = lang.debugger.as_ref().map(|dap| dap.command.to_string());
@ -273,11 +277,15 @@ pub fn language(lang_str: String) -> std::io::Result<()> {
}
};
// TODO multiple language servers
probe_protocol(
"language server",
lang.language_server
.as_ref()
.map(|lsp| lsp.command.to_string()),
lang.language_servers.first().and_then(|ls| {
syn_loader_conf
.language_server
.get(&ls.name)
.map(|config| config.command.clone())
}),
)?;
probe_protocol(

@ -2,7 +2,6 @@ pub mod default;
pub mod macros;
pub use crate::commands::MappableCommand;
use crate::config::Config;
use arc_swap::{
access::{DynAccess, DynGuard},
ArcSwap,
@ -16,7 +15,7 @@ use std::{
sync::Arc,
};
use default::default;
pub use default::default;
use macros::key;
#[derive(Debug, Clone)]
@ -417,12 +416,10 @@ impl Default for Keymaps {
}
/// Merge default config keys with user overwritten keys for custom user config.
pub fn merge_keys(mut config: Config) -> Config {
let mut delta = std::mem::replace(&mut config.keys, default());
for (mode, keys) in &mut config.keys {
pub fn merge_keys(dst: &mut HashMap<Mode, Keymap>, mut delta: HashMap<Mode, Keymap>) {
for (mode, keys) in dst {
keys.merge(delta.remove(mode).unwrap_or_default())
}
config
}
#[cfg(test)]
@ -449,26 +446,24 @@ mod tests {
#[test]
fn merge_partial_keys() {
let config = Config {
keys: hashmap! {
Mode::Normal => Keymap::new(
keymap!({ "Normal mode"
"i" => normal_mode,
"无" => insert_mode,
"z" => jump_backward,
"g" => { "Merge into goto mode"
"$" => goto_line_end,
"g" => delete_char_forward,
},
})
)
},
..Default::default()
let keymap = hashmap! {
Mode::Normal => Keymap::new(
keymap!({ "Normal mode"
"i" => normal_mode,
"无" => insert_mode,
"z" => jump_backward,
"g" => { "Merge into goto mode"
"$" => goto_line_end,
"g" => delete_char_forward,
},
})
)
};
let mut merged_config = merge_keys(config.clone());
assert_ne!(config, merged_config);
let mut merged_keyamp = default();
merge_keys(&mut merged_keyamp, keymap.clone());
assert_ne!(keymap, merged_keyamp);
let mut keymap = Keymaps::new(Box::new(Constant(merged_config.keys.clone())));
let mut keymap = Keymaps::new(Box::new(Constant(merged_keyamp.clone())));
assert_eq!(
keymap.get(Mode::Normal, key!('i')),
KeymapResult::Matched(MappableCommand::normal_mode),
@ -486,7 +481,7 @@ mod tests {
"Leaf should replace node"
);
let keymap = merged_config.keys.get_mut(&Mode::Normal).unwrap();
let keymap = merged_keyamp.get_mut(&Mode::Normal).unwrap();
// Assumes that `g` is a node in default keymap
assert_eq!(
keymap.root().search(&[key!('g'), key!('$')]).unwrap(),
@ -506,30 +501,28 @@ mod tests {
"Old leaves in subnode should be present in merged node"
);
assert!(merged_config.keys.get(&Mode::Normal).unwrap().len() > 1);
assert!(merged_config.keys.get(&Mode::Insert).unwrap().len() > 0);
assert!(merged_keyamp.get(&Mode::Normal).unwrap().len() > 1);
assert!(merged_keyamp.get(&Mode::Insert).unwrap().len() > 0);
}
#[test]
fn order_should_be_set() {
let config = Config {
keys: hashmap! {
Mode::Normal => Keymap::new(
keymap!({ "Normal mode"
"space" => { ""
"s" => { ""
"v" => vsplit,
"c" => hsplit,
},
let keymap = hashmap! {
Mode::Normal => Keymap::new(
keymap!({ "Normal mode"
"space" => { ""
"s" => { ""
"v" => vsplit,
"c" => hsplit,
},
})
)
},
..Default::default()
},
})
)
};
let mut merged_config = merge_keys(config.clone());
assert_ne!(config, merged_config);
let keymap = merged_config.keys.get_mut(&Mode::Normal).unwrap();
let mut merged_keyamp = default();
merge_keys(&mut merged_keyamp, keymap.clone());
assert_ne!(keymap, merged_keyamp);
let keymap = merged_keyamp.get_mut(&Mode::Normal).unwrap();
// Make sure mapping works
assert_eq!(
keymap

@ -79,6 +79,7 @@ pub fn default() -> HashMap<Mode, Keymap> {
"s" => select_regex,
"A-s" => split_selection_on_newline,
"A-minus" => merge_selections,
"A-_" => merge_consecutive_selections,
"S" => split_selection,
";" => collapse_selection,

@ -3,7 +3,7 @@ use crossterm::event::EventStream;
use helix_loader::VERSION_AND_GIT_HASH;
use helix_term::application::Application;
use helix_term::args::Args;
use helix_term::config::Config;
use helix_term::config::{Config, ConfigLoadError};
use std::path::PathBuf;
fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> {
@ -126,18 +126,19 @@ FLAGS:
helix_loader::initialize_config_file(args.config_file.clone());
let config = match std::fs::read_to_string(helix_loader::config_file()) {
Ok(config) => toml::from_str(&config)
.map(helix_term::keymap::merge_keys)
.unwrap_or_else(|err| {
eprintln!("Bad config: {}", err);
eprintln!("Press <ENTER> to continue with default config");
use std::io::Read;
let _ = std::io::stdin().read(&mut []);
Config::default()
}),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Config::default(),
Err(err) => return Err(Error::new(err)),
let config = match Config::load_default() {
Ok(config) => config,
Err(ConfigLoadError::Error(err)) if err.kind() == std::io::ErrorKind::NotFound => {
Config::default()
}
Err(ConfigLoadError::Error(err)) => return Err(Error::new(err)),
Err(ConfigLoadError::BadConfig(err)) => {
eprintln!("Bad config: {}", err);
eprintln!("Press <ENTER> to continue with default config");
use std::io::Read;
let _ = std::io::stdin().read(&mut []);
Config::default()
}
};
let syn_loader_conf = helix_core::config::user_syntax_loader().unwrap_or_else(|err| {

@ -15,8 +15,7 @@ use helix_view::{graphics::Rect, Document, Editor};
use crate::commands;
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
use helix_lsp::{lsp, util};
use lsp::CompletionItem;
use helix_lsp::{lsp, util, OffsetEncoding};
impl menu::Item for CompletionItem {
type Data = ();
@ -26,28 +25,30 @@ impl menu::Item for CompletionItem {
#[inline]
fn filter_text(&self, _data: &Self::Data) -> Cow<str> {
self.filter_text
self.item
.filter_text
.as_ref()
.unwrap_or(&self.label)
.unwrap_or(&self.item.label)
.as_str()
.into()
}
fn format(&self, _data: &Self::Data) -> menu::Row {
let deprecated = self.deprecated.unwrap_or_default()
|| self.tags.as_ref().map_or(false, |tags| {
let deprecated = self.item.deprecated.unwrap_or_default()
|| self.item.tags.as_ref().map_or(false, |tags| {
tags.contains(&lsp::CompletionItemTag::DEPRECATED)
});
menu::Row::new(vec![
menu::Cell::from(Span::styled(
self.label.as_str(),
self.item.label.as_str(),
if deprecated {
Style::default().add_modifier(Modifier::CROSSED_OUT)
} else {
Style::default()
},
)),
menu::Cell::from(match self.kind {
menu::Cell::from(match self.item.kind {
Some(lsp::CompletionItemKind::TEXT) => "text",
Some(lsp::CompletionItemKind::METHOD) => "method",
Some(lsp::CompletionItemKind::FUNCTION) => "function",
@ -79,15 +80,17 @@ impl menu::Item for CompletionItem {
}
None => "",
}),
// self.detail.as_deref().unwrap_or("")
// self.label_details
// .as_ref()
// .or(self.detail())
// .as_str(),
])
}
}
#[derive(Debug, PartialEq, Default, Clone)]
pub struct CompletionItem {
pub item: lsp::CompletionItem,
pub language_server_id: usize,
pub resolved: bool,
}
/// Wraps a Menu.
pub struct Completion {
popup: Popup<Menu<CompletionItem>>,
@ -104,21 +107,20 @@ impl Completion {
editor: &Editor,
savepoint: Arc<SavePoint>,
mut items: Vec<CompletionItem>,
offset_encoding: helix_lsp::OffsetEncoding,
start_offset: usize,
trigger_offset: usize,
) -> Self {
let replace_mode = editor.config().completion_replace;
// Sort completion items according to their preselect status (given by the LSP server)
items.sort_by_key(|item| !item.preselect.unwrap_or(false));
items.sort_by_key(|item| !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,
view_id: ViewId,
item: &CompletionItem,
offset_encoding: helix_lsp::OffsetEncoding,
item: &lsp::CompletionItem,
offset_encoding: OffsetEncoding,
trigger_offset: usize,
include_placeholder: bool,
replace_mode: bool,
@ -141,16 +143,12 @@ impl Completion {
}
};
let start_offset =
match util::lsp_pos_to_pos(doc.text(), edit.range.start, offset_encoding) {
Some(start) => start as i128 - primary_cursor as i128,
None => return Transaction::new(doc.text()),
};
let end_offset =
match util::lsp_pos_to_pos(doc.text(), edit.range.end, offset_encoding) {
Some(end) => end as i128 - primary_cursor as i128,
None => return Transaction::new(doc.text()),
};
let Some(range) = util::lsp_range_to_range(doc.text(), edit.range, offset_encoding) else{
return Transaction::new(doc.text());
};
let start_offset = range.anchor as i128 - primary_cursor as i128;
let end_offset = range.head as i128 - primary_cursor as i128;
(Some((start_offset, end_offset)), edit.new_text)
} else {
@ -213,77 +211,107 @@ impl Completion {
let (view, doc) = current!(editor);
// if more text was entered, remove it
doc.restore(view, &savepoint);
macro_rules! language_server {
($item:expr) => {
match editor
.language_servers
.get_by_id($item.language_server_id)
{
Some(ls) => ls,
None => {
editor.set_error("completions are outdated");
// TODO close the completion menu somehow,
// currently there is no trivial way to access the EditorView to close the completion menu
return;
}
}
};
}
match event {
PromptEvent::Abort => {
editor.last_completion = None;
}
PromptEvent::Abort => {}
PromptEvent::Update => {
// Update creates "ghost" transactions which are not sent to the
// lsp server to avoid messing up re-requesting completions. Once a
// completion has been selected (with tab, c-n or c-p) it's always accepted whenever anything
// is typed. The only way to avoid that is to explicitly abort the completion
// with c-c. This will remove the "ghost" transaction.
//
// The ghost transaction is modeled with a transaction that is not sent to the LS.
// (apply_temporary) and a savepoint. It's extremely important this savepoint is restored
// (also without sending the transaction to the LS) *before any further transaction is applied*.
// Otherwise incremental sync breaks (since the state of the LS doesn't match the state the transaction
// is applied to).
if editor.last_completion.is_none() {
editor.last_completion = Some(CompleteAction::Selected {
savepoint: doc.savepoint(view),
})
}
// if more text was entered, remove it
doc.restore(view, &savepoint, false);
// always present here
let item = item.unwrap();
let transaction = item_to_transaction(
doc,
view.id,
item,
offset_encoding,
&item.item,
language_server!(item).offset_encoding(),
trigger_offset,
true,
replace_mode,
);
// initialize a savepoint
doc.apply(&transaction, view.id);
editor.last_completion = Some(CompleteAction {
trigger_offset,
changes: completion_changes(&transaction, trigger_offset),
});
doc.apply_temporary(&transaction, view.id);
}
PromptEvent::Validate => {
if let Some(CompleteAction::Selected { savepoint }) =
editor.last_completion.take()
{
doc.restore(view, &savepoint, false);
}
// always present here
let item = item.unwrap();
let mut item = item.unwrap().clone();
let language_server = language_server!(item);
let offset_encoding = language_server.offset_encoding();
let language_server = editor
.language_servers
.get_by_id(item.language_server_id)
.unwrap();
// resolve item if not yet resolved
if !item.resolved {
if let Some(resolved) =
Self::resolve_completion_item(language_server, item.item.clone())
{
item.item = resolved;
}
};
// if more text was entered, remove it
doc.restore(view, &savepoint, true);
let transaction = item_to_transaction(
doc,
view.id,
item,
&item.item,
offset_encoding,
trigger_offset,
false,
replace_mode,
);
doc.apply(&transaction, view.id);
editor.last_completion = Some(CompleteAction {
editor.last_completion = Some(CompleteAction::Applied {
trigger_offset,
changes: completion_changes(&transaction, trigger_offset),
});
// apply additional edits, mostly used to auto import unqualified types
let resolved_item = if item
.additional_text_edits
.as_ref()
.map(|edits| !edits.is_empty())
.unwrap_or(false)
{
None
} else {
Self::resolve_completion_item(doc, item.clone())
};
if let Some(additional_edits) = resolved_item
.as_ref()
.and_then(|item| item.additional_text_edits.as_ref())
.or(item.additional_text_edits.as_ref())
{
// TODO: add additional _edits to completion_changes?
if let Some(additional_edits) = item.item.additional_text_edits {
if !additional_edits.is_empty() {
let transaction = util::generate_transaction_from_edits(
doc.text(),
additional_edits.clone(),
additional_edits,
offset_encoding, // TODO: should probably transcode in Client
);
doc.apply(&transaction, view.id);
@ -308,11 +336,9 @@ impl Completion {
}
fn resolve_completion_item(
doc: &Document,
language_server: &helix_lsp::Client,
completion_item: lsp::CompletionItem,
) -> Option<CompletionItem> {
let language_server = doc.language_server()?;
) -> Option<lsp::CompletionItem> {
let future = language_server.resolve_completion_item(completion_item)?;
let response = helix_lsp::block_on(future);
match response {
@ -363,7 +389,7 @@ impl Completion {
self.popup.contents().is_empty()
}
fn replace_item(&mut self, old_item: lsp::CompletionItem, new_item: lsp::CompletionItem) {
fn replace_item(&mut self, old_item: CompletionItem, new_item: CompletionItem) {
self.popup.contents_mut().replace_option(old_item, new_item);
}
@ -379,20 +405,14 @@ impl Completion {
// > 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
let current_item = match self.popup.contents().selection() {
Some(item) if item.documentation.is_none() => item.clone(),
Some(item) if !item.resolved => item.clone(),
_ => return false,
};
let language_server = match doc!(cx.editor).language_server() {
Some(language_server) => language_server,
None => return false,
};
let Some(language_server) = cx.editor.language_server_by_id(current_item.language_server_id) else { return false; };
// This method should not block the compositor so we handle the response asynchronously.
let future = match language_server.resolve_completion_item(current_item.clone()) {
Some(future) => future,
None => return false,
};
let Some(future) = language_server.resolve_completion_item(current_item.item.clone()) else { return false; };
cx.callback(
future,
@ -407,6 +427,12 @@ impl Completion {
.unwrap()
.completion
{
let resolved_item = CompletionItem {
item: resolved_item,
language_server_id: current_item.language_server_id,
resolved: true,
};
completion.replace_item(current_item, resolved_item);
}
},
@ -414,6 +440,10 @@ impl Completion {
true
}
pub fn area(&mut self, viewport: Rect, editor: &Editor) -> Rect {
self.popup.area(viewport, editor)
}
}
impl Component for Completion {
@ -457,31 +487,31 @@ impl Component for Completion {
Markdown::new(md, cx.editor.syn_loader.clone())
};
let mut markdown_doc = match &option.documentation {
let mut markdown_doc = match &option.item.documentation {
Some(lsp::Documentation::String(contents))
| Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
kind: lsp::MarkupKind::PlainText,
value: contents,
})) => {
// TODO: convert to wrapped text
markdowned(language, option.detail.as_deref(), Some(contents))
markdowned(language, option.item.detail.as_deref(), Some(contents))
}
Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: contents,
})) => {
// TODO: set language based on doc scope
markdowned(language, option.detail.as_deref(), Some(contents))
markdowned(language, option.item.detail.as_deref(), Some(contents))
}
None if option.detail.is_some() => {
None if option.item.detail.is_some() => {
// TODO: set language based on doc scope
markdowned(language, option.detail.as_deref(), None)
markdowned(language, option.item.detail.as_deref(), None)
}
None => return,
};
let popup_area = {
let (popup_x, popup_y) = self.popup.get_rel_position(area, cx);
let (popup_x, popup_y) = self.popup.get_rel_position(area, cx.editor);
let (popup_width, popup_height) = self.popup.get_size();
Rect::new(popup_x, popup_y, popup_width, popup_height)
};

@ -118,7 +118,7 @@ pub fn render_document(
fn translate_positions(
char_pos: usize,
first_visisble_char_idx: usize,
first_visible_char_idx: usize,
translated_positions: &mut [TranslatedPosition],
text_fmt: &TextFormat,
renderer: &mut TextRenderer,
@ -126,7 +126,7 @@ fn translate_positions(
) {
// check if any positions translated on the fly (like cursor) has been reached
for (char_idx, callback) in &mut *translated_positions {
if *char_idx < char_pos && *char_idx >= first_visisble_char_idx {
if *char_idx < char_pos && *char_idx >= first_visible_char_idx {
// by replacing the char_index with usize::MAX large number we ensure
// that the same position is only translated once
// text will never reach usize::MAX as rust memory allocations are limited
@ -175,7 +175,6 @@ pub fn render_text<'t>(
text_annotations,
);
row_off += offset.vertical_offset;
assert_eq!(0, offset.vertical_offset);
let (mut formatter, mut first_visible_char_idx) =
DocumentFormatter::new_at_prev_checkpoint(text, text_fmt, text_annotations, offset.anchor);
@ -260,7 +259,7 @@ pub fn render_text<'t>(
}
}
// aquire the correct grapheme style
// acquire the correct grapheme style
if char_pos >= style_span.1 {
style_span = styles.next().unwrap_or((Style::default(), usize::MAX));
}
@ -405,7 +404,7 @@ impl<'a> TextRenderer<'a> {
let cut_off_start = self.col_offset.saturating_sub(position.col);
let is_whitespace = grapheme.is_whitespace();
// TODO is it correct to apply the whitspace style to all unicode white spaces?
// TODO is it correct to apply the whitespace style to all unicode white spaces?
if is_whitespace {
style = style.patch(self.whitespace_style);
}

@ -20,7 +20,7 @@ use helix_core::{
text_annotations::TextAnnotations,
tree_sitter::{QueryCursor, QueryMatch},
unicode::width::UnicodeWidthStr,
visual_offset_from_block, Position, Range, Selection, Transaction,
visual_offset_from_block, Change, Position, Range, Selection, Transaction,
};
use helix_view::{
document::{Mode, SavePoint, SCRATCH_BUFFER_NAME},
@ -34,7 +34,7 @@ use std::{collections::HashSet, mem::take, num::NonZeroUsize, path::PathBuf, rc:
use tui::{buffer::Buffer as Surface, text::Span};
use super::{document::render_text, statusline};
use super::{completion::CompletionItem, document::render_text, statusline};
use super::{document::LineDecoration, lsp::SignatureHelp};
#[derive(Debug, Clone)]
@ -75,7 +75,10 @@ pub struct EditorView {
#[derive(Debug, Clone)]
pub enum InsertEvent {
Key(KeyEvent),
CompletionApply(CompleteAction),
CompletionApply {
trigger_offset: usize,
changes: Vec<Change>,
},
TriggerCompletion,
RequestCompletion,
}
@ -121,40 +124,6 @@ impl EditorView {
let mut line_decorations: Vec<Box<dyn LineDecoration>> = Vec::new();
let mut translated_positions: Vec<TranslatedPosition> = Vec::new();
// DAP: Highlight current stack frame position
let stack_frame = editor.debugger.as_ref().and_then(|debugger| {
if let (Some(frame), Some(thread_id)) = (debugger.active_frame, debugger.thread_id) {
debugger
.stack_frames
.get(&thread_id)
.and_then(|bt| bt.get(frame))
} else {
None
}
});
if let Some(frame) = stack_frame {
if doc.path().is_some()
&& frame
.source
.as_ref()
.and_then(|source| source.path.as_ref())
== doc.path()
{
let line = frame.line - 1; // convert to 0-indexing
let style = theme.get("ui.highlight");
let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| {
if pos.doc_line != line {
return;
}
renderer
.surface
.set_style(Rect::new(area.x, pos.visual_line, area.width, 1), style);
};
line_decorations.push(Box::new(line_decoration));
}
}
if is_focused && config.cursorline {
line_decorations.push(Self::cursorline_decorator(doc, view, theme))
}
@ -163,6 +132,23 @@ impl EditorView {
Self::highlight_cursorcolumn(doc, view, surface, theme, inner, &text_annotations);
}
// Set DAP highlights, if needed.
if let Some(frame) = editor.current_stack_frame() {
let dap_line = frame.line.saturating_sub(1);
let style = theme.get("ui.highlight.frameline");
let line_decoration = move |renderer: &mut TextRenderer, pos: LinePos| {
if pos.doc_line != dap_line {
return;
}
renderer.surface.set_style(
Rect::new(inner.x, inner.y + pos.visual_line, inner.width, 1),
style,
);
};
line_decorations.push(Box::new(line_decoration));
}
let mut highlights =
Self::doc_syntax_highlights(doc, view.offset.anchor, inner.height, theme);
let overlay_highlights = Self::overlay_syntax_highlights(
@ -469,6 +455,7 @@ impl EditorView {
let primary_selection_scope = theme
.find_scope_index_exact("ui.selection.primary")
.unwrap_or(selection_scope);
let base_cursor_scope = theme
.find_scope_index_exact("ui.cursor")
.unwrap_or(selection_scope);
@ -726,7 +713,7 @@ impl EditorView {
.primary()
.cursor(doc.text().slice(..));
let diagnostics = doc.diagnostics().iter().filter(|diagnostic| {
let diagnostics = doc.shown_diagnostics().filter(|diagnostic| {
diagnostic.range.start <= cursor && diagnostic.range.end >= cursor
});
@ -1225,7 +1212,7 @@ impl EditorView {
}
(Mode::Insert, Mode::Normal) => {
// if exiting insert mode, remove completion
self.completion = None;
self.clear_completion(cxt.editor);
cxt.editor.completion_request_handle = None;
// TODO: Use an on_mode_change hook to remove signature help
@ -1303,22 +1290,25 @@ impl EditorView {
for key in self.last_insert.1.clone() {
match key {
InsertEvent::Key(key) => self.insert_mode(cxt, key),
InsertEvent::CompletionApply(compl) => {
InsertEvent::CompletionApply {
trigger_offset,
changes,
} => {
let (view, doc) = current!(cxt.editor);
if let Some(last_savepoint) = last_savepoint.as_deref() {
doc.restore(view, last_savepoint);
doc.restore(view, last_savepoint, true);
}
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 };
|pos: usize| -> usize { pos + cursor - trigger_offset };
let tx = Transaction::change(
doc.text(),
compl.changes.iter().cloned().map(|(start, end, t)| {
changes.iter().cloned().map(|(start, end, t)| {
(shift_position(start), shift_position(end), t)
}),
);
@ -1359,36 +1349,46 @@ impl EditorView {
&mut self,
editor: &mut Editor,
savepoint: Arc<SavePoint>,
items: Vec<helix_lsp::lsp::CompletionItem>,
offset_encoding: helix_lsp::OffsetEncoding,
items: Vec<CompletionItem>,
start_offset: usize,
trigger_offset: usize,
size: Rect,
) {
let mut completion = Completion::new(
editor,
savepoint,
items,
offset_encoding,
start_offset,
trigger_offset,
);
) -> Option<Rect> {
let mut completion =
Completion::new(editor, savepoint, items, start_offset, trigger_offset);
if completion.is_empty() {
// skip if we got no completion results
return;
return None;
}
let area = completion.area(size, editor);
editor.last_completion = None;
self.last_insert.1.push(InsertEvent::TriggerCompletion);
// TODO : propagate required size on resize to completion too
completion.required_size((size.width, size.height));
self.completion = Some(completion);
Some(area)
}
pub fn clear_completion(&mut self, editor: &mut Editor) {
self.completion = None;
if let Some(last_completion) = editor.last_completion.take() {
match last_completion {
CompleteAction::Applied {
trigger_offset,
changes,
} => self.last_insert.1.push(InsertEvent::CompletionApply {
trigger_offset,
changes,
}),
CompleteAction::Selected { savepoint } => {
let (view, doc) = current!(editor);
doc.restore(view, &savepoint, false);
}
}
}
// Clear any savepoints
editor.clear_idle_timer(); // don't retrigger
@ -1434,10 +1434,15 @@ impl EditorView {
..
} = *event;
let pos_and_view = |editor: &Editor, row, column| {
let pos_and_view = |editor: &Editor, row, column, ignore_virtual_text| {
editor.tree.views().find_map(|(view, _focus)| {
view.pos_at_screen_coords(&editor.documents[&view.doc], row, column, true)
.map(|pos| (pos, view.id))
view.pos_at_screen_coords(
&editor.documents[&view.doc],
row,
column,
ignore_virtual_text,
)
.map(|pos| (pos, view.id))
})
};
@ -1452,7 +1457,7 @@ impl EditorView {
MouseEventKind::Down(MouseButton::Left) => {
let editor = &mut cxt.editor;
if let Some((pos, view_id)) = pos_and_view(editor, row, column) {
if let Some((pos, view_id)) = pos_and_view(editor, row, column, true) {
let doc = doc_mut!(editor, &view!(editor, view_id).doc);
if modifiers == KeyModifiers::ALT {
@ -1516,7 +1521,7 @@ impl EditorView {
_ => unreachable!(),
};
match pos_and_view(cxt.editor, row, column) {
match pos_and_view(cxt.editor, row, column, false) {
Some((_, view_id)) => cxt.editor.tree.focus = view_id,
None => return EventResult::Ignored(None),
}
@ -1587,7 +1592,7 @@ impl EditorView {
return EventResult::Consumed(None);
}
if let Some((pos, view_id)) = pos_and_view(editor, row, column) {
if let Some((pos, view_id)) = pos_and_view(editor, row, column, true) {
let doc = doc_mut!(editor, &view!(editor, view_id).doc);
doc.set_selection(view_id, Selection::point(pos));
cxt.editor.focus(view_id);
@ -1663,30 +1668,44 @@ impl Component for EditorView {
// let completion swallow the event if necessary
let mut consumed = false;
if let Some(completion) = &mut self.completion {
// use a fake context here
let mut cx = Context {
editor: cx.editor,
jobs: cx.jobs,
scroll: None,
let res = {
// use a fake context here
let mut cx = Context {
editor: cx.editor,
jobs: cx.jobs,
scroll: None,
};
if let EventResult::Consumed(callback) =
completion.handle_event(event, &mut cx)
{
consumed = true;
Some(callback)
} else if let EventResult::Consumed(callback) =
completion.handle_event(&Event::Key(key!(Enter)), &mut cx)
{
Some(callback)
} else {
None
}
};
let res = completion.handle_event(event, &mut cx);
if let EventResult::Consumed(callback) = res {
consumed = true;
if let Some(callback) = res {
if callback.is_some() {
// assume close_fn
self.clear_completion(cx.editor);
// In case the popup was deleted because of an intersection w/ the auto-complete menu.
commands::signature_help_impl(
&mut cx,
commands::SignatureHelpInvoked::Automatic,
);
}
}
}
// if completion didn't take the event, we pass it onto commands
if !consumed {
if let Some(compl) = cx.editor.last_completion.take() {
self.last_insert.1.push(InsertEvent::CompletionApply(compl));
}
self.insert_mode(&mut cx, key);
// record last_insert key

@ -54,7 +54,7 @@ impl QueryAtom {
}
fn indices(&self, matcher: &Matcher, item: &str, indices: &mut Vec<usize>) -> bool {
// for inverse there are no indicies to return
// for inverse there are no indices to return
// just return whether we matched
if self.inverse {
return self.matches(matcher, item);
@ -120,7 +120,7 @@ enum QueryAtomKind {
///
/// Usage: `foo`
Fuzzy,
/// Item contains query atom as a continous substring
/// Item contains query atom as a continuous substring
///
/// Usage `'foo`
Substring,
@ -213,7 +213,7 @@ impl FuzzyQuery {
Some(score)
}
pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> {
pub fn fuzzy_indices(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> {
let (score, mut indices) = self.first_fuzzy_atom.as_ref().map_or_else(
|| Some((0, Vec::new())),
|atom| matcher.fuzzy_indices(item, atom),

@ -7,8 +7,8 @@ fn run_test<'a>(query: &str, items: &'a [&'a str]) -> Vec<String> {
items
.iter()
.filter_map(|item| {
let (_, indicies) = query.fuzzy_indicies(item, &matcher)?;
let matched_string = indicies
let (_, indices) = query.fuzzy_indices(item, &matcher)?;
let matched_string = indices
.iter()
.map(|&pos| item.chars().nth(pos).unwrap())
.collect();

@ -9,7 +9,7 @@ use std::sync::Arc;
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag};
use helix_core::{
syntax::{self, HighlightEvent, Syntax},
syntax::{self, HighlightEvent, InjectionLanguageMarker, Syntax},
Rope,
};
use helix_view::{
@ -47,9 +47,11 @@ pub fn highlighted_code_block<'a>(
let rope = Rope::from(text.as_ref());
let syntax = config_loader
.language_configuration_for_injection_string(language)
.language_configuration_for_injection_string(&InjectionLanguageMarker::Name(
language.into(),
))
.and_then(|config| config.highlight_config(theme.scopes()))
.map(|config| Syntax::new(&rope, config, Arc::clone(&config_loader)));
.and_then(|config| Syntax::new(&rope, config, Arc::clone(&config_loader)));
let syntax = match syntax {
Some(s) => s,

@ -347,6 +347,7 @@ impl<T: Item + 'static> Component for Menu<T> {
offset: scroll,
selected: self.cursor,
},
false,
);
if let Some(cursor) = self.cursor {

@ -17,7 +17,7 @@ mod text;
use crate::compositor::{Component, Compositor};
use crate::filter_picker_entry;
use crate::job::{self, Callback};
pub use completion::Completion;
pub use completion::{Completion, CompletionItem};
pub use editor::EditorView;
pub use markdown::Markdown;
pub use menu::Menu;
@ -238,6 +238,7 @@ pub mod completers {
use crate::ui::prompt::Completion;
use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
use fuzzy_matcher::FuzzyMatcher;
use helix_core::syntax::LanguageServerFeature;
use helix_view::document::SCRATCH_BUFFER_NAME;
use helix_view::theme;
use helix_view::{editor::Config, Editor};
@ -393,20 +394,11 @@ pub mod completers {
pub fn lsp_workspace_command(editor: &Editor, input: &str) -> Vec<Completion> {
let matcher = Matcher::default();
let (_, doc) = current_ref!(editor);
let language_server = match doc.language_server() {
Some(language_server) => language_server,
None => {
return vec![];
}
};
let options = match &language_server.capabilities().execute_command_provider {
Some(options) => options,
None => {
return vec![];
}
let Some(options) = doc!(editor)
.language_servers_with_feature(LanguageServerFeature::WorkspaceCommand)
.find_map(|ls| ls.capabilities().execute_command_provider.as_ref())
else {
return vec![];
};
let mut matches: Vec<_> = options

@ -16,7 +16,7 @@ pub struct Overlay<T> {
}
/// Surrounds the component with a margin of 5% on each side, and an additional 2 rows at the bottom
pub fn overlayed<T>(content: T) -> Overlay<T> {
pub fn overlaid<T>(content: T) -> Overlay<T> {
Overlay {
content,
calc_child_size: Box::new(|rect: Rect| clip_rect_relative(rect.clip_bottom(2), 90, 90)),

@ -1,7 +1,9 @@
use crate::{
alt,
compositor::{Component, Compositor, Context, Event, EventResult},
ctrl, key, shift,
compositor::{self, Component, Compositor, Context, Event, EventResult},
ctrl,
job::Callback,
key, shift,
ui::{
self,
document::{render_document, LineDecoration, LinePos, TextRenderer},
@ -9,7 +11,7 @@ use crate::{
EditorView,
},
};
use futures_util::future::BoxFuture;
use futures_util::{future::BoxFuture, FutureExt};
use tui::{
buffer::Buffer as Surface,
layout::Constraint,
@ -26,7 +28,7 @@ use std::{collections::HashMap, io::Read, path::PathBuf};
use crate::ui::{Prompt, PromptEvent};
use helix_core::{
movement::Direction, text_annotations::TextAnnotations,
unicode::segmentation::UnicodeSegmentation, Position,
unicode::segmentation::UnicodeSegmentation, Position, Syntax,
};
use helix_view::{
editor::Action,
@ -122,7 +124,7 @@ impl Preview<'_, '_> {
}
}
impl<T: Item> FilePicker<T> {
impl<T: Item + 'static> FilePicker<T> {
pub fn new(
options: Vec<T>,
editor_data: T::Data,
@ -208,29 +210,64 @@ impl<T: Item> FilePicker<T> {
}
fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult {
let Some((current_file, _)) = self.current_file(cx.editor) else {
return EventResult::Consumed(None)
};
// Try to find a document in the cache
let doc = self
.current_file(cx.editor)
.and_then(|(path, _range)| match path {
PathOrId::Id(doc_id) => Some(doc_mut!(cx.editor, &doc_id)),
PathOrId::Path(path) => match self.preview_cache.get_mut(&path) {
Some(CachedPreview::Document(doc)) => Some(doc),
_ => None,
},
});
let doc = match &current_file {
PathOrId::Id(doc_id) => doc_mut!(cx.editor, doc_id),
PathOrId::Path(path) => match self.preview_cache.get_mut(path) {
Some(CachedPreview::Document(ref mut doc)) => doc,
_ => return EventResult::Consumed(None),
},
};
let mut callback: Option<compositor::Callback> = None;
// Then attempt to highlight it if it has no language set
if let Some(doc) = doc {
if doc.language_config().is_none() {
if doc.language_config().is_none() {
if let Some(language_config) = doc.detect_language_config(&cx.editor.syn_loader) {
doc.language = Some(language_config.clone());
let text = doc.text().clone();
let loader = cx.editor.syn_loader.clone();
doc.detect_language(loader);
let job = tokio::task::spawn_blocking(move || {
let syntax = language_config
.highlight_config(&loader.scopes())
.and_then(|highlight_config| Syntax::new(&text, highlight_config, loader));
let callback = move |editor: &mut Editor, compositor: &mut Compositor| {
let Some(syntax) = syntax else {
log::info!("highlighting picker item failed");
return
};
let Some(Overlay { content: picker, .. }) = compositor.find::<Overlay<Self>>() else {
log::info!("picker closed before syntax highlighting finished");
return
};
// Try to find a document in the cache
let doc = match current_file {
PathOrId::Id(doc_id) => doc_mut!(editor, &doc_id),
PathOrId::Path(path) => match picker.preview_cache.get_mut(&path) {
Some(CachedPreview::Document(ref mut doc)) => doc,
_ => return,
},
};
doc.syntax = Some(syntax);
};
Callback::EditorCompositor(Box::new(callback))
});
let tmp: compositor::Callback = Box::new(move |_, ctx| {
ctx.jobs
.callback(job.map(|res| res.map_err(anyhow::Error::from)))
});
callback = Some(Box::new(tmp))
}
// QUESTION: do we want to compute inlay hints in pickers too ? Probably not for now
// but it could be interesting in the future
}
EventResult::Consumed(None)
// QUESTION: do we want to compute inlay hints in pickers too ? Probably not for now
// but it could be interesting in the future
EventResult::Consumed(callback)
}
}
@ -373,6 +410,10 @@ impl<T: Item + 'static> Component for FilePicker<T> {
self.picker.required_size((picker_width, height))?;
Some((width, height))
}
fn id(&self) -> Option<&'static str> {
Some("file-picker")
}
}
#[derive(PartialEq, Eq, Debug)]
@ -794,7 +835,7 @@ impl<T: Item + 'static> Component for Picker<T> {
// might be inconsistencies. This is the best we can do since only the
// text in Row is displayed to the end user.
let (_score, highlights) = FuzzyQuery::new(self.prompt.line())
.fuzzy_indicies(&line, &self.matcher)
.fuzzy_indices(&line, &self.matcher)
.unwrap_or_default();
let highlight_byte_ranges: Vec<_> = line
@ -885,6 +926,7 @@ impl<T: Item + 'static> Component for Picker<T> {
offset: 0,
selected: Some(cursor),
},
self.truncate_start,
);
}
@ -944,17 +986,16 @@ impl<T: Item + Send + 'static> Component for DynamicPicker<T> {
cx.jobs.callback(async move {
let new_options = new_options.await?;
let callback =
crate::job::Callback::EditorCompositor(Box::new(move |editor, compositor| {
// Wrapping of pickers in overlay is done outside the picker code,
// so this is fragile and will break if wrapped in some other widget.
let picker = match compositor.find_id::<Overlay<DynamicPicker<T>>>(Self::ID) {
Some(overlay) => &mut overlay.content.file_picker.picker,
None => return,
};
picker.set_options(new_options);
editor.reset_idle_timer();
}));
let callback = Callback::EditorCompositor(Box::new(move |editor, compositor| {
// Wrapping of pickers in overlay is done outside the picker code,
// so this is fragile and will break if wrapped in some other widget.
let picker = match compositor.find_id::<Overlay<DynamicPicker<T>>>(Self::ID) {
Some(overlay) => &mut overlay.content.file_picker.picker,
None => return,
};
picker.set_options(new_options);
editor.reset_idle_timer();
}));
anyhow::Ok(callback)
});
EventResult::Consumed(None)

@ -6,7 +6,10 @@ use crate::{
use tui::buffer::Buffer as Surface;
use helix_core::Position;
use helix_view::graphics::{Margin, Rect};
use helix_view::{
graphics::{Margin, Rect},
Editor,
};
// TODO: share logic with Menu, it's essentially Popup(render_fn), but render fn needs to return
// a width/height hint. maybe Popup(Box<Component>)
@ -88,10 +91,10 @@ impl<T: Component> Popup<T> {
/// Calculate the position where the popup should be rendered and return the coordinates of the
/// top left corner.
pub fn get_rel_position(&mut self, viewport: Rect, cx: &Context) -> (u16, u16) {
pub fn get_rel_position(&mut self, viewport: Rect, editor: &Editor) -> (u16, u16) {
let position = self
.position
.get_or_insert_with(|| cx.editor.cursor().0.unwrap_or_default());
.get_or_insert_with(|| editor.cursor().0.unwrap_or_default());
let (width, height) = self.size;
@ -155,6 +158,16 @@ impl<T: Component> Popup<T> {
pub fn contents_mut(&mut self) -> &mut T {
&mut self.contents
}
pub fn area(&mut self, viewport: Rect, editor: &Editor) -> Rect {
// trigger required_size so we recalculate if the child changed
self.required_size((viewport.width, viewport.height));
let (rel_x, rel_y) = self.get_rel_position(viewport, editor);
// clip to viewport
viewport.intersection(Rect::new(rel_x, rel_y, self.size.0, self.size.1))
}
}
impl<T: Component> Component for Popup<T> {
@ -232,16 +245,9 @@ impl<T: Component> Component for Popup<T> {
}
fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) {
// trigger required_size so we recalculate if the child changed
self.required_size((viewport.width, viewport.height));
let area = self.area(viewport, cx.editor);
cx.scroll = Some(self.scroll);
let (rel_x, rel_y) = self.get_rel_position(viewport, cx);
// clip to viewport
let area = viewport.intersection(Rect::new(rel_x, rel_y, self.size.0, self.size.1));
// clear area
let background = cx.editor.theme.get("ui.popup");
surface.clear_with(area, background);

@ -511,11 +511,21 @@ impl Component for Prompt {
ctrl!('e') | key!(End) => self.move_end(),
ctrl!('a') | key!(Home) => self.move_start(),
ctrl!('w') | alt!(Backspace) | ctrl!(Backspace) => {
self.delete_word_backwards(cx.editor)
self.delete_word_backwards(cx.editor);
(self.callback_fn)(cx, &self.line, PromptEvent::Update);
}
alt!('d') | alt!(Delete) | ctrl!(Delete) => {
self.delete_word_forwards(cx.editor);
(self.callback_fn)(cx, &self.line, PromptEvent::Update);
}
ctrl!('k') => {
self.kill_to_end_of_line(cx.editor);
(self.callback_fn)(cx, &self.line, PromptEvent::Update);
}
ctrl!('u') => {
self.kill_to_start_of_line(cx.editor);
(self.callback_fn)(cx, &self.line, PromptEvent::Update);
}
alt!('d') | alt!(Delete) | ctrl!(Delete) => self.delete_word_forwards(cx.editor),
ctrl!('k') => self.kill_to_end_of_line(cx.editor),
ctrl!('u') => self.kill_to_start_of_line(cx.editor),
ctrl!('h') | key!(Backspace) | shift!(Backspace) => {
self.delete_char_backwards(cx.editor);
(self.callback_fn)(cx, &self.line, PromptEvent::Update);

@ -197,15 +197,15 @@ where
);
}
// TODO think about handling multiple language servers
fn render_lsp_spinner<F>(context: &mut RenderContext, write: F)
where
F: Fn(&mut RenderContext, String, Option<Style>) + Copy,
{
let language_server = context.doc.language_servers().next();
write(
context,
context
.doc
.language_server()
language_server
.and_then(|srv| {
context
.spinners
@ -225,8 +225,7 @@ where
{
let (warnings, errors) = context
.doc
.diagnostics()
.iter()
.shown_diagnostics()
.fold((0, 0), |mut counts, diag| {
use helix_core::diagnostic::Severity;
match diag.severity {
@ -266,7 +265,7 @@ where
.diagnostics
.values()
.flatten()
.fold((0, 0), |mut counts, diag| {
.fold((0, 0), |mut counts, (diag, _)| {
match diag.severity {
Some(DiagnosticSeverity::WARNING) => counts.0 += 1,
Some(DiagnosticSeverity::ERROR) | None => counts.1 += 1,
@ -276,7 +275,7 @@ where
});
if warnings > 0 || errors > 0 {
write(context, format!(" {} ", "W"), None);
write(context, " W ".into(), None);
}
if warnings > 0 {

@ -2,8 +2,6 @@
mod test {
mod helpers;
use std::path::PathBuf;
use helix_core::{syntax::AutoPairConfig, Selection};
use helix_term::config::Config;

@ -12,15 +12,13 @@ async fn test_selection_duplication() -> anyhow::Result<()> {
#[lo|]#rem
ipsum
dolor
"})
.as_str(),
"}),
"CC",
platform_line(indoc! {"\
#(lo|)#rem
#(ip|)#sum
#[do|]#lor
"})
.as_str(),
"}),
))
.await?;
@ -30,15 +28,13 @@ async fn test_selection_duplication() -> anyhow::Result<()> {
#[|lo]#rem
ipsum
dolor
"})
.as_str(),
"}),
"CC",
platform_line(indoc! {"\
#(|lo)#rem
#(|ip)#sum
#[|do]#lor
"})
.as_str(),
"}),
))
.await?;
@ -47,14 +43,12 @@ async fn test_selection_duplication() -> anyhow::Result<()> {
platform_line(indoc! {"\
test
#[testitem|]#
"})
.as_str(),
"}),
"<A-C>",
platform_line(indoc! {"\
test
#[testitem|]#
"})
.as_str(),
"}),
))
.await?;
@ -63,14 +57,12 @@ async fn test_selection_duplication() -> anyhow::Result<()> {
platform_line(indoc! {"\
test
#[test|]#
"})
.as_str(),
"}),
"<A-C>",
platform_line(indoc! {"\
#[test|]#
#(test|)#
"})
.as_str(),
"}),
))
.await?;
@ -79,14 +71,12 @@ async fn test_selection_duplication() -> anyhow::Result<()> {
platform_line(indoc! {"\
#[testitem|]#
test
"})
.as_str(),
"}),
"C",
platform_line(indoc! {"\
#[testitem|]#
test
"})
.as_str(),
"}),
))
.await?;
@ -95,14 +85,12 @@ async fn test_selection_duplication() -> anyhow::Result<()> {
platform_line(indoc! {"\
#[test|]#
test
"})
.as_str(),
"}),
"C",
platform_line(indoc! {"\
#(test|)#
#[test|]#
"})
.as_str(),
"}),
))
.await?;
Ok(())
@ -174,15 +162,13 @@ async fn test_multi_selection_paste() -> anyhow::Result<()> {
#[|lorem]#
#(|ipsum)#
#(|dolor)#
"})
.as_str(),
"}),
"yp",
platform_line(indoc! {"\
lorem#[|lorem]#
ipsum#(|ipsum)#
dolor#(|dolor)#
"})
.as_str(),
"}),
))
.await?;
@ -197,8 +183,7 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> {
#[|lorem]#
#(|ipsum)#
#(|dolor)#
"})
.as_str(),
"}),
"|echo foo<ret>",
platform_line(indoc! {"\
#[|foo\n]#
@ -207,8 +192,7 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> {
#(|foo\n)#
"})
.as_str(),
"}),
))
.await?;
@ -218,8 +202,7 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> {
#[|lorem]#
#(|ipsum)#
#(|dolor)#
"})
.as_str(),
"}),
"!echo foo<ret>",
platform_line(indoc! {"\
#[|foo\n]#
@ -228,8 +211,7 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> {
ipsum
#(|foo\n)#
dolor
"})
.as_str(),
"}),
))
.await?;
@ -239,8 +221,7 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> {
#[|lorem]#
#(|ipsum)#
#(|dolor)#
"})
.as_str(),
"}),
"<A-!>echo foo<ret>",
platform_line(indoc! {"\
lorem#[|foo\n]#
@ -249,8 +230,7 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> {
dolor#(|foo\n)#
"})
.as_str(),
"}),
))
.await?;
@ -294,16 +274,14 @@ async fn test_extend_line() -> anyhow::Result<()> {
ipsum
dolor
"})
.as_str(),
"}),
"x2x",
platform_line(indoc! {"\
#[lorem
ipsum
dolor\n|]#
"})
.as_str(),
"}),
))
.await?;
@ -313,15 +291,13 @@ async fn test_extend_line() -> anyhow::Result<()> {
#[l|]#orem
ipsum
"})
.as_str(),
"}),
"2x",
platform_line(indoc! {"\
#[lorem
ipsum\n|]#
"})
.as_str(),
"}),
))
.await?;
@ -385,3 +361,68 @@ async fn test_character_info() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_delete_char_backward() -> anyhow::Result<()> {
// don't panic when deleting overlapping ranges
test((
platform_line("#(x|)# #[x|]#"),
"c<space><backspace><esc>",
platform_line("#[\n|]#"),
))
.await?;
test((
platform_line("#( |)##( |)#a#( |)#axx#[x|]#a"),
"li<backspace><esc>",
platform_line("#(a|)##(|a)#xx#[|a]#"),
))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_delete_word_backward() -> anyhow::Result<()> {
// don't panic when deleting overlapping ranges
test((
platform_line("fo#[o|]#ba#(r|)#"),
"a<C-w><esc>",
platform_line("#[\n|]#"),
))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_delete_word_forward() -> anyhow::Result<()> {
// don't panic when deleting overlapping ranges
test((
platform_line("fo#[o|]#b#(|ar)#"),
"i<A-d><esc>",
platform_line("fo#[\n|]#"),
))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_delete_char_forward() -> anyhow::Result<()> {
test((
platform_line(indoc! {"\
#[abc|]#def
#(abc|)#ef
#(abc|)#f
#(abc|)#
"}),
"a<del><esc>",
platform_line(indoc! {"\
#[abc|]#ef
#(abc|)#f
#(abc|)#
#(abc|)#
"}),
))
.await?;
Ok(())
}

@ -3,7 +3,7 @@ use std::{
ops::RangeInclusive,
};
use helix_core::diagnostic::Severity;
use helix_core::{diagnostic::Severity, path::get_normalized_path};
use helix_view::doc;
use super::*;
@ -23,7 +23,7 @@ async fn test_write_quit_fail() -> anyhow::Result<()> {
assert_eq!(1, docs.len());
let doc = docs.pop().unwrap();
assert_eq!(Some(file.path()), doc.path().map(PathBuf::as_path));
assert_eq!(Some(&get_normalized_path(file.path())), doc.path());
assert_eq!(&Severity::Error, app.editor.get_status().unwrap().1);
}),
false,
@ -269,7 +269,7 @@ async fn test_write_scratch_to_new_path() -> anyhow::Result<()> {
assert_eq!(1, docs.len());
let doc = docs.pop().unwrap();
assert_eq!(Some(&file.path().to_path_buf()), doc.path());
assert_eq!(Some(&get_normalized_path(file.path())), doc.path());
}),
false,
)
@ -341,7 +341,7 @@ async fn test_write_new_path() -> anyhow::Result<()> {
Some(&|app| {
let doc = doc!(app.editor);
assert!(!app.editor.is_err());
assert_eq!(file1.path(), doc.path().unwrap());
assert_eq!(&get_normalized_path(file1.path()), doc.path().unwrap());
}),
),
(
@ -349,7 +349,7 @@ async fn test_write_new_path() -> anyhow::Result<()> {
Some(&|app| {
let doc = doc!(app.editor);
assert!(!app.editor.is_err());
assert_eq!(file2.path(), doc.path().unwrap());
assert_eq!(&get_normalized_path(file2.path()), doc.path().unwrap());
assert!(app.editor.document_by_path(file1.path()).is_none());
}),
),
@ -407,3 +407,41 @@ async fn test_write_fail_new_path() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn test_write_utf_bom_file() -> anyhow::Result<()> {
// "ABC" with utf8 bom
const UTF8_FILE: [u8; 6] = [0xef, 0xbb, 0xbf, b'A', b'B', b'C'];
// "ABC" in UTF16 with bom
const UTF16LE_FILE: [u8; 8] = [0xff, 0xfe, b'A', 0x00, b'B', 0x00, b'C', 0x00];
const UTF16BE_FILE: [u8; 8] = [0xfe, 0xff, 0x00, b'A', 0x00, b'B', 0x00, b'C'];
edit_file_with_content(&UTF8_FILE).await?;
edit_file_with_content(&UTF16LE_FILE).await?;
edit_file_with_content(&UTF16BE_FILE).await?;
Ok(())
}
async fn edit_file_with_content(file_content: &[u8]) -> anyhow::Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.as_file_mut().write_all(&file_content)?;
helpers::test_key_sequence(
&mut helpers::AppBuilder::new().build()?,
Some(&format!(":o {}<ret>:x<ret>", file.path().to_string_lossy())),
None,
true,
)
.await?;
file.rewind()?;
let mut new_file_content: Vec<u8> = Vec::new();
file.read_to_end(&mut new_file_content)?;
assert_eq!(file_content, new_file_content);
Ok(())
}

@ -1,6 +1,7 @@
use std::{
fs::File,
io::{Read, Write},
mem::replace,
path::PathBuf,
time::Duration,
};
@ -222,10 +223,11 @@ pub fn temp_file_with_contents<S: AsRef<str>>(
/// Generates a config with defaults more suitable for integration tests
pub fn test_config() -> Config {
merge_keys(Config {
Config {
editor: test_editor_config(),
keys: helix_term::keymap::default(),
..Default::default()
})
}
}
pub fn test_editor_config() -> helix_view::editor::Config {
@ -300,8 +302,10 @@ impl AppBuilder {
// 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 = helix_term::keymap::merge_keys(config);
pub fn with_config(mut self, mut config: Config) -> Self {
let keys = replace(&mut config.keys, helix_term::keymap::default());
merge_keys(&mut config.keys, keys);
self.config = config;
self
}

@ -391,7 +391,7 @@ async fn cursor_position_newly_opened_file() -> anyhow::Result<()> {
#[tokio::test(flavor = "multi_thread")]
async fn cursor_position_append_eof() -> anyhow::Result<()> {
// Selection is fowards
// Selection is forwards
test((
"#[foo|]#",
"abar<esc>",

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

@ -16,11 +16,11 @@ include = ["src/**/*", "README.md"]
default = ["crossterm"]
[dependencies]
bitflags = "2.0"
bitflags = "2.3"
cassowary = "0.3"
unicode-segmentation = "1.10"
crossterm = { version = "0.26", optional = true }
termini = "0.1"
termini = "1.0"
serde = { version = "1", "optional" = true, features = ["derive"]}
once_cell = "1.17"
log = "~0.4"

@ -63,6 +63,7 @@ pub struct CrosstermBackend<W: Write> {
buffer: W,
capabilities: Capabilities,
supports_keyboard_enhancement_protocol: OnceCell<bool>,
mouse_capture_enabled: bool,
}
impl<W> CrosstermBackend<W>
@ -74,25 +75,25 @@ where
buffer,
capabilities: Capabilities::from_env_or_default(config),
supports_keyboard_enhancement_protocol: OnceCell::new(),
mouse_capture_enabled: false,
}
}
#[inline]
fn supports_keyboard_enhancement_protocol(&self) -> io::Result<bool> {
self.supports_keyboard_enhancement_protocol
.get_or_try_init(|| {
fn supports_keyboard_enhancement_protocol(&self) -> bool {
*self.supports_keyboard_enhancement_protocol
.get_or_init(|| {
use std::time::Instant;
let now = Instant::now();
let support = terminal::supports_keyboard_enhancement();
let supported = matches!(terminal::supports_keyboard_enhancement(), Ok(true));
log::debug!(
"The keyboard enhancement protocol is {}supported in this terminal (checked in {:?})",
if matches!(support, Ok(true)) { "" } else { "not " },
if supported { "" } else { "not " },
Instant::now().duration_since(now)
);
support
supported
})
.copied()
}
}
@ -124,8 +125,9 @@ where
execute!(self.buffer, terminal::Clear(terminal::ClearType::All))?;
if config.enable_mouse_capture {
execute!(self.buffer, EnableMouseCapture)?;
self.mouse_capture_enabled = true;
}
if self.supports_keyboard_enhancement_protocol()? {
if self.supports_keyboard_enhancement_protocol() {
execute!(
self.buffer,
PushKeyboardEnhancementFlags(
@ -137,13 +139,26 @@ where
Ok(())
}
fn reconfigure(&mut self, config: Config) -> io::Result<()> {
if self.mouse_capture_enabled != config.enable_mouse_capture {
if config.enable_mouse_capture {
execute!(self.buffer, EnableMouseCapture)?;
} else {
execute!(self.buffer, DisableMouseCapture)?;
}
self.mouse_capture_enabled = config.enable_mouse_capture;
}
Ok(())
}
fn restore(&mut self, config: Config) -> io::Result<()> {
// reset cursor shape
write!(self.buffer, "\x1B[0 q")?;
if config.enable_mouse_capture {
execute!(self.buffer, DisableMouseCapture)?;
}
if self.supports_keyboard_enhancement_protocol()? {
if self.supports_keyboard_enhancement_protocol() {
execute!(self.buffer, PopKeyboardEnhancementFlags)?;
}
execute!(
@ -345,9 +360,9 @@ impl ModifierDiff {
}
}
/// 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.
/// Crossterm uses semicolon as a separator for colors
/// this is actually not spec compliant (although commonly supported)
/// However the correct approach is to use colons as a separator.
/// 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.

@ -14,6 +14,7 @@ pub use self::test::TestBackend;
pub trait Backend {
fn claim(&mut self, config: Config) -> Result<(), io::Error>;
fn reconfigure(&mut self, config: Config) -> Result<(), io::Error>;
fn restore(&mut self, config: Config) -> Result<(), io::Error>;
fn force_restore() -> Result<(), io::Error>;
fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error>

@ -111,6 +111,10 @@ impl Backend for TestBackend {
Ok(())
}
fn reconfigure(&mut self, _config: Config) -> Result<(), io::Error> {
Ok(())
}
fn restore(&mut self, _config: Config) -> Result<(), io::Error> {
Ok(())
}

@ -433,6 +433,47 @@ impl Buffer {
(x_offset as u16, y)
}
pub fn set_spans_truncated(&mut self, x: u16, y: u16, spans: &Spans, width: u16) -> (u16, u16) {
// prevent panic if out of range
if !self.in_bounds(x, y) || width == 0 {
return (x, y);
}
let mut x_offset = x as usize;
let max_offset = min(self.area.right(), width.saturating_add(x));
let mut start_index = self.index_of(x, y);
let mut index = self.index_of(max_offset, y);
let content_width = spans.width();
let truncated = content_width > width as usize;
if truncated {
self.content[start_index].set_symbol("…");
start_index += 1;
} else {
index -= width as usize - content_width;
}
for span in spans.0.iter().rev() {
for s in span.content.graphemes(true).rev() {
let width = s.width();
if width == 0 {
continue;
}
let start = index - width;
if start < start_index {
break;
}
self.content[start].set_symbol(s);
self.content[start].set_style(span.style);
for i in start + 1..index {
self.content[i].reset();
}
index -= width;
x_offset += width;
}
}
(x_offset as u16, y)
}
pub fn set_spans(&mut self, x: u16, y: u16, spans: &Spans, width: u16) -> (u16, u16) {
let mut remaining_width = width;
let mut x = x;

@ -116,6 +116,10 @@ where
self.backend.claim(config)
}
pub fn reconfigure(&mut self, config: Config) -> io::Result<()> {
self.backend.reconfigure(config)
}
pub fn restore(&mut self, config: Config) -> io::Result<()> {
self.backend.restore(config)
}

@ -7,8 +7,9 @@ use crate::{
use helix_view::graphics::{Rect, Style};
/// Border render type. Defaults to [`BorderType::Plain`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum BorderType {
#[default]
Plain,
Rounded,
Double,
@ -26,12 +27,6 @@ impl BorderType {
}
}
impl Default for BorderType {
fn default() -> BorderType {
BorderType::Plain
}
}
/// Base widget to be used with all upper level ones. It may be used to display a box border around
/// the widget and/or add a title.
///

@ -354,7 +354,13 @@ impl TableState {
impl<'a> Table<'a> {
// type State = TableState;
pub fn render_table(mut self, area: Rect, buf: &mut Buffer, state: &mut TableState) {
pub fn render_table(
mut self,
area: Rect,
buf: &mut Buffer,
state: &mut TableState,
truncate: bool,
) {
if area.area() == 0 {
return;
}
@ -401,6 +407,7 @@ impl<'a> Table<'a> {
width: *width,
height: max_header_height,
},
truncate,
);
col += *width + self.column_spacing;
}
@ -457,6 +464,7 @@ impl<'a> Table<'a> {
width: *width,
height: table_row.height,
},
truncate,
);
col += *width + self.column_spacing;
}
@ -464,20 +472,24 @@ impl<'a> Table<'a> {
}
}
fn render_cell(buf: &mut Buffer, cell: &Cell, area: Rect) {
fn render_cell(buf: &mut Buffer, cell: &Cell, area: Rect, truncate: bool) {
buf.set_style(area, cell.style);
for (i, spans) in cell.content.lines.iter().enumerate() {
if i as u16 >= area.height {
break;
}
buf.set_spans(area.x, area.y + i as u16, spans, area.width);
if truncate {
buf.set_spans_truncated(area.x, area.y + i as u16, spans, area.width);
} else {
buf.set_spans(area.x, area.y + i as u16, spans, area.width);
}
}
}
impl<'a> Widget for Table<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mut state = TableState::default();
Table::render_table(self, area, buf, &mut state);
Table::render_table(self, area, buf, &mut state, false);
}
}

@ -17,8 +17,9 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "p
parking_lot = "0.12"
arc-swap = { version = "1.6.0" }
gix = { version = "0.41.0", default-features = false , optional = true }
gix = { version = "0.44.1", default-features = false , optional = true }
imara-diff = "0.1.5"
anyhow = "1"
log = "0.4"

@ -1,3 +1,4 @@
use anyhow::{bail, Context, Result};
use arc_swap::ArcSwap;
use std::path::Path;
use std::sync::Arc;
@ -14,7 +15,7 @@ mod test;
pub struct Git;
impl Git {
fn open_repo(path: &Path, ceiling_dir: Option<&Path>) -> Option<ThreadSafeRepository> {
fn open_repo(path: &Path, ceiling_dir: Option<&Path>) -> Result<ThreadSafeRepository> {
// custom open options
let mut git_open_opts_map = gix::sec::trust::Mapping::<gix::open::Options>::default();
@ -22,7 +23,7 @@ impl Git {
// This path depends on the install location of git and therefore requires some overhead to lookup
// This is basically only used on windows and has some overhead hence it's disabled on other platforms.
// `gitoxide` doesn't use this as default
let config = gix::permissions::Config {
let config = gix::open::permissions::Config {
system: true,
git: true,
user: true,
@ -31,40 +32,50 @@ impl Git {
git_binary: cfg!(windows),
};
// change options for config permissions without touching anything else
git_open_opts_map.reduced = git_open_opts_map.reduced.permissions(gix::Permissions {
git_open_opts_map.reduced = git_open_opts_map
.reduced
.permissions(gix::open::Permissions {
config,
..gix::open::Permissions::default_for_level(gix::sec::Trust::Reduced)
});
git_open_opts_map.full = git_open_opts_map.full.permissions(gix::open::Permissions {
config,
..gix::Permissions::default_for_level(gix::sec::Trust::Reduced)
});
git_open_opts_map.full = git_open_opts_map.full.permissions(gix::Permissions {
config,
..gix::Permissions::default_for_level(gix::sec::Trust::Full)
..gix::open::Permissions::default_for_level(gix::sec::Trust::Full)
});
let mut open_options = gix::discover::upwards::Options::default();
if let Some(ceiling_dir) = ceiling_dir {
open_options.ceiling_dirs = vec![ceiling_dir.to_owned()];
}
let open_options = gix::discover::upwards::Options {
ceiling_dirs: ceiling_dir
.map(|dir| vec![dir.to_owned()])
.unwrap_or_default(),
dot_git_only: true,
..Default::default()
};
ThreadSafeRepository::discover_with_environment_overrides_opts(
let res = ThreadSafeRepository::discover_with_environment_overrides_opts(
path,
open_options,
git_open_opts_map,
)
.ok()
)?;
Ok(res)
}
}
impl DiffProvider for Git {
fn get_diff_base(&self, file: &Path) -> Option<Vec<u8>> {
fn get_diff_base(&self, file: &Path) -> Result<Vec<u8>> {
debug_assert!(!file.exists() || file.is_file());
debug_assert!(file.is_absolute());
// TODO cache repository lookup
let repo = Git::open_repo(file.parent()?, None)?.to_thread_local();
let head = repo.head_commit().ok()?;
let repo_dir = file.parent().context("file has no parent directory")?;
let repo = Git::open_repo(repo_dir, None)
.context("failed to open git repo")?
.to_thread_local();
let head = repo.head_commit()?;
let file_oid = find_file_in_commit(&repo, &head, file)?;
let file_object = repo.find_object(file_oid).ok()?;
let file_object = repo.find_object(file_oid)?;
let mut data = file_object.detach().data;
// convert LF to CRLF if configured to avoid showing every line as changed
if repo
@ -87,35 +98,42 @@ impl DiffProvider for Git {
}
data = normalized_file
}
Some(data)
Ok(data)
}
fn get_current_head_name(&self, file: &Path) -> Option<Arc<ArcSwap<Box<str>>>> {
fn get_current_head_name(&self, file: &Path) -> Result<Arc<ArcSwap<Box<str>>>> {
debug_assert!(!file.exists() || file.is_file());
debug_assert!(file.is_absolute());
let repo = Git::open_repo(file.parent()?, None)?.to_thread_local();
let head_ref = repo.head_ref().ok()?;
let head_commit = repo.head_commit().ok()?;
let repo_dir = file.parent().context("file has no parent directory")?;
let repo = Git::open_repo(repo_dir, None)
.context("failed to open git repo")?
.to_thread_local();
let head_ref = repo.head_ref()?;
let head_commit = repo.head_commit()?;
let name = match head_ref {
Some(reference) => reference.name().shorten().to_string(),
None => head_commit.id.to_hex_with_len(8).to_string(),
};
Some(Arc::new(ArcSwap::from_pointee(name.into_boxed_str())))
Ok(Arc::new(ArcSwap::from_pointee(name.into_boxed_str())))
}
}
/// Finds the object that contains the contents of a file at a specific commit.
fn find_file_in_commit(repo: &Repository, commit: &Commit, file: &Path) -> Option<ObjectId> {
let repo_dir = repo.work_dir()?;
let rel_path = file.strip_prefix(repo_dir).ok()?;
let tree = commit.tree().ok()?;
let tree_entry = tree.lookup_entry_by_path(rel_path).ok()??;
fn find_file_in_commit(repo: &Repository, commit: &Commit, file: &Path) -> Result<ObjectId> {
let repo_dir = repo.work_dir().context("repo has no worktree")?;
let rel_path = file.strip_prefix(repo_dir)?;
let tree = commit.tree()?;
let tree_entry = tree
.lookup_entry_by_path(rel_path)?
.context("file is untracked")?;
match tree_entry.mode() {
// not a file, everything is new, do not show diff
EntryMode::Tree | EntryMode::Commit | EntryMode::Link => None,
mode @ (EntryMode::Tree | EntryMode::Commit | EntryMode::Link) => {
bail!("entry at {} is not a file but a {mode:?}", file.display())
}
// found a file
EntryMode::Blob | EntryMode::BlobExecutable => Some(tree_entry.object_id()),
EntryMode::Blob | EntryMode::BlobExecutable => Ok(tree_entry.object_id()),
}
}

@ -54,7 +54,7 @@ fn missing_file() {
let file = temp_git.path().join("file.txt");
File::create(&file).unwrap().write_all(b"foo").unwrap();
assert_eq!(Git.get_diff_base(&file), None);
assert!(Git.get_diff_base(&file).is_err());
}
#[test]
@ -64,7 +64,7 @@ fn unmodified_file() {
let contents = b"foo".as_slice();
File::create(&file).unwrap().write_all(contents).unwrap();
create_commit(temp_git.path(), true);
assert_eq!(Git.get_diff_base(&file), Some(Vec::from(contents)));
assert_eq!(Git.get_diff_base(&file).unwrap(), Vec::from(contents));
}
#[test]
@ -76,7 +76,7 @@ fn modified_file() {
create_commit(temp_git.path(), true);
File::create(&file).unwrap().write_all(b"bar").unwrap();
assert_eq!(Git.get_diff_base(&file), Some(Vec::from(contents)));
assert_eq!(Git.get_diff_base(&file).unwrap(), Vec::from(contents));
}
/// Test that `get_file_head` does not return content for a directory.
@ -95,7 +95,7 @@ fn directory() {
std::fs::remove_dir_all(&dir).unwrap();
File::create(&dir).unwrap().write_all(b"bar").unwrap();
assert_eq!(Git.get_diff_base(&dir), None);
assert!(Git.get_diff_base(&dir).is_err());
}
/// Test that `get_file_head` does not return content for a symlink.
@ -116,6 +116,6 @@ fn symlink() {
symlink("file.txt", &file_link).unwrap();
create_commit(temp_git.path(), true);
assert_eq!(Git.get_diff_base(&file_link), None);
assert_eq!(Git.get_diff_base(&file), Some(Vec::from(contents)));
assert!(Git.get_diff_base(&file_link).is_err());
assert_eq!(Git.get_diff_base(&file).unwrap(), Vec::from(contents));
}

@ -1,3 +1,4 @@
use anyhow::{bail, Result};
use arc_swap::ArcSwap;
use std::{path::Path, sync::Arc};
@ -18,19 +19,19 @@ pub trait DiffProvider {
/// if this provider is used.
/// The data is returned as raw byte without any decoding or encoding performed
/// to ensure all file encodings are handled correctly.
fn get_diff_base(&self, file: &Path) -> Option<Vec<u8>>;
fn get_current_head_name(&self, file: &Path) -> Option<Arc<ArcSwap<Box<str>>>>;
fn get_diff_base(&self, file: &Path) -> Result<Vec<u8>>;
fn get_current_head_name(&self, file: &Path) -> Result<Arc<ArcSwap<Box<str>>>>;
}
#[doc(hidden)]
pub struct Dummy;
impl DiffProvider for Dummy {
fn get_diff_base(&self, _file: &Path) -> Option<Vec<u8>> {
None
fn get_diff_base(&self, _file: &Path) -> Result<Vec<u8>> {
bail!("helix was compiled without git support")
}
fn get_current_head_name(&self, _file: &Path) -> Option<Arc<ArcSwap<Box<str>>>> {
None
fn get_current_head_name(&self, _file: &Path) -> Result<Arc<ArcSwap<Box<str>>>> {
bail!("helix was compiled without git support")
}
}
@ -42,13 +43,27 @@ impl DiffProviderRegistry {
pub fn get_diff_base(&self, file: &Path) -> Option<Vec<u8>> {
self.providers
.iter()
.find_map(|provider| provider.get_diff_base(file))
.find_map(|provider| match provider.get_diff_base(file) {
Ok(res) => Some(res),
Err(err) => {
log::info!("{err:#?}");
log::info!("failed to open diff base for {}", file.display());
None
}
})
}
pub fn get_current_head_name(&self, file: &Path) -> Option<Arc<ArcSwap<Box<str>>>> {
self.providers
.iter()
.find_map(|provider| provider.get_current_head_name(file))
.find_map(|provider| match provider.get_current_head_name(file) {
Ok(res) => Some(res),
Err(err) => {
log::info!("{err:#?}");
log::info!("failed to obtain current head name for {}", file.display());
None
}
})
}
}

@ -14,7 +14,7 @@ default = []
term = ["crossterm"]
[dependencies]
bitflags = "2.0"
bitflags = "2.3"
anyhow = "1"
helix-core = { version = "0.6", path = "../helix-core" }
helix-loader = { version = "0.6", path = "../helix-loader" }

@ -68,7 +68,7 @@ macro_rules! command_provider {
#[cfg(windows)]
pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> {
Box::new(provider::WindowsProvider::default())
Box::<provider::WindowsProvider>::default()
}
#[cfg(target_os = "macos")]

@ -5,7 +5,8 @@ use futures_util::future::BoxFuture;
use futures_util::FutureExt;
use helix_core::auto_pairs::AutoPairs;
use helix_core::doc_formatter::TextFormat;
use helix_core::syntax::Highlight;
use helix_core::encoding::Encoding;
use helix_core::syntax::{Highlight, LanguageServerFeature};
use helix_core::text_annotations::{InlineAnnotation, TextAnnotations};
use helix_core::Range;
use helix_vcs::{DiffHandle, DiffProviderRegistry};
@ -113,6 +114,19 @@ pub struct SavePoint {
/// The view this savepoint is associated with
pub view: ViewId,
revert: Mutex<Transaction>,
pub text: Rope,
}
impl SavePoint {
pub fn cursor(&self) -> usize {
// we always create transactions with selections
self.revert
.lock()
.selection()
.unwrap()
.primary()
.cursor(self.text.slice(..))
}
}
pub struct Document {
@ -130,6 +144,7 @@ pub struct Document {
path: Option<PathBuf>,
encoding: &'static encoding::Encoding,
has_bom: bool,
pub restore_cursor: bool,
@ -139,9 +154,9 @@ pub struct Document {
/// The document's default line ending.
pub line_ending: LineEnding,
syntax: Option<Syntax>,
pub syntax: Option<Syntax>,
/// Corresponding language scope name. Usually `source.<lang>`.
pub(crate) language: Option<Arc<LanguageConfiguration>>,
pub language: Option<Arc<LanguageConfiguration>>,
/// Pending changes since last history commit.
changes: ChangeSet,
@ -164,11 +179,14 @@ pub struct Document {
version: i32, // should be usize?
pub(crate) modified_since_accessed: bool,
diagnostics: Vec<Diagnostic>,
language_server: Option<Arc<helix_lsp::Client>>,
pub(crate) diagnostics: Vec<Diagnostic>,
pub(crate) language_servers: HashMap<LanguageServerName, Arc<Client>>,
diff_handle: Option<DiffHandle>,
version_control_head: Option<Arc<ArcSwap<Box<str>>>>,
// when document was used for most-recent-used buffer picker
pub focused_at: std::time::Instant,
}
/// Inlay hints for a single `(Document, View)` combo.
@ -274,16 +292,104 @@ impl fmt::Debug for DocumentInlayHintsId {
}
}
enum Encoder {
Utf16Be,
Utf16Le,
EncodingRs(encoding::Encoder),
}
impl Encoder {
fn from_encoding(encoding: &'static encoding::Encoding) -> Self {
if encoding == encoding::UTF_16BE {
Self::Utf16Be
} else if encoding == encoding::UTF_16LE {
Self::Utf16Le
} else {
Self::EncodingRs(encoding.new_encoder())
}
}
fn encode_from_utf8(
&mut self,
src: &str,
dst: &mut [u8],
is_empty: bool,
) -> (encoding::CoderResult, usize, usize) {
if src.is_empty() {
return (encoding::CoderResult::InputEmpty, 0, 0);
}
let mut write_to_buf = |convert: fn(u16) -> [u8; 2]| {
let to_write = src.char_indices().map(|(indice, char)| {
let mut encoded: [u16; 2] = [0, 0];
(
indice,
char.encode_utf16(&mut encoded)
.iter_mut()
.flat_map(|char| convert(*char))
.collect::<Vec<u8>>(),
)
});
let mut total_written = 0usize;
for (indice, utf16_bytes) in to_write {
let character_size = utf16_bytes.len();
if dst.len() <= (total_written + character_size) {
return (encoding::CoderResult::OutputFull, indice, total_written);
}
for character in utf16_bytes {
dst[total_written] = character;
total_written += 1;
}
}
(encoding::CoderResult::InputEmpty, src.len(), total_written)
};
match self {
Self::Utf16Be => write_to_buf(u16::to_be_bytes),
Self::Utf16Le => write_to_buf(u16::to_le_bytes),
Self::EncodingRs(encoder) => {
let (code_result, read, written, ..) = encoder.encode_from_utf8(src, dst, is_empty);
(code_result, read, written)
}
}
}
}
// Apply BOM if encoding permit it, return the number of bytes written at the start of buf
fn apply_bom(encoding: &'static encoding::Encoding, buf: &mut [u8; BUF_SIZE]) -> usize {
if encoding == encoding::UTF_8 {
buf[0] = 0xef;
buf[1] = 0xbb;
buf[2] = 0xbf;
3
} else if encoding == encoding::UTF_16BE {
buf[0] = 0xfe;
buf[1] = 0xff;
2
} else if encoding == encoding::UTF_16LE {
buf[0] = 0xff;
buf[1] = 0xfe;
2
} else {
0
}
}
// The documentation and implementation of this function should be up-to-date with
// its sibling function, `to_writer()`.
//
/// Decodes a stream of bytes into UTF-8, returning a `Rope` and the
/// encoding it was decoded as. The optional `encoding` parameter can
/// be used to override encoding auto-detection.
/// encoding it was decoded as with BOM information. The optional `encoding`
/// parameter can be used to override encoding auto-detection.
pub fn from_reader<R: std::io::Read + ?Sized>(
reader: &mut R,
encoding: Option<&'static encoding::Encoding>,
) -> Result<(Rope, &'static encoding::Encoding), Error> {
encoding: Option<&'static Encoding>,
) -> Result<(Rope, &'static Encoding, bool), Error> {
// These two buffers are 8192 bytes in size each and are used as
// intermediaries during the decoding process. Text read into `buf`
// from `reader` is decoded into `buf_out` as UTF-8. Once either
@ -293,25 +399,32 @@ pub fn from_reader<R: std::io::Read + ?Sized>(
let mut buf_out = [0u8; BUF_SIZE];
let mut builder = RopeBuilder::new();
// By default, the encoding of the text is auto-detected via the
// `chardetng` crate which requires sample data from the reader.
// By default, the encoding of the text is auto-detected by
// `encoding_rs` for_bom, and if it fails, from `chardetng`
// crate which requires sample data from the reader.
// As a manual override to this auto-detection is possible, the
// same data is read into `buf` to ensure symmetry in the upcoming
// loop.
let (encoding, mut decoder, mut slice, mut is_empty) = {
let (encoding, has_bom, mut decoder, mut slice, mut is_empty) = {
let read = reader.read(&mut buf)?;
let is_empty = read == 0;
let encoding = encoding.unwrap_or_else(|| {
let mut encoding_detector = chardetng::EncodingDetector::new();
encoding_detector.feed(&buf, is_empty);
encoding_detector.guess(None, true)
});
let (encoding, has_bom) = encoding
.map(|encoding| (encoding, false))
.or_else(|| {
encoding::Encoding::for_bom(&buf).map(|(encoding, _bom_size)| (encoding, true))
})
.unwrap_or_else(|| {
let mut encoding_detector = chardetng::EncodingDetector::new();
encoding_detector.feed(&buf, is_empty);
(encoding_detector.guess(None, true), false)
});
let decoder = encoding.new_decoder();
// If the amount of bytes read from the reader is less than
// `buf.len()`, it is undesirable to read the bytes afterwards.
let slice = &buf[..read];
(encoding, decoder, slice, is_empty)
(encoding, has_bom, decoder, slice, is_empty)
};
// `RopeBuilder::append()` expects a `&str`, so this is the "real"
@ -379,7 +492,7 @@ pub fn from_reader<R: std::io::Read + ?Sized>(
is_empty = read == 0;
}
let rope = builder.finish();
Ok((rope, encoding))
Ok((rope, encoding, has_bom))
}
// The documentation and implementation of this function should be up-to-date with
@ -390,7 +503,7 @@ pub fn from_reader<R: std::io::Read + ?Sized>(
/// replacement characters may appear in the encoded text.
pub async fn to_writer<'a, W: tokio::io::AsyncWriteExt + Unpin + ?Sized>(
writer: &'a mut W,
encoding: &'static encoding::Encoding,
encoding_with_bom_info: (&'static Encoding, bool),
rope: &'a Rope,
) -> Result<(), Error> {
// Text inside a `Rope` is stored as non-contiguous blocks of data called
@ -399,13 +512,22 @@ pub async fn to_writer<'a, W: tokio::io::AsyncWriteExt + Unpin + ?Sized>(
// determined by filtering the iterator to remove all empty chunks and then
// appending an empty chunk to it. This is valuable for detecting when all
// chunks in the `Rope` have been iterated over in the subsequent loop.
let (encoding, has_bom) = encoding_with_bom_info;
let iter = rope
.chunks()
.filter(|c| !c.is_empty())
.chain(std::iter::once(""));
let mut buf = [0u8; BUF_SIZE];
let mut encoder = encoding.new_encoder();
let mut total_written = 0usize;
let mut total_written = if has_bom {
apply_bom(encoding, &mut buf)
} else {
0
};
let mut encoder = Encoder::from_encoding(encoding);
for chunk in iter {
let is_empty = chunk.is_empty();
let mut total_read = 0usize;
@ -446,6 +568,7 @@ pub async fn to_writer<'a, W: tokio::io::AsyncWriteExt + Unpin + ?Sized>(
break;
}
}
Ok(())
}
@ -457,16 +580,16 @@ where
*mut_ref = f(mem::take(mut_ref));
}
use helix_lsp::lsp;
use helix_lsp::{lsp, Client, LanguageServerName};
use url::Url;
impl Document {
pub fn from(
text: Rope,
encoding: Option<&'static encoding::Encoding>,
encoding_with_bom_info: Option<(&'static Encoding, bool)>,
config: Arc<dyn DynAccess<Config>>,
) -> Self {
let encoding = encoding.unwrap_or(encoding::UTF_8);
let (encoding, has_bom) = encoding_with_bom_info.unwrap_or((encoding::UTF_8, false));
let changes = ChangeSet::new(&text);
let old_state = None;
@ -474,6 +597,7 @@ impl Document {
id: DocumentId::default(),
path: None,
encoding,
has_bom,
text,
selections: HashMap::default(),
inlay_hints: HashMap::default(),
@ -492,10 +616,11 @@ impl Document {
last_saved_time: SystemTime::now(),
last_saved_revision: 0,
modified_since_accessed: false,
language_server: None,
language_servers: HashMap::new(),
diff_handle: None,
config,
version_control_head: None,
focused_at: std::time::Instant::now(),
}
}
pub fn default(config: Arc<dyn DynAccess<Config>>) -> Self {
@ -507,21 +632,21 @@ impl Document {
/// overwritten with the `encoding` parameter.
pub fn open(
path: &Path,
encoding: Option<&'static encoding::Encoding>,
encoding: Option<&'static Encoding>,
config_loader: Option<Arc<syntax::Loader>>,
config: Arc<dyn DynAccess<Config>>,
) -> Result<Self, Error> {
// Open the file if it exists, otherwise assume it is a new file (and thus empty).
let (rope, encoding) = if path.exists() {
let (rope, encoding, has_bom) = if path.exists() {
let mut file =
std::fs::File::open(path).context(format!("unable to open {:?}", path))?;
from_reader(&mut file, encoding)?
} else {
let encoding = encoding.unwrap_or(encoding::UTF_8);
(Rope::from(DEFAULT_LINE_ENDING.as_str()), encoding)
(Rope::from(DEFAULT_LINE_ENDING.as_str()), encoding, false)
};
let mut doc = Self::from(rope, Some(encoding), config);
let mut doc = Self::from(rope, Some((encoding, has_bom)), config);
// set the path and try detecting the language
doc.set_path(Some(path))?;
@ -572,7 +697,7 @@ impl Document {
})?;
{
let mut stdin = process.stdin.take().ok_or(FormatterError::BrokenStdin)?;
to_writer(&mut stdin, encoding::UTF_8, &text)
to_writer(&mut stdin, (encoding::UTF_8, false), &text)
.await
.map_err(|_| FormatterError::BrokenStdin)?;
}
@ -605,10 +730,12 @@ impl Document {
return Some(formatting_future.boxed());
};
let language_server = self.language_server()?;
let text = self.text.clone();
// finds first language server that supports formatting and then formats
let language_server = self
.language_servers_with_feature(LanguageServerFeature::Format)
.next()?;
let offset_encoding = language_server.offset_encoding();
let request = language_server.text_document_formatting(
self.identifier(),
lsp::FormattingOptions {
@ -672,20 +799,18 @@ impl Document {
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();
let language_servers = self.language_servers.clone();
// mark changes up to now as saved
let current_rev = self.get_current_revision();
let doc_id = self.id();
let encoding = self.encoding;
let encoding_with_bom_info = (self.encoding, self.has_bom);
let last_saved_time = self.last_saved_time;
// We encode the file according to the `Document`'s encoding.
@ -697,7 +822,7 @@ impl Document {
if force {
std::fs::DirBuilder::new().recursive(true).create(parent)?;
} else {
bail!("can't save file, parent directory does not exist");
bail!("can't save file, parent directory does not exist (use :w! to create it)");
}
}
}
@ -714,7 +839,7 @@ impl Document {
}
let mut file = File::create(&path).await?;
to_writer(&mut file, encoding, &text).await?;
to_writer(&mut file, encoding_with_bom_info, &text).await?;
let event = DocumentSavedEvent {
revision: current_rev,
@ -723,14 +848,13 @@ impl Document {
text: text.clone(),
};
if let Some(language_server) = language_server {
for (_, language_server) in language_servers {
if !language_server.is_initialized() {
return Ok(event);
}
if let Some(identifier) = identifier {
if let Some(identifier) = &identifier {
if let Some(notification) =
language_server.text_document_did_save(identifier, &text)
language_server.text_document_did_save(identifier.clone(), &text)
{
notification.await?;
}
@ -745,12 +869,20 @@ impl Document {
/// Detect the programming language based on the file type.
pub fn detect_language(&mut self, config_loader: Arc<syntax::Loader>) {
if let Some(path) = &self.path {
let language_config = config_loader
.language_config_for_file_name(path)
.or_else(|| config_loader.language_config_for_shebang(self.text()));
self.set_language(language_config, Some(config_loader));
}
self.set_language(
self.detect_language_config(&config_loader),
Some(config_loader),
);
}
/// Detect the programming language based on the file type.
pub fn detect_language_config(
&self,
config_loader: &syntax::Loader,
) -> Option<Arc<helix_core::syntax::LanguageConfiguration>> {
config_loader
.language_config_for_file_name(self.path.as_ref()?)
.or_else(|| config_loader.language_config_for_shebang(self.text()))
}
/// Detect the indentation used in the file, or otherwise defaults to the language indentation
@ -772,7 +904,7 @@ impl Document {
provider_registry: &DiffProviderRegistry,
redraw_handle: RedrawHandle,
) -> Result<(), Error> {
let encoding = &self.encoding;
let encoding = self.encoding;
let path = self
.path()
.filter(|path| path.exists())
@ -806,13 +938,16 @@ impl Document {
/// Sets the [`Document`]'s encoding with the encoding correspondent to `label`.
pub fn set_encoding(&mut self, label: &str) -> Result<(), Error> {
self.encoding = encoding::Encoding::for_label(label.as_bytes())
.ok_or_else(|| anyhow!("unknown encoding"))?;
let encoding =
Encoding::for_label(label.as_bytes()).ok_or_else(|| anyhow!("unknown encoding"))?;
self.encoding = encoding;
Ok(())
}
/// Returns the [`Document`]'s current encoding.
pub fn encoding(&self) -> &'static encoding::Encoding {
pub fn encoding(&self) -> &'static Encoding {
self.encoding
}
@ -837,8 +972,7 @@ impl Document {
) {
if let (Some(language_config), Some(loader)) = (language_config, loader) {
if let Some(highlight_config) = language_config.highlight_config(&loader.scopes()) {
let syntax = Syntax::new(&self.text, highlight_config, loader);
self.syntax = Some(syntax);
self.syntax = Syntax::new(&self.text, highlight_config, loader);
}
self.language = Some(language_config);
@ -870,11 +1004,6 @@ impl Document {
Ok(())
}
/// Set the LSP.
pub fn set_language_server(&mut self, language_server: Option<Arc<helix_lsp::Client>>) {
self.language_server = language_server;
}
/// Select text within the [`Document`].
pub fn set_selection(&mut self, view_id: ViewId, selection: Selection) {
// TODO: use a transaction?
@ -908,6 +1037,11 @@ impl Document {
}
}
/// Mark document as recent used for MRU sorting
pub fn mark_as_focused(&mut self) {
self.focused_at = std::time::Instant::now();
}
/// Remove a view's selection and inlay hints from this document.
pub fn remove_view(&mut self, view_id: ViewId) {
self.selections.remove(&view_id);
@ -915,7 +1049,12 @@ impl Document {
}
/// Apply a [`Transaction`] to the [`Document`] to change its text.
fn apply_impl(&mut self, transaction: &Transaction, view_id: ViewId) -> bool {
fn apply_impl(
&mut self,
transaction: &Transaction,
view_id: ViewId,
emit_lsp_notification: bool,
) -> bool {
use helix_core::Assoc;
let old_doc = self.text().clone();
@ -968,9 +1107,11 @@ impl Document {
// update tree-sitter syntax tree
if let Some(syntax) = &mut self.syntax {
// TODO: no unwrap
syntax
.update(&old_doc, &self.text, transaction.changes())
.unwrap();
let res = syntax.update(&old_doc, &self.text, transaction.changes());
if res.is_err() {
log::error!("TS parser failed, disabeling TS for the current buffer: {res:?}");
self.syntax = None;
}
}
let changes = transaction.changes();
@ -1011,25 +1152,31 @@ impl Document {
apply_inlay_hint_changes(padding_after_inlay_hints);
}
// emit lsp notification
if let Some(language_server) = self.language_server() {
let notify = language_server.text_document_did_change(
self.versioned_identifier(),
&old_doc,
self.text(),
changes,
);
if emit_lsp_notification {
// emit lsp notification
for language_server in self.language_servers() {
let notify = language_server.text_document_did_change(
self.versioned_identifier(),
&old_doc,
self.text(),
changes,
);
if let Some(notify) = notify {
tokio::spawn(notify);
if let Some(notify) = notify {
tokio::spawn(notify);
}
}
}
}
success
}
/// Apply a [`Transaction`] to the [`Document`] to change its text.
pub fn apply(&mut self, transaction: &Transaction, view_id: ViewId) -> bool {
fn apply_inner(
&mut self,
transaction: &Transaction,
view_id: ViewId,
emit_lsp_notification: bool,
) -> bool {
// store the state just before any changes are made. This allows us to undo to the
// state just before a transaction was applied.
if self.changes.is_empty() && !transaction.changes().is_empty() {
@ -1039,7 +1186,7 @@ impl Document {
});
}
let success = self.apply_impl(transaction, view_id);
let success = self.apply_impl(transaction, view_id, emit_lsp_notification);
if !transaction.changes().is_empty() {
// Compose this transaction with the previous one
@ -1049,12 +1196,23 @@ impl Document {
}
success
}
/// Apply a [`Transaction`] to the [`Document`] to change its text.
pub fn apply(&mut self, transaction: &Transaction, view_id: ViewId) -> bool {
self.apply_inner(transaction, view_id, true)
}
/// Apply a [`Transaction`] to the [`Document`] to change its text
/// without notifying the language servers. This is useful for temporary transactions
/// that must not influence the server.
pub fn apply_temporary(&mut self, transaction: &Transaction, view_id: ViewId) -> bool {
self.apply_inner(transaction, view_id, false)
}
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, true)
} else {
false
};
@ -1086,15 +1244,32 @@ impl Document {
/// the state it had when this function was called.
pub fn savepoint(&mut self, view: &View) -> Arc<SavePoint> {
let revert = Transaction::new(self.text()).with_selection(self.selection(view.id).clone());
// check if there is already an existing (identical) savepoint around
if let Some(savepoint) = self
.savepoints
.iter()
.rev()
.find_map(|savepoint| savepoint.upgrade())
{
let transaction = savepoint.revert.lock();
if savepoint.view == view.id
&& transaction.changes().is_empty()
&& transaction.selection() == revert.selection()
{
drop(transaction);
return savepoint;
}
}
let savepoint = Arc::new(SavePoint {
view: view.id,
revert: Mutex::new(revert),
text: self.text.clone(),
});
self.savepoints.push(Arc::downgrade(&savepoint));
savepoint
}
pub fn restore(&mut self, view: &mut View, savepoint: &SavePoint) {
pub fn restore(&mut self, view: &mut View, savepoint: &SavePoint, emit_lsp_notification: bool) {
assert_eq!(
savepoint.view, view.id,
"Savepoint must not be used with a different view!"
@ -1109,7 +1284,7 @@ impl Document {
let savepoint_ref = self.savepoints.remove(savepoint_idx);
let mut revert = savepoint.revert.lock();
self.apply(&revert, view.id);
self.apply_inner(&revert, view.id, emit_lsp_notification);
*revert = Transaction::new(self.text()).with_selection(self.selection(view.id).clone());
self.savepoints.push(savepoint_ref)
}
@ -1122,7 +1297,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, true) {
success = true;
}
}
@ -1235,18 +1410,13 @@ impl Document {
.map(|language| language.language_id.as_str())
}
/// Language ID for the document. Either the `language-id` from the
/// `language-server` configuration, or the document language if no
/// `language-id` has been specified.
/// Language ID for the document. Either the `language-id`,
/// or the document language name if no `language-id` has been specified.
pub fn language_id(&self) -> Option<&str> {
let language_config = self.language.as_deref()?;
language_config
.language_server
.as_ref()?
.language_id
self.language_config()?
.language_server_language_id
.as_deref()
.or(Some(language_config.language_id.as_str()))
.or_else(|| self.language_name())
}
/// Corresponding [`LanguageConfiguration`].
@ -1259,10 +1429,45 @@ impl Document {
self.version
}
/// Language server if it has been initialized.
pub fn language_server(&self) -> Option<&helix_lsp::Client> {
let server = self.language_server.as_deref()?;
server.is_initialized().then_some(server)
/// maintains the order as configured in the language_servers TOML array
pub fn language_servers(&self) -> impl Iterator<Item = &helix_lsp::Client> {
self.language_config().into_iter().flat_map(move |config| {
config.language_servers.iter().filter_map(move |features| {
let ls = &**self.language_servers.get(&features.name)?;
if ls.is_initialized() {
Some(ls)
} else {
None
}
})
})
}
pub fn remove_language_server_by_name(&mut self, name: &str) -> Option<Arc<Client>> {
self.language_servers.remove(name)
}
pub fn language_servers_with_feature(
&self,
feature: LanguageServerFeature,
) -> impl Iterator<Item = &helix_lsp::Client> {
self.language_config().into_iter().flat_map(move |config| {
config.language_servers.iter().filter_map(move |features| {
let ls = &**self.language_servers.get(&features.name)?;
if ls.is_initialized()
&& ls.supports_feature(feature)
&& features.has_feature(feature)
{
Some(ls)
} else {
None
}
})
})
}
pub fn supports_language_server(&self, id: usize) -> bool {
self.language_servers().any(|l| l.id() == id)
}
pub fn diff_handle(&self) -> Option<&DiffHandle> {
@ -1271,7 +1476,7 @@ impl Document {
/// Intialize/updates the differ for this document with a new base.
pub fn set_diff_base(&mut self, diff_base: Vec<u8>, redraw_handle: RedrawHandle) {
if let Ok((diff_base, _)) = from_reader(&mut diff_base.as_slice(), Some(self.encoding)) {
if let Ok((diff_base, ..)) = from_reader(&mut diff_base.as_slice(), Some(self.encoding)) {
if let Some(differ) = &self.diff_handle {
differ.update_diff_base(diff_base);
return;
@ -1385,12 +1590,29 @@ impl Document {
&self.diagnostics
}
pub fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>) {
self.diagnostics = diagnostics;
pub fn shown_diagnostics(&self) -> impl Iterator<Item = &Diagnostic> + DoubleEndedIterator {
self.diagnostics.iter().filter(|d| {
self.language_servers_with_feature(LanguageServerFeature::Diagnostics)
.any(|ls| ls.id() == d.language_server_id)
})
}
pub fn replace_diagnostics(
&mut self,
mut diagnostics: Vec<Diagnostic>,
language_server_id: usize,
) {
self.clear_diagnostics(language_server_id);
self.diagnostics.append(&mut diagnostics);
self.diagnostics
.sort_unstable_by_key(|diagnostic| diagnostic.range);
}
pub fn clear_diagnostics(&mut self, language_server_id: usize) {
self.diagnostics
.retain(|d| d.language_server_id != language_server_id);
}
/// Get the document's auto pairs. If the document has a recognized
/// language config with auto pairs configured, returns that;
/// otherwise, falls back to the global auto pairs config. If the global
@ -1715,7 +1937,7 @@ mod test {
assert!(ref_path.exists());
let mut file = std::fs::File::open(path).unwrap();
let text = from_reader(&mut file, Some(encoding))
let text = from_reader(&mut file, Some(encoding.into()))
.unwrap()
.0
.to_string();
@ -1741,7 +1963,7 @@ mod test {
let text = Rope::from_str(&std::fs::read_to_string(path).unwrap());
let mut buf: Vec<u8> = Vec::new();
helix_lsp::block_on(to_writer(&mut buf, encoding, &text)).unwrap();
helix_lsp::block_on(to_writer(&mut buf, (encoding, false), &text)).unwrap();
let expectation = std::fs::read(ref_path).unwrap();
assert_eq!(buf, expectation);

@ -1,7 +1,7 @@
use crate::{
align_view,
clipboard::{get_clipboard_provider, ClipboardProvider},
document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode},
document::{DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint},
graphics::{CursorKind, Rect},
info::Info,
input::KeyEvent,
@ -10,6 +10,7 @@ use crate::{
view::ViewPosition,
Align, Document, DocumentId, View, ViewId,
};
use dap::StackFrame;
use helix_vcs::DiffProviderRegistry;
use futures_util::stream::select_all::SelectAll;
@ -281,6 +282,8 @@ pub struct Config {
/// Whether to color modes with different colors. Defaults to `false`.
pub color_modes: bool,
pub soft_wrap: SoftWrap,
/// Workspace specific lsp ceiling dirs
pub workspace_lsp_roots: Vec<PathBuf>,
/// Contextual information on top of the viewport
pub sticky_context: StickyContextConfig,
}
@ -306,6 +309,8 @@ pub struct StickyContextConfig {
/// Whether or not the Sticky context shall also depend on the cursor position
/// Default to off
pub follow_cursor: bool,
/// Workspace specific lsp ceiling dirs
pub workspace_lsp_roots: Vec<PathBuf>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -374,6 +379,10 @@ pub struct LspConfig {
pub display_signature_help_docs: bool,
/// Display inlay hints
pub display_inlay_hints: bool,
/// Whether to enable snippet support
pub snippets: bool,
/// Whether to include declaration in the goto reference query
pub goto_reference_include_declaration: bool,
}
impl Default for LspConfig {
@ -384,6 +393,8 @@ impl Default for LspConfig {
auto_signature_help: true,
display_signature_help_docs: true,
display_inlay_hints: false,
snippets: true,
goto_reference_include_declaration: true,
}
}
}
@ -557,10 +568,11 @@ impl Default for CursorShapeConfig {
}
/// bufferline render modes
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BufferLine {
/// Don't render bufferline
#[default]
Never,
/// Always render
Always,
@ -568,12 +580,6 @@ pub enum BufferLine {
Multiple,
}
impl Default for BufferLine {
fn default() -> Self {
BufferLine::Never
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LineNumber {
@ -773,10 +779,14 @@ impl Default for Config {
bufferline: BufferLine::default(),
indent_guides: IndentGuidesConfig::default(),
color_modes: false,
soft_wrap: SoftWrap::default(),
sticky_context: StickyContextConfig::default(),
soft_wrap: SoftWrap {
enable: Some(false),
..SoftWrap::default()
},
text_width: 80,
completion_replace: false,
workspace_lsp_roots: Vec::new(),
sticky_context: StickyContextConfig::default(),
}
}
}
@ -836,7 +846,7 @@ pub struct Editor {
pub macro_recording: Option<(char, Vec<KeyEvent>)>,
pub macro_replaying: Vec<char>,
pub language_servers: helix_lsp::Registry,
pub diagnostics: BTreeMap<lsp::Url, Vec<lsp::Diagnostic>>,
pub diagnostics: BTreeMap<lsp::Url, Vec<(lsp::Diagnostic, usize)>>,
pub diff_providers: DiffProviderRegistry,
pub debugger: Option<dap::Client>,
@ -875,7 +885,7 @@ pub struct Editor {
pub config_events: (UnboundedSender<ConfigEvent>, UnboundedReceiver<ConfigEvent>),
/// Allows asynchronous tasks to control the rendering
/// The `Notify` allows asynchronous tasks to request the editor to perform a redraw
/// The `RwLock` blocks the editor from performing the render until an exclusive lock can be aquired
/// The `RwLock` blocks the editor from performing the render until an exclusive lock can be acquired
pub redraw_handle: RedrawHandle,
pub needs_redraw: bool,
/// Cached position of the cursor calculated during rendering.
@ -892,7 +902,7 @@ pub struct Editor {
/// times during rendering and should not be set by other functions.
pub cursor_cache: Cell<Option<Option<Position>>>,
/// When a new completion request is sent to the server old
/// unifinished request must be dropped. Each completion
/// unfinished request must be dropped. Each completion
/// request is associated with a channel that cancels
/// when the channel is dropped. That channel is stored
/// here. When a new completion request is sent this
@ -924,9 +934,14 @@ enum ThemeAction {
}
#[derive(Debug, Clone)]
pub struct CompleteAction {
pub trigger_offset: usize,
pub changes: Vec<Change>,
pub enum CompleteAction {
Applied {
trigger_offset: usize,
changes: Vec<Change>,
},
/// A savepoint of the currently selected completion. The savepoint
/// MUST be restored before sending any event to the LSP
Selected { savepoint: Arc<SavePoint> },
}
#[derive(Debug, Copy, Clone)]
@ -954,6 +969,7 @@ impl Editor {
syn_loader: Arc<syntax::Loader>,
config: Arc<dyn DynAccess<Config>>,
) -> Self {
let language_servers = helix_lsp::Registry::new(syn_loader.clone());
let conf = config.load();
let auto_pairs = (&conf.auto_pairs).into();
@ -973,7 +989,7 @@ impl Editor {
macro_recording: None,
macro_replaying: Vec::new(),
theme: theme_loader.default(),
language_servers: helix_lsp::Registry::new(),
language_servers,
diagnostics: BTreeMap::new(),
diff_providers: DiffProviderRegistry::default(),
debugger: None,
@ -1105,60 +1121,75 @@ impl Editor {
self._refresh();
}
#[inline]
pub fn language_server_by_id(&self, language_server_id: usize) -> Option<&helix_lsp::Client> {
self.language_servers.get_by_id(language_server_id)
}
/// Refreshes the language server for a given document
pub fn refresh_language_server(&mut self, doc_id: DocumentId) -> Option<()> {
self.launch_language_server(doc_id)
pub fn refresh_language_servers(&mut self, doc_id: DocumentId) -> Option<()> {
self.launch_language_servers(doc_id)
}
/// Launch a language server for a given document
fn launch_language_server(&mut self, doc_id: DocumentId) -> Option<()> {
fn launch_language_servers(&mut self, doc_id: DocumentId) -> Option<()> {
if !self.config().lsp.enable {
return None;
}
// if doc doesn't have a URL it's a scratch buffer, ignore it
let (lang, path) = {
let doc = self.document(doc_id)?;
(doc.language.clone(), doc.path().cloned())
};
let doc = self.documents.get_mut(&doc_id)?;
let doc_url = doc.url()?;
let (lang, path) = (doc.language.clone(), doc.path().cloned());
let config = doc.config.load();
let root_dirs = &config.workspace_lsp_roots;
// try to find a language server based on the language name
let language_server = lang.as_ref().and_then(|language| {
// try to find language servers based on the language name
let language_servers = lang.as_ref().and_then(|language| {
self.language_servers
.get(language, path.as_ref())
.get(language, path.as_ref(), root_dirs, config.lsp.snippets)
.map_err(|e| {
log::error!(
"Failed to initialize the LSP for `{}` {{ {} }}",
"Failed to initialize the language servers for `{}` {{ {} }}",
language.scope(),
e
)
})
.ok()
.flatten()
});
let doc = self.document_mut(doc_id)?;
let doc_url = doc.url()?;
if let Some(language_servers) = language_servers {
let language_id = doc.language_id().map(ToOwned::to_owned).unwrap_or_default();
if let Some(language_server) = language_server {
// only spawn a new lang server if the servers aren't the same
if Some(language_server.id()) != doc.language_server().map(|server| server.id()) {
if let Some(language_server) = doc.language_server() {
tokio::spawn(language_server.text_document_did_close(doc.identifier()));
}
// only spawn new language servers if the servers aren't the same
let doc_language_servers_not_in_registry =
doc.language_servers.iter().filter(|(name, doc_ls)| {
language_servers
.get(*name)
.map_or(true, |ls| ls.id() != doc_ls.id())
});
for (_, language_server) in doc_language_servers_not_in_registry {
tokio::spawn(language_server.text_document_did_close(doc.identifier()));
}
let language_id = doc.language_id().map(ToOwned::to_owned).unwrap_or_default();
let language_servers_not_in_doc = language_servers.iter().filter(|(name, ls)| {
doc.language_servers
.get(*name)
.map_or(true, |doc_ls| ls.id() != doc_ls.id())
});
for (_, language_server) in language_servers_not_in_doc {
// TODO: this now races with on_init code if the init happens too quickly
tokio::spawn(language_server.text_document_did_open(
doc_url,
doc_url.clone(),
doc.version(),
doc.text(),
language_id,
language_id.clone(),
));
doc.set_language_server(Some(language_server));
}
doc.language_servers = language_servers;
}
Some(())
}
@ -1194,6 +1225,7 @@ impl Editor {
let doc = doc_mut!(self, &doc_id);
doc.ensure_view_init(view.id);
view.sync_changes(doc);
doc.mark_as_focused();
align_view(doc, view, Align::Center);
}
@ -1264,6 +1296,7 @@ impl Editor {
let view_id = view!(self).id;
let doc = doc_mut!(self, &id);
doc.ensure_view_init(view_id);
doc.mark_as_focused();
return;
}
Action::HorizontalSplit | Action::VerticalSplit => {
@ -1285,6 +1318,7 @@ impl Editor {
// initialize selection for view
let doc = doc_mut!(self, &id);
doc.ensure_view_init(view_id);
doc.mark_as_focused();
}
}
@ -1320,10 +1354,10 @@ impl Editor {
}
pub fn new_file_from_stdin(&mut self, action: Action) -> Result<DocumentId, Error> {
let (rope, encoding) = crate::document::from_reader(&mut stdin(), None)?;
let (rope, encoding, has_bom) = crate::document::from_reader(&mut stdin(), None)?;
Ok(self.new_file_from_document(
action,
Document::from(rope, Some(encoding), self.config.clone()),
Document::from(rope, Some((encoding, has_bom)), self.config.clone()),
))
}
@ -1348,7 +1382,7 @@ impl Editor {
doc.set_version_control_head(self.diff_providers.get_current_head_name(&path));
let id = self.new_document(doc);
let _ = self.launch_language_server(id);
let _ = self.launch_language_servers(id);
id
};
@ -1378,7 +1412,7 @@ impl Editor {
// This will also disallow any follow-up writes
self.saves.remove(&doc_id);
if let Some(language_server) = doc.language_server() {
for language_server in doc.language_servers() {
// TODO: track error
tokio::spawn(language_server.text_document_did_close(doc.identifier()));
}
@ -1435,6 +1469,7 @@ impl Editor {
let view_id = self.tree.insert(view);
let doc = doc_mut!(self, &doc_id);
doc.ensure_view_init(view_id);
doc.mark_as_focused();
}
self._refresh();
@ -1489,6 +1524,10 @@ impl Editor {
view.sync_changes(doc);
}
}
let view = view!(self, view_id);
let doc = doc_mut!(self, &view.doc);
doc.mark_as_focused();
}
pub fn focus_next(&mut self) {
@ -1683,6 +1722,12 @@ impl Editor {
doc.restore_cursor = false;
}
}
pub fn current_stack_frame(&self) -> Option<&StackFrame> {
self.debugger
.as_ref()
.and_then(|debugger| debugger.current_stack_frame())
}
}
fn try_restore_indent(doc: &mut Document, view: &mut View) {

@ -1,8 +1,10 @@
use std::fmt::Write;
use helix_core::syntax::LanguageServerFeature;
use crate::{
editor::GutterType,
graphics::{Color, Style, UnderlineStyle},
graphics::{Style, UnderlineStyle},
Document, Editor, Theme, View,
};
@ -56,7 +58,7 @@ pub fn diagnostic<'doc>(
let error = theme.get("error");
let info = theme.get("info");
let hint = theme.get("hint");
let diagnostics = doc.diagnostics();
let diagnostics = &doc.diagnostics;
Box::new(
move |line: Option<usize>, _selected: bool, first_visual_line: bool, out: &mut String| {
@ -65,28 +67,24 @@ pub fn diagnostic<'doc>(
}
let line = line?;
use helix_core::diagnostic::Severity;
if let Ok(index) = diagnostics.binary_search_by_key(&line, |d| d.line) {
let after = diagnostics[index..].iter().take_while(|d| d.line == line);
let before = diagnostics[..index]
.iter()
.rev()
.take_while(|d| d.line == line);
let diagnostics_on_line = after.chain(before);
// This unwrap is safe because the iterator cannot be empty as it contains at least the item found by the binary search.
let diagnostic = diagnostics_on_line.max_by_key(|d| d.severity).unwrap();
write!(out, "●").unwrap();
return Some(match diagnostic.severity {
let first_diag_idx_maybe_on_line = diagnostics.partition_point(|d| d.line < line);
let diagnostics_on_line = diagnostics[first_diag_idx_maybe_on_line..]
.iter()
.take_while(|d| {
d.line == line
&& doc
.language_servers_with_feature(LanguageServerFeature::Diagnostics)
.any(|ls| ls.id() == d.language_server_id)
});
diagnostics_on_line.max_by_key(|d| d.severity).map(|d| {
write!(out, "●").ok();
match d.severity {
Some(Severity::Error) => error,
Some(Severity::Warning) | None => warning,
Some(Severity::Info) => info,
Some(Severity::Hint) => hint,
});
}
None
}
})
},
)
}
@ -255,9 +253,9 @@ pub fn breakpoints<'doc>(
theme: &Theme,
_is_focused: bool,
) -> GutterFn<'doc> {
let warning = theme.get("warning");
let error = theme.get("error");
let info = theme.get("info");
let breakpoint_style = theme.get("ui.debug.breakpoint");
let breakpoints = doc.path().and_then(|path| editor.breakpoints.get(path));
@ -276,30 +274,53 @@ pub fn breakpoints<'doc>(
.iter()
.find(|breakpoint| breakpoint.line == line)?;
let mut style = if breakpoint.condition.is_some() && breakpoint.log_message.is_some() {
let style = if breakpoint.condition.is_some() && breakpoint.log_message.is_some() {
error.underline_style(UnderlineStyle::Line)
} else if breakpoint.condition.is_some() {
error
} else if breakpoint.log_message.is_some() {
info
} else {
warning
breakpoint_style
};
if !breakpoint.verified {
// Faded colors
style = if let Some(Color::Rgb(r, g, b)) = style.fg {
style.fg(Color::Rgb(
((r as f32) * 0.4).floor() as u8,
((g as f32) * 0.4).floor() as u8,
((b as f32) * 0.4).floor() as u8,
))
} else {
style.fg(Color::Gray)
}
};
let sym = if breakpoint.verified { "●" } else { "◯" };
write!(out, "{}", sym).unwrap();
Some(style)
},
)
}
fn execution_pause_indicator<'doc>(
editor: &'doc Editor,
doc: &'doc Document,
theme: &Theme,
is_focused: bool,
) -> GutterFn<'doc> {
let style = theme.get("ui.debug.active");
let current_stack_frame = editor.current_stack_frame();
let frame_line = current_stack_frame.map(|frame| frame.line - 1);
let frame_source_path = current_stack_frame.map(|frame| {
frame
.source
.as_ref()
.and_then(|source| source.path.as_ref())
});
let should_display_for_current_doc =
doc.path().is_some() && frame_source_path.unwrap_or(None) == doc.path();
Box::new(
move |line: Option<usize>, _selected: bool, first_visual_line: bool, out: &mut String| {
let line = line?;
if !first_visual_line
|| !is_focused
|| line != frame_line?
|| !should_display_for_current_doc
{
return None;
}
let sym = if breakpoint.verified { "▲" } else { "⊚" };
let sym = "▶";
write!(out, "{}", sym).unwrap();
Some(style)
},
@ -315,9 +336,11 @@ pub fn diagnostics_or_breakpoints<'doc>(
) -> GutterFn<'doc> {
let mut diagnostics = diagnostic(editor, doc, view, theme, is_focused);
let mut breakpoints = breakpoints(editor, doc, view, theme, is_focused);
let mut execution_pause_indicator = execution_pause_indicator(editor, doc, theme, is_focused);
Box::new(move |line, selected, first_visual_line: bool, out| {
breakpoints(line, selected, first_visual_line, out)
execution_pause_indicator(line, selected, first_visual_line, out)
.or_else(|| breakpoints(line, selected, first_visual_line, out))
.or_else(|| diagnostics(line, selected, first_visual_line, out))
})
}

@ -321,6 +321,7 @@ impl Editor {
}
}
None => {
self.debugger = None;
self.set_status(
"Terminated debugging session and disconnected debugger.",
);

@ -128,7 +128,7 @@ impl Loader {
let parent_palette = parent_theme_toml.get("palette");
let palette = theme_toml.get("palette");
// handle the table seperately since it needs a `merge_depth` of 2
// handle the table separately since it needs a `merge_depth` of 2
// this would conflict with the rest of the theme merge strategy
let palette_values = match (parent_palette, palette) {
(Some(parent_palette), Some(palette)) => {

@ -728,12 +728,11 @@ mod test {
tree.focus = l0;
let view = View::new(DocumentId::default(), GutterConfig::default());
tree.split(view, Layout::Vertical);
let l2 = tree.focus;
// Tree in test
// | L0 | L2 | |
// | L1 | R0 |
tree.focus = l2;
let l2 = tree.focus;
assert_eq!(Some(l0), tree.find_split_in_direction(l2, Direction::Left));
assert_eq!(Some(l1), tree.find_split_in_direction(l2, Direction::Down));
assert_eq!(Some(r0), tree.find_split_in_direction(l2, Direction::Right));

@ -7,9 +7,13 @@ use crate::{
};
use helix_core::{
char_idx_at_visual_offset, doc_formatter::TextFormat, syntax::Highlight,
text_annotations::TextAnnotations, visual_offset_from_anchor, visual_offset_from_block,
Position, RopeSlice, Selection, Transaction,
char_idx_at_visual_offset,
doc_formatter::TextFormat,
syntax::Highlight,
text_annotations::TextAnnotations,
visual_offset_from_anchor, visual_offset_from_block, Position, RopeSlice, Selection,
Transaction,
VisualOffsetError::{PosAfterMaxRow, PosBeforeAnchorRow},
};
use std::{
@ -213,46 +217,38 @@ impl View {
// - 1 so we have at least one gap in the middle.
// a height of 6 with padding of 3 on each side will keep shifting the view back and forth
// as we type
let scrolloff = scrolloff.min(viewport.height.saturating_sub(1) as usize / 2);
let scrolloff = if CENTERING {
0
} else {
scrolloff.min(viewport.height.saturating_sub(1) as usize / 2)
};
let cursor = doc.selection(self.id).primary().cursor(doc_text);
let mut offset = self.offset;
let off = visual_offset_from_anchor(
doc_text,
offset.anchor,
cursor,
&text_fmt,
&annotations,
vertical_viewport_end,
);
let (visual_off, mut at_top) = if cursor >= offset.anchor {
let off = visual_offset_from_anchor(
doc_text,
offset.anchor,
cursor,
&text_fmt,
&annotations,
vertical_viewport_end,
);
(off, false)
} else if CENTERING {
// cursor out of view
return None;
} else {
(None, true)
};
let new_anchor = match visual_off {
Some((visual_pos, _)) if visual_pos.row < scrolloff + offset.vertical_offset => {
if CENTERING && visual_pos.row < offset.vertical_offset {
let (new_anchor, at_top) = match off {
Ok((visual_pos, _)) if visual_pos.row < scrolloff + offset.vertical_offset => {
if CENTERING {
// cursor out of view
return None;
}
at_top = true;
true
(true, true)
}
Some((visual_pos, _)) if visual_pos.row + scrolloff + 1 >= vertical_viewport_end => {
if CENTERING && visual_pos.row >= vertical_viewport_end {
// cursor out of view
return None;
}
true
Ok((visual_pos, _)) if visual_pos.row + scrolloff >= vertical_viewport_end => {
(true, false)
}
Some(_) => false,
None => true,
Ok((_, _)) => (false, false),
Err(_) if CENTERING => return None,
Err(PosBeforeAnchorRow) => (true, true),
Err(PosAfterMaxRow) => (true, false),
};
if new_anchor {
@ -269,8 +265,8 @@ impl View {
offset.horizontal_offset = 0;
} else {
// determine the current visual column of the text
let col = visual_off
.unwrap_or_else(|| {
let col = off
.unwrap_or_else(|_| {
visual_offset_from_block(
doc_text,
offset.anchor,
@ -360,8 +356,9 @@ impl View {
);
match pos {
Some((Position { row, .. }, _)) => row.saturating_sub(self.offset.vertical_offset),
None => visual_height.saturating_sub(1),
Ok((Position { row, .. }, _)) => row.saturating_sub(self.offset.vertical_offset),
Err(PosAfterMaxRow) => visual_height.saturating_sub(1),
Err(PosBeforeAnchorRow) => 0,
}
}
@ -390,7 +387,8 @@ impl View {
&text_fmt,
&annotations,
viewport.height as usize,
)?
)
.ok()?
.0;
if pos.row < self.offset.vertical_offset {
return None;

File diff suppressed because it is too large Load Diff

@ -107,6 +107,7 @@
(null) @constant
(number_literal) @constant.numeric
(char_literal) @constant.character
(escape_sequence) @constant.character.escape
(call_expression
function: (identifier) @function)

@ -48,4 +48,7 @@
((variable) @constant
(#match? @constant "^[A-Z][A-Z_0-9]*$"))
[
(param)
(mount_param)
] @constant

@ -0,0 +1,39 @@
; highlights.scm
(comment) @comment
[
"ELEMENT"
"ATTLIST"
] @keyword
[
"#REQUIRED"
"#IMPLIED"
"#FIXED"
"#PCDATA"
] @keyword.directive
[
"EMPTY"
"ANY"
"SYSTEM"
"PUBLIC"
] @constant
(element_name) @module
(attribute_name) @attribute
(system_literal) @string
(pubid_literal) @string
(attribute_value) @string
[
">"
"</"
"<?"
"?>"
"<!"
] @punctuation.bracket

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

Loading…
Cancel
Save