Compare commits

...

206 Commits

Author SHA1 Message Date
Skyler Hawthorne
fdd3f8b88f dead10ck 2025-09-15 12:10:29 -04:00
Skyler Hawthorne
09de695156 fix as-is tests 2025-09-15 12:07:34 -04:00
Skyler Hawthorne
99775897ac account for overlapping deletes 2025-09-15 12:07:34 -04:00
Skyler Hawthorne
631c3e3644 insert double whitespace inside pair 2025-09-15 12:07:34 -04:00
Skyler Hawthorne
bb0198ee73 Delete pairs with multi-char-range selections
This completes auto pair deletions. Currently, auto pairs only get
deleted when the range is a single grapheme wide, since otherwise,
the selection would get computed incorrectly through the normal change
mapping process. Now auto pairs get deleted even with larger ranges, and
the resulting selection is correct.
2025-09-15 12:07:34 -04:00
Skyler Hawthorne
8dfb0b5b85 add delete_by_and_with_selection 2025-09-15 12:07:34 -04:00
Skyler Hawthorne
15834cfe81 Change auto pair hook to operate on single changes
Change the auto pair hook to operate on single ranges to allow
transactions that mix auto pair changes with other operations, such as
inserting or deleting a single char, and denendting.
2025-09-15 12:07:34 -04:00
Skyler Hawthorne
0578063eed backfill auto pair delete tests 2025-09-15 12:07:34 -04:00
Skyler Hawthorne
d26feaf1a3 Add Transaction::change_by_and_with_selection
Adds `Transaction::change_by_and_with_selection` which centralizes
logic for producing change sets with a potentially new selection that
is applied incrementally, rather than all at once at the end with
`with_selection`. It also centralizes the offset tracking logic so that
the caller can construct a new selection with ranges as if they were
operating on the text as-is.
2025-09-15 12:07:34 -04:00
Skyler Hawthorne
699391dd19 feat(command): expand_selection_around
Introduces a new command `expand_selection_around` that expands the
selection to the parent node, like `expand_selection`, except it splits
on the selection you start with and continues expansion around this
initial selection.
2025-09-15 12:00:53 -04:00
Skyler Hawthorne
d445661815 Generalize View::object_selections into map
This allows using multiple distinct state histories. By default, all
history is also cleared any time a view's selection is set, unless
explicitly told to save the state. This way, we can have control over
when selection history is saved. They are also cleared on any text
edit, since an edit could invalidate the previous selection, potentially
causing a panic.

Additionally, the object selections have been moved into `Document`
so that it is easier to manipulate them when changes to the document
happen. They have been put into a wrapper struct named `ViewData`, where
the intention is that any further fields that we want to add in the
future that must be associated with a view, but are more convenient to
store in a document, can be added here, instead of further polluting the
core `Document` type.
2025-09-15 12:00:53 -04:00
Skyler Hawthorne
a938646274 Add Selection::{overlaps,without} and fix contains
Adds two helper functions to `Selection`:

* `overlaps`: tests whether two `Selection`s contain any ranges which
  overlap with each other
* `without`: Computes a new `Selection` that is the set difference
  of two `Selection`s, i.e. everything in the first `Selection`
  with everything that overlaps in the second `Selection` removed,
  potentially leaving holes in the original ranges.

It also fixes a bug with `Selection::contains`: it assumes that if the
second `Selection` has a greater number of ranges than the first, then
the first cannot contain the second; but this is false, since one range
from the first could contain multiple smaller ranges in the second.
2025-09-15 12:00:53 -04:00
Skyler Hawthorne
d49c8efa6e shrink_selection without history selects first contained child
This changes the behavior of `shrink_selection` to iterate through child
nodes until it finds one that is contained within the selection, with
at least one of the ends of the selection being exclusively inside the
starting selection (though not necessarily both ends). This produces
more intuitive behavior for selecting the "first logical thing" inside
the selection.
2025-09-15 12:00:53 -04:00
Skyler Hawthorne
9816696784 backfill expand/shrink tests 2025-09-15 12:00:53 -04:00
Sri Senthil Balaji J
d015eff4aa fix(helix-view/clipboard): use serde generated name for Win32Yank (#14455) 2025-09-15 10:18:49 -05:00
CalebLarsen
a4d780483b fix: Make Table Cells set the style of their text (#13776) 2025-09-15 09:01:21 -05:00
Ash Joseph
ea2541f424 Added Ashokai theme family (#14446) 2025-09-15 08:45:35 -05:00
Sri Senthil Balaji J
69713975c6 Switch svelte treesitter and update queries (#14343) 2025-09-15 08:44:20 -05:00
Xavier R. Guérin
2dddace12e Add support for the SlightLisp language (#14236) 2025-09-15 08:35:50 -05:00
CalebLarsen
66737c6a4f Close prompts when switching windows (#13620) 2025-09-15 08:32:43 -05:00
Matt
fbf6407ab3 Extend DebugArgumentValue to support toml tables, ref #14282 (#14283) 2025-09-15 05:56:59 -03:00
Kristoffer Plagborg Bak Sørensen
1f020b1d72 feat(languages): add latexmkrc to perl file-types (#14450) 2025-09-14 19:11:17 -04:00
Antti Eskelinen
9acfa51209 Add .tmux.conf glob to bash file-types (#14449) 2025-09-14 09:11:06 -04:00
Lukáš Lalinský
2d7436fc20 Add support for matching function call arguments in Zig (#14436) 2025-09-13 20:13:21 -04:00
Igor Támara
1388166570 feat: add support for grammar wikitext the mediawiki dialect (#14432) 2025-09-13 20:02:01 -04:00
Steffen
342f2a982d Update fortran tree sitter files (#14448) 2025-09-13 19:28:20 -04:00
Steffen
5bfc7bfc07 feat: add uppercase file extensions for Fortran (#14447) 2025-09-13 19:25:32 -04:00
Kristoffer Plagborg Bak Sørensen
5609e3b3c2 feat: add .kube/kuberc to list of yaml file-types (#14444) 2025-09-13 19:13:51 -04:00
Kristoffer Plagborg Bak Sørensen
c614467be8 feat(languages): add podman *.conf files to toml file-types (#14443) 2025-09-13 19:13:10 -04:00
Michael Davis
c531b7a4fa Add debug logging of reset-cursor escape sequence
This can be used to debug the escape sequence used to reset the cursor
when quitting Helix. The cursor should reset to whatever is configured
in the terminal but this usually needs information from terminfo.
2025-09-12 10:42:44 -04:00
Valentin Cocaud
92b0a2f414 feat(helix-tui): add configuration to manually enable/disable KKP (#14398) 2025-09-12 09:32:22 -05:00
Michael Davis
378b27cad9 Bump termina to v0.1.1
This fixes the build for Illumos as termina now skips compiling
macOS-specific polling functions when not building for macOS.
2025-09-12 10:12:55 -04:00
fl0
209558645a Add new theme: Nvim-dark (#14403) 2025-09-10 09:09:30 -05:00
dependabot[bot]
987b04bd26 build(deps): bump the rust-dependencies group with 6 updates (#14410)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-10 09:08:49 -05:00
Grégoire Locqueville
322bb1c189 Fix Lean LSP module imports (#14381)
Co-authored-by: Grégoire Locqueville <gregoire.locqueville@cnrs.fr>
2025-09-10 09:07:59 -05:00
quantonganh
34e0f7e82f Add a simple language server for HDL (nand2tetris) (#14415) 2025-09-10 08:45:50 -05:00
Kristoffer Plagborg Bak Sørensen
97293c9f36 feat(languages): add gitlab-ci language specialized from yaml (#14396) 2025-09-10 08:41:04 -05:00
Kristoffer Plagborg Bak Sørensen
71038266e8 feat(highlights): add support for syntax features in hurl version 6.0.0 (#12725) 2025-09-10 08:39:34 -05:00
Charles Hall
05a99c2cca fix white/bright white base16 color mixup (#14409) 2025-09-10 08:38:45 -05:00
Axlefublr
14030d0b63 kdl queries: basic tag.scm and niri injections (#14401) 2025-09-10 08:28:31 -05:00
Abderrahmane TAHRI JOUTI
ff376b0d1a move cyan_light to jetbrains_cyan_light (#14412) 2025-09-10 08:27:25 -05:00
Kristoffer Plagborg Bak Sørensen
d25726f573 feat: add fods and fodt extensions to xml filetype (#14416) 2025-09-10 08:26:52 -05:00
may
8f2af68b30 scheme: add block comment token, textobjects.scm (#14408)
* lang(scheme): add block comment token

* queries(scheme): add comment text objects

* chore: run cargo xtask docgen
2025-09-10 08:26:27 -05:00
Kristoffer Plagborg Bak Sørensen
fe8e21a07f feat(bash): add init scripts for xinit and startx to bash file-types (#14397) 2025-09-10 08:22:10 -05:00
may
d0218f7e78 queries(scheme): mark the variables of do as @variable 2025-09-05 10:36:13 -04:00
may
e2333f60ae queries(scheme): convert a #match? to a #any-of? 2025-09-05 10:36:13 -04:00
may
70187c430c queries(scheme): more consistently indent with two spaces
... and also remove a trailing space
2025-09-05 10:36:13 -04:00
kpbaks
8058fefef0 feat: add docker-language-server
Official language server for Dockerfiles, Compose files, and Bake files.
https://github.com/docker/docker-language-server/
2025-09-05 10:35:26 -04:00
kpbaks
0928e5ea1c feat: add docker-bake language
Docker bake files are primarily written in `hcl`, but you can also write
it in `json` and `yaml`. The official material and documentation for the
feature uses `hcl`, which is why this commit makes the choice of
associating the `docker-bake` language with `docker-bake.hcl` files.
The primary motivation of this specialization is to inject the
`dockerfile` language into the `dockerfile-inline` attribute.
2025-09-05 10:35:26 -04:00
Ariel Chenet
b391185716 Add Expert LSP support for Elixir and Heex languages (#14395)
Co-authored-by: Ariel Chenet <arielchenet@pm.me>
2025-09-05 09:31:37 -05:00
may
f59dc9e48f mark arguments of case-lambda as variable, mark case-lambda as keyword (#14386) 2025-09-04 09:08:20 -05:00
Kristoffer Plagborg Bak Sørensen
d63c2d2fea feat: add runhaskell and stack as haskell shebangs (#14385) 2025-09-04 09:06:27 -05:00
Kristoffer Plagborg Bak Sørensen
0a4207be32 feat: detect .wslconfig as ini filetype (#14383) 2025-09-04 09:06:01 -05:00
Kristoffer Plagborg Bak Sørensen
3adc021c06 feat(ruby): detect .irbrc file as ruby (#14382) 2025-09-04 09:05:34 -05:00
Axlefublr
d1750a7502 fish injection query for nu, on nu -c (#14376) 2025-09-03 10:17:07 -05:00
dependabot[bot]
c5f0a4bc22 build(deps): bump the rust-dependencies group with 2 updates (#14373)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-03 08:16:33 -05:00
Kristoffer Plagborg Bak Sørensen
4967229e85 feat(ruby): inject bash into builtin ways of running system commands (#14372) 2025-09-02 17:51:42 -05:00
Nora Breitmoser-Widdecke
68f11f9324 language support for strictdoc (#14314) 2025-09-02 08:56:15 -05:00
Kristoffer Plagborg Bak Sørensen
af74a61ad4 feat(languages): detect more vim file-types (#14365) 2025-09-02 08:39:21 -05:00
Andrey R.
cfb5158cd1 fix(theme): correct goto_word and comments background in flatwhite.toml (#14348) 2025-09-01 10:23:26 -04:00
Dang
e3fafb6bad use constant.builtin.boolean scope for ecma boolean (#14357) 2025-08-31 17:24:50 -04:00
Michael Davis
6e9939a2d1 Improve "written" message for small (<1024B) files
Using a float when the file size is in bytes is confusing. Instead we
should show "123B" for <1024B and use floats only for KiB and above.
2025-08-31 12:44:55 -04:00
Michael Davis
b08aba8e8e Remove Backend::get_cursor
It is unused and cannot be used on some terminal hosts like `conhost`
that do not respond to VT queries. This doesn't have any affect on
behavior - I'm removing it so that we don't rely on it in the future.
2025-08-31 12:25:01 -04:00
Michael Davis
83abbe56df Replace Crossterm with Termina
This change switches out the terminal manipulation library to one I've
been working on: Termina. It's somewhat similar to Crossterm API-wise
but is a bit lower-level. It's also influenced a lot by TermWiz - the
terminal manipulation library in WezTerm which we've considered
switching to a few times.

Termina is more verbose than Crossterm as it has a lower level interface
that exposes escape sequences and pushes handling to the application.
API-wise the important piece is that the equivalents of Crossterm's
`poll_internal` / `read_internal` are exposed. This is used for reading
the cursor position in both Crossterm and Termina, for example, but also
now can be used to detect features like the Kitty keyboard protocol and
synchronized output sequences simultaneously.
2025-08-31 12:25:01 -04:00
Michael Davis
9cc912a63e tui: Refactor Config type handling in backends
The `Config` can be passed when creating the backend (for example
`CrosstermBackend::new`) and is already updated in the
`Backend::reconfigure` callback. Recreating the tui `Config` during
`claim` and `restore` is unnecessary and causes a clone of the editor's
Config which is a fairly large type. This change drops the `Config`
parameter from those callbacks and updates the callers. Instead it is
passed to `CrosstermBackend` which then owns it.

I've also moved the override from the `editor.undercurl` key onto the
tui `Config` type - I believe it was just an oversight that this was not
done originally. And I've updated the `From<EditorConfig> for Config`
to take a reference to the editor's `Config` to avoid the unnecessary
clone during `CrosstermBackend::new` and `Backend::reconfigure`.
2025-08-31 10:50:02 -04:00
Nik Revenco
fe1393cec8 queries(rust): Highlight type infer in more places (#14351) 2025-08-31 09:01:45 -04:00
Nik Revenco
392e444ff9 queries(rust): properly highlight Dioxus' rsx! macro (#14354) 2025-08-31 09:01:05 -04:00
Nik Revenco
0ea5d87985 queries(rust): Highlight tacit functions when we are 100% sure they are (#14350) 2025-08-31 08:58:46 -04:00
Kristoffer Plagborg Bak Sørensen
6b73c3c550 feat: add shellcheckrc language (#14202) 2025-08-31 08:53:42 -04:00
Michael Davis
b309d72688 Fix bugs in Editor::focus (#14262) 2025-08-31 08:52:40 -04:00
Kalpaj Chaudhari
d546a799e5 Add tag queries for java, kotlin, protobuf and bash (#14349) 2025-08-31 08:47:58 -04:00
Arthur
7c37e8acea support cython (#14128) 2025-08-31 08:35:33 -04:00
Michael Davis
d4c91daa5e queries: Inject regex into regular Rust string literals
Previously regex was injected only into raw string literals.

    Regex::new(r"[a-z]") // was highlighted
    Regex::new("[a-z]") // is now also highlighted
2025-08-31 08:23:08 -04:00
Robin Kraft
dc7c2acc08 add a link to release packe on debian installation docs (#14346) 2025-08-31 07:43:57 -04:00
Kristoffer Plagborg Bak Sørensen
99cea8c284 feat(bash): inject bash into builtins expecting bash code as input (#14268) 2025-08-30 13:46:08 -04:00
Tijs-B
077c901be9 fix(htmldjango): add roots to htmldjango language (#14305) 2025-08-30 13:42:23 -04:00
Bryce Berger
a5bf7c0d5e queries: update highlights/injections for jjconfig (#14308) 2025-08-30 13:38:26 -04:00
Michael Davis
8ab20720da Update tree-sitter-gleam
The latest changes in the grammar add support for `echo ... as`
expressions.
2025-08-30 13:30:33 -04:00
dependabot[bot]
feeaec097a build(deps): bump the rust-dependencies group with 8 updates (#14317)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-30 13:27:26 -04:00
Georgios Tsoulis
4f5eaa4186 docs: fix possible error when building the optimized version from source (#14330) 2025-08-30 13:25:53 -04:00
Jonas Köhnen
7a5b618fe5 Fix shell pipe command lines not using expansions (#14191) 2025-08-30 13:16:22 -04:00
RoloEdits
77ff51caa4 feat(grammar): update zig tree-sitter (#14336) 2025-08-29 10:16:45 -04:00
Erik
7e4e556f84 feat(languages): Add buck2 PACKAGE files as starlark file type (#14324) 2025-08-27 09:46:17 -05:00
zoey
96c60198ec update: nyxvamp-theme (#14319) 2025-08-26 17:20:07 -04:00
Ross Smyth
3dadd82c89 nix: Remove debug ls -al (#14320) 2025-08-26 17:19:41 -04:00
Jaakko Paju
5a8fb732f2 add AWS config and kube config file support (#14316) 2025-08-26 10:44:01 -05:00
Kristoffer Plagborg Bak Sørensen
8671882ee2 feat(languages): specialize toml file-type for git-cliff config file (#14301) 2025-08-25 08:50:38 -05:00
Sylvain Terrien
1d3e65fdbc theme: add color-modes support to molokai (#14250) 2025-08-25 08:49:46 -05:00
Uładzisłaŭ Hińko
f81b59fc15 Add NvChad's solarized dark theme (#14280) 2025-08-25 08:49:09 -05:00
Vítor Galvão
cc8e890906 languages.toml: Add GPX extension to XML (#14300)
Co-authored-by: Poliorcetics <poliorcetics@users.noreply.github.com>
2025-08-25 08:38:04 -05:00
Michael Davis
aa14cd38fc queries: Fix precedence of Rust for-loop keyword highlight
The `for` literal node is marked as a `keyword` since it can also show
up outside of for loops, like in `for<'a> fn(&'a T)`. The for loop
highlight which tags `keyword.control.repeat` needs to move lower in the
file than the `keyword` one to take precedence.
2025-08-25 09:31:12 -04:00
Michael Davis
22a3b10dd8 themes: Fix other modus vivendi licenses
This is the same as 95c378a764 but for the other variants.
2025-08-22 09:35:34 -04:00
ishanray
535e6ee77b Make buffer picker default to last file (#14176) 2025-08-22 08:30:31 -05:00
Aidan Gauland
4b40b45527 Add Flatbuffers language and grammar (#14275) 2025-08-22 08:05:54 -05:00
Michael Davis
95c378a764 themes: Fix modus vivendi license
The GFDL-1.3-only text must've been copied from the modus vivendi website
<https://protesilaos.com/emacs/modus-themes#h:b14c3fcb-13dd-4144-9d92-2c58b3ed16d3>.
The actual license is a GPL-3.0-or-later:
<https://github.com/protesilaos/modus-themes/blob/main/COPYING>.
2025-08-22 08:59:54 -04:00
Kristoffer Plagborg Bak Sørensen
74bb02ffe7 feat(languages): specilize toml file-type for cross-rs config file (#14274) 2025-08-19 18:19:44 -04:00
Matej Almáši
b81ee02db4 fix adwaita-light ui.virtual color (#14238) 2025-08-19 18:09:34 -04:00
dependabot[bot]
9ec07cf1f6 build(deps): bump the rust-dependencies group with 3 updates (#14270)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-19 10:57:21 -05:00
evie
9f34f8b5ff don't remove highlights for selections in modus_* themes (#14232) 2025-08-18 08:25:03 -05:00
Kristoffer Plagborg Bak Sørensen
3fb1443162 feat(python): add regex injection to functions in the std re module (#14254) 2025-08-18 08:24:38 -05:00
Kristoffer Plagborg Bak Sørensen
207c0e3899 feat: inject jq into jq "..." string literals in common shells (#14253) 2025-08-18 08:23:53 -05:00
Erasin Wang
f9f5fe6b12 Add rainbows for Lua (#14256) 2025-08-18 08:22:50 -05:00
Erasin Wang
e5e7fe43ce Add queries for Wgsl (#14257) 2025-08-18 08:22:15 -05:00
Erasin Wang
da4ede9535 Add rainbows for ron (#14258) 2025-08-18 08:21:48 -05:00
Ricardo Fernández Serrata
6f26a257d5 Recognize .git/info/exclude as .gitignore (#14264) 2025-08-18 08:20:44 -05:00
Michael Davis
b1eb9c09f4 queries: Fix highlight of ':' in TypeScript ternary expressions
The ':' node is set to punctuation.delimiter in typescript and this
comes after the ternary_expression pattern in the ecma highlights. So we
need to copy that pattern after the punctuation.delimiter pattern.
2025-08-18 09:16:41 -04:00
Gabor Pihaj
b6ccbddc77 fix: add runtimeDir as propagated build input (#14247) 2025-08-16 10:51:17 -04:00
Gareth Widlansky
a4a2b50a50 Add support for KConfig (#14205) 2025-08-15 09:52:58 -05:00
Francesco Urbani
e5d1f6c517 feat: add a tree-sitter grammar for systemverilog (#14174) 2025-08-15 09:06:10 -05:00
Erik
050e1d9b47 feat: add a tree-sitter grammar for Doxyfile (#14235) 2025-08-15 09:01:42 -05:00
Nick Pupko
2b9cc20d23 Improve error messages for goto LSP commands (#14171) 2025-08-15 08:33:57 -05:00
Artyom Vasich
9e3b510dc7 fix(go): enable markdown injections in function doc comments (#14230) 2025-08-15 08:30:42 -05:00
Gareth Widlansky
6b93c77033 add rainbows.scm for tsq (#14216) 2025-08-15 08:30:23 -05:00
Erasin Wang
fdaec3f972 Add rainbows.scm for PHP (#14228) 2025-08-15 08:27:15 -05:00
rusty-snake
8a898c88bc book: Add Helix mode in Lite XL and Lapce (#14210) 2025-08-15 08:26:46 -05:00
Fabian Mettler
e0544d01f1 Remove the Ubuntu PPA installation method from the docs (#14237) 2025-08-15 08:25:00 -05:00
dependabot[bot]
001efa801e build(deps): bump actions/download-artifact from 4 to 5 (#14219)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-08-12 14:12:22 -05:00
dependabot[bot]
00dbca93c4 build(deps): bump the rust-dependencies group with 5 updates (#14222)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 07:53:31 -05:00
dependabot[bot]
6726c1f41c build(deps): bump actions/checkout from 4 to 5 (#14220)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 07:52:43 -05:00
evie
7747d3b93e adjust cyan in modus_vivendi rainbow (and yellow in _tritanopia) (#14218) 2025-08-12 07:51:46 -05:00
Peter Rice
4d0466d30c Add rainbows.scm queries for Janet (#14203) 2025-08-10 16:52:32 -04:00
Mike Boutin
ed2807ae07 Improve onedarker theme by adding highlight scopes (#14194) 2025-08-10 09:41:04 -04:00
Kristoffer Plagborg Bak Sørensen
327f3852f4 feat: add pip-requirements language (#14161) 2025-08-09 20:17:42 -04:00
Friedrich Stoltzfus
155fde5178 feat: add tree-sitter rainbow query for Swift (#14165) 2025-08-09 20:16:05 -04:00
Nik Revenco
f5a399c7f9 fix: 2 bugs in the Alt-) and Alt-( commands (#13985) 2025-08-09 20:13:37 -04:00
Trevor Gross
cb7188d5cc jj: Set the indent to four spaces for descriptions (#14199) 2025-08-09 20:07:03 -04:00
Poliorcetics
a44695e4e8 just: bump grammar support to fix alias name bug and add tag queries (#14169) 2025-08-09 19:57:16 -04:00
Jonas Köhnen
ef3a49d03c Support features of :open in :vsplit & :hsplit (#13461) 2025-08-09 19:50:49 -04:00
Kristoffer Plagborg Bak Sørensen
56fa9bf7c1 feat: inject css into standard browser API methods that expect css selector as argument (#14181) 2025-08-09 19:42:27 -04:00
Álan Crístoffer
b5a9c34e14 languages(matlab): bump grammar version (#14195) 2025-08-09 19:39:39 -04:00
infastin
e8e36a6a8e feat: update go grammar and queries (#14167) 2025-08-09 18:45:23 -04:00
infastin
18572973e6 feat: update css grammar and highlights.scm (#14147)
* feat: update css grammar and highlights.scm

* chore: replace any-of with match

* fix: replace match with eq

Co-authored-by: Michael Davis <mcarsondavis@gmail.com>

---------

Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-08-08 09:34:09 +09:00
Isaac Corbrey
4a25f63169 languages: Add doc comment token for C# (#14164) 2025-08-08 09:33:27 +09:00
dependabot[bot]
0345400c41 build(deps): bump the rust-dependencies group with 4 updates (#14163)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 08:26:46 -05:00
quantonganh
43990ed0c8 feat: add a tree-sitter grammar for hdl (#14140) 2025-08-03 17:38:22 -04:00
Kristoffer Plagborg Bak Sørensen
178c55708a feat: add robots.txt language (#14089) 2025-08-03 17:19:09 -04:00
Kristoffer Plagborg Bak Sørensen
6c0d598183 Update go-format-string grammar and queries (#14106) 2025-08-03 17:18:09 -04:00
evie
5b5f6daab3 fix parentheses in popup description for html tag matching (#14157) 2025-08-03 17:17:44 -04:00
zoey
601c904e50 chore: visual fixes to nyxvamp radiance base variant theme (#14130) 2025-08-03 17:11:51 -04:00
chtenb
93cf3b1baf Update koka grammar and queries to support koka v3 (#14135) 2025-08-03 17:04:49 -04:00
Kristoffer Plagborg Bak Sørensen
fdfc6df122 Add --no-format flag to :update command (#14136) 2025-08-03 16:56:33 -04:00
Rich
d2595930fa docs: add encodings for < and > literals in remapping (#14144)
Currently docs do not include the remappings for literal < or >, which can be important characters for macros relating to markup languages.
2025-08-02 11:27:27 -04:00
evie
758f80a4fc give modus-vivendi rainbows (#14131) 2025-07-31 09:01:53 -05:00
Trevor Gross
e58b08d22a docker: Update the default indentation to match other editors (#14108) 2025-07-30 08:50:20 -05:00
Alex Pearwin
62f3cd3f5a docs: Correct expansion argument formatting (#14123) 2025-07-30 08:47:50 -05:00
Mo Bitar
39cccc23e5 book: Update inline diagnostics defaults (#14124) 2025-07-30 08:45:14 -05:00
dependabot[bot]
f0be627dcb build(deps): bump the rust-dependencies group with 7 updates (#14109)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 09:40:56 -05:00
wcampbell
1bdd8ae784 Update rst to v0.2.0 (#14111) 2025-07-29 09:40:00 -05:00
Remo Senekowitsch
2d5a19f081 editorconfig: allow empty alternates (#14107) 2025-07-28 18:02:42 -05:00
David Else
d9fe4798fa Add rainbow brackets for dark_plus (#14103) 2025-07-28 09:45:03 -05:00
Matthew Toohey
39eec87284 Remove lean server memory limit (#14075) 2025-07-27 23:13:08 -04:00
Emran
285a7440a3 Update tree-sitter-blade and queries (#14097)
Co-authored-by: Emran <Emran@MacBook.local>
2025-07-27 19:29:17 -04:00
Knoqx
2f7cc9d0ae Reduce weight of 1 space indents in indent detection (#14088) 2025-07-27 18:36:18 -04:00
Michael Davis
ca4f638dfd Run 'cargo xtask docgen' 2025-07-27 15:18:36 -04:00
George Hollister
4480da752c feat(theme): Sync catppuccin themes to include rainbow array (#14094) 2025-07-27 15:18:00 -04:00
Michael Davis
1f7b593857 Add rainbows.scm queries for rust-format-args-macro
This fixes rainbow brackets within format macros like `println!`.
2025-07-27 15:09:59 -04:00
alice pellerin
6807e32ec1 Preserve file creation time on macOS and Windows (#13561) 2025-07-26 20:29:37 -04:00
Michael Davis
54e748b0ce Run 'cargo xtask docgen' 2025-07-26 18:37:15 -04:00
jyn
c8224bcf4e add insert_character_interactive command (#11411) 2025-07-26 18:32:41 -04:00
Michael Davis
1941f0b639 Rainbow tree-sitter matches 🌈 (#13530) 2025-07-26 18:02:29 -04:00
Kristoffer Plagborg Bak Sørensen
e17b80a5a2 feat: add grammar for lua pattern language (#14082) 2025-07-26 17:25:46 -04:00
Michael Davis
6dc4722665 Enable inline diagnostics by default (#13970) 2025-07-26 17:23:15 -04:00
Muhammad Fathir Irhas
6ea3677b9f Current working directory status (#10998)
Co-authored-by: mfirhas <mfathirirhas@gmail.com>
2025-07-26 17:12:07 -04:00
rathewolf
fe2291a59b add aura 'n aurara themes (#11791) 2025-07-26 17:08:59 -04:00
Daniel Rotter
a789ec7f4b Use separate color for primary selection in jetbrains_dark (#12606)
Co-authored-by: Yannic Nuwenhof <ynuwenhof@pm.me>
2025-07-26 16:42:17 -04:00
CalebLarsen
6b511964bb docs: Add jumplist documentation to the book (#13849) 2025-07-26 16:41:40 -04:00
Michael Davis
ddbac29d14 docs: Add architecture notes for stdx and event crates
Co-authored-by: kirawi <67773714+kirawi@users.noreply.github.com>E
2025-07-26 16:38:58 -04:00
Md Atiquz Zaman
f4557d0bff Enhance Sidra theme color palette and JS support (#14033) 2025-07-26 16:05:30 -04:00
RoloEdits
6479f74a57 feat(lsp): sort diagnostic picker by severity (#13806) 2025-07-26 15:58:04 -04:00
Axlefublr
27c90b7fff add more quote types to markdown autopairs (#13693) 2025-07-26 15:54:31 -04:00
Chuyang Chen
9ea190b729 Improve the keymap doc in the book (#13655) 2025-07-26 15:39:41 -04:00
Sylvain Terrien
c2782568f1 feat: add language filtering to hx --health (#13484) 2025-07-26 15:38:25 -04:00
Daniel Fichtinger
8dbc664a30 feat: add yamlfmt as default yaml formatter (#13310) 2025-07-26 15:22:19 -04:00
Nik Revenco
f72b6f758b feat: add workspace_directory and current_working_directory variable expansions (#13068)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-07-26 14:58:32 -04:00
Daniel Fichtinger
4fd4588482 feat: Add vimscript language support (#13062) 2025-07-26 14:51:15 -04:00
Hendrik
94c96cfe0e fix(queries): typst function highlight (#14068) 2025-07-26 12:16:48 -04:00
Carter Watson
44b5413716 fix: gruvbox theme collision (#14063) 2025-07-26 12:16:21 -04:00
Alexander
e15134beac prompt: fix crash on unwrap graphemes (#14050)
Co-authored-by: Poliorcetics <poliorcetics@users.noreply.github.com>
2025-07-26 12:14:55 -04:00
Kristoffer Plagborg Bak Sørensen
22e60d6a71 feat: highlight go string interpolation that use stdlib fmt package (#14069) 2025-07-26 11:51:10 -04:00
RoloEdits
8297d60ca0 ci(release): update runner os for windows and macos (#14073) 2025-07-26 11:48:45 -04:00
Valtteri Koskivuori
4281228da3 fix(queries): Fix filesystem permissions for snakemake (#14061) 2025-07-24 13:09:40 -04:00
kiara
395a71bf53 languages: nix formatter (#14046) 2025-07-23 12:51:17 -04:00
Ian Hobson
1e4bf6704a Update Koto grammar and queries, add formatter (#14049) 2025-07-23 12:47:47 -04:00
Alexander Meinhardt Scheurer-Volkmann
b01fbb4a22 Fix symlink directories in file explorer (#14028) 2025-07-21 14:10:06 -04:00
MrWheatley
f75a26cb9b added janet indents (#14020) 2025-07-21 14:07:11 -04:00
MrWheatley
21ae1c98fb fix janet highlights (#14017) 2025-07-21 14:00:21 -04:00
Fea
7b8a4b7a51 feat: Add kotlin-lsp to languages.toml (#14021) 2025-07-21 14:00:08 -04:00
Yorick Peterse
715d4ae2d5 tree-sitter: update Inko grammar and queries (#14022) 2025-07-21 13:51:50 -04:00
Ivan Shymkiv
22b184b570 Fixed theme location (#14016) 2025-07-19 17:33:47 -05:00
Ivan Shymkiv
665ee4da22 feat(theme): add Gruvbox Material Dark theme variants (#14005) 2025-07-19 15:45:15 -05:00
Ivan Shymkiv
ecd18e3eb2 feat(themes): add Gruvbox Material Light theme (#14007) 2025-07-19 15:44:42 -05:00
Poliorcetics
e7f95ca6b2 just: bump grammar support to handle module path in aliases and recipes dependencies (#14009) 2025-07-19 15:18:18 -04:00
Michael Davis
4418e338e8 Use syntax symbol pickers for Erlang
Neither language server robustly supports workspace symbol search.
`erlang-ls`'s symbol picker takes a long time to open successfully on
boot. `elp`'s is faster but not faster than the tags query.
2025-07-18 11:17:10 -04:00
Michael Davis
6c71fc00b2 Document tags.scm queries, commands and language support 2025-07-18 11:17:10 -04:00
Michael Davis
727758e068 Add syntax symbol pickers based on tags.scm queries 2025-07-18 11:16:42 -04:00
Michael Davis
63eb1b870c Add initial tags.scm queries
Co-authored-by: cgahr <26804763+cgahr@users.noreply.github.com>
Co-authored-by: eh <correia.eh@gmail.com>
2025-07-18 11:12:41 -04:00
Michael Davis
2d5826d194 Complete words from open buffers (#13206) 2025-07-18 09:51:00 -05:00
Michael Davis
9f4ef2fc3d Add release notes for 25.07.1
(cherry picked from commit a05c151bb6)
2025-07-18 10:39:27 -04:00
RoloEdits
fd8aacc1a4 build: lower ubuntu version from 24.04 to 22.04 (#13983) 2025-07-18 09:16:21 -05:00
Björn Ganslandt
2ee11a0a9d Add textobjects for XML, HTML and JSX (#11158) 2025-07-16 09:02:52 -05:00
Poliorcetics
9512cb9472 contrib(completions/nushell): switch from deprecated filter to where (#13848) 2025-07-16 08:43:33 -05:00
Bryce Berger
3658e97c2b Add tree-sitter injections for jj config files (#13926) 2025-07-16 08:36:54 -05:00
dependabot[bot]
ab668c2dfc build(deps): bump toml from 0.8.23 to 0.9.2 in the rust-dependencies group (#13955)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-07-16 08:18:17 -05:00
Anton Romanov
ef2ebc5f24 [theme] Fix zenburn doc comment color (#13962) 2025-07-16 08:10:57 -05:00
Michael Davis
5cda70e866 Add changelog notes for 25.07 (#13939) 2025-07-15 13:26:52 -05:00
Meiram Shunshalin
c67c3faa78 feat(themes): add soft-wrap style for nightfox (#13957) 2025-07-15 08:42:41 -05:00
335 changed files with 16263 additions and 3332 deletions

View File

@@ -20,7 +20,7 @@ jobs:
if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule'
steps:
- name: Checkout sources
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install MSRV toolchain
uses: dtolnay/rust-toolchain@master
@@ -51,7 +51,7 @@ jobs:
HELIX_LOG_LEVEL: info
steps:
- name: Checkout sources
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install MSRV toolchain
uses: dtolnay/rust-toolchain@master
@@ -85,7 +85,7 @@ jobs:
if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule'
steps:
- name: Checkout sources
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install MSRV toolchain
uses: dtolnay/rust-toolchain@master
@@ -121,7 +121,7 @@ jobs:
if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule'
steps:
- name: Checkout sources
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install MSRV toolchain
uses: dtolnay/rust-toolchain@master

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install nix
uses: cachix/install-nix-action@v31

View File

@@ -11,7 +11,7 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v2

View File

@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
@@ -58,23 +58,21 @@ jobs:
strategy:
fail-fast: false # don't fail other jobs if one fails
matrix:
build: [x86_64-linux, aarch64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc
build: [x86_64-linux, aarch64-linux, x86_64-macos, x86_64-windows, aarch64-macos] #, x86_64-win-gnu, win32-msvc
include:
- build: x86_64-linux
os: ubuntu-24.04
# WARN: When changing this to a newer version, make sure that the GLIBC isnt too new, as this can cause issues
# with portablity on older systems that dont follow ubuntus more rapid release cadence.
os: ubuntu-22.04
rust: stable
target: x86_64-unknown-linux-gnu
cross: false
- build: aarch64-linux
os: ubuntu-24.04-arm
# Version should be kept in lockstep with the x86_64 version
os: ubuntu-22.04-arm
rust: stable
target: aarch64-unknown-linux-gnu
cross: false
# - build: riscv64-linux
# os: ubuntu-22.04
# rust: stable
# target: riscv64gc-unknown-linux-gnu
# cross: true
- build: x86_64-macos
os: macos-latest
rust: stable
@@ -85,13 +83,16 @@ jobs:
rust: stable
target: x86_64-pc-windows-msvc
cross: false
# 23.03: build issues
- build: aarch64-macos
os: macos-latest
rust: stable
target: aarch64-apple-darwin
cross: false
skip_tests: true # x86_64 host can't run aarch64 code
# - build: riscv64-linux
# os: ubuntu-22.04
# rust: stable
# target: riscv64gc-unknown-linux-gnu
# cross: true
# - build: x86_64-win-gnu
# os: windows-2019
# rust: stable-x86_64-gnu
@@ -103,16 +104,16 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download grammars
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
- name: Move grammars under runtime
if: "!startsWith(matrix.os, 'windows')"
run: |
mkdir -p runtime/grammars/sources
tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources
tar xJf 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
@@ -213,7 +214,7 @@ jobs:
shell: bash
run: |
mkdir -p dist
if [ "${{ matrix.os }}" = "windows-2019" ]; then
if [ "${{ matrix.os }}" = "windows-latest" ]; then
cp "target/${{ matrix.target }}/opt/hx.exe" "dist/"
else
cp "target/${{ matrix.target }}/opt/hx" "dist/"
@@ -234,9 +235,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
uses: actions/checkout@v5
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v5
- name: Build archive
shell: bash
@@ -291,7 +292,7 @@ jobs:
file_glob: true
tag: ${{ github.ref_name }}
overwrite: true
- name: Upload binaries as artifact
uses: actions/upload-artifact@v4
if: env.preview == 'true'

View File

@@ -20,6 +20,306 @@ Updated languages and queries:
Packaging:
-->
# 25.07.1 (2025-07-18)
This is a patch release which lowers the GLIBC requirements of the release artifacts published to GitHub ([#13983](https://github.com/helix-editor/helix/pull/13983))
# 25.07 (2025-07-15)
As always, a big thank you to all of the contributors! This release saw changes from 195 contributors.
Breaking changes:
* The parsing of the command line has been rewritten and now supports flags and expansions ([#12527](https://github.com/helix-editor/helix/pull/12527), [#13018](https://github.com/helix-editor/helix/pull/13018), [9574e55](https://github.com/helix-editor/helix/commit/9574e55), [2d4c2a1](https://github.com/helix-editor/helix/commit/2d4c2a1), [#13192](https://github.com/helix-editor/helix/pull/13192), [67f1fe2](https://github.com/helix-editor/helix/commit/67f1fe2), [#13466](https://github.com/helix-editor/helix/pull/13466), [#13467](https://github.com/helix-editor/helix/pull/13467), [#13840](https://github.com/helix-editor/helix/pull/13840))
* Quoting and spaces are now handled differently. This can break existing keymaps which use typable commands, in particular `:sh`, `:set-option` or `:toggle-option`.
* The `:rsort` command has been removed. Use the reverse flag instead: `:sort --reverse`
Features:
* Add a picker which explores directories ([#11285](https://github.com/helix-editor/helix/pull/11285), [d4aed40](https://github.com/helix-editor/helix/commit/d4aed40))
* Allow cycling through multiple LSP Hover responses with `A-n`/`A-p` ([#10122](https://github.com/helix-editor/helix/pull/10122), [2367b20](https://github.com/helix-editor/helix/commit/2367b20))
* Add support for incomplete LSP completions ([5c1f3f8](https://github.com/helix-editor/helix/commit/5c1f3f8))
* Add support for EditorConfig ([#13056](https://github.com/helix-editor/helix/pull/13056), [#13443](https://github.com/helix-editor/helix/pull/13443))
* Add support for LSP document colors ([#12308](https://github.com/helix-editor/helix/pull/12308), [d43de14](https://github.com/helix-editor/helix/commit/d43de14), [47cdd23](https://github.com/helix-editor/helix/commit/47cdd23), [ba54b6a](https://github.com/helix-editor/helix/commit/ba54b6a), [#13188](https://github.com/helix-editor/helix/pull/13188))
* Support expansions in external formatter arguments ([#13429](https://github.com/helix-editor/helix/pull/13429))
* Switch out the highlighter for the `tree-house` crate ([#12972](https://github.com/helix-editor/helix/pull/12972), [09bc67a](https://github.com/helix-editor/helix/commit/09bc67a), [a7c3a43](https://github.com/helix-editor/helix/commit/a7c3a43), [3ceae88](https://github.com/helix-editor/helix/commit/3ceae88), [05ae617](https://github.com/helix-editor/helix/commit/05ae617), [5a1dcc2](https://github.com/helix-editor/helix/commit/5a1dcc2), [ebf96bd](https://github.com/helix-editor/helix/commit/ebf96bd), [#13644](https://github.com/helix-editor/helix/pull/13644), [b1f4717](https://github.com/helix-editor/helix/commit/b1f4717), [7410fe3](https://github.com/helix-editor/helix/commit/7410fe3), [633c5fb](https://github.com/helix-editor/helix/commit/633c5fb), [362e97e](https://github.com/helix-editor/helix/commit/362e97e), [#13828](https://github.com/helix-editor/helix/pull/13828), [6fd1efd](https://github.com/helix-editor/helix/commit/6fd1efd))
* This fixes a number of highlighter bugs.
* Locals like parameter highlights are now highlighted even when the definition is not in view.
* Markdown is now injected into rust doc comments (`///` and `//!`).
* Add support for the DAP `startDebugging` reverse request ([#13403](https://github.com/helix-editor/helix/pull/13403))
Commands:
* Add `copy_between_registers` for interactive copying between two registers ([066e938](https://github.com/helix-editor/helix/commit/066e938))
* Add `extend_to_file_{start,end}`, select-mode variants of `goto_file_{start,end}` ([#11767](https://github.com/helix-editor/helix/pull/11767))
* Add `:!` alias for `:sh` and `:|` for `:pipe` ([#13263](https://github.com/helix-editor/helix/pull/13263))
* Add `goto_column` and `extend_to_column` ([#13440](https://github.com/helix-editor/helix/pull/13440))
* Add an `--insensitive`/`-i` flag to the `:sort` command ([#13560](https://github.com/helix-editor/helix/pull/13560))
* Add `rotate_selections_first` and `rotate_selections_last` ([#13615](https://github.com/helix-editor/helix/pull/13615))
* Add a `--no-format` flag for all `:write` commands ([2f56091](https://github.com/helix-editor/helix/commit/2f56091))
* Add a `--skip-visible` flag for `:buffer-close-others` and `:buffer-close-others!` ([#5393](https://github.com/helix-editor/helix/pull/5393))
Usability improvements:
* Replace current file using `A-ret` in pickers rather than loading it in the background ([#12605](https://github.com/helix-editor/helix/pull/12605))
* Set multiple selections when passing a file with multiple locations to `hx` ([#12192](https://github.com/helix-editor/helix/pull/12192))
* Add path completion for multiple cursors ([#12550](https://github.com/helix-editor/helix/pull/12550), [c9dc940](https://github.com/helix-editor/helix/commit/c9dc940))
* Truncate long prompt lines with "…" ([#12036](https://github.com/helix-editor/helix/pull/12036), [9d6ea77](https://github.com/helix-editor/helix/commit/9d6ea77), [0b9701e](https://github.com/helix-editor/helix/commit/0b9701e), [d3fb8fc](https://github.com/helix-editor/helix/commit/d3fb8fc))
* Allow specifying languages in `:lsp-stop` and `:lsp-restart` ([#12578](https://github.com/helix-editor/helix/pull/12578), [3d7e273](https://github.com/helix-editor/helix/commit/3d7e273))
* Add `m` (nearest matching pair) to infobox popups for `md` and `mr` ([#12650](https://github.com/helix-editor/helix/pull/12650))
* Add a hint message in the statusline when using `:sort` on a single selection ([#12585](https://github.com/helix-editor/helix/pull/12585))
* Avoid wrapping around in `goto_{next,prev}_diag` ([#12704](https://github.com/helix-editor/helix/pull/12704))
* Support responses from multiple language servers for LSP goto-definition (and declaration, type definition and implementation) and goto-references ([f7394d5](https://github.com/helix-editor/helix/commit/f7394d5), [1a821ac](https://github.com/helix-editor/helix/commit/1a821ac), [d285a8a](https://github.com/helix-editor/helix/commit/d285a8a))
* Show formatter errors in `:format` ([47f84d0](https://github.com/helix-editor/helix/commit/47f84d0))
* Show typable command docs in keybinding infobox popups when the command takes no arguments ([e9c16b7](https://github.com/helix-editor/helix/commit/e9c16b7))
* Add per-command titles to register selection infobox popups for `select_register`, `insert_register` and `copy_between_registers` ([e0da129](https://github.com/helix-editor/helix/commit/e0da129))
* Add container name column to the LSP symbol picker ([#12930](https://github.com/helix-editor/helix/pull/12930))
* Add a theme key for highlighting directories in completions and picker items ([#12855](https://github.com/helix-editor/helix/pull/12855), [7bebe0a](https://github.com/helix-editor/helix/commit/7bebe0a))
* Add `editor.trim-final-newlines` and `editor.trim-trailing-whitespace` config options ([aa20eb8](https://github.com/helix-editor/helix/commit/aa20eb8))
* Warn when the configured theme is unusable because true-color is not available ([#13058](https://github.com/helix-editor/helix/pull/13058))
* Allow configuring `[workspace-]diagnostic` statusline element severities ([#13288](https://github.com/helix-editor/helix/pull/13288), [b0528bb](https://github.com/helix-editor/helix/commit/b0528bb))
* Improve completion for shell commands ([#12883](https://github.com/helix-editor/helix/pull/12883), [532f241](https://github.com/helix-editor/helix/commit/532f241))
* Show the primary selection index in the `selections` statusline element when there are multiple selections ([#12326](https://github.com/helix-editor/helix/pull/12326))
* Use configured language server names when possible in `--health` output ([#13573](https://github.com/helix-editor/helix/pull/13573))
* Add a statusline element for indentation style ([#13632](https://github.com/helix-editor/helix/pull/13632))
* Set the working directory of language server commands to the workspace root ([#13691](https://github.com/helix-editor/helix/pull/13691))
* Avoid jumpiness in the picker preview for languages with non-default tab widths ([#13761](https://github.com/helix-editor/helix/pull/13761))
* Add a config option for limiting LSP inlay hint length ([#13742](https://github.com/helix-editor/helix/pull/13742))
* Improve heuristics used in the diff gutter ([#13722](https://github.com/helix-editor/helix/pull/13722))
* Allow moving a file with `:move` when its old path does not exist ([#13748](https://github.com/helix-editor/helix/pull/13748))
* Allow moving a file into a directory with `:move` ([#13922](https://github.com/helix-editor/helix/pull/13922))
* Show human-readable file sizes in the statusline message for file writes ([#13627](https://github.com/helix-editor/helix/pull/13627))
* Add diagnostic source to the diagnosics pickers ([#13758](https://github.com/helix-editor/helix/pull/13758))
* Show all active scopes under the cursor in `:tree-sitter-highlight-name` ([4a85171](https://github.com/helix-editor/helix/commit/4a85171))
* Auto-close the LSP code-actions popup ([#13832](https://github.com/helix-editor/helix/pull/13832))
* Add a configuration option for controlling atomic writes to disk ([#13656](https://github.com/helix-editor/helix/pull/13656))
Fixes:
* Fix panic from using `search_selection_detect_word_boundaries` (`*`) at the end of the file ([#12611](https://github.com/helix-editor/helix/pull/12611))
* Discard placeholder text for zero tabstop `${0:placeholder}` ([#12647](https://github.com/helix-editor/helix/pull/12647))
* Fix panic in `goto_file` (`gf`) on file names with non-ASCII characters ([#12673](https://github.com/helix-editor/helix/pull/12673))
* Only accept unmodified characters in `goto_word` (`gw`) ([f5f9f49](https://github.com/helix-editor/helix/commit/f5f9f49), [0364521](https://github.com/helix-editor/helix/commit/0364521))
* Skip recording keys pressed by macros while recording a macro ([#12733](https://github.com/helix-editor/helix/pull/12733))
* Deny unknown fields in `editor.smart-tab` config ([28047fe](https://github.com/helix-editor/helix/commit/28047fe))
* Fix soft-wrap word boundary detection for Unicode combining accent characters ([#12483](https://github.com/helix-editor/helix/pull/12483))
* Fix clearing of infobox popups in `select_register` and `insert_register` commands ([e882a75](https://github.com/helix-editor/helix/commit/e882a75))
* Fix handling of `stderr` of DAP child processes ([d0d1693](https://github.com/helix-editor/helix/commit/d0d1693))
* Cancel all pending requests when a DAP session terminates ([26db541](https://github.com/helix-editor/helix/commit/26db541))
* Properly discard out-of-date diagnostics ([313a647](https://github.com/helix-editor/helix/commit/313a647))
* Fix display of multiple language servers in `hx --health` ([#12841](https://github.com/helix-editor/helix/pull/12841))
* Respect `editor.default-yank-register` in `:yank-joined` ([#12890](https://github.com/helix-editor/helix/pull/12890))
* Escape percent character when pasting the history register into the picker ([#12886](https://github.com/helix-editor/helix/pull/12886))
* Render rulers before the cursor ([2d3b75a](https://github.com/helix-editor/helix/commit/2d3b75a))
* Avoid inserting final newlines in empty files ([67879a1](https://github.com/helix-editor/helix/commit/67879a1))
* Gracefully handle partial failure in multi-language-server requests ([#13156](https://github.com/helix-editor/helix/pull/13156), [14cab4b](https://github.com/helix-editor/helix/commit/14cab4b))
* Improve LSP progress message display in the statusline ([#13180](https://github.com/helix-editor/helix/pull/13180))
* Fix behavior of `<esc>` removing added indentation in documents with CRLF line endings ([702a961](https://github.com/helix-editor/helix/commit/702a961))
* Append changes to document history before pushing jumplist jumps ([#13619](https://github.com/helix-editor/helix/pull/13619))
* Fix overflow in the display of large chunks of text in the signature-help component ([#13566](https://github.com/helix-editor/helix/pull/13566))
* Fix panic from clearing whitespace when changing multiple selections on one line ([#13673](https://github.com/helix-editor/helix/pull/13673))
* Include formatting options in LSP range formatting request ([#13734](https://github.com/helix-editor/helix/pull/13734))
* Consistently set statusline errors when LSP features are not available ([#12577](https://github.com/helix-editor/helix/pull/12577))
* Fix `goto_file` on Windows ([#13770](https://github.com/helix-editor/helix/pull/13770))
* Fix crash in `goto_word` (`gw`) when `editor.jump-label-alphabet` is configured to be empty ([#13863](https://github.com/helix-editor/helix/pull/13863))
* Fix `open_above` / `open_below` (`o` / `O`) when using a count on a document with CRLF line-endings ([#13905](https://github.com/helix-editor/helix/pull/13905))
Themes:
* Update `modus` themes ([#12670](https://github.com/helix-editor/helix/pull/12670))
* Update `snazzy` ([#11089](https://github.com/helix-editor/helix/pull/11089))
* Update `gruber-darker` ([#12797](https://github.com/helix-editor/helix/pull/12797))
* Update `cyan_light` ([#12864](https://github.com/helix-editor/helix/pull/12864), [#12891](https://github.com/helix-editor/helix/pull/12891))
* Update `onedarker` ([#12833](https://github.com/helix-editor/helix/pull/12833))
* Update `github_light` ([#12907](https://github.com/helix-editor/helix/pull/12907))
* Update `kanagawa` ([#12895](https://github.com/helix-editor/helix/pull/12895))
* Add `beans` ([#12963](https://github.com/helix-editor/helix/pull/12963))
* Update `base16_transparent` ([#13080](https://github.com/helix-editor/helix/pull/13080))
* Update `sunset` ([#13086](https://github.com/helix-editor/helix/pull/13086))
* Add `carbon` ([#13067](https://github.com/helix-editor/helix/pull/13067))
* Update `soralized` ([#13121](https://github.com/helix-editor/helix/pull/13121))
* Add `focus_nova` ([#13144](https://github.com/helix-editor/helix/pull/13144))
* Update `onedark` ([#13166](https://github.com/helix-editor/helix/pull/13166))
* Update `adwaita-light` ([#13174](https://github.com/helix-editor/helix/pull/13174))
* Add `earl_grey` ([#13203](https://github.com/helix-editor/helix/pull/13203))
* Update `spacebones` ([#13213](https://github.com/helix-editor/helix/pull/13213))
* Add `peachpuff` ([#13225](https://github.com/helix-editor/helix/pull/13225))
* Update catppuccin themes ([#13262](https://github.com/helix-editor/helix/pull/13262))
* Update gruvbox themes ([#13315](https://github.com/helix-editor/helix/pull/13315))
* Update serika themes ([#13341](https://github.com/helix-editor/helix/pull/13341))
* Add `gruvbox-material` ([#13311](https://github.com/helix-editor/helix/pull/13311))
* Add `ashen` ([#13366](https://github.com/helix-editor/helix/pull/13366))
* Update Zed themes ([#13370](https://github.com/helix-editor/helix/pull/13370))
* Update Tokyonight themes ([#13375](https://github.com/helix-editor/helix/pull/13375))
* Update `onelight` ([#13413](https://github.com/helix-editor/helix/pull/13413))
* Add `ataraxia` ([#13390](https://github.com/helix-editor/helix/pull/13390))
* Add `vesper` ([#13394](https://github.com/helix-editor/helix/pull/13394))
* Add `kinda_nvim` and `kinda_nvim_light` ([#13406](https://github.com/helix-editor/helix/pull/13406))
* Update `sonokai` ([#13410](https://github.com/helix-editor/helix/pull/13410))
* Add `nyxvamp` themes ([#12185](https://github.com/helix-editor/helix/pull/12185))
* Update nord themes ([#13574](https://github.com/helix-editor/helix/pull/13574))
* Add `lapis_aquamarine` ([#13726](https://github.com/helix-editor/helix/pull/13726))
* Add `sidra` ([#13575](https://github.com/helix-editor/helix/pull/13575))
* Add `dark-synthwave` ([#13857](https://github.com/helix-editor/helix/pull/13857))
* Update `rose_pine` ([#13908](https://github.com/helix-editor/helix/pull/13908))
* Add `doom-one` ([#13933](https://github.com/helix-editor/helix/pull/13933))
* Update `nightfox` ([#13957](https://github.com/helix-editor/helix/pull/13957))
New languages:
* Ghostty config ([#12703](https://github.com/helix-editor/helix/pull/12703))
* Tera ([#12756](https://github.com/helix-editor/helix/pull/12756))
* FGA ([#12763](https://github.com/helix-editor/helix/pull/12763))
* CSV ([#11973](https://github.com/helix-editor/helix/pull/11973))
* Yara ([#12753](https://github.com/helix-editor/helix/pull/12753))
* Djot ([#12562](https://github.com/helix-editor/helix/pull/12562))
* Ink ([#12773](https://github.com/helix-editor/helix/pull/12773))
* Mail ([#12945](https://github.com/helix-editor/helix/pull/12945))
* SourcePawn ([#13028](https://github.com/helix-editor/helix/pull/13028))
* TLA+ ([#13081](https://github.com/helix-editor/helix/pull/13081))
* Werk ([#13136](https://github.com/helix-editor/helix/pull/13136))
* Debian control file ([#13245](https://github.com/helix-editor/helix/pull/13245))
* WESL ([#13267](https://github.com/helix-editor/helix/pull/13267))
* Fennel ([#13260](https://github.com/helix-editor/helix/pull/13260), [6081a5d](https://github.com/helix-editor/helix/commit/6081a5d))
* Quarto ([#13339](https://github.com/helix-editor/helix/pull/13339))
* Pug ([#13435](https://github.com/helix-editor/helix/pull/13435))
* Slang ([#13449](https://github.com/helix-editor/helix/pull/13449))
* Dunst config ([#13458](https://github.com/helix-editor/helix/pull/13458))
* Luau ([#13702](https://github.com/helix-editor/helix/pull/13702))
* Caddyfile ([#13859](https://github.com/helix-editor/helix/pull/13859))
* Java properties ([#13874](https://github.com/helix-editor/helix/pull/13874))
* Git notes ([#13885](https://github.com/helix-editor/helix/pull/13885))
* systemd (split from INI) ([#13907](https://github.com/helix-editor/helix/pull/13907))
* JSON-LD (split from JSON) ([#13925](https://github.com/helix-editor/helix/pull/13925))
* Django HTML ([#13935](https://github.com/helix-editor/helix/pull/13935))
Updated languages and queries:
* Add `ruby-lsp` for Ruby ([#12511](https://github.com/helix-editor/helix/pull/12511))
* Add `wat_server` for Wat ([#12581](https://github.com/helix-editor/helix/pull/12581))
* Recognize `bun.lock` as JSONC ([fcf981b](https://github.com/helix-editor/helix/commit/fcf981b))
* Update tree-sitter-rust ([#12607](https://github.com/helix-editor/helix/pull/12607), [1afa63d](https://github.com/helix-editor/helix/commit/1afa63d))
* Fix configuration of `cs-lsp` ([#12615](https://github.com/helix-editor/helix/pull/12615))
* Add `beancount-language-server` for Beancount ([#12610](https://github.com/helix-editor/helix/pull/12610))
* Update tree-sitter-fish ([#12456](https://github.com/helix-editor/helix/pull/12456))
* Add `fish-lsp` for Fish ([#12456](https://github.com/helix-editor/helix/pull/12456))
* Update tree-sitter-ini ([#12456](https://github.com/helix-editor/helix/pull/12456), [#13088](https://github.com/helix-editor/helix/pull/13088))
* Recognize `hgrc` as INI ([#12456](https://github.com/helix-editor/helix/pull/12456))
* Restrict tagged template injection languages for ECMA languages ([#12217](https://github.com/helix-editor/helix/pull/12217))
* Update tree-sitter-zig ([#11980](https://github.com/helix-editor/helix/pull/11980), [#12708](https://github.com/helix-editor/helix/pull/12708))
* Update tree-sitter-elixir ([8bf9adf](https://github.com/helix-editor/helix/commit/8bf9adf))
* Add `asm-lsp` for Assembly dialects ([#12684](https://github.com/helix-editor/helix/pull/12684))
* Update tree-sitter-just ([#12692](https://github.com/helix-editor/helix/pull/12692), #)
* Update tree-sitter-cairo ([#12712](https://github.com/helix-editor/helix/pull/12712))
* Configure a comment token for Svelte ([#12743](https://github.com/helix-editor/helix/pull/12743))
* Recognize `.sublime-*` files ([#12750](https://github.com/helix-editor/helix/pull/12750))
* Highlight `$` tagged templates as shell commands in ECMA languages ([#12751](https://github.com/helix-editor/helix/pull/12751))
* Add `#'` comment token for R ([#12748](https://github.com/helix-editor/helix/pull/12748))
* Fix module/namespace highlight in Unison ([93fa990](https://github.com/helix-editor/helix/commit/93fa990))
* Add missing `#not-eq?` and `#not-match?` highlights in TSQ ([3824010](https://github.com/helix-editor/helix/commit/3824010))
* Reverse the precedence order of highlight queries ([#9458](https://github.com/helix-editor/helix/pull/9458), [#12777](https://github.com/helix-editor/helix/pull/12777), [#12795](https://github.com/helix-editor/helix/pull/12795), [144a4f4](https://github.com/helix-editor/helix/commit/144a4f4), [e1c26eb](https://github.com/helix-editor/helix/commit/e1c26eb), [e1060a2](https://github.com/helix-editor/helix/commit/e1060a2), [7f41670](https://github.com/helix-editor/helix/commit/7f41670), [#13293](https://github.com/helix-editor/helix/pull/13293))
* Update Rust highlights ([b8bfc44](https://github.com/helix-editor/helix/commit/b8bfc44), [#12871](https://github.com/helix-editor/helix/pull/12871), [#13664](https://github.com/helix-editor/helix/pull/13664))
* Add block comment configuration for PHP ([0ab403d](https://github.com/helix-editor/helix/commit/0ab403d))
* Update Gren highlights ([#12769](https://github.com/helix-editor/helix/pull/12769))
* Remove `ERROR` node highlighting from all highlight queries ([16ff063](https://github.com/helix-editor/helix/commit/16ff063))
* Update tree-sitter-erlang and highlights ([18b9eb9](https://github.com/helix-editor/helix/commit/18b9eb9), [9f3b193](https://github.com/helix-editor/helix/commit/9f3b193), [12139a4](https://github.com/helix-editor/helix/commit/12139a4))
* Update Nix injections ([#12776](https://github.com/helix-editor/helix/pull/12776), [#12774](https://github.com/helix-editor/helix/pull/12774), [#13851](https://github.com/helix-editor/helix/pull/13851))
* Add indent queries for Nix ([#12829](https://github.com/helix-editor/helix/pull/12829))
* Update Markdown highlights ([#12696](https://github.com/helix-editor/helix/pull/12696))
* Recognize `xsl` as XML ([#12834](https://github.com/helix-editor/helix/pull/12834))
* Remove deprecated `typst-lsp` config ([5a66270](https://github.com/helix-editor/helix/commit/5a66270))
* Replace `pkgbuild-language-server` with `termux-language-server` ([c3c9a0d](https://github.com/helix-editor/helix/commit/c3c9a0d))
* Update SQL highlights ([#12837](https://github.com/helix-editor/helix/pull/12837))
* Recognize `mpd` and `smil` as XML ([#12916](https://github.com/helix-editor/helix/pull/12916))
* Add indents and textojbects for Kotlin ([#12925](https://github.com/helix-editor/helix/pull/12925))
* Fix module highlights in Koto ([7e87a36](https://github.com/helix-editor/helix/commit/7e87a36))
* Update language servers for Protobuf ([#12936](https://github.com/helix-editor/helix/pull/12936))
* Add `astro-ls` for Astro ([#12939](https://github.com/helix-editor/helix/pull/12939))
* Fix recognition of "scons*" files as Python ([#12943](https://github.com/helix-editor/helix/pull/12943))
* Update C# queries ([#12948](https://github.com/helix-editor/helix/pull/12948))
* Add comment textojbect to TOML ([#12952](https://github.com/helix-editor/helix/pull/12952))
* Add `starpls` as Starlark language server ([#12958](https://github.com/helix-editor/helix/pull/12958))
* Add `pkl-lsp` for PKL ([#12962](https://github.com/helix-editor/helix/pull/12962))
* Add `kdlfmt` formatter for KDL ([#12967](https://github.com/helix-editor/helix/pull/12967))
* Update CSS highlights ([#12497](https://github.com/helix-editor/helix/pull/12497), [fed3edc](https://github.com/helix-editor/helix/commit/fed3edc))
* Add `harper-ls` ([#13029](https://github.com/helix-editor/helix/pull/13029))
* Change `wgsl_analyzer` to `wgsl-analyzer` ([#13063](https://github.com/helix-editor/helix/pull/13063))
* Update tree-sitter-vhdl ([#13091](https://github.com/helix-editor/helix/pull/13091))
* Update tree-sitter-openscad ([#13033](https://github.com/helix-editor/helix/pull/13033))
* Update Rust injections ([694b615](https://github.com/helix-editor/helix/commit/694b615), [1bd7a39](https://github.com/helix-editor/helix/commit/1bd7a39))
* Update Ruby highlights ([#13055](https://github.com/helix-editor/helix/pull/13055))
* Recognize `gitconfig` as an extension ([#13115](https://github.com/helix-editor/helix/pull/13115))
* Add `///` comment token for Amber ([#13122](https://github.com/helix-editor/helix/pull/13122))
* Add indent queries for Starlark ([#13126](https://github.com/helix-editor/helix/pull/13126))
* Recognize more systemd file types as INI ([#13139](https://github.com/helix-editor/helix/pull/13139))
* Update scheme queries ([#13143](https://github.com/helix-editor/helix/pull/13143))
* Recognize `tmTheme` as XML ([#13202](https://github.com/helix-editor/helix/pull/13202))
* Update `golangci-lint` command for v2 ([#13204](https://github.com/helix-editor/helix/pull/13204))
* Add `just-lsp` for Just ([#13276](https://github.com/helix-editor/helix/pull/13276))
* Add a tree-sitter-prolog grammar ([#11611](https://github.com/helix-editor/helix/pull/11611))
* Fix typos in Ada queries ([#13251](https://github.com/helix-editor/helix/pull/13251))
* Update mint language server args ([#13248](https://github.com/helix-editor/helix/pull/13248))
* Update typescript highlights ([#13250](https://github.com/helix-editor/helix/pull/13250))
* Update tree-sitter-jjdescription ([#13329](https://github.com/helix-editor/helix/pull/13329))
* Add injection queries for Quint ([#13322](https://github.com/helix-editor/helix/pull/13322))
* Update tree-sitter-scss and highlights ([#13414](https://github.com/helix-editor/helix/pull/13414))
* Update tree-sitter-go-mod ([#13395](https://github.com/helix-editor/helix/pull/13395))
* Update tree-sitter-svelte ([#13423](https://github.com/helix-editor/helix/pull/13423))
* Update Lua highlights ([#13401](https://github.com/helix-editor/helix/pull/13401))
* Update Go highlights ([#13425](https://github.com/helix-editor/helix/pull/13425), [25b299a](https://github.com/helix-editor/helix/commit/25b299a), [#13825](https://github.com/helix-editor/helix/pull/13825))
* Recognize `.git-blame-ignore-revs` as gitignore ([#13460](https://github.com/helix-editor/helix/pull/13460))
* Update Verilog highlights ([#13473](https://github.com/helix-editor/helix/pull/13473), [#13493](https://github.com/helix-editor/helix/pull/13493))
* Update tree-sitter-v ([#13469](https://github.com/helix-editor/helix/pull/13469))
* Update WGSL highlights ([#13479](https://github.com/helix-editor/helix/pull/13479))
* Update Bash highlights ([#13477](https://github.com/helix-editor/helix/pull/13477))
* Update tree-sitter-cpp ([#13504](https://github.com/helix-editor/helix/pull/13504))
* Update rust-analyzer config to use server-side file watching ([#13432](https://github.com/helix-editor/helix/pull/13432))
* Update Vue injections ([#13511](https://github.com/helix-editor/helix/pull/13511))
* Recognize `sld` as Scheme ([#13528](https://github.com/helix-editor/helix/pull/13528))
* Recognize more files as git-attributes ([#13540](https://github.com/helix-editor/helix/pull/13540))
* Update tree-sitter-haskell and queries ([#13475](https://github.com/helix-editor/helix/pull/13475))
* Align INI highlights with TOML ([#13589](https://github.com/helix-editor/helix/pull/13589))
* Add tree-sitter-rust-format-args for `format_args!` injections in Rust ([#13533](https://github.com/helix-editor/helix/pull/13533), [#13657](https://github.com/helix-editor/helix/pull/13657), [4dd4ba7](https://github.com/helix-editor/helix/commit/4dd4ba7), [86f10ae](https://github.com/helix-editor/helix/commit/86f10ae))
* Update Ungrammar highlights ([8d58f6c](https://github.com/helix-editor/helix/commit/8d58f6c))
* Add `ty` language server for Python ([#13643](https://github.com/helix-editor/helix/pull/13643))
* Add `clarinet` language server for Clarity ([#13647](https://github.com/helix-editor/helix/pull/13647))
* Update prisma config to avoid a crash in the language server ([f6878f6](https://github.com/helix-editor/helix/commit/f6878f6))
* Add `pyrefly` for Python ([#13713](https://github.com/helix-editor/helix/pull/13713))
* Update Python highlights ([#13715](https://github.com/helix-editor/helix/pull/13715))
* Update Mojo language server and formatter to `pixi` ([#13648](https://github.com/helix-editor/helix/pull/13648))
* Add `tombi` for TOML ([#13723](https://github.com/helix-editor/helix/pull/13723))
* Add `neocmakelsp` for CMake ([#13740](https://github.com/helix-editor/helix/pull/13740))
* Update C and C++ highlights ([#13747](https://github.com/helix-editor/helix/pull/13747), [#13772](https://github.com/helix-editor/helix/pull/13772))
* Highlight escape sequences in ECMA languages ([#13762](https://github.com/helix-editor/helix/pull/13762))
* Add an external formatter config for Crystal ([#13759](https://github.com/helix-editor/helix/pull/13759))
* Add `amber-lsp` for Amber ([#13763](https://github.com/helix-editor/helix/pull/13763))
* Update HTML highlights ([#13753](https://github.com/helix-editor/helix/pull/13753))
* Update tree-sitter-purescript and highlights ([#13782](https://github.com/helix-editor/helix/pull/13782))
* Update tree-sitter-gleam and highlights ([#13793](https://github.com/helix-editor/helix/pull/13793), [#13807](https://github.com/helix-editor/helix/pull/13807), [#13813](https://github.com/helix-editor/helix/pull/13813))
* Recognize Buck files as Starlark ([#13810](https://github.com/helix-editor/helix/pull/13810))
* Use tree-sitter-crystal instead of tree-sitter-ruby for Crystal and add custom queries ([#13805](https://github.com/helix-editor/helix/pull/13805))
* Update tree-sitter-twig ([#13689](https://github.com/helix-editor/helix/pull/13689))
* Recognize `jsconfig.json` as JSONC, use as JavaScript and JSX roots ([#13822](https://github.com/helix-editor/helix/pull/13822))
* Recognize `.gem/credentials` as YAML ([#13843](https://github.com/helix-editor/helix/pull/13843))
* Update Dockerfile injections ([#13845](https://github.com/helix-editor/helix/pull/13845), 13852)
* Change tree-sitter parser for Git commit message files ([44293df](https://github.com/helix-editor/helix/commit/44293df))
* Recognize `mimeapps.list` as INI ([#13850](https://github.com/helix-editor/helix/pull/13850))
* Update tree-sitter-odin, highlights and indents ([#13877](https://github.com/helix-editor/helix/pull/13877), [#13917](https://github.com/helix-editor/helix/pull/13917))
* Add locals queries for C, improve parameter highlighting ([#13876](https://github.com/helix-editor/helix/pull/13876))
* Add textobjects for QML ([#13855](https://github.com/helix-editor/helix/pull/13855))
* Add comment tokens for DTD ([#13904](https://github.com/helix-editor/helix/pull/13904))
* Add `dts-lsp` for DeviceTree ([#13907](https://github.com/helix-editor/helix/pull/13907))
* Update gomod highlights ([#13913](https://github.com/helix-editor/helix/pull/13913))
* Recognize `compose.yaml` and `compose.yml` as Docker Compose ([#13930](https://github.com/helix-editor/helix/pull/13930))
Packaging:
* Fix handling of spaces in Bash completion ([#12828](https://github.com/helix-editor/helix/pull/12828))
* Refactor Nix flake ([#12831](https://github.com/helix-editor/helix/pull/12831), [#13024](https://github.com/helix-editor/helix/pull/13024), [cb1ecc9](https://github.com/helix-editor/helix/commit/cb1ecc9), [#13305](https://github.com/helix-editor/helix/pull/13305))
* Add `ConsoleOnly` to `Helix.desktop` categories ([#13236](https://github.com/helix-editor/helix/pull/13236))
* Drop Nix flake dependency on flake-utils ([60a03a3](https://github.com/helix-editor/helix/commit/60a03a3))
* Increase the MSRV to 1.82 ([#13275](https://github.com/helix-editor/helix/pull/13275))
# 25.01.1 (2025-01-19)
25.01.1 is a patch release focusing on fixing bugs and panics from changes in 25.01.

666
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,18 +41,20 @@ tree-house = { version = "0.3.0", default-features = false }
nucleo = "0.5.0"
slotmap = "1.0.7"
thiserror = "2.0"
tempfile = "3.20.0"
tempfile = "3.21.0"
bitflags = "2.9"
unicode-segmentation = "1.2"
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
foldhash = "0.1"
foldhash = "0.2"
parking_lot = "0.12"
futures-executor = "0.3"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
tokio-stream = "0.1.17"
toml = "0.9"
termina = "0.1"
[workspace.package]
version = "25.1.1"
version = "25.7.1"
edition = "2021"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
categories = ["editor"]

View File

@@ -11,6 +11,7 @@
- [Textobjects](./textobjects.md)
- [Syntax aware motions](./syntax-aware-motions.md)
- [Pickers](./pickers.md)
- [Jumplist](./jumplist.md)
- [Keymap](./keymap.md)
- [Command line](./command-line.md)
- [Commands](./commands.md)
@@ -28,3 +29,5 @@
- [Adding textobject queries](./guides/textobject.md)
- [Adding indent queries](./guides/indent.md)
- [Adding injection queries](./guides/injection.md)
- [Adding tags queries](./guides/tags.md)
- [Adding rainbow bracket queries](./guides/rainbow_bracket_queries.md)

View File

@@ -42,7 +42,7 @@ RUSTFLAGS="-C target-feature=-crt-static"
# Optimized
cargo install \
--profile opt \
--config 'build.rustflags="-C target-cpu=native"' \
--config 'build.rustflags=["-C", "target-cpu=native"]' \
--path helix-term \
--locked
```

View File

@@ -47,6 +47,8 @@ The following variables are supported:
| `cursor_column` | The column number of the primary cursor in the currently focused document, starting at 1. This is counted as the number of grapheme clusters from the start of the line rather than bytes or codepoints. |
| `buffer_name` | The relative path of the currently focused document. `[scratch]` is expanded instead for scratch buffers. |
| `line_ending` | A string containing the line ending of the currently focused document. For example on Unix systems this is usually a line-feed character (`\n`) but on Windows systems this may be a carriage-return plus a line-feed (`\r\n`). The line ending kind of the currently focused document can be inspected with the `:line-ending` command. |
| `current_working_directory` | Current working directory |
| `workspace_directory` | Nearest ancestor directory of the current working directory that contains `.git`, `.svn`, `jj` or `.helix` |
| `language` | A string containing the language name of the currently focused document.|
| `selection` | A string containing the contents of the primary selection of the currently focused document. |
| `selection_line_start` | The line number of the start of the primary selection in the currently focused document, starting at 1. |

View File

@@ -19,6 +19,7 @@
- [`[editor.soft-wrap]` Section](#editorsoft-wrap-section)
- [`[editor.smart-tab]` Section](#editorsmart-tab-section)
- [`[editor.inline-diagnostics]` Section](#editorinline-diagnostics-section)
- [`[editor.word-completion]` Section](#editorword-completion-section)
### `[editor]` Section
@@ -59,9 +60,11 @@
| `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` |
| `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid`
| `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | `"abcdefghijklmnopqrstuvwxyz"`
| `end-of-line-diagnostics` | Minimum severity of diagnostics to render at the end of the line. Set to `disable` to disable entirely. Refer to the setting about `inline-diagnostics` for more details | "disable"
| `clipboard-provider` | Which API to use for clipboard interaction. One of `pasteboard` (MacOS), `wayland`, `x-clip`, `x-sel`, `win-32-yank`, `termux`, `tmux`, `windows`, `termcode`, `none`, or a custom command set. | Platform and environment specific. |
| `end-of-line-diagnostics` | Minimum severity of diagnostics to render at the end of the line. Set to `disable` to disable entirely. Refer to the setting about `inline-diagnostics` for more details | `"hint"`
| `clipboard-provider` | Which API to use for clipboard interaction. One of `pasteboard` (MacOS), `wayland`, `x-clip`, `x-sel`, `win32-yank`, `termux`, `tmux`, `windows`, `termcode`, `none`, or a custom command set. | Platform and environment specific. |
| `editor-config` | Whether to read settings from [EditorConfig](https://editorconfig.org) files | `true` |
| `rainbow-brackets` | Whether to render rainbow colors for matching brackets. Requires tree-sitter `rainbows.scm` queries for the language. | `false` |
| `kitty-keyboard-protocol` | Whether to enable Kitty Keyboard Protocol. Can be `enabled`, `disabled` or `auto` | `auto` |
### `[editor.clipboard-provider]` Section
@@ -131,6 +134,7 @@ The following statusline elements can be configured:
| `file-name` | The path/name of the opened file |
| `file-absolute-path` | The absolute path/name of the opened file |
| `file-base-name` | The basename of the opened file |
| `current-working-directory` | The current working directory |
| `file-modification-indicator` | The indicator to show whether the file is modified (a `[+]` appears when there are unsaved changes) |
| `file-encoding` | The encoding of the opened file if it differs from UTF-8 |
| `file-line-ending` | The file line endings (CRLF or LF) |
@@ -450,7 +454,7 @@ fn main() {
| Key | Description | Default |
|------------|-------------|---------|
| `cursor-line` | The minimum severity that a diagnostic must have to be shown inline on the line that contains the primary cursor. Set to `disable` to not show any diagnostics inline. This option does not have any effect when in insert-mode and will only take effect 350ms after moving the cursor to a different line. | `"disable"` |
| `cursor-line` | The minimum severity that a diagnostic must have to be shown inline on the line that contains the primary cursor. Set to `disable` to not show any diagnostics inline. This option does not have any effect when in insert-mode and will only take effect 350ms after moving the cursor to a different line. | `"warning"` |
| `other-lines` | The minimum severity that a diagnostic must have to be shown inline on a line that does not contain the cursor-line. Set to `disable` to not show any diagnostics inline. | `"disable"` |
| `prefix-len` | How many horizontal bars `─` are rendered before the diagnostic text. | `1` |
| `max-wrap` | Equivalent of the `editor.soft-wrap.max-wrap` option for diagnostics. | `20` |
@@ -468,12 +472,20 @@ fn main() {
}
```
### `[editor.word-completion]` Section
The new diagnostic rendering is not yet enabled by default. As soon as end of line or inline diagnostics are enabled the old diagnostics rendering is automatically disabled. The recommended default setting are:
Options for controlling completion of words from open buffers.
| Key | Description | Default |
| --- | --- | --- |
| `enable` | Whether word completion is enabled | `true` |
| `trigger-length` | Number of word characters to type before triggering completion | `7` |
Example:
```toml
[editor]
end-of-line-diagnostics = "hint"
[editor.inline-diagnostics]
cursor-line = "warning" # show warnings and errors on the cursorline inline
[editor.word-completion]
enable = true
# Set the trigger length lower so that words are completed more often
trigger-length = 4
```

View File

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

View File

@@ -106,10 +106,14 @@
| `code_action` | Perform code action | normal: `` <space>a ``, select: `` <space>a `` |
| `buffer_picker` | Open buffer picker | normal: `` <space>b ``, select: `` <space>b `` |
| `jumplist_picker` | Open jumplist picker | normal: `` <space>j ``, select: `` <space>j `` |
| `symbol_picker` | Open symbol picker | normal: `` <space>s ``, select: `` <space>s `` |
| `symbol_picker` | Open symbol picker | |
| `syntax_symbol_picker` | Open symbol picker from syntax information | |
| `lsp_or_syntax_symbol_picker` | Open symbol picker from LSP or syntax information | normal: `` <space>s ``, select: `` <space>s `` |
| `changed_file_picker` | Open changed file picker | normal: `` <space>g ``, select: `` <space>g `` |
| `select_references_to_symbol_under_cursor` | Select symbol references | normal: `` <space>h ``, select: `` <space>h `` |
| `workspace_symbol_picker` | Open workspace symbol picker | normal: `` <space>S ``, select: `` <space>S `` |
| `workspace_symbol_picker` | Open workspace symbol picker | |
| `syntax_workspace_symbol_picker` | Open workspace symbol picker from syntax information | |
| `lsp_or_syntax_workspace_symbol_picker` | Open workspace symbol picker from LSP or syntax information | normal: `` <space>S ``, select: `` <space>S `` |
| `diagnostics_picker` | Open diagnostic picker | normal: `` <space>d ``, select: `` <space>d `` |
| `workspace_diagnostics_picker` | Open workspace diagnostic picker | normal: `` <space>D ``, select: `` <space>D `` |
| `last_picker` | Open last picker | normal: `` <space>' ``, select: `` <space>' `` |
@@ -168,6 +172,8 @@
| `smart_tab` | Insert tab if all cursors have all whitespace to their left; otherwise, run a separate command. | insert: `` <tab> `` |
| `insert_tab` | Insert tab char | insert: `` <S-tab> `` |
| `insert_newline` | Insert newline char | insert: `` <C-j> ``, `` <ret> `` |
| `insert_char_interactive` | Insert an interactively-chosen char | |
| `append_char_interactive` | Append an interactively-chosen char | |
| `delete_char_backward` | Delete previous char | insert: `` <C-h> ``, `` <backspace> ``, `` <S-backspace> `` |
| `delete_char_forward` | Delete next char | insert: `` <C-d> ``, `` <del> `` |
| `delete_word_backward` | Delete previous word | insert: `` <C-w> ``, `` <A-backspace> `` |
@@ -267,6 +273,8 @@
| `goto_prev_comment` | Goto previous comment | normal: `` [c ``, select: `` [c `` |
| `goto_next_test` | Goto next test | normal: `` ]T ``, select: `` ]T `` |
| `goto_prev_test` | Goto previous test | normal: `` [T ``, select: `` [T `` |
| `goto_next_xml_element` | Goto next (X)HTML element | normal: `` ]x ``, select: `` ]x `` |
| `goto_prev_xml_element` | Goto previous (X)HTML element | normal: `` [x ``, select: `` [x `` |
| `goto_next_entry` | Goto next pairing | normal: `` ]e ``, select: `` ]e `` |
| `goto_prev_entry` | Goto previous pairing | normal: `` [e ``, select: `` [e `` |
| `goto_next_paragraph` | Goto next paragraph | normal: `` ]p ``, select: `` ]p `` |

View File

@@ -1,4 +1,4 @@
# Guides
This section contains guides for adding new language server configurations,
tree-sitter grammars, textobject queries, and other similar items.
tree-sitter grammars, textobject and rainbow bracket queries, and other similar items.

View File

@@ -0,0 +1,132 @@
# Adding Rainbow Bracket Queries
Helix uses `rainbows.scm` tree-sitter query files to provide rainbow bracket
functionality.
Tree-sitter queries are documented in the tree-sitter online documentation.
If you're writing queries for the first time, be sure to check out the section
on [syntax highlighting queries] and on [query syntax].
Rainbow queries have two captures: `@rainbow.scope` and `@rainbow.bracket`.
`@rainbow.scope` should capture any node that increases the nesting level
while `@rainbow.bracket` should capture any bracket nodes. Put another way:
`@rainbow.scope` switches to the next rainbow color for all nodes in the tree
under it while `@rainbow.bracket` paints captured nodes with the current
rainbow color.
For an example, let's add rainbow queries for the tree-sitter query (TSQ)
language itself. These queries will go into a
`runtime/queries/tsq/rainbows.scm` file in the repository root.
First we'll add the `@rainbow.bracket` captures. TSQ only has parentheses and
square brackets:
```tsq
["(" ")" "[" "]"] @rainbow.bracket
```
The ordering of the nodes within the alternation (square brackets) is not
taken into consideration.
> Note: Why are these nodes quoted? Most syntax highlights capture text
> surrounded by parentheses. These are _named nodes_ and correspond to the
> names of rules in the grammar. Brackets are usually written in tree-sitter
> grammars as literal strings, for example:
>
> ```js
> {
> // ...
> arguments: seq("(", repeat($.argument), ")"),
> // ...
> }
> ```
>
> Nodes written as literal strings in tree-sitter grammars may be captured
> in queries with those same literal strings.
Then we'll add `@rainbow.scope` captures. The easiest way to do this is to
view the `grammar.js` file in the tree-sitter grammar's repository. For TSQ,
that file is [here][tsq grammar.js]. As we scroll down the `grammar.js`, we
see that the `(alternation)`, (L36) `(group)` (L57), `(named_node)` (L59),
`(predicate)` (L87) and `(wildcard_node)` (L97) nodes all contain literal
parentheses or square brackets in their definitions. These nodes are all
direct parents of brackets and happen to also be the nodes we want to change
to the next rainbow color, so we capture them as `@rainbow.scope`.
```tsq
[
(group)
(named_node)
(wildcard_node)
(predicate)
(alternation)
] @rainbow.scope
```
This strategy works as a rule of thumb for most programming and configuration
languages. Markup languages can be trickier and may take additional
experimentation to find the correct nodes to use for scopes and brackets.
The `:tree-sitter-subtree` command shows the syntax tree under the primary
selection in S-expression format and can be a useful tool for determining how
to write a query.
### Properties
The `rainbow.include-children` property may be applied to `@rainbow.scope`
captures. By default, all `@rainbow.bracket` captures must be direct descendant
of a node captured with `@rainbow.scope` in a syntax tree in order to be
highlighted. The `rainbow.include-children` property disables that check and
allows `@rainbow.bracket` captures to be highlighted if they are direct or
indirect descendants of some node captured with `@rainbow.scope`.
For example, this property is used in the HTML rainbow queries.
For a document like `<a>link</a>`, the syntax tree is:
```tsq
(element ; <a>link</a>
(start_tag ; <a>
(tag_name)) ; a
(text) ; link
(end_tag ; </a>
(tag_name))) ; a
```
If we want to highlight the `<`, `>` and `</` nodes with rainbow colors, we
capture them as `@rainbow.bracket`:
```tsq
["<" ">" "</"] @rainbow.bracket
```
And we capture `(element)` as `@rainbow.scope` because `(element)` nodes nest
within each other: they increment the nesting level and switch to the next
color in the rainbow.
```tsq
(element) @rainbow.scope
```
But this combination of `@rainbow.scope` and `@rainbow.bracket` will not
highlight any nodes. `<`, `>` and `</` are children of the `(start_tag)` and
`(end_tag)` nodes. We can't capture `(start_tag)` and `(end_tag)` as
`@rainbow.scope` because they don't nest other elements. We can fix this case
by removing the requirement that `<`, `>` and `</` are direct descendants of
`(element)` using the `rainbow.include-children` property.
```tsq
((element) @rainbow.scope
(#set! rainbow.include-children))
```
With this property set, `<`, `>`, and `</` will highlight with rainbow colors
even though they aren't direct descendents of the `(element)` node.
`rainbow.include-children` is not necessary for the vast majority of programming
languages. It is only necessary when the node that increments the nesting level
(changes rainbow color) is not the direct parent of the bracket node.
[syntax highlighting queries]: https://tree-sitter.github.io/tree-sitter/syntax-highlighting#highlights
[query syntax]: https://tree-sitter.github.io/tree-sitter/using-parsers#pattern-matching-with-queries
[tsq grammar.js]: https://github.com/the-mikedavis/tree-sitter-tsq/blob/48b5e9f82ae0a4727201626f33a17f69f8e0ff86/grammar.js

34
book/src/guides/tags.md Normal file
View File

@@ -0,0 +1,34 @@
## Adding tags queries
See tree-sitter's documentation on [Code Navigation Systems] for more
background on tags queries.
Helix provides LSP-like features such as document and workspace symbol pickers
out-of-the-box for languages with `tags.scm` queries based on syntax trees. To
be analyzed a language must have a tree-sitter grammar and a `tags.scm` query
file which pattern matches interesting nodes from syntax trees.
Query files should be placed in `runtime/queries/{language}/tags.scm`
when contributing to Helix. You may place these under your local runtime
directory (`~/.config/helix/runtime` in Linux for example) for the sake of
testing.
The following [captures][tree-sitter-captures] are recognized:
| Capture name |
|--- |
| `definition.class` |
| `definition.constant` |
| `definition.function` |
| `definition.interface` |
| `definition.macro` |
| `definition.module` |
| `definition.struct` |
| `definition.type` |
[Example query files][example-queries] can be found in the Helix GitHub
repository.
[Code Navigation Systems]: https://tree-sitter.github.io/tree-sitter/4-code-navigation.html
[tree-sitter-captures]: https://tree-sitter.github.io/tree-sitter/using-parsers/queries/index.html
[example-queries]: https://github.com/search?q=repo%3Ahelix-editor%2Fhelix+path%3A%2A%2A/tags.scm&type=Code

View File

@@ -28,6 +28,8 @@ The following [captures][tree-sitter-captures] are recognized:
| `comment.around` |
| `entry.inside` |
| `entry.around` |
| `xml-element.inside` |
| `xml-element.around` |
[Example query files][textobject-examples] can be found in the helix GitHub repository.

36
book/src/jumplist.md Normal file
View File

@@ -0,0 +1,36 @@
## Using the jumplist
To help with quick navigation, Helix maintains a list of "jumps" called the jumplist.
Whenever you make a significant movement (see next section), Helix stores your selections from before the move as a jump.
A jump serves as a kind of checkpoint, allowing you to jump to a separate location, make edits, and return to where you were with your previous selections.
This way, the jumplist tracks both your previous location and your selections.
You can manually save a jump by using `Ctrl-s`.
To jump backward in the jumplist, use `Ctrl-o`; to go forward, use `Ctrl-i`. To view and select from the full jumplist, use `Space-j` to open the jumplist picker.
### What makes a jump
The following is a non-exhaustive list of which actions add a jump to the jumplist:
- Switching buffers
- Using the buffer picker, going to the next/previous buffer
- Going to the last accessed/modified file
- Making a new file (`:new FILE`)
- Opening a file (`:open FILE`)
- Includes `:log-open`, `:config-open`, `:config-open-workspace`, `:tutor`
- Navigating by pickers, global search, or the file explorer
- `goto_file` (`gf`)
- Big in-file movements
- `select_regex` (`s`)
- `split_regex` (`S`)
- `search` (`/`)
- `keep_selections` and `remove_selections` (`K` and `<A-K>`)
- `goto_file_start` (`gg`)
- `goto_file_end`
- `goto_last_line` (`ge`)
- `:goto 123` / `:123` / `123G`
- `goto_definition` (`gd`)
- `goto_declaration` (`gD`)
- `goto_type_definition` (`gy`)
- `goto_reference` (`gr`)
- Other
- `Ctrl-s` manually creates a jump
- Trying to close a modified buffer can switch you to that buffer and create a jump
- The debugger can create jumps as you jump stack frames

View File

@@ -35,6 +35,8 @@ Normal mode is the default mode when you launch helix. You can return to it from
> NOTE: Unlike Vim, `f`, `F`, `t` and `T` are not confined to the current line.
> Hereafter, `<n>` represents an integer by typing a sequence of digits.
| Key | Description | Command |
| ----- | ----------- | ------- |
| `h`, `Left` | Move left | `move_char_left` |
@@ -51,7 +53,7 @@ Normal mode is the default mode when you launch helix. You can return to it from
| `f` | Find next char | `find_next_char` |
| `T` | Find till previous char | `till_prev_char` |
| `F` | Find previous char | `find_prev_char` |
| `G` | Go to line number `<n>` | `goto_line` |
| `<n>G`, `<n>gg` | Go to line number `<n>` | `goto_line` |
| `Alt-.` | Repeat last motion (`f`, `t`, `m`, `[` or `]`) | `repeat_last_motion` |
| `Home` | Move to the start of the line | `goto_line_start` |
| `End` | Move to the end of the line | `goto_line_end` |
@@ -212,8 +214,10 @@ Jumps to various locations.
| Key | Description | Command |
| ----- | ----------- | ------- |
| `g` | Go to line number `<n>` else start of file | `goto_file_start` |
| <code>&#124;</code> | Go to column number `<n>` else start of line | `goto_column` |
| `<n>g`| Go to line number `<n>` | `goto_file_start` |
| `g` | Go to the start of the file | `goto_file_start` |
| <code>&lt;n&gt;&#124;</code> | Go to column number `<n>` | `goto_column` |
| <code>&#124;</code> | Go to the start of line | `goto_column` |
| `e` | Go to the end of the file | `goto_last_line` |
| `f` | Go to files in the selections | `goto_file` |
| `h` | Go to the start of the line | `goto_line_start` |
@@ -348,30 +352,32 @@ Displays the signature of the selected completion item. Remapping currently not
These mappings are in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired).
| Key | Description | Command |
| ----- | ----------- | ------- |
| `]d` | Go to next diagnostic (**LSP**) | `goto_next_diag` |
| `[d` | Go to previous diagnostic (**LSP**) | `goto_prev_diag` |
| `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` |
| `[D` | Go to first diagnostic in document (**LSP**) | `goto_first_diag` |
| `]f` | Go to next function (**TS**) | `goto_next_function` |
| `[f` | Go to previous function (**TS**) | `goto_prev_function` |
| `]t` | Go to next type definition (**TS**) | `goto_next_class` |
| `[t` | Go to previous type definition (**TS**) | `goto_prev_class` |
| `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` |
| `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` |
| `]c` | Go to next comment (**TS**) | `goto_next_comment` |
| `[c` | Go to previous comment (**TS**) | `goto_prev_comment` |
| `]T` | Go to next test (**TS**) | `goto_next_test` |
| `[T` | Go to previous test (**TS**) | `goto_prev_test` |
| `]p` | Go to next paragraph | `goto_next_paragraph` |
| `[p` | Go to previous paragraph | `goto_prev_paragraph` |
| `]g` | Go to next change | `goto_next_change` |
| `[g` | Go to previous change | `goto_prev_change` |
| `]G` | Go to last change | `goto_last_change` |
| `[G` | Go to first change | `goto_first_change` |
| `]Space` | Add newline below | `add_newline_below` |
| `[Space` | Add newline above | `add_newline_above` |
| Key | Description | Command |
| ----- | ----------- | ------- |
| `]d` | Go to next diagnostic (**LSP**) | `goto_next_diag` |
| `[d` | Go to previous diagnostic (**LSP**) | `goto_prev_diag` |
| `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` |
| `[D` | Go to first diagnostic in document (**LSP**) | `goto_first_diag` |
| `]f` | Go to next function (**TS**) | `goto_next_function` |
| `[f` | Go to previous function (**TS**) | `goto_prev_function` |
| `]t` | Go to next type definition (**TS**) | `goto_next_class` |
| `[t` | Go to previous type definition (**TS**) | `goto_prev_class` |
| `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` |
| `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` |
| `]c` | Go to next comment (**TS**) | `goto_next_comment` |
| `[c` | Go to previous comment (**TS**) | `goto_prev_comment` |
| `]T` | Go to next test (**TS**) | `goto_next_test` |
| `[T` | Go to previous test (**TS**) | `goto_prev_test` |
| `]p` | Go to next paragraph | `goto_next_paragraph` |
| `[p` | Go to previous paragraph | `goto_prev_paragraph` |
| `]g` | Go to next change | `goto_next_change` |
| `[g` | Go to previous change | `goto_prev_change` |
| `]G` | Go to last change | `goto_last_change` |
| `[G` | Go to first change | `goto_first_change` |
| `[x` | Go to next (X)HTML element | `goto_next_xml_element` |
| `]x` | Go to previous (X)HTML element | `goto_prev_xml_element` |
| `]Space` | Add newline below | `add_newline_below` |
| `[Space` | Add newline above | `add_newline_above` |
## Insert mode

View File

@@ -71,8 +71,10 @@ These configuration keys are available:
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` |
| `rulers` | Overrides the `editor.rulers` config key for the language. |
| `path-completion` | Overrides the `editor.path-completion` config key for the language. |
| `word-completion` | Overrides the [`editor.word-completion`](./editor.md#editorword-completion-section) configuration for the language. |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. |
| `persistent-diagnostic-sources` | An array of LSP diagnostic sources assumed unchanged when the language server resends the same set of diagnostics. Helix can track the position for these diagnostics internally instead. Useful for diagnostics that are recomputed on save.
| `rainbow-brackets` | Overrides the `editor.rainbow-brackets` config key for the language |
### File-type detection and the `file-types` key
@@ -109,7 +111,7 @@ of the formatter command. In particular, the `%{buffer_name}` variable can be pa
argument to the formatter:
```toml
formatter = { command = "mylang-formatter" , args = ["--stdin", "--stdin-filename %{buffer_name}"] }
formatter = { command = "mylang-formatter" , args = ["--stdin", "--stdin-filename", "%{buffer_name}"] }
```
## Language Server configuration

View File

@@ -15,6 +15,8 @@ Helix' keymap and interaction model ([Using Helix](#usage.md)) is easier to adop
| [Visual Studio Code](https://code.visualstudio.com/) | [Helix for VS Code](https://marketplace.visualstudio.com/items?itemName=jasew.vscode-helix-emulation) extension|
| [Zed](https://zed.dev/) | native via keybindings ([Bug](https://github.com/zed-industries/zed/issues/4642)) |
| [CodeMirror](https://codemirror.net/) | [codemirror-helix](https://gitlab.com/_rvidal/codemirror-helix) |
| [Lite XL](https://lite-xl.com/) | [lite-modal-hx](https://codeberg.org/Mandarancio/lite-modal-hx) |
| [Lapce](https://lap.dev/lapce/) | | Requested: https://github.com/lapce/lapce/issues/281 |
## Shells

View File

@@ -2,7 +2,6 @@
- [Linux](#linux)
- [Ubuntu/Debian](#ubuntudebian)
- [Ubuntu (PPA)](#ubuntu-ppa)
- [Fedora/RHEL](#fedorarhel)
- [Arch Linux extra](#arch-linux-extra)
- [NixOS](#nixos)
@@ -26,21 +25,11 @@ The following third party repositories are available:
### Ubuntu/Debian
Install the Debian package from the release page.
Install the Debian package [from the release page](https://github.com/helix-editor/helix/releases/latest).
If you are running a system older than Ubuntu 22.04, Mint 21, or Debian 12, you can build the `.deb` file locally
[from source](./building-from-source.md#building-the-debian-package).
### Ubuntu (PPA)
Add the `PPA` for Helix:
```sh
sudo add-apt-repository ppa:maveonair/helix-editor
sudo apt update
sudo apt install helix
```
### Fedora/RHEL
```sh

View File

@@ -89,24 +89,26 @@ Cmd-s = ":write" # Cmd or Win or Meta and 's' to write
Special keys are encoded as follows:
| Key name | Representation |
| --- | --- |
| Backspace | `"backspace"` |
| Space | `"space"` |
| Return/Enter | `"ret"` |
| Left | `"left"` |
| Right | `"right"` |
| Up | `"up"` |
| Down | `"down"` |
| Home | `"home"` |
| End | `"end"` |
| Page Up | `"pageup"` |
| Page Down | `"pagedown"` |
| Tab | `"tab"` |
| Delete | `"del"` |
| Insert | `"ins"` |
| Null | `"null"` |
| Escape | `"esc"` |
| Key name | Representation |
| --- | --- |
| Backspace | `"backspace"` |
| Space | `"space"` |
| Return/Enter | `"ret"` |
| Left | `"left"` |
| Right | `"right"` |
| Up | `"up"` |
| Down | `"down"` |
| Home | `"home"` |
| End | `"end"` |
| Page Up | `"pageup"` |
| Page Down | `"pagedown"` |
| Tab | `"tab"` |
| Delete | `"del"` |
| Insert | `"ins"` |
| Null | `"null"` |
| Escape | `"esc"` |
| Less Than (<) | `"lt"` |
| Greater Than (>) | `"gt"` |
Keys can be disabled by binding them to the `no_op` command.

View File

@@ -24,6 +24,7 @@ function or block of code.
| `c` | Comment |
| `T` | Test |
| `g` | Change |
| `x` | (X)HTML element |
> 💡 `f`, `t`, etc. need a tree-sitter grammar active for the current
document and a special tree-sitter query file to work properly. [Only

View File

@@ -130,6 +130,17 @@ inherits = "boo_berry"
berry = "#2A2A4D"
```
### Rainbow
The `rainbow` key is used for rainbow highlight for matching brackets.
The key is a list of styles.
```toml
rainbow = ["#ff0000", "#ffa500", "#fff000", { fg = "#00ff00", modifiers = ["bold"] }]
```
Colors from the palette and modifiers may be used.
### Scopes
The following is a list of scopes available to use for styling:

View File

@@ -47,6 +47,12 @@
<content_rating type="oars-1.1" />
<releases>
<release version="25.07.1" date="2025-07-18">
<url>https://github.com/helix-editor/helix/releases/tag/25.07.1</url>
</release>
<release version="25.07" date="2025-07-15">
<url>https://helix-editor.com/news/release-25-07-highlights/</url>
</release>
<release version="25.01.1" date="2025-01-19">
<url>https://github.com/helix-editor/helix/releases/tag/25.01.1</url>
</release>

View File

@@ -13,8 +13,8 @@ _hx() {
return 0
;;
--health)
languages=$(hx --health | tail -n '+7' | awk '{print $1}' | sed 's/\x1b\[[0-9;]*m//g')
mapfile -t COMPREPLY < <(compgen -W """$languages""" -- "$cur")
languages=$(hx --health all-languages | tail -n '+2' | awk '{print $1}' | sed 's/\x1b\[[0-9;]*m//g')
mapfile -t COMPREPLY < <(compgen -W """clipboard languages all-languages all $languages""" -- "$cur")
return 0
;;
esac

View File

@@ -4,6 +4,10 @@
complete -c hx -s h -l help -d "Prints help information"
complete -c hx -l tutor -d "Loads the tutorial"
complete -c hx -l health -xa "(__hx_langs_ops)" -d "Checks for errors"
complete -c hx -l health -xka all -d "Prints all diagnostic informations"
complete -c hx -l health -xka all-languages -d "Lists all languages"
complete -c hx -l health -xka languages -d "Lists user configured languages"
complete -c hx -l health -xka clipboard -d "Prints system clipboard provider"
complete -c hx -s g -l grammar -x -a "fetch build" -d "Fetch or build tree-sitter grammars"
complete -c hx -s v -o vv -o vvv -d "Increases logging verbosity"
complete -c hx -s V -l version -d "Prints version information"
@@ -14,5 +18,5 @@ complete -c hx -l log -r -d "Specifies a file to use for logging"
complete -c hx -s w -l working-dir -d "Specify initial working directory" -xa "(__fish_complete_directories)"
function __hx_langs_ops
hx --health languages | tail -n '+2' | string replace -fr '^(\S+) .*' '$1'
hx --health all-languages | tail -n '+2' | string replace -fr '^(\S+) .*' '$1'
end

View File

@@ -5,8 +5,8 @@
# The help message won't be overridden though, so it will still be present here
def health_categories [] {
let languages = ^hx --health languages | detect columns | get Language | filter { $in != null }
let completions = [ "all", "clipboard", "languages" ] | append $languages
let languages = ^hx --health all-languages | detect columns | get Language | where { $in != null }
let completions = [ "all", "clipboard", "languages", "all-languages" ] | append $languages
return $completions
}

View File

@@ -25,7 +25,7 @@ _hx() {
case "$state" in
health)
local languages=($(hx --health | tail -n '+11' | awk '{print $1}' | sed 's/\x1b\[[0-9;]*m//g;s/[✘✓]//g'))
local languages=($(hx --health all-languages | tail -n '+2' | awk '{print $1}' | sed 's/\x1b\[[0-9;]*m//g;s/[✘✓]//g'))
_values 'language' $languages
;;
grammar)

View File

@@ -46,6 +46,8 @@ in
allowBuiltinFetchGit = true;
};
propagatedBuildInputs = [ runtimeDir ];
nativeBuildInputs = [
installShellFiles
git

View File

@@ -1,10 +1,12 @@
| Crate | Description |
| ----------- | ----------- |
| helix-stdx | Extensions to the standard library (similar to [`rust-analyzer`'s](https://github.com/rust-lang/rust-analyzer/blob/ea413f67a8f730b4211c09e103f8207c62e7dbc3/crates/stdx/Cargo.toml#L5)) |
| helix-core | Core editing primitives, functional. |
| helix-lsp | Language server client |
| helix-lsp-types | Language Server Protocol type definitions |
| helix-dap | Debug Adapter Protocol (DAP) client |
| helix-event | Primitives for defining and handling events within the editor |
| helix-loader | Functions for building, fetching, and loading external resources |
| helix-view | UI abstractions for use in backends, imperative shell. |
| helix-term | Terminal UI |
@@ -110,3 +112,17 @@ The `main` function sets up a new `Application` that runs the event loop.
## TUI / Term
TODO: document Component and rendering related stuff
## Event
The `helix-event` crate defines primitives for defining and acting on events
within the editor. "Events" cover things like opening, changing and closing of
documents, starting and stopping of language servers and more.
`helix-event` has tools for defining events and registering _hooks_ which run
any time an event is emitted. `helix-event` also provides `AsyncHook` - a tool
for running cancellable tasks which run after events with _debouncing_.
See the `AsyncHook` type for more information. Events can be created within the
`events!` macro. Synchronous hooks can be created with `register_hook!`. And
editor-wide events can be sent to hooks with `helix_event::dispatch`.

View File

@@ -87,8 +87,6 @@
$CC -c src/parser.c -o parser.o $FLAGS
$CXX -shared -o $NAME.so *.o
ls -al
runHook postBuild
'';

View File

@@ -44,7 +44,7 @@ log = "0.4"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
toml.workspace = true
imara-diff = "0.2.0"
encoding_rs = "0.8"

View File

@@ -1,11 +1,9 @@
//! When typing the opening character of one of the possible pairs defined below,
//! this module provides the functionality to insert the paired closing character.
use crate::{graphemes, movement::Direction, Range, Rope, Selection, Tendril, Transaction};
use crate::{graphemes, movement::Direction, Change, Deletion, Range, Rope, Tendril};
use std::collections::HashMap;
use smallvec::SmallVec;
// Heavily based on https://github.com/codemirror/closebrackets/
pub const DEFAULT_PAIRS: &[(char, char)] = &[
('(', ')'),
@@ -106,37 +104,128 @@ impl Default for AutoPairs {
}
}
// insert hook:
// Fn(doc, selection, char) => Option<Transaction>
// problem is, we want to do this per range, so we can call default handler for some ranges
// so maybe ret Vec<Option<Change>>
// but we also need to be able to return transactions...
//
// to simplify, maybe return Option<Transaction> and just reimplement the default
// [TODO]
// * delete implementation where it erases the whole bracket (|) -> |
// * change to multi character pairs to handle cases like placing the cursor in the
// middle of triple quotes, and more exotic pairs like Jinja's {% %}
#[must_use]
pub fn hook(doc: &Rope, selection: &Selection, ch: char, pairs: &AutoPairs) -> Option<Transaction> {
log::trace!("autopairs hook selection: {:#?}", selection);
pub fn hook_insert(
doc: &Rope,
range: &Range,
ch: char,
pairs: &AutoPairs,
) -> Option<(Change, Range)> {
log::trace!("autopairs hook range: {:#?}", range);
if let Some(pair) = pairs.get(ch) {
if pair.same() {
return Some(handle_same(doc, selection, pair));
return handle_insert_same(doc, range, pair);
} else if pair.open == ch {
return Some(handle_open(doc, selection, pair));
return handle_insert_open(doc, range, pair);
} else if pair.close == ch {
// && char_at pos == close
return Some(handle_close(doc, selection, pair));
return handle_insert_close(doc, range, pair);
}
} else if ch.is_whitespace() {
return handle_insert_whitespace(doc, range, ch, pairs);
}
None
}
#[must_use]
pub fn hook_delete(doc: &Rope, range: &Range, pairs: &AutoPairs) -> Option<(Deletion, Range)> {
log::trace!("autopairs delete hook range: {:#?}", range);
let text = doc.slice(..);
let cursor = range.cursor(text);
let cur = doc.get_char(cursor)?;
let prev = prev_char(doc, cursor)?;
// check for whitespace surrounding a pair
if doc.len_chars() >= 4 && prev.is_whitespace() && cur.is_whitespace() {
let second_prev = doc.get_char(graphemes::nth_prev_grapheme_boundary(text, cursor, 2))?;
let second_next = doc.get_char(graphemes::next_grapheme_boundary(text, cursor))?;
log::debug!("second_prev: {}, second_next: {}", second_prev, second_next);
if let Some(pair) = pairs.get(second_prev) {
if pair.open == second_prev && pair.close == second_next {
return handle_delete(doc, range);
}
}
}
let pair = pairs.get(cur)?;
if pair.open != prev || pair.close != cur {
return None;
}
handle_delete(doc, range)
}
pub fn handle_delete(doc: &Rope, range: &Range) -> Option<(Deletion, Range)> {
let text = doc.slice(..);
let cursor = range.cursor(text);
let end_next = graphemes::next_grapheme_boundary(text, cursor);
let end_prev = graphemes::prev_grapheme_boundary(text, cursor);
let delete = (end_prev, end_next);
let size_delete = end_next - end_prev;
let next_head = graphemes::next_grapheme_boundary(text, range.head) - size_delete;
// if the range is a single grapheme cursor, we do not want to shrink the
// range, just move it, so we only subtract the size of the closing pair char
let next_anchor = match (range.direction(), range.is_single_grapheme(text)) {
// single grapheme forward needs to move, but only the width of the
// character under the cursor, which is the closer
(Direction::Forward, true) => range.anchor - (end_next - cursor),
(Direction::Backward, true) => range.anchor - (cursor - end_prev),
(Direction::Forward, false) => range.anchor,
(Direction::Backward, false) => range.anchor - size_delete,
};
let next_range = Range::new(next_anchor, next_head);
log::trace!(
"auto pair delete: {:?}, range: {:?}, next_range: {:?}, text len: {}",
delete,
range,
next_range,
text.len_chars()
);
Some((delete, next_range))
}
fn handle_insert_whitespace(
doc: &Rope,
range: &Range,
ch: char,
pairs: &AutoPairs,
) -> Option<(Change, Range)> {
let text = doc.slice(..);
let cursor = range.cursor(text);
let cur = doc.get_char(cursor)?;
let prev = prev_char(doc, cursor)?;
let pair = pairs.get(cur)?;
if pair.open != prev || pair.close != cur {
return None;
}
let whitespace_pair = Pair {
open: ch,
close: ch,
};
handle_insert_same(doc, range, &whitespace_pair)
}
fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
if pos == 0 {
return None;
@@ -146,7 +235,7 @@ fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
}
/// calculate what the resulting range should be for an auto pair insertion
fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: usize) -> Range {
fn get_next_range(doc: &Rope, start_range: &Range, len_inserted: usize) -> Range {
// When the character under the cursor changes due to complete pair
// insertion, we must look backward a grapheme and then add the length
// of the insertion to put the resulting cursor in the right place, e.g.
@@ -165,10 +254,7 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
// inserting at the very end of the document after the last newline
if start_range.head == doc.len_chars() && start_range.anchor == doc.len_chars() {
return Range::new(
start_range.anchor + offset + 1,
start_range.head + offset + 1,
);
return Range::new(start_range.anchor + 1, start_range.head + 1);
}
let doc_slice = doc.slice(..);
@@ -177,7 +263,7 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
// just skip over graphemes
if len_inserted == 0 {
let end_anchor = if single_grapheme {
graphemes::next_grapheme_boundary(doc_slice, start_range.anchor) + offset
graphemes::next_grapheme_boundary(doc_slice, start_range.anchor)
// even for backward inserts with multiple grapheme selections,
// we want the anchor to stay where it is so that the relative
@@ -185,42 +271,38 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
//
// foo([) wor]d -> insert ) -> foo()[ wor]d
} else {
start_range.anchor + offset
start_range.anchor
};
return Range::new(
end_anchor,
graphemes::next_grapheme_boundary(doc_slice, start_range.head) + offset,
graphemes::next_grapheme_boundary(doc_slice, start_range.head),
);
}
// trivial case: only inserted a single-char opener, just move the selection
if len_inserted == 1 {
let end_anchor = if single_grapheme || start_range.direction() == Direction::Backward {
start_range.anchor + offset + 1
start_range.anchor + 1
} else {
start_range.anchor + offset
start_range.anchor
};
return Range::new(end_anchor, start_range.head + offset + 1);
return Range::new(end_anchor, start_range.head + 1);
}
// If the head = 0, then we must be in insert mode with a backward
// cursor, which implies the head will just move
let end_head = if start_range.head == 0 || start_range.direction() == Direction::Backward {
start_range.head + offset + 1
start_range.head + 1
} else {
// We must have a forward cursor, which means we must move to the
// other end of the grapheme to get to where the new characters
// are inserted, then move the head to where it should be
let prev_bound = graphemes::prev_grapheme_boundary(doc_slice, start_range.head);
log::trace!(
"prev_bound: {}, offset: {}, len_inserted: {}",
prev_bound,
offset,
len_inserted
);
prev_bound + offset + len_inserted
log::trace!("prev_bound: {}, len_inserted: {}", prev_bound, len_inserted);
prev_bound + len_inserted
};
let end_anchor = match (start_range.len(), start_range.direction()) {
@@ -239,7 +321,7 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
// if we are appending, the anchor stays where it is; only offset
// for multiple range insertions
} else {
start_range.anchor + offset
start_range.anchor
}
}
@@ -248,13 +330,11 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
// if we're backward, then the head is at the first char
// of the typed char, so we need to add the length of
// the closing char
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor)
+ len_inserted
+ offset
graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + len_inserted
} else {
// when we are inserting in front of a selection, we need to move
// the anchor over by however many characters were inserted overall
start_range.anchor + offset + len_inserted
start_range.anchor + len_inserted
}
}
};
@@ -262,112 +342,76 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted:
Range::new(end_anchor, end_head)
}
fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offs = 0;
fn handle_insert_open(doc: &Rope, range: &Range, pair: &Pair) -> Option<(Change, Range)> {
let cursor = range.cursor(doc.slice(..));
let next_char = doc.get_char(cursor);
let len_inserted;
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let cursor = start_range.cursor(doc.slice(..));
let next_char = doc.get_char(cursor);
let len_inserted;
// Since auto pairs are currently limited to single chars, we're either
// inserting exactly one or two chars. When arbitrary length pairs are
// added, these will need to be changed.
let change = match next_char {
Some(_) if !pair.should_close(doc, range) => {
return None;
}
_ => {
// insert open & close
let pair_str = Tendril::from_iter([pair.open, pair.close]);
len_inserted = 2;
(cursor, cursor, Some(pair_str))
}
};
// Since auto pairs are currently limited to single chars, we're either
// inserting exactly one or two chars. When arbitrary length pairs are
// added, these will need to be changed.
let change = match next_char {
Some(_) if !pair.should_close(doc, start_range) => {
len_inserted = 1;
let mut tendril = Tendril::new();
tendril.push(pair.open);
(cursor, cursor, Some(tendril))
}
_ => {
// insert open & close
let pair_str = Tendril::from_iter([pair.open, pair.close]);
len_inserted = 2;
(cursor, cursor, Some(pair_str))
}
};
let next_range = get_next_range(doc, range, len_inserted);
let result = (change, next_range);
let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range);
offs += len_inserted;
log::debug!("auto pair change: {:#?}", &result);
change
});
let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index()));
log::debug!("auto pair transaction: {:#?}", t);
t
Some(result)
}
fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offs = 0;
fn handle_insert_close(doc: &Rope, range: &Range, pair: &Pair) -> Option<(Change, Range)> {
let cursor = range.cursor(doc.slice(..));
let next_char = doc.get_char(cursor);
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let cursor = start_range.cursor(doc.slice(..));
let next_char = doc.get_char(cursor);
let mut len_inserted = 0;
let change = if next_char == Some(pair.close) {
// return transaction that moves past close
(cursor, cursor, None) // no-op
} else {
return None;
};
let change = if next_char == Some(pair.close) {
// return transaction that moves past close
(cursor, cursor, None) // no-op
} else {
len_inserted = 1;
let mut tendril = Tendril::new();
tendril.push(pair.close);
(cursor, cursor, Some(tendril))
};
let next_range = get_next_range(doc, range, 0);
let result = (change, next_range);
let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range);
offs += len_inserted;
log::debug!("auto pair change: {:#?}", &result);
change
});
let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index()));
log::debug!("auto pair transaction: {:#?}", t);
t
Some(result)
}
/// handle cases where open and close is the same, or in triples ("""docstring""")
fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction {
let mut end_ranges = SmallVec::with_capacity(selection.len());
fn handle_insert_same(doc: &Rope, range: &Range, pair: &Pair) -> Option<(Change, Range)> {
let cursor = range.cursor(doc.slice(..));
let mut len_inserted = 0;
let next_char = doc.get_char(cursor);
let mut offs = 0;
let change = if next_char == Some(pair.open) {
// return transaction that moves past close
(cursor, cursor, None) // no-op
} else {
if !pair.should_close(doc, range) {
return None;
}
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let cursor = start_range.cursor(doc.slice(..));
let mut len_inserted = 0;
let next_char = doc.get_char(cursor);
let pair_str = Tendril::from_iter([pair.open, pair.close]);
len_inserted = 2;
(cursor, cursor, Some(pair_str))
};
let change = if next_char == Some(pair.open) {
// return transaction that moves past close
(cursor, cursor, None) // no-op
} else {
let mut pair_str = Tendril::new();
pair_str.push(pair.open);
let next_range = get_next_range(doc, range, len_inserted);
let result = (change, next_range);
// for equal pairs, don't insert both open and close if either
// side has a non-pair char
if pair.should_close(doc, start_range) {
pair_str.push(pair.close);
}
log::debug!("auto pair change: {:#?}", &result);
len_inserted += pair_str.chars().count();
(cursor, cursor, Some(pair_str))
};
let next_range = get_next_range(doc, start_range, offs, len_inserted);
end_ranges.push(next_range);
offs += len_inserted;
change
});
let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index()));
log::debug!("auto pair transaction: {:#?}", t);
t
Some(result)
}

View File

@@ -16,6 +16,7 @@ pub struct CompletionItem {
pub enum CompletionProvider {
Lsp(LanguageServerId),
Path,
Word,
}
impl From<LanguageServerId> for CompletionProvider {

View File

@@ -228,6 +228,7 @@ impl FromStr for Ini {
let glob = GlobBuilder::new(&glob_str)
.literal_separator(true)
.backslash_escape(true)
.empty_alternates(true)
.build()?;
ini.sections.push(Section {
glob: glob.compile_matcher(),

View File

@@ -153,6 +153,12 @@ pub fn auto_detect_indent_style(document_text: &Rope) -> Option<IndentStyle> {
// Give more weight to tabs, because their presence is a very
// strong indicator.
histogram[0] *= 2;
// Gives less weight to single indent, as single spaces are
// often used in certain languages' comment systems and rarely
// used as the actual document indentation.
if histogram[1] > 1 {
histogram[1] /= 2;
}
histogram
};

View File

@@ -25,38 +25,59 @@ pub fn expand_selection(syntax: &Syntax, text: RopeSlice, selection: Selection)
}
pub fn shrink_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
select_node_impl(
syntax,
text,
selection,
|cursor| {
cursor.goto_first_child();
},
None,
)
selection.transform(move |range| {
let (from, to) = range.into_byte_range(text);
let mut cursor = syntax.walk();
cursor.reset_to_byte_range(from, to);
if let Some(node) = cursor
.into_iter()
.find(|node| node.is_named() && node.is_contained_within(from..to))
{
return Range::from_node(node, text, range.direction());
}
range
})
}
pub fn select_next_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
select_node_impl(
syntax,
text,
selection,
|cursor| {
while !cursor.goto_next_sibling() {
if !cursor.goto_parent() {
break;
}
selection.transform(move |range| {
let (from, to) = range.into_byte_range(text);
let mut cursor = syntax.walk();
cursor.reset_to_byte_range(from, to);
while !cursor.goto_next_sibling() {
if !cursor.goto_parent() {
return range;
}
},
Some(Direction::Forward),
)
}
Range::from_node(cursor.node(), text, Direction::Forward)
})
}
pub fn select_prev_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
selection.transform(move |range| {
let (from, to) = range.into_byte_range(text);
let mut cursor = syntax.walk();
cursor.reset_to_byte_range(from, to);
while !cursor.goto_previous_sibling() {
if !cursor.goto_parent() {
return range;
}
}
Range::from_node(cursor.node(), text, Direction::Backward)
})
}
pub fn select_all_siblings(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
let mut cursor = syntax.walk();
selection.transform_iter(move |range| {
let (from, to) = range.into_byte_range(text);
cursor.reset_to_byte_range(from as u32, to as u32);
cursor.reset_to_byte_range(from, to);
if !cursor.goto_parent_with(|parent| parent.child_count() > 1) {
return vec![range].into_iter();
@@ -70,7 +91,7 @@ pub fn select_all_children(syntax: &Syntax, text: RopeSlice, selection: Selectio
let mut cursor = syntax.walk();
selection.transform_iter(move |range| {
let (from, to) = range.into_byte_range(text);
cursor.reset_to_byte_range(from as u32, to as u32);
cursor.reset_to_byte_range(from, to);
select_children(&mut cursor, text, range).into_iter()
})
}
@@ -88,47 +109,3 @@ fn select_children(cursor: &mut TreeCursor, text: RopeSlice, range: Range) -> Ve
vec![range]
}
}
pub fn select_prev_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection {
select_node_impl(
syntax,
text,
selection,
|cursor| {
while !cursor.goto_previous_sibling() {
if !cursor.goto_parent() {
break;
}
}
},
Some(Direction::Backward),
)
}
fn select_node_impl<F>(
syntax: &Syntax,
text: RopeSlice,
selection: Selection,
motion: F,
direction: Option<Direction>,
) -> Selection
where
F: Fn(&mut TreeCursor),
{
let cursor = &mut syntax.walk();
selection.transform(|range| {
let from = text.char_to_byte(range.from()) as u32;
let to = text.char_to_byte(range.to()) as u32;
cursor.reset_to_byte_range(from, to);
motion(cursor);
let node = cursor.node();
let from = text.byte_to_char(node.start_byte() as usize);
let to = text.byte_to_char(node.end_byte() as usize);
Range::new(from, to).with_direction(direction.unwrap_or_else(|| range.direction()))
})
}

View File

@@ -387,8 +387,11 @@ impl Range {
/// Converts this char range into an in order byte range, discarding
/// direction.
pub fn into_byte_range(&self, text: RopeSlice) -> (usize, usize) {
(text.char_to_byte(self.from()), text.char_to_byte(self.to()))
pub fn into_byte_range(&self, text: RopeSlice) -> (u32, u32) {
(
text.char_to_byte(self.from()) as u32,
text.char_to_byte(self.to()) as u32,
)
}
}
@@ -700,22 +703,161 @@ impl Selection {
pub fn contains(&self, other: &Selection) -> bool {
is_subset::<true>(self.range_bounds(), other.range_bounds())
}
/// returns true if self has at least one range that overlaps with at least one range from other
pub fn overlaps(&self, other: &Selection) -> bool {
let (mut iter_self, mut iter_other) = (self.iter(), other.iter());
let (mut ele_self, mut ele_other) = (iter_self.next(), iter_other.next());
loop {
match (ele_self, ele_other) {
(Some(ra), Some(rb)) => {
if ra.overlaps(rb) {
return true;
} else if ra.from() > rb.from() {
ele_self = iter_self.next();
} else {
ele_other = iter_other.next();
}
}
_ => return false,
}
}
}
/// Returns the given selection with the overlapping portions of `other`
/// cut out. If one range from this selection is equal to one from `other`,
/// this range is removed. If this results in an entirely empty selection,
/// `None` is returned.
pub fn without(self, other: &Selection) -> Option<Self> {
if other.contains(&self) {
return None;
}
let mut primary_index = self.primary_index;
let mut ranges = smallvec!();
let (mut iter_self, mut iter_other) = (self.into_iter(), other.iter());
let (mut ele_self, mut ele_other) = (iter_self.next(), iter_other.next());
let mut cur_index = 0;
loop {
match (ele_self, ele_other) {
(Some(ra), Some(rb)) => {
if !ra.overlaps(rb) {
// there's no overlap and it's on the left of rb
if ra.to() <= rb.from() {
ranges.push(ra);
ele_self = iter_self.next();
cur_index += 1;
// otherwise it must be on the right, so move rb forward
} else {
ele_other = iter_other.next();
}
continue;
}
// otherwise there is overlap, so truncate or split
if rb.contains_range(&ra) {
ele_self = iter_self.next();
cur_index += 1;
continue;
}
// [ ra ]
// [ rb ]
if ra.from() <= rb.from() && ra.to() <= rb.to() && ra.to() >= rb.from() {
let new = if ra.direction() == Direction::Backward {
Range::new(rb.from(), ra.head)
} else {
Range::new(ra.anchor, rb.from())
};
ranges.push(new);
ele_self = iter_self.next();
cur_index += 1;
// [ ra ]
// [ rb ]
} else if ra.from() >= rb.from() && ra.to() >= rb.to() && ra.from() <= rb.to() {
let new = if ra.direction() == Direction::Backward {
Range::new(ra.anchor, rb.to() + 1)
} else {
Range::new(rb.to(), ra.head)
};
// don't settle on the new range yet because the next
// rb could chop off the other end of ra
ele_self = Some(new);
ele_other = iter_other.next();
// [ ra ]
// [ rb ]
} else if ra.from() < rb.from() && ra.to() > rb.to() {
// we must split the range into two
let left = if ra.direction() == Direction::Backward {
Range::new(rb.from(), ra.head)
} else {
Range::new(ra.anchor, rb.from())
};
let right = if ra.direction() == Direction::Backward {
Range::new(ra.anchor, rb.to())
} else {
Range::new(rb.to(), ra.head)
};
// We do NOT push right onto the results right away.
// We must put it back into the iterator and check it
// again in case a further range splits it again.
ranges.push(left);
ele_other = iter_other.next();
// offset the primary index whenever we split
if cur_index < primary_index {
primary_index += 1;
}
cur_index += 1;
ele_self = Some(right);
}
}
// the rest just get included as is
(Some(range), None) => {
ranges.push(range);
ele_self = iter_self.next();
cur_index += 1;
}
// exhausted `self`, nothing left to do
(None, _) => {
break;
}
}
}
if ranges.is_empty() {
return None;
}
Some(Selection::new(ranges, primary_index))
}
}
impl<'a> IntoIterator for &'a Selection {
type Item = &'a Range;
type IntoIter = std::slice::Iter<'a, Range>;
fn into_iter(self) -> std::slice::Iter<'a, Range> {
fn into_iter(self) -> Self::IntoIter {
self.ranges().iter()
}
}
impl IntoIterator for Selection {
type Item = Range;
type IntoIter = smallvec::IntoIter<[Range; 1]>;
type IntoIter = smallvec::IntoIter<[Self::Item; 1]>;
fn into_iter(self) -> smallvec::IntoIter<[Range; 1]> {
fn into_iter(self) -> Self::IntoIter {
self.ranges.into_iter()
}
}
@@ -882,6 +1024,7 @@ pub fn split_on_matches(text: RopeSlice, selection: &Selection, regex: &rope::Re
#[cfg(test)]
mod test {
use super::*;
use crate::test;
use crate::Rope;
#[test]
@@ -972,7 +1115,7 @@ mod test {
}
#[test]
fn test_overlaps() {
fn test_range_overlaps() {
fn overlaps(a: (usize, usize), b: (usize, usize)) -> bool {
Range::new(a.0, a.1).overlaps(&Range::new(b.0, b.1))
}
@@ -1022,6 +1165,160 @@ mod test {
assert!(overlaps((1, 1), (1, 1)));
}
#[test]
fn test_selection_overlaps() {
fn overlaps(a: &[(usize, usize)], b: &[(usize, usize)]) -> bool {
let a = Selection::new(
a.iter()
.map(|(anchor, head)| Range::new(*anchor, *head))
.collect(),
0,
);
let b = Selection::new(
b.iter()
.map(|(anchor, head)| Range::new(*anchor, *head))
.collect(),
0,
);
a.overlaps(&b)
}
// Two non-zero-width ranges, no overlap.
assert!(!overlaps(&[(0, 3)], &[(3, 6)]));
assert!(!overlaps(&[(0, 3)], &[(6, 3)]));
assert!(!overlaps(&[(3, 0)], &[(3, 6)]));
assert!(!overlaps(&[(3, 0)], &[(6, 3)]));
assert!(!overlaps(&[(3, 6)], &[(0, 3)]));
assert!(!overlaps(&[(3, 6)], &[(3, 0)]));
assert!(!overlaps(&[(6, 3)], &[(0, 3)]));
assert!(!overlaps(&[(6, 3)], &[(3, 0)]));
// more ranges in one or the other, no overlap
assert!(!overlaps(&[(6, 3), (9, 6)], &[(3, 0)]));
assert!(!overlaps(&[(6, 3), (6, 9)], &[(3, 0)]));
assert!(!overlaps(&[(3, 6), (9, 6)], &[(3, 0)]));
assert!(!overlaps(&[(3, 6), (6, 9)], &[(3, 0)]));
assert!(!overlaps(&[(6, 3), (9, 6)], &[(0, 3)]));
assert!(!overlaps(&[(6, 3), (6, 9)], &[(0, 3)]));
assert!(!overlaps(&[(3, 6), (9, 6)], &[(0, 3)]));
assert!(!overlaps(&[(3, 6), (6, 9)], &[(0, 3)]));
assert!(!overlaps(&[(6, 3)], &[(3, 0), (9, 6)]));
assert!(!overlaps(&[(6, 3)], &[(3, 0), (6, 9)]));
assert!(!overlaps(&[(3, 6)], &[(3, 0), (9, 6)]));
assert!(!overlaps(&[(3, 6)], &[(3, 0), (6, 9)]));
assert!(!overlaps(&[(6, 3)], &[(0, 3), (9, 6)]));
assert!(!overlaps(&[(6, 3)], &[(0, 3), (6, 9)]));
assert!(!overlaps(&[(3, 6)], &[(0, 3), (9, 6)]));
assert!(!overlaps(&[(3, 6)], &[(0, 3), (6, 9)]));
// Two non-zero-width ranges, overlap.
assert!(overlaps(&[(0, 4)], &[(3, 6)]));
assert!(overlaps(&[(0, 4)], &[(6, 3)]));
assert!(overlaps(&[(4, 0)], &[(3, 6)]));
assert!(overlaps(&[(4, 0)], &[(6, 3)]));
assert!(overlaps(&[(3, 6)], &[(0, 4)]));
assert!(overlaps(&[(3, 6)], &[(4, 0)]));
assert!(overlaps(&[(6, 3)], &[(0, 4)]));
assert!(overlaps(&[(6, 3)], &[(4, 0)]));
// Two non-zero-width ranges, overlap, extra from one or the other
assert!(overlaps(&[(0, 4), (7, 10)], &[(3, 6)]));
assert!(overlaps(&[(0, 4), (7, 10)], &[(6, 3)]));
assert!(overlaps(&[(4, 0), (7, 10)], &[(3, 6)]));
assert!(overlaps(&[(4, 0), (7, 10)], &[(6, 3)]));
assert!(overlaps(&[(3, 6), (7, 10)], &[(0, 4)]));
assert!(overlaps(&[(3, 6), (7, 10)], &[(4, 0)]));
assert!(overlaps(&[(6, 3), (7, 10)], &[(0, 4)]));
assert!(overlaps(&[(6, 3), (7, 10)], &[(4, 0)]));
assert!(overlaps(&[(0, 4), (10, 7)], &[(3, 6)]));
assert!(overlaps(&[(0, 4), (10, 7)], &[(6, 3)]));
assert!(overlaps(&[(4, 0), (10, 7)], &[(3, 6)]));
assert!(overlaps(&[(4, 0), (10, 7)], &[(6, 3)]));
assert!(overlaps(&[(3, 6), (10, 7)], &[(0, 4)]));
assert!(overlaps(&[(3, 6), (10, 7)], &[(4, 0)]));
assert!(overlaps(&[(6, 3), (10, 7)], &[(0, 4)]));
assert!(overlaps(&[(6, 3), (10, 7)], &[(4, 0)]));
assert!(overlaps(&[(0, 4)], &[(3, 6), (7, 10)]));
assert!(overlaps(&[(0, 4)], &[(6, 3), (7, 10)]));
assert!(overlaps(&[(4, 0)], &[(3, 6), (7, 10)]));
assert!(overlaps(&[(4, 0)], &[(6, 3), (7, 10)]));
assert!(overlaps(&[(3, 6)], &[(0, 4), (7, 10)]));
assert!(overlaps(&[(3, 6)], &[(4, 0), (7, 10)]));
assert!(overlaps(&[(6, 3)], &[(0, 4), (7, 10)]));
assert!(overlaps(&[(6, 3)], &[(4, 0), (7, 10)]));
assert!(overlaps(&[(0, 4)], &[(3, 6), (10, 7)]));
assert!(overlaps(&[(0, 4)], &[(6, 3), (10, 7)]));
assert!(overlaps(&[(4, 0)], &[(3, 6), (10, 7)]));
assert!(overlaps(&[(4, 0)], &[(6, 3), (10, 7)]));
assert!(overlaps(&[(3, 6)], &[(0, 4), (10, 7)]));
assert!(overlaps(&[(3, 6)], &[(4, 0), (10, 7)]));
assert!(overlaps(&[(6, 3)], &[(0, 4), (10, 7)]));
assert!(overlaps(&[(6, 3)], &[(4, 0), (10, 7)]));
// Zero-width and non-zero-width range, no overlap.
assert!(!overlaps(&[(0, 3)], &[(3, 3)]));
assert!(!overlaps(&[(3, 0)], &[(3, 3)]));
assert!(!overlaps(&[(3, 3)], &[(0, 3)]));
assert!(!overlaps(&[(3, 3)], &[(3, 0)]));
assert!(!overlaps(&[(0, 3), (7, 10)], &[(3, 3)]));
assert!(!overlaps(&[(3, 0), (7, 10)], &[(3, 3)]));
assert!(!overlaps(&[(3, 3), (7, 10)], &[(0, 3)]));
assert!(!overlaps(&[(3, 3), (7, 10)], &[(3, 0)]));
assert!(!overlaps(&[(0, 3)], &[(3, 3), (7, 10)]));
assert!(!overlaps(&[(3, 0)], &[(3, 3), (7, 10)]));
assert!(!overlaps(&[(3, 3)], &[(0, 3), (7, 10)]));
assert!(!overlaps(&[(3, 3)], &[(3, 0), (7, 10)]));
// Zero-width and non-zero-width range, overlap.
assert!(overlaps(&[(1, 4)], &[(1, 1)]));
assert!(overlaps(&[(4, 1)], &[(1, 1)]));
assert!(overlaps(&[(1, 1)], &[(1, 4)]));
assert!(overlaps(&[(1, 1)], &[(4, 1)]));
assert!(overlaps(&[(1, 4)], &[(3, 3)]));
assert!(overlaps(&[(4, 1)], &[(3, 3)]));
assert!(overlaps(&[(3, 3)], &[(1, 4)]));
assert!(overlaps(&[(3, 3)], &[(4, 1)]));
assert!(overlaps(&[(1, 4), (7, 10)], &[(1, 1)]));
assert!(overlaps(&[(4, 1), (7, 10)], &[(1, 1)]));
assert!(overlaps(&[(1, 1), (7, 10)], &[(1, 4)]));
assert!(overlaps(&[(1, 1), (7, 10)], &[(4, 1)]));
assert!(overlaps(&[(1, 4), (7, 10)], &[(3, 3)]));
assert!(overlaps(&[(4, 1), (7, 10)], &[(3, 3)]));
assert!(overlaps(&[(3, 3), (7, 10)], &[(1, 4)]));
assert!(overlaps(&[(3, 3), (7, 10)], &[(4, 1)]));
assert!(overlaps(&[(1, 4)], &[(1, 1), (7, 10)]));
assert!(overlaps(&[(4, 1)], &[(1, 1), (7, 10)]));
assert!(overlaps(&[(1, 1)], &[(1, 4), (7, 10)]));
assert!(overlaps(&[(1, 1)], &[(4, 1), (7, 10)]));
assert!(overlaps(&[(1, 4)], &[(3, 3), (7, 10)]));
assert!(overlaps(&[(4, 1)], &[(3, 3), (7, 10)]));
assert!(overlaps(&[(3, 3)], &[(1, 4), (7, 10)]));
assert!(overlaps(&[(3, 3)], &[(4, 1), (7, 10)]));
// Two zero-width ranges, no overlap.
assert!(!overlaps(&[(0, 0)], &[(1, 1)]));
assert!(!overlaps(&[(1, 1)], &[(0, 0)]));
assert!(!overlaps(&[(0, 0), (2, 2)], &[(1, 1)]));
assert!(!overlaps(&[(0, 0), (2, 2)], &[(1, 1)]));
assert!(!overlaps(&[(1, 1)], &[(0, 0), (2, 2)]));
assert!(!overlaps(&[(1, 1)], &[(0, 0), (2, 2)]));
// Two zero-width ranges, overlap.
assert!(overlaps(&[(1, 1)], &[(1, 1)]));
assert!(overlaps(&[(1, 1), (2, 2)], &[(1, 1)]));
assert!(overlaps(&[(1, 1)], &[(1, 1), (2, 2)]));
}
#[test]
fn test_grapheme_aligned() {
let r = Rope::from_str("\r\nHi\r\n");
@@ -1378,9 +1675,15 @@ mod test {
// multiple matches
assert!(contains(vec!((1, 1), (2, 2)), vec!((1, 1), (2, 2))));
// smaller set can't contain bigger
// extra items out of range
assert!(!contains(vec!((1, 1)), vec!((1, 1), (2, 2))));
// one big range can contain multiple smaller ranges
assert!(contains(
vec!((1, 10)),
vec!((1, 1), (2, 2), (3, 3), (3, 5), (7, 10))
));
assert!(contains(
vec!((1, 1), (2, 4), (5, 6), (7, 9), (10, 13)),
vec!((3, 4), (7, 9))
@@ -1393,4 +1696,143 @@ mod test {
vec!((1, 2), (3, 4), (7, 9))
));
}
#[test]
fn test_selection_without() {
let without = |one, two, expected: Option<_>| {
println!("one: {:?}", one);
println!("two: {:?}", two);
println!("expected: {:?}", expected);
let (one_text, one_sel) = test::print(one);
let (two_text, two_sel) = test::print(two);
assert_eq!(one_text, two_text); // sanity
let actual_sel = one_sel.without(&two_sel);
let expected_sel = expected.map(|exp| {
let (expected_text, expected_sel) = test::print(exp);
assert_eq!(two_text, expected_text);
expected_sel
});
let actual = actual_sel
.as_ref()
.map(|sel| test::plain(two_text.to_string(), sel));
println!("actual: {:?}\n\n", actual);
assert_eq!(
expected_sel,
actual_sel,
"expected: {:?}, got: {:?}",
expected_sel
.as_ref()
.map(|sel| test::plain(two_text.to_string(), sel)),
actual,
);
};
without(
"#[foo bar baz|]#",
"foo #[bar|]# baz",
Some("#[foo |]#bar#( baz|)#"),
);
without("#[foo bar baz|]#", "#[foo bar baz|]#", None);
without("#[foo bar|]# baz", "#[foo bar baz|]#", None);
without("foo #[bar|]# baz", "#[foo bar baz|]#", None);
// direction is preserved
without(
"#[|foo bar baz]#",
"foo #[|bar]# baz",
Some("#[|foo ]#bar#(| baz)#"),
);
// preference for direction is given to the left
without(
"#[|foo bar baz]#",
"foo #[bar|]# baz",
Some("#[|foo ]#bar#(| baz)#"),
);
// disjoint ranges on the right are ignored
without(
"#[foo bar|]# baz",
"foo bar #[baz|]#",
Some("#[foo bar|]# baz"),
);
without(
"#[foo bar|]# baz",
"foo bar#[ baz|]#",
Some("#[foo bar|]# baz"),
);
without(
"#(foo|)# #[bar|]# baz",
"foo#[ b|]#ar ba#(z|)#",
Some("#(foo|)# b#[ar|]# baz"),
);
// ranges contained by those one on the right are removed
without(
"#[foo bar|]# #(b|)#az",
"foo bar#[ b|]#a#(z|)#",
Some("#[foo bar|]# baz"),
);
without(
"#[foo|]# bar #(baz|)#",
"foo bar#[ b|]#a#(z|)#",
Some("#[foo|]# bar b#(a|)#z"),
);
without(
"#[foo bar|]# #(b|)#az",
"foo bar #[b|]#a#(z|)#",
Some("#[foo bar|]# baz"),
);
without(
"#[foo bar|]# #(b|)#a#(z|)#",
"foo bar #[b|]#a#(z|)#",
Some("#[foo bar|]# baz"),
);
without(
"#[foo bar|]# #(b|)#a#(z|)#",
"foo bar #[b|]#a#(z|)#",
Some("#[foo bar|]# baz"),
);
// more than one range intersected by a single range on the right
without(
"#[foo bar|]# #(baz|)#",
"foo b#[ar ba|]#z",
Some("#[foo b|]#ar ba#(z|)#"),
);
// partial overlap
without(
"#[foo bar|]# baz",
"foo #[bar baz|]#",
Some("#[foo |]#bar baz"),
);
without(
"#[foo bar|]# baz",
"foo#[ bar baz|]#",
Some("#[foo|]# bar baz"),
);
without(
"#[foo bar|]# baz",
"foo ba#[r baz|]#",
Some("#[foo ba|]#r baz"),
);
without(
"foo ba#[r baz|]#",
"#[foo bar|]# baz",
Some("foo bar#[ baz|]#"),
);
// primary selection is moved - preference given to the left of a split
without(
"#(|foo)# #(|bar baz)# #[|quux]#",
"f#(o|)#o ba#[r b|]#az q#(uu|)#x",
Some("#(|f)#o#(|o)# #(|ba)#r b#(|az)# #[|q]#uu#(|x)#"),
);
}
}

View File

@@ -13,6 +13,7 @@ use std::{
use anyhow::{Context, Result};
use arc_swap::{ArcSwap, Guard};
use config::{Configuration, FileType, LanguageConfiguration, LanguageServerConfiguration};
use foldhash::HashSet;
use helix_loader::grammar::get_language;
use helix_stdx::rope::RopeSliceExt as _;
use once_cell::sync::OnceCell;
@@ -20,7 +21,10 @@ use ropey::RopeSlice;
use tree_house::{
highlighter,
query_iter::QueryIter,
tree_sitter::{Grammar, InactiveQueryCursor, InputEdit, Node, Query, RopeInput, Tree},
tree_sitter::{
query::{InvalidPredicateError, UserPredicate},
Capture, Grammar, InactiveQueryCursor, InputEdit, Node, Pattern, Query, RopeInput, Tree,
},
Error, InjectionLanguageMarker, LanguageConfig as SyntaxConfig, Layer,
};
@@ -28,6 +32,7 @@ use crate::{indent::IndentQuery, tree_sitter, ChangeSet, Language};
pub use tree_house::{
highlighter::{Highlight, HighlightEvent},
query_iter::QueryIterEvent,
Error as HighlighterError, LanguageLoader, TreeCursor, TREE_SITTER_MATCH_LIMIT,
};
@@ -37,6 +42,8 @@ pub struct LanguageData {
syntax: OnceCell<Option<SyntaxConfig>>,
indent_query: OnceCell<Option<IndentQuery>>,
textobject_query: OnceCell<Option<TextObjectQuery>>,
tag_query: OnceCell<Option<TagQuery>>,
rainbow_query: OnceCell<Option<RainbowQuery>>,
}
impl LanguageData {
@@ -46,6 +53,8 @@ impl LanguageData {
syntax: OnceCell::new(),
indent_query: OnceCell::new(),
textobject_query: OnceCell::new(),
tag_query: OnceCell::new(),
rainbow_query: OnceCell::new(),
}
}
@@ -154,6 +163,74 @@ impl LanguageData {
.as_ref()
}
/// Compiles the tags.scm query for a language.
/// This function should only be used by this module or the xtask crate.
pub fn compile_tag_query(
grammar: Grammar,
config: &LanguageConfiguration,
) -> Result<Option<TagQuery>> {
let name = &config.language_id;
let text = read_query(name, "tags.scm");
if text.is_empty() {
return Ok(None);
}
let query = Query::new(grammar, &text, |_pattern, predicate| match predicate {
// TODO: these predicates are allowed in tags.scm queries but not yet used.
UserPredicate::IsPropertySet { key: "local", .. } => Ok(()),
UserPredicate::Other(pred) => match pred.name() {
"strip!" | "select-adjacent!" => Ok(()),
_ => Err(InvalidPredicateError::unknown(predicate)),
},
_ => Err(InvalidPredicateError::unknown(predicate)),
})
.with_context(|| format!("Failed to compile tags.scm query for '{name}'"))?;
Ok(Some(TagQuery { query }))
}
fn tag_query(&self, loader: &Loader) -> Option<&TagQuery> {
self.tag_query
.get_or_init(|| {
let grammar = self.syntax_config(loader)?.grammar;
Self::compile_tag_query(grammar, &self.config)
.map_err(|err| {
log::error!("{err}");
})
.ok()
.flatten()
})
.as_ref()
}
/// Compiles the rainbows.scm query for a language.
/// This function should only be used by this module or the xtask crate.
pub fn compile_rainbow_query(
grammar: Grammar,
config: &LanguageConfiguration,
) -> Result<Option<RainbowQuery>> {
let name = &config.language_id;
let text = read_query(name, "rainbows.scm");
if text.is_empty() {
return Ok(None);
}
let rainbow_query = RainbowQuery::new(grammar, &text)
.with_context(|| format!("Failed to compile rainbows.scm query for '{name}'"))?;
Ok(Some(rainbow_query))
}
fn rainbow_query(&self, loader: &Loader) -> Option<&RainbowQuery> {
self.rainbow_query
.get_or_init(|| {
let grammar = self.syntax_config(loader)?.grammar;
Self::compile_rainbow_query(grammar, &self.config)
.map_err(|err| {
log::error!("{err}");
})
.ok()
.flatten()
})
.as_ref()
}
fn reconfigure(&self, scopes: &[String]) {
if let Some(Some(config)) = self.syntax.get() {
reconfigure_highlights(config, scopes);
@@ -339,6 +416,14 @@ impl Loader {
self.language(lang).textobject_query(self)
}
pub fn tag_query(&self, lang: Language) -> Option<&TagQuery> {
self.language(lang).tag_query(self)
}
fn rainbow_query(&self, lang: Language) -> Option<&RainbowQuery> {
self.language(lang).rainbow_query(self)
}
pub fn language_server_configs(&self) -> &HashMap<String, LanguageServerConfiguration> {
&self.language_server_configs
}
@@ -511,6 +596,92 @@ impl Syntax {
{
QueryIter::new(&self.inner, source, loader, range)
}
pub fn tags<'a>(
&'a self,
source: RopeSlice<'a>,
loader: &'a Loader,
range: impl RangeBounds<u32>,
) -> QueryIter<'a, 'a, impl FnMut(Language) -> Option<&'a Query> + 'a, ()> {
self.query_iter(
source,
|lang| loader.tag_query(lang).map(|q| &q.query),
range,
)
}
pub fn rainbow_highlights(
&self,
source: RopeSlice,
rainbow_length: usize,
loader: &Loader,
range: impl RangeBounds<u32>,
) -> OverlayHighlights {
struct RainbowScope<'tree> {
end: u32,
node: Option<Node<'tree>>,
highlight: Highlight,
}
let mut scope_stack = Vec::<RainbowScope>::new();
let mut highlights = Vec::new();
let mut query_iter = self.query_iter::<_, (), _>(
source,
|lang| loader.rainbow_query(lang).map(|q| &q.query),
range,
);
while let Some(event) = query_iter.next() {
let QueryIterEvent::Match(mat) = event else {
continue;
};
let rainbow_query = loader
.rainbow_query(query_iter.current_language())
.expect("language must have a rainbow query to emit matches");
let byte_range = mat.node.byte_range();
// Pop any scopes that end before this capture begins.
while scope_stack
.last()
.is_some_and(|scope| byte_range.start >= scope.end)
{
scope_stack.pop();
}
let capture = Some(mat.capture);
if capture == rainbow_query.scope_capture {
scope_stack.push(RainbowScope {
end: byte_range.end,
node: if rainbow_query
.include_children_patterns
.contains(&mat.pattern)
{
None
} else {
Some(mat.node.clone())
},
highlight: Highlight::new((scope_stack.len() % rainbow_length) as u32),
});
} else if capture == rainbow_query.bracket_capture {
if let Some(scope) = scope_stack.last() {
if !scope
.node
.as_ref()
.is_some_and(|node| mat.node.parent().as_ref() != Some(node))
{
let start = source
.byte_to_char(source.floor_char_boundary(byte_range.start as usize));
let end =
source.byte_to_char(source.ceil_char_boundary(byte_range.end as usize));
highlights.push((scope.highlight, start..end));
}
}
}
}
OverlayHighlights::Heterogenous { highlights }
}
}
pub type Highlighter<'a> = highlighter::Highlighter<'a, 'a, Loader>;
@@ -881,6 +1052,11 @@ impl TextObjectQuery {
}
}
#[derive(Debug)]
pub struct TagQuery {
pub query: Query,
}
pub fn pretty_print_tree<W: fmt::Write>(fmt: &mut W, node: Node) -> fmt::Result {
if node.child_count() == 0 {
if node_is_visible(&node) {
@@ -953,6 +1129,57 @@ fn pretty_print_tree_impl<W: fmt::Write>(
Ok(())
}
/// Finds the child of `node` which contains the given byte range.
pub fn child_for_byte_range<'a>(node: &Node<'a>, range: ops::Range<u32>) -> Option<Node<'a>> {
for child in node.children() {
let child_range = child.byte_range();
if range.start >= child_range.start && range.end <= child_range.end {
return Some(child);
}
}
None
}
#[derive(Debug)]
pub struct RainbowQuery {
query: Query,
include_children_patterns: HashSet<Pattern>,
scope_capture: Option<Capture>,
bracket_capture: Option<Capture>,
}
impl RainbowQuery {
fn new(grammar: Grammar, source: &str) -> Result<Self, tree_sitter::query::ParseError> {
let mut include_children_patterns = HashSet::default();
let query = Query::new(grammar, source, |pattern, predicate| match predicate {
UserPredicate::SetProperty {
key: "rainbow.include-children",
val,
} => {
if val.is_some() {
return Err(
"property 'rainbow.include-children' does not take an argument".into(),
);
}
include_children_patterns.insert(pattern);
Ok(())
}
_ => Err(InvalidPredicateError::unknown(predicate)),
})?;
Ok(Self {
include_children_patterns,
scope_capture: query.get_capture("rainbow.scope"),
bracket_capture: query.get_capture("rainbow.bracket"),
query,
})
}
}
#[cfg(test)]
mod test {
use once_cell::sync::Lazy;

View File

@@ -7,6 +7,7 @@ use serde::{ser::SerializeSeq as _, Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
fmt::{self, Display},
num::NonZeroU8,
path::PathBuf,
str::FromStr,
};
@@ -60,6 +61,8 @@ pub struct LanguageConfiguration {
/// If set, overrides `editor.path-completion`.
pub path_completion: Option<bool>,
/// If set, overrides `editor.word-completion`.
pub word_completion: Option<WordCompletion>,
#[serde(default)]
pub diagnostic_severity: Severity,
@@ -98,6 +101,8 @@ pub struct LanguageConfiguration {
pub workspace_lsp_roots: Option<Vec<PathBuf>>,
#[serde(default)]
pub persistent_diagnostic_sources: Vec<String>,
/// Overrides the `editor.rainbow-brackets` config key for the language.
pub rainbow_brackets: Option<bool>,
}
impl LanguageConfiguration {
@@ -441,6 +446,7 @@ pub enum DebugArgumentValue {
String(String),
Array(Vec<String>),
Boolean(bool),
Table(HashMap<String, String>),
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
@@ -572,6 +578,13 @@ pub struct SoftWrap {
pub wrap_at_text_width: Option<bool>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct WordCompletion {
pub enable: Option<bool>,
pub trigger_length: Option<NonZeroU8>,
}
fn deserialize_regex<'de, D>(deserializer: D) -> Result<Option<rope::Regex>, D::Error>
where
D: serde::Deserializer<'de>,

View File

@@ -19,6 +19,16 @@ pub enum Operation {
Insert(Tendril),
}
impl Operation {
/// The number of characters affected by the operation.
pub fn len_chars(&self) -> usize {
match self {
Self::Retain(n) | Self::Delete(n) => *n,
Self::Insert(s) => s.chars().count(),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Assoc {
Before,
@@ -513,6 +523,49 @@ impl ChangeSet {
pub fn changes_iter(&self) -> ChangeIterator {
ChangeIterator::new(self)
}
pub fn from_change(doc: &Rope, change: Change) -> Self {
Self::from_changes(doc, std::iter::once(change))
}
/// Generate a ChangeSet from a set of changes.
pub fn from_changes<I>(doc: &Rope, changes: I) -> Self
where
I: Iterator<Item = Change>,
{
let len = doc.len_chars();
let (lower, upper) = changes.size_hint();
let size = upper.unwrap_or(lower);
let mut changeset = ChangeSet::with_capacity(2 * size + 1); // rough estimate
let mut last = 0;
for (from, to, tendril) in changes {
// Verify ranges are ordered and not overlapping
debug_assert!(last <= from);
// Verify ranges are correct
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);
let span = to - from;
match tendril {
Some(text) => {
changeset.insert(text);
changeset.delete(span);
}
None => changeset.delete(span),
}
last = to;
}
changeset.retain(len - last);
changeset
}
}
/// Transaction represents a single undoable unit of changes. Several changes can be grouped into
@@ -606,38 +659,7 @@ impl Transaction {
where
I: Iterator<Item = Change>,
{
let len = doc.len_chars();
let (lower, upper) = changes.size_hint();
let size = upper.unwrap_or(lower);
let mut changeset = ChangeSet::with_capacity(2 * size + 1); // rough estimate
let mut last = 0;
for (from, to, tendril) in changes {
// Verify ranges are ordered and not overlapping
debug_assert!(last <= from);
// Verify ranges are correct
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);
let span = to - from;
match tendril {
Some(text) => {
changeset.insert(text);
changeset.delete(span);
}
None => changeset.delete(span),
}
last = to;
}
changeset.retain(len - last);
Self::from(changeset)
Self::from(ChangeSet::from_changes(doc, changes))
}
/// Generate a transaction from a set of potentially overlapping deletions
@@ -726,9 +748,60 @@ impl Transaction {
)
}
/// Generate a transaction with a change per selection range, which
/// generates a new selection as well. Each range is operated upon by
/// the given function and can optionally produce a new range. If none
/// is returned by the function, that range is mapped through the change
/// as usual.
pub fn change_by_and_with_selection<F>(doc: &Rope, selection: &Selection, mut f: F) -> Self
where
F: FnMut(&Range) -> (Change, Option<Range>),
{
let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offset = 0;
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let ((from, to, replacement), end_range) = f(start_range);
let mut change_size = to as isize - from as isize;
if let Some(ref text) = replacement {
change_size = text.chars().count() as isize - change_size;
} else {
change_size = -change_size;
}
let new_range = if let Some(end_range) = end_range {
end_range
} else {
let changeset = ChangeSet::from_change(doc, (from, to, replacement.clone()));
start_range.map(&changeset)
};
let offset_range = Range::new(
(new_range.anchor as isize + offset) as usize,
(new_range.head as isize + offset) as usize,
);
end_ranges.push(offset_range);
offset += change_size;
log::trace!(
"from: {}, to: {}, replacement: {:?}, offset: {}",
from,
to,
replacement,
offset
);
(from, to, replacement)
});
transaction.with_selection(Selection::new(end_ranges, selection.primary_index()))
}
/// 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
/// In that case they are merged.
pub fn delete_by_selection<F>(doc: &Rope, selection: &Selection, f: F) -> Self
where
F: FnMut(&Range) -> Deletion,
@@ -736,6 +809,59 @@ impl Transaction {
Self::delete(doc, selection.iter().map(f))
}
/// Generate a transaction with a delete per selection range, which
/// generates a new selection as well. Each range is operated upon by
/// the given function and can optionally produce a new range. If none
/// is returned by the function, that range is mapped through the change
/// as usual.
///
/// Compared to using `change_by_and_with_selection` directly these ranges
/// may overlap. In that case they are merged.
pub fn delete_by_and_with_selection<F>(doc: &Rope, selection: &Selection, mut f: F) -> Self
where
F: FnMut(&Range) -> (Deletion, Option<Range>),
{
let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offset = 0;
let mut last = 0;
let transaction = Transaction::delete_by_selection(doc, selection, |start_range| {
let ((from, to), end_range) = f(start_range);
// must account for possibly overlapping deletes
let change_size = if last > from { to - last } else { to - from };
let new_range = if let Some(end_range) = end_range {
end_range
} else {
let changeset = ChangeSet::from_change(doc, (from, to, None));
start_range.map(&changeset)
};
let offset_range = Range::new(
new_range.anchor.saturating_sub(offset),
new_range.head.saturating_sub(offset),
);
log::trace!(
"delete from: {}, to: {}, offset: {}, new_range: {:?}, offset_range: {:?}",
from,
to,
offset,
new_range,
offset_range
);
end_ranges.push(offset_range);
offset += change_size;
last = to;
(from, to)
});
transaction.with_selection(Selection::new(end_ranges, selection.primary_index()))
}
/// Insert text at each selection head.
pub fn insert(doc: &Rope, selection: &Selection, text: Tendril) -> Self {
Self::change_by_selection(doc, selection, |range| {

View File

@@ -13,7 +13,7 @@ homepage.workspace = true
[dependencies]
foldhash.workspace = true
hashbrown = "0.15"
hashbrown = "0.16"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot", "macros"] }
# the event registry is essentially read only but must be an rwlock so we can
# setup new events on initialization, hardware-lock-elision hugely benefits this case

View File

@@ -19,7 +19,7 @@ helix-stdx = { path = "../helix-stdx" }
anyhow = "1"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
toml.workspace = true
etcetera = "0.10"
once_cell = "1.21"
log = "0.4"

View File

@@ -213,6 +213,27 @@ fn get_grammar_configs() -> Result<Vec<GrammarConfiguration>> {
Ok(grammars)
}
pub fn get_grammar_names() -> Result<Option<HashSet<String>>> {
let config: Configuration = crate::config::user_lang_config()
.context("Could not parse languages.toml")?
.try_into()?;
let grammars = match config.grammar_selection {
Some(GrammarSelection::Only { only: selections }) => Some(selections),
Some(GrammarSelection::Except { except: rejections }) => Some(
config
.grammar
.into_iter()
.map(|grammar| grammar.grammar_id)
.filter(|id| !rejections.contains(id))
.collect(),
),
None => None,
};
Ok(grammars)
}
fn run_parallel<F, Res>(grammars: Vec<GrammarConfiguration>, job: F) -> Vec<(String, Result<Res>)>
where
F: Fn(GrammarConfiguration) -> Result<Res> + Send + 'static + Clone,

View File

@@ -244,7 +244,12 @@ pub fn merge_toml_values(left: toml::Value, right: toml::Value, merge_depth: usi
/// Otherwise (workspace, false) is returned
pub fn find_workspace() -> (PathBuf, bool) {
let current_dir = current_working_dir();
for ancestor in current_dir.ancestors() {
find_workspace_in(current_dir)
}
pub fn find_workspace_in(dir: impl AsRef<Path>) -> (PathBuf, bool) {
let dir = dir.as_ref();
for ancestor in dir.ancestors() {
if ancestor.join(".git").exists()
|| ancestor.join(".svn").exists()
|| ancestor.join(".jj").exists()
@@ -254,7 +259,7 @@ pub fn find_workspace() -> (PathBuf, bool) {
}
}
(current_dir, true)
(dir.to_owned(), true)
}
fn default_config_file() -> PathBuf {

View File

@@ -23,7 +23,7 @@ license = "MIT"
[dependencies]
bitflags.workspace = true
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
serde_json = "1.0.143"
url = {version = "2.5.4", features = ["serde"]}
[features]

View File

@@ -25,7 +25,7 @@ globset = "0.4.16"
log = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.46", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio = { version = "1.47", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio-stream.workspace = true
parking_lot.workspace = true
arc-swap = "1"

View File

@@ -19,11 +19,11 @@ which = "8.0"
regex-cursor = "0.1.5"
bitflags.workspace = true
once_cell = "1.21"
regex-automata = "0.4.9"
regex-automata = "0.4.10"
unicode-segmentation.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.0", features = ["fs"] }

View File

@@ -71,6 +71,16 @@ mod imp {
perms.set_mode(new_perms);
}
#[cfg(target_os = "macos")]
{
use std::fs::{File, FileTimes};
use std::os::macos::fs::FileTimesExt;
let to_file = File::options().write(true).open(to)?;
let times = FileTimes::new().set_created(from_meta.created()?);
to_file.set_times(times)?;
}
std::fs::set_permissions(to, perms)?;
Ok(())
@@ -109,7 +119,13 @@ mod imp {
use std::ffi::c_void;
use std::os::windows::{ffi::OsStrExt, fs::OpenOptionsExt, io::AsRawHandle};
use std::os::windows::{
ffi::OsStrExt,
fs::{FileTimesExt, OpenOptionsExt},
io::AsRawHandle,
};
use std::fs::{File, FileTimes};
struct SecurityDescriptor {
sd: PSECURITY_DESCRIPTOR,
@@ -413,6 +429,10 @@ mod imp {
let meta = std::fs::metadata(from)?;
let perms = meta.permissions();
let to_file = File::options().write(true).open(to)?;
let times = FileTimes::new().set_created(meta.created()?);
to_file.set_times(times)?;
std::fs::set_permissions(to, perms)?;
Ok(())

View File

@@ -54,14 +54,14 @@ anyhow = "1"
once_cell = "1.21"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] }
tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] }
crossterm = { version = "0.28", features = ["event-stream"] }
tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["termina"] }
termina = { workspace = true, features = ["event-stream"] }
signal-hook = "0.3"
tokio-stream = "0.1"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
arc-swap = { version = "1.7.1" }
termini = "1"
indexmap = "2.10"
indexmap = "2.11"
# Logging
fern = "0.7"
@@ -82,7 +82,7 @@ open = "5.3.2"
url = "2.5.4"
# config
toml = "0.8"
toml.workspace = true
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
@@ -91,12 +91,11 @@ serde = { version = "1.0", features = ["derive"] }
grep-regex = "0.1.13"
grep-searcher = "0.1.14"
dashmap = "6.0"
[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.174"
[target.'cfg(target_os = "macos")'.dependencies]
crossterm = { version = "0.28", features = ["event-stream", "use-dev-tty", "libc"] }
libc = "0.2.175"
[build-dependencies]
helix-loader = { path = "../helix-loader" }

View File

@@ -30,28 +30,27 @@ use crate::{
};
use log::{debug, error, info, warn};
#[cfg(not(feature = "integration"))]
use std::io::stdout;
use std::{io::stdin, path::Path, sync::Arc};
use std::{
io::{stdin, IsTerminal},
path::Path,
sync::Arc,
};
#[cfg(not(windows))]
use anyhow::Context;
use anyhow::Error;
use anyhow::{Context, Error};
use crossterm::{event::Event as CrosstermEvent, tty::IsTty};
#[cfg(not(windows))]
use {signal_hook::consts::signal, signal_hook_tokio::Signals};
#[cfg(windows)]
type Signals = futures_util::stream::Empty<()>;
#[cfg(not(feature = "integration"))]
use tui::backend::CrosstermBackend;
use tui::backend::TerminaBackend;
#[cfg(feature = "integration")]
use tui::backend::TestBackend;
#[cfg(not(feature = "integration"))]
type TerminalBackend = CrosstermBackend<std::io::Stdout>;
type TerminalBackend = TerminaBackend;
#[cfg(feature = "integration")]
type TerminalBackend = TestBackend;
@@ -104,7 +103,8 @@ impl Application {
let theme_loader = theme::Loader::new(&theme_parent_dirs);
#[cfg(not(feature = "integration"))]
let backend = CrosstermBackend::new(stdout(), &config.editor);
let backend = TerminaBackend::new((&config.editor).into())
.context("failed to create terminal backend")?;
#[cfg(feature = "integration")]
let backend = TestBackend::new(120, 150);
@@ -123,7 +123,11 @@ impl Application {
})),
handlers,
);
Self::load_configured_theme(&mut editor, &config.load());
Self::load_configured_theme(
&mut editor,
&config.load(),
terminal.backend().supports_true_color(),
);
let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| {
&config.keys
@@ -214,7 +218,7 @@ impl Application {
} else {
editor.new_file(Action::VerticalSplit);
}
} else if stdin().is_tty() || cfg!(feature = "integration") {
} else if stdin().is_terminal() || cfg!(feature = "integration") {
editor.new_file(Action::VerticalSplit);
} else {
editor
@@ -282,7 +286,7 @@ impl Application {
pub async fn event_loop<S>(&mut self, input_stream: &mut S)
where
S: Stream<Item = std::io::Result<crossterm::event::Event>> + Unpin,
S: Stream<Item = std::io::Result<termina::Event>> + Unpin,
{
self.render().await;
@@ -295,7 +299,7 @@ impl Application {
pub async fn event_loop_until_idle<S>(&mut self, input_stream: &mut S) -> bool
where
S: Stream<Item = std::io::Result<crossterm::event::Event>> + Unpin,
S: Stream<Item = std::io::Result<termina::Event>> + Unpin,
{
loop {
if self.editor.should_close() {
@@ -367,7 +371,7 @@ 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()) {
if let Err(err) = self.terminal.reconfigure((&app_config.editor).into()) {
self.editor.set_error(err.to_string());
};
self.config.store(Arc::new(app_config));
@@ -396,7 +400,11 @@ impl Application {
// the sake of locals highlighting.
let lang_loader = helix_core::config::user_lang_loader()?;
self.editor.syn_loader.store(Arc::new(lang_loader));
Self::load_configured_theme(&mut self.editor, &default_config);
Self::load_configured_theme(
&mut self.editor,
&default_config,
self.terminal.backend().supports_true_color(),
);
// Re-parse any open documents with the new language config.
let lang_loader = self.editor.syn_loader.load();
@@ -412,8 +420,7 @@ impl Application {
document.replace_diagnostics(diagnostics, &[], None);
}
self.terminal
.reconfigure(default_config.editor.clone().into())?;
self.terminal.reconfigure((&default_config.editor).into())?;
// Store new config
self.config.store(Arc::new(default_config));
Ok(())
@@ -430,8 +437,8 @@ impl Application {
}
/// Load the theme set in configuration
fn load_configured_theme(editor: &mut Editor, config: &Config) {
let true_color = config.editor.true_color || crate::true_color();
fn load_configured_theme(editor: &mut Editor, config: &Config, terminal_true_color: bool) {
let true_color = terminal_true_color || config.editor.true_color || crate::true_color();
let theme = config
.theme
.as_ref()
@@ -503,7 +510,7 @@ impl Application {
// https://github.com/neovim/neovim/issues/12322
// https://github.com/neovim/neovim/pull/13084
for retries in 1..=10 {
match self.claim_term().await {
match self.terminal.claim() {
Ok(()) => break,
Err(err) if retries == 10 => panic!("Failed to claim terminal: {}", err),
Err(_) => continue,
@@ -573,24 +580,41 @@ impl Application {
doc.set_last_saved_revision(doc_save_event.revision, doc_save_event.save_time);
let lines = doc_save_event.text.len_lines();
let mut sz = doc_save_event.text.len_bytes() as f32;
let size = doc_save_event.text.len_bytes();
const SUFFIX: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
let mut i = 0;
while i < SUFFIX.len() - 1 && sz >= 1024.0 {
sz /= 1024.0;
i += 1;
enum Size {
Bytes(u16),
HumanReadable(f32, &'static str),
}
impl std::fmt::Display for Size {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bytes(bytes) => write!(f, "{bytes}B"),
Self::HumanReadable(size, suffix) => write!(f, "{size:.1}{suffix}"),
}
}
}
let size = if size < 1024 {
Size::Bytes(size as u16)
} else {
const SUFFIX: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
let mut size = size as f32;
let mut i = 0;
while i < SUFFIX.len() - 1 && size >= 1024.0 {
size /= 1024.0;
i += 1;
}
Size::HumanReadable(size, SUFFIX[i])
};
self.editor
.set_doc_path(doc_save_event.doc_id, &doc_save_event.path);
// TODO: fix being overwritten by lsp
self.editor.set_status(format!(
"'{}' written, {}L {:.1}{}",
"'{}' written, {lines}L {size}",
get_relative_path(&doc_save_event.path).to_string_lossy(),
lines,
sz,
SUFFIX[i],
));
}
@@ -635,7 +659,7 @@ impl Application {
false
}
pub async fn handle_terminal_events(&mut self, event: std::io::Result<CrosstermEvent>) {
pub async fn handle_terminal_events(&mut self, event: std::io::Result<termina::Event>) {
let mut cx = crate::compositor::Context {
editor: &mut self.editor,
jobs: &mut self.jobs,
@@ -643,9 +667,9 @@ impl Application {
};
// Handle key events
let should_redraw = match event.unwrap() {
CrosstermEvent::Resize(width, height) => {
termina::Event::WindowResized(termina::WindowSize { rows, cols, .. }) => {
self.terminal
.resize(Rect::new(0, 0, width, height))
.resize(Rect::new(0, 0, cols, rows))
.expect("Unable to resize terminal");
let area = self.terminal.size().expect("couldn't get terminal size");
@@ -653,11 +677,11 @@ impl Application {
self.compositor.resize(area);
self.compositor
.handle_event(&Event::Resize(width, height), &mut cx)
.handle_event(&Event::Resize(cols, rows), &mut cx)
}
// Ignore keyboard release events.
CrosstermEvent::Key(crossterm::event::KeyEvent {
kind: crossterm::event::KeyEventKind::Release,
termina::Event::Key(termina::event::KeyEvent {
kind: termina::event::KeyEventKind::Release,
..
}) => false,
event => self.compositor.handle_event(&event.into(), &mut cx),
@@ -1099,36 +1123,48 @@ impl Application {
lsp::ShowDocumentResult { success: true }
}
async fn claim_term(&mut self) -> std::io::Result<()> {
let terminal_config = self.config.load().editor.clone().into();
self.terminal.claim(terminal_config)
}
fn restore_term(&mut self) -> std::io::Result<()> {
let terminal_config = self.config.load().editor.clone().into();
use helix_view::graphics::CursorKind;
self.terminal
.backend_mut()
.show_cursor(CursorKind::Block)
.ok();
self.terminal.restore(terminal_config)
self.terminal.restore()
}
#[cfg(not(feature = "integration"))]
pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<termina::Event>> + Unpin {
use termina::Terminal as _;
let reader = self.terminal.backend().terminal().event_reader();
termina::EventStream::new(reader, |event| !event.is_escape())
}
#[cfg(feature = "integration")]
pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<termina::Event>> + Unpin {
use std::{
pin::Pin,
task::{Context, Poll},
};
/// A dummy stream that never polls as ready.
pub struct DummyEventStream;
impl Stream for DummyEventStream {
type Item = std::io::Result<termina::Event>;
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Poll::Pending
}
}
DummyEventStream
}
pub async fn run<S>(&mut self, input_stream: &mut S) -> Result<i32, Error>
where
S: Stream<Item = std::io::Result<crossterm::event::Event>> + Unpin,
S: Stream<Item = std::io::Result<termina::Event>> + Unpin,
{
self.claim_term().await?;
// Exit the alternate screen and disable raw mode before panicking
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// We can't handle errors properly inside this closure. And it's
// probably not a good idea to `unwrap()` inside a panic handler.
// So we just ignore the `Result`.
let _ = TerminalBackend::force_restore();
hook(info);
}));
self.terminal.claim()?;
self.event_loop(input_stream).await;

View File

@@ -1,5 +1,6 @@
pub(crate) mod dap;
pub(crate) mod lsp;
pub(crate) mod syntax;
pub(crate) mod typed;
pub use dap::*;
@@ -11,6 +12,7 @@ use helix_stdx::{
};
use helix_vcs::{FileChange, Hunk};
pub use lsp::*;
pub use syntax::*;
use tui::{
text::{Span, Spans},
widgets::Cell,
@@ -20,7 +22,8 @@ pub use typed::*;
use helix_core::{
char_idx_at_visual_offset,
chars::char_is_word,
command_line, comment,
command_line::{self, Args},
comment,
doc_formatter::TextFormat,
encoding, find_workspace,
graphemes::{self, next_grapheme_boundary},
@@ -44,6 +47,7 @@ use helix_core::{
use helix_view::{
document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
editor::Action,
expansion,
info::Info,
input::KeyEvent,
keyboard::KeyCode,
@@ -405,9 +409,13 @@ impl MappableCommand {
buffer_picker, "Open buffer picker",
jumplist_picker, "Open jumplist picker",
symbol_picker, "Open symbol picker",
syntax_symbol_picker, "Open symbol picker from syntax information",
lsp_or_syntax_symbol_picker, "Open symbol picker from LSP or syntax information",
changed_file_picker, "Open changed file picker",
select_references_to_symbol_under_cursor, "Select symbol references",
workspace_symbol_picker, "Open workspace symbol picker",
syntax_workspace_symbol_picker, "Open workspace symbol picker from syntax information",
lsp_or_syntax_workspace_symbol_picker, "Open workspace symbol picker from LSP or syntax information",
diagnostics_picker, "Open diagnostic picker",
workspace_diagnostics_picker, "Open workspace diagnostic picker",
last_picker, "Open last picker",
@@ -466,6 +474,8 @@ impl MappableCommand {
smart_tab, "Insert tab if all cursors have all whitespace to their left; otherwise, run a separate command.",
insert_tab, "Insert tab char",
insert_newline, "Insert newline char",
insert_char_interactive, "Insert an interactively-chosen char",
append_char_interactive, "Append an interactively-chosen char",
delete_char_backward, "Delete previous char",
delete_char_forward, "Delete next char",
delete_word_backward, "Delete previous word",
@@ -520,6 +530,7 @@ impl MappableCommand {
select_prev_sibling, "Select previous sibling the in syntax tree",
select_all_siblings, "Select all siblings of the current node",
select_all_children, "Select all children of the current node",
expand_selection_around, "Expand selection to parent syntax node, but exclude the selection you started with",
jump_forward, "Jump forward on jumplist",
jump_backward, "Jump backward on jumplist",
save_selection, "Save current selection to jumplist",
@@ -565,6 +576,8 @@ impl MappableCommand {
goto_prev_comment, "Goto previous comment",
goto_next_test, "Goto next test",
goto_prev_test, "Goto previous test",
goto_next_xml_element, "Goto next (X)HTML element",
goto_prev_xml_element, "Goto previous (X)HTML element",
goto_next_entry, "Goto next pairing",
goto_prev_entry, "Goto previous pairing",
goto_next_paragraph, "Goto next paragraph",
@@ -3189,9 +3202,11 @@ fn buffer_picker(cx: &mut Context) {
.into()
}),
];
let initial_cursor = if items.len() <= 1 { 0 } else { 1 };
let picker = Picker::new(columns, 2, items, (), |cx, meta, action| {
cx.editor.switch(meta.id, action);
})
.with_initial_cursor(initial_cursor)
.with_preview(|editor, meta| {
let doc = &editor.documents.get(&meta.id)?;
let lines = doc.selections().values().next().map(|selection| {
@@ -3928,6 +3943,7 @@ fn goto_first_diag(cx: &mut Context) {
Some(diag) => Selection::single(diag.range.start, diag.range.end),
None => return,
};
doc.set_selection(view.id, selection);
view.diagnostics_handler
.immediately_show_diagnostic(doc, view.id);
@@ -3939,6 +3955,7 @@ fn goto_last_diag(cx: &mut Context) {
Some(diag) => Selection::single(diag.range.start, diag.range.end),
None => return,
};
doc.set_selection(view.id, selection);
view.diagnostics_handler
.immediately_show_diagnostic(doc, view.id);
@@ -3995,6 +4012,7 @@ fn goto_prev_diag(cx: &mut Context) {
view.diagnostics_handler
.immediately_show_diagnostic(doc, view.id);
};
cx.editor.apply_motion(motion)
}
@@ -4096,7 +4114,7 @@ fn hunk_range(hunk: Hunk, text: RopeSlice) -> Range {
}
pub mod insert {
use crate::events::PostInsertChar;
use crate::{events::PostInsertChar, key};
use super::*;
pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
@@ -4113,16 +4131,6 @@ pub mod insert {
}
}
// The default insert hook: simply insert the character
#[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature
fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
let cursors = selection.clone().cursors(doc.slice(..));
let mut t = Tendril::new();
t.push(ch);
let transaction = Transaction::insert(doc, &cursors, t);
Some(transaction)
}
use helix_core::auto_pairs;
use helix_view::editor::SmartTabConfig;
@@ -4132,15 +4140,25 @@ pub mod insert {
let selection = doc.selection(view.id);
let auto_pairs = doc.auto_pairs(cx.editor);
let transaction = auto_pairs
.as_ref()
.and_then(|ap| auto_pairs::hook(text, selection, c, ap))
.or_else(|| insert(text, selection, c));
let insert_char = |range: Range, ch: char| {
let cursor = range.cursor(text.slice(..));
let t = Tendril::from_iter([ch]);
((cursor, cursor, Some(t)), None)
};
let (view, doc) = current!(cx.editor);
if let Some(t) = transaction {
doc.apply(&t, view.id);
}
let transaction = Transaction::change_by_and_with_selection(text, selection, |range| {
auto_pairs
.as_ref()
.and_then(|ap| {
auto_pairs::hook_insert(text, range, c, ap)
.map(|(change, range)| (change, Some(range)))
.or_else(|| Some(insert_char(*range, c)))
})
.unwrap_or_else(|| insert_char(*range, c))
});
let doc = doc_mut!(cx.editor, &doc.id());
doc.apply(&transaction, view.id);
helix_event::dispatch(PostInsertChar { c, cx });
}
@@ -4175,11 +4193,15 @@ pub mod insert {
}
pub fn insert_tab(cx: &mut Context) {
insert_tab_impl(cx, 1)
}
fn insert_tab_impl(cx: &mut Context, count: usize) {
let (view, doc) = current!(cx.editor);
// TODO: round out to nearest indentation level (for example a line with 3 spaces should
// indent by one to reach 4 spaces).
let indent = Tendril::from(doc.indent_style.as_str());
let indent = Tendril::from(doc.indent_style.as_str().repeat(count));
let transaction = Transaction::insert(
doc.text(),
&doc.selection(view.id).clone().cursors(doc.text().slice(..)),
@@ -4188,6 +4210,49 @@ pub mod insert {
doc.apply(&transaction, view.id);
}
pub fn append_char_interactive(cx: &mut Context) {
// Save the current mode, so we can restore it later.
let mode = cx.editor.mode;
append_mode(cx);
insert_selection_interactive(cx, mode);
}
pub fn insert_char_interactive(cx: &mut Context) {
let mode = cx.editor.mode;
insert_mode(cx);
insert_selection_interactive(cx, mode);
}
fn insert_selection_interactive(cx: &mut Context, old_mode: Mode) {
let count = cx.count();
// need to wait for next key
cx.on_next_key(move |cx, event| {
match event {
KeyEvent {
code: KeyCode::Char(ch),
..
} => {
for _ in 0..count {
insert::insert_char(cx, ch)
}
}
key!(Enter) => {
if count != 1 {
cx.editor
.set_error("inserting multiple newlines not yet supported");
return;
}
insert_newline(cx)
}
key!(Tab) => insert_tab_impl(cx, count),
_ => (),
};
// Restore the old mode.
cx.editor.mode = old_mode;
});
}
pub fn insert_newline(cx: &mut Context) {
let config = cx.editor.config();
let (view, doc) = current_ref!(cx.editor);
@@ -4337,82 +4402,96 @@ pub mod insert {
doc.apply(&transaction, view.id);
}
fn dedent(doc: &Document, range: &Range) -> Option<Deletion> {
let text = doc.text().slice(..);
let pos = range.cursor(text);
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.
let fragment = Cow::from(text.slice(line_start_pos..pos));
if fragment.is_empty() || !fragment.chars().all(|ch| ch == ' ' || ch == '\t') {
return None;
}
if text.get_char(pos.saturating_sub(1)) == Some('\t') {
// fast path, delete one char
return Some((graphemes::nth_prev_grapheme_boundary(text, pos, 1), pos));
}
let tab_width = doc.tab_width();
let indent_width = doc.indent_width();
let width: usize = fragment
.chars()
.map(|ch| {
if ch == '\t' {
tab_width
} else {
// it can be none if it still meet control characters other than '\t'
// here just set the width to 1 (or some value better?).
ch.width().unwrap_or(1)
}
})
.sum();
// round down to nearest unit
let mut drop = width % indent_width;
// if it's already at a unit, consume a whole unit
if drop == 0 {
drop = indent_width
};
let mut chars = fragment.chars().rev();
let mut start = pos;
for _ in 0..drop {
// delete up to `drop` spaces
match chars.next() {
Some(' ') => start -= 1,
_ => break,
}
}
Some((start, pos)) // delete!
}
pub fn delete_char_backward(cx: &mut Context) {
let count = cx.count();
let (view, doc) = current_ref!(cx.editor);
let text = doc.text().slice(..);
let tab_width = doc.tab_width();
let indent_width = doc.indent_width();
let auto_pairs = doc.auto_pairs(cx.editor);
let transaction =
Transaction::delete_by_selection(doc.text(), doc.selection(view.id), |range| {
let transaction = Transaction::delete_by_and_with_selection(
doc.text(),
doc.selection(view.id),
|range| {
let pos = range.cursor(text);
log::debug!("cursor: {}, len: {}", pos, text.len_chars());
if pos == 0 {
return (pos, pos);
return ((pos, pos), None);
}
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.
let fragment = Cow::from(text.slice(line_start_pos..pos));
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)
} else {
let width: usize = fragment
.chars()
.map(|ch| {
if ch == '\t' {
tab_width
} else {
// it can be none if it still meet control characters other than '\t'
// here just set the width to 1 (or some value better?).
ch.width().unwrap_or(1)
}
})
.sum();
let mut drop = width % indent_width; // round down to nearest unit
if drop == 0 {
drop = indent_width
}; // if it's already at a unit, consume a whole unit
let mut chars = fragment.chars().rev();
let mut start = pos;
for _ in 0..drop {
// delete up to `drop` spaces
match chars.next() {
Some(' ') => start -= 1,
_ => break,
}
}
(start, pos) // delete!
}
} else {
match (
text.get_char(pos.saturating_sub(1)),
text.get_char(pos),
auto_pairs,
) {
(Some(_x), Some(_y), Some(ap))
if range.is_single_grapheme(text)
&& ap.get(_x).is_some()
&& ap.get(_x).unwrap().open == _x
&& ap.get(_x).unwrap().close == _y =>
// delete both autopaired characters
{
(
graphemes::nth_prev_grapheme_boundary(text, pos, count),
graphemes::nth_next_grapheme_boundary(text, pos, count),
)
}
_ =>
// delete 1 char
{
(graphemes::nth_prev_grapheme_boundary(text, pos, count), pos)
}
}
}
});
let (view, doc) = current!(cx.editor);
dedent(doc, range)
.map(|dedent| (dedent, None))
.or_else(|| {
auto_pairs::hook_delete(doc.text(), range, doc.auto_pairs(cx.editor)?)
.map(|(delete, new_range)| (delete, Some(new_range)))
})
.unwrap_or_else(|| {
(
(graphemes::nth_prev_grapheme_boundary(text, pos, count), pos),
None,
)
})
},
);
log::debug!("delete_char_backward transaction: {:?}", transaction);
let doc = doc_mut!(cx.editor, &doc.id());
doc.apply(&transaction, view.id);
}
@@ -5311,6 +5390,7 @@ fn rotate_selections_last(cx: &mut Context) {
doc.set_selection(view.id, selection);
}
#[derive(Debug)]
enum ReorderStrategy {
RotateForward,
RotateBackward,
@@ -5323,34 +5403,50 @@ fn reorder_selection_contents(cx: &mut Context, strategy: ReorderStrategy) {
let text = doc.text().slice(..);
let selection = doc.selection(view.id);
let mut fragments: Vec<_> = selection
let mut ranges: Vec<_> = selection
.slices(text)
.map(|fragment| fragment.chunks().collect())
.collect();
let group = count
.map(|count| count.get())
.unwrap_or(fragments.len()) // default to rotating everything as one group
.min(fragments.len());
let rotate_by = count.map_or(1, |count| count.get().min(ranges.len()));
for chunk in fragments.chunks_mut(group) {
// TODO: also modify main index
match strategy {
ReorderStrategy::RotateForward => chunk.rotate_right(1),
ReorderStrategy::RotateBackward => chunk.rotate_left(1),
ReorderStrategy::Reverse => chunk.reverse(),
};
}
let primary_index = match strategy {
ReorderStrategy::RotateForward => {
ranges.rotate_right(rotate_by);
// Like `usize::wrapping_add`, but provide a custom range from `0` to `ranges.len()`
(selection.primary_index() + ranges.len() + rotate_by) % ranges.len()
}
ReorderStrategy::RotateBackward => {
ranges.rotate_left(rotate_by);
// Like `usize::wrapping_sub`, but provide a custom range from `0` to `ranges.len()`
(selection.primary_index() + ranges.len() - rotate_by) % ranges.len()
}
ReorderStrategy::Reverse => {
if rotate_by % 2 == 0 {
// nothing changed, if we reverse something an even
// amount of times, the output will be the same
return;
}
ranges.reverse();
// -1 to turn 1-based len into 0-based index
(ranges.len() - 1) - selection.primary_index()
}
};
let transaction = Transaction::change(
doc.text(),
selection
.ranges()
.iter()
.zip(fragments)
.zip(ranges)
.map(|(range, fragment)| (range.from(), range.to(), Some(fragment))),
);
doc.set_selection(
view.id,
Selection::new(selection.ranges().into(), primary_index),
);
doc.apply(&transaction, view.id);
}
@@ -5366,6 +5462,10 @@ fn reverse_selection_contents(cx: &mut Context) {
// tree sitter node selection
const EXPAND_KEY: &str = "expand";
const EXPAND_AROUND_BASE_KEY: &str = "expand_around_base";
const PARENTS_KEY: &str = "parents";
fn expand_selection(cx: &mut Context) {
let motion = |editor: &mut Editor| {
let (view, doc) = current!(editor);
@@ -5373,42 +5473,154 @@ fn expand_selection(cx: &mut Context) {
if let Some(syntax) = doc.syntax() {
let text = doc.text().slice(..);
let current_selection = doc.selection(view.id);
let current_selection = doc.selection(view.id).clone();
let selection = object::expand_selection(syntax, text, current_selection.clone());
// check if selection is different from the last one
if *current_selection != selection {
// save current selection so it can be restored using shrink_selection
view.object_selections.push(current_selection.clone());
if current_selection != selection {
let prev_selections = doc
.view_data_mut(view.id)
.object_selections
.entry(EXPAND_KEY)
.or_default();
doc.set_selection(view.id, selection);
// save current selection so it can be restored using shrink_selection
prev_selections.push(current_selection);
doc.set_selection_clear(view.id, selection, false);
}
}
};
cx.editor.apply_motion(motion);
}
fn shrink_selection(cx: &mut Context) {
let motion = |editor: &mut Editor| {
let (view, doc) = current!(editor);
let current_selection = doc.selection(view.id);
let current_selection = doc.selection(view.id).clone();
let prev_expansions = doc
.view_data_mut(view.id)
.object_selections
.entry(EXPAND_KEY)
.or_default();
// try to restore previous selection
if let Some(prev_selection) = view.object_selections.pop() {
if current_selection.contains(&prev_selection) {
doc.set_selection(view.id, prev_selection);
return;
} else {
// clear existing selection as they can't be shrunk to anyway
view.object_selections.clear();
if let Some(prev_selection) = prev_expansions.pop() {
// allow shrinking the selection only if current selection contains the previous object selection
doc.set_selection_clear(view.id, prev_selection, false);
// Do a corresponding pop of the parents from `expand_selection_around`
doc.view_data_mut(view.id)
.object_selections
.entry(PARENTS_KEY)
.and_modify(|parents| {
parents.pop();
});
// need to do this again because borrowing
let prev_expansions = doc
.view_data_mut(view.id)
.object_selections
.entry(EXPAND_KEY)
.or_default();
// if we've emptied out the previous expansions, then clear out the
// base history as well so it doesn't get used again erroneously
if prev_expansions.is_empty() {
doc.view_data_mut(view.id)
.object_selections
.entry(EXPAND_AROUND_BASE_KEY)
.and_modify(|base| {
base.clear();
});
}
return;
}
// if not previous selection, shrink to first child
if let Some(syntax) = doc.syntax() {
let text = doc.text().slice(..);
let selection = object::shrink_selection(syntax, text, current_selection.clone());
doc.set_selection(view.id, selection);
let selection = object::shrink_selection(syntax, text, current_selection);
doc.set_selection_clear(view.id, selection, false);
}
};
cx.editor.apply_motion(motion);
}
fn expand_selection_around(cx: &mut Context) {
let motion = |editor: &mut Editor| {
let (view, doc) = current!(editor);
if doc.syntax().is_some() {
// [NOTE] we do this pop and push dance because if we don't take
// ownership of the objects, then we require multiple
// mutable references to the view's object selections
let mut parents_selection = doc
.view_data_mut(view.id)
.object_selections
.entry(PARENTS_KEY)
.or_default()
.pop();
let mut base_selection = doc
.view_data_mut(view.id)
.object_selections
.entry(EXPAND_AROUND_BASE_KEY)
.or_default()
.pop();
let current_selection = doc.selection(view.id).clone();
if parents_selection.is_none() || base_selection.is_none() {
parents_selection = Some(current_selection.clone());
base_selection = Some(current_selection.clone());
}
let text = doc.text().slice(..);
let syntax = doc.syntax().unwrap();
let outside_selection =
object::expand_selection(syntax, text, parents_selection.clone().unwrap());
let target_selection = match outside_selection
.clone()
.without(&base_selection.clone().unwrap())
{
Some(sel) => sel,
None => outside_selection.clone(),
};
// check if selection is different from the last one
if target_selection != current_selection {
// save current selection so it can be restored using shrink_selection
doc.view_data_mut(view.id)
.object_selections
.entry(EXPAND_KEY)
.or_default()
.push(current_selection);
doc.set_selection_clear(view.id, target_selection, false);
}
let parents = doc
.view_data_mut(view.id)
.object_selections
.entry(PARENTS_KEY)
.or_default();
parents.push(parents_selection.unwrap());
parents.push(outside_selection);
doc.view_data_mut(view.id)
.object_selections
.entry(EXPAND_AROUND_BASE_KEY)
.or_default()
.push(base_selection.unwrap());
}
};
cx.editor.apply_motion(motion);
}
@@ -5426,6 +5638,7 @@ where
doc.set_selection(view.id, selection);
}
};
cx.editor.apply_motion(motion);
}
@@ -5527,8 +5740,6 @@ fn match_brackets(cx: &mut Context) {
doc.set_selection(view.id, selection);
}
//
fn jump_forward(cx: &mut Context) {
let count = cx.count();
let config = cx.editor.config();
@@ -5882,6 +6093,14 @@ fn goto_prev_test(cx: &mut Context) {
goto_ts_object_impl(cx, "test", Direction::Backward)
}
fn goto_next_xml_element(cx: &mut Context) {
goto_ts_object_impl(cx, "xml-element", Direction::Forward)
}
fn goto_prev_xml_element(cx: &mut Context) {
goto_ts_object_impl(cx, "xml-element", Direction::Backward)
}
fn goto_next_entry(cx: &mut Context) {
goto_ts_object_impl(cx, "entry", Direction::Forward)
}
@@ -5949,6 +6168,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
'c' => textobject_treesitter("comment", range),
'T' => textobject_treesitter("test", range),
'e' => textobject_treesitter("entry", range),
'x' => textobject_treesitter("xml-element", range),
'p' => textobject::textobject_paragraph(text, range, objtype, count),
'm' => textobject::textobject_pair_surround_closest(
doc.syntax(),
@@ -5993,6 +6213,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
("e", "Data structure entry (tree-sitter)"),
("m", "Closest surrounding pair (tree-sitter)"),
("g", "Change"),
("x", "(X)HTML element (tree-sitter)"),
(" ", "... or any character acting as a pair"),
];
@@ -6170,64 +6391,52 @@ enum ShellBehavior {
}
fn shell_pipe(cx: &mut Context) {
shell_prompt(cx, "pipe:".into(), ShellBehavior::Replace);
shell_prompt_for_behavior(cx, "pipe:".into(), ShellBehavior::Replace);
}
fn shell_pipe_to(cx: &mut Context) {
shell_prompt(cx, "pipe-to:".into(), ShellBehavior::Ignore);
shell_prompt_for_behavior(cx, "pipe-to:".into(), ShellBehavior::Ignore);
}
fn shell_insert_output(cx: &mut Context) {
shell_prompt(cx, "insert-output:".into(), ShellBehavior::Insert);
shell_prompt_for_behavior(cx, "insert-output:".into(), ShellBehavior::Insert);
}
fn shell_append_output(cx: &mut Context) {
shell_prompt(cx, "append-output:".into(), ShellBehavior::Append);
shell_prompt_for_behavior(cx, "append-output:".into(), ShellBehavior::Append);
}
fn shell_keep_pipe(cx: &mut Context) {
ui::prompt(
cx,
"keep-pipe:".into(),
Some('|'),
ui::completers::none,
move |cx, input: &str, event: PromptEvent| {
let shell = &cx.editor.config().shell;
if event != PromptEvent::Validate {
return;
}
if input.is_empty() {
return;
}
let (view, doc) = current!(cx.editor);
let selection = doc.selection(view.id);
shell_prompt(cx, "keep-pipe:".into(), |cx, args| {
let shell = &cx.editor.config().shell;
let (view, doc) = current!(cx.editor);
let selection = doc.selection(view.id);
let mut ranges = SmallVec::with_capacity(selection.len());
let old_index = selection.primary_index();
let mut index: Option<usize> = None;
let text = doc.text().slice(..);
let mut ranges = SmallVec::with_capacity(selection.len());
let old_index = selection.primary_index();
let mut index: Option<usize> = None;
let text = doc.text().slice(..);
for (i, range) in selection.ranges().iter().enumerate() {
let fragment = range.slice(text);
if let Err(err) = shell_impl(shell, input, Some(fragment.into())) {
log::debug!("Shell command failed: {}", err);
} else {
ranges.push(*range);
if i >= old_index && index.is_none() {
index = Some(ranges.len() - 1);
}
for (i, range) in selection.ranges().iter().enumerate() {
let fragment = range.slice(text);
if let Err(err) = shell_impl(shell, args.join(" ").as_str(), Some(fragment.into())) {
log::debug!("Shell command failed: {}", err);
} else {
ranges.push(*range);
if i >= old_index && index.is_none() {
index = Some(ranges.len() - 1);
}
}
}
if ranges.is_empty() {
cx.editor.set_error("No selections remaining");
return;
}
if ranges.is_empty() {
cx.editor.set_error("No selections remaining");
return;
}
let index = index.unwrap_or_else(|| ranges.len() - 1);
doc.set_selection(view.id, Selection::new(ranges, index));
},
);
let index = index.unwrap_or_else(|| ranges.len() - 1);
doc.set_selection(view.id, Selection::new(ranges, index));
});
}
fn shell_impl(shell: &[String], cmd: &str, input: Option<Rope>) -> anyhow::Result<Tendril> {
@@ -6382,25 +6591,35 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) {
view.ensure_cursor_in_view(doc, config.scrolloff);
}
fn shell_prompt(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) {
fn shell_prompt<F>(cx: &mut Context, prompt: Cow<'static, str>, mut callback_fn: F)
where
F: FnMut(&mut compositor::Context, Args) + 'static,
{
ui::prompt(
cx,
prompt,
Some('|'),
ui::completers::shell,
move |cx, input: &str, event: PromptEvent| {
if event != PromptEvent::Validate {
|editor, input| complete_command_args(editor, SHELL_SIGNATURE, &SHELL_COMPLETER, input, 0),
move |cx, input, event| {
if event != PromptEvent::Validate || input.is_empty() {
return;
}
if input.is_empty() {
return;
match Args::parse(input, SHELL_SIGNATURE, true, |token| {
expansion::expand(cx.editor, token).map_err(|err| err.into())
}) {
Ok(args) => callback_fn(cx, args),
Err(err) => cx.editor.set_error(err.to_string()),
}
shell(cx, input, &behavior);
},
);
}
fn shell_prompt_for_behavior(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) {
shell_prompt(cx, prompt, move |cx, args| {
shell(cx, args.join(" ").as_str(), &behavior)
})
}
fn suspend(_cx: &mut Context) {
#[cfg(not(windows))]
{
@@ -6823,3 +7042,34 @@ fn jump_to_word(cx: &mut Context, behaviour: Movement) {
}
jump_to_label(cx, words, behaviour)
}
fn lsp_or_syntax_symbol_picker(cx: &mut Context) {
let doc = doc!(cx.editor);
if doc
.language_servers_with_feature(LanguageServerFeature::DocumentSymbols)
.next()
.is_some()
{
lsp::symbol_picker(cx);
} else if doc.syntax().is_some() {
syntax_symbol_picker(cx);
} else {
cx.editor
.set_error("No language server supporting document symbols or syntax info available");
}
}
fn lsp_or_syntax_workspace_symbol_picker(cx: &mut Context) {
let doc = doc!(cx.editor);
if doc
.language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols)
.next()
.is_some()
{
lsp::workspace_symbol_picker(cx);
} else {
syntax_workspace_symbol_picker(cx);
}
}

View File

@@ -164,6 +164,13 @@ pub fn dap_start_impl(
arr.iter().map(|v| v.replace(&pattern, &param)).collect(),
),
DebugArgumentValue::Boolean(_) => value,
DebugArgumentValue::Table(map) => DebugArgumentValue::Table(
map.into_iter()
.map(|(mk, mv)| {
(mk.replace(&pattern, &param), mv.replace(&pattern, &param))
})
.collect(),
),
};
}
}
@@ -182,6 +189,9 @@ pub fn dap_start_impl(
DebugArgumentValue::Boolean(bool) => {
args.insert(k, to_value(bool).unwrap());
}
DebugArgumentValue::Table(map) => {
args.insert(k, to_value(map).unwrap());
}
}
}

View File

@@ -231,6 +231,13 @@ fn diag_picker(
}
}
flat_diag.sort_by(|a, b| {
a.diag
.severity
.unwrap_or(lsp::DiagnosticSeverity::HINT)
.cmp(&b.diag.severity.unwrap_or(lsp::DiagnosticSeverity::HINT))
});
let styles = DiagnosticStyles {
hint: cx.editor.theme.get("hint"),
info: cx.editor.theme.get("info"),
@@ -928,7 +935,13 @@ where
}
let call = move |editor: &mut Editor, compositor: &mut Compositor| {
if locations.is_empty() {
editor.set_error("No definition found.");
editor.set_error(match feature {
LanguageServerFeature::GotoDeclaration => "No declaration found.",
LanguageServerFeature::GotoDefinition => "No definition found.",
LanguageServerFeature::GotoTypeDefinition => "No type definition found.",
LanguageServerFeature::GotoImplementation => "No implementation found.",
_ => "No location found.",
});
} else {
goto_impl(editor, compositor, locations);
}

View File

@@ -0,0 +1,446 @@
use std::{
collections::HashSet,
iter,
path::{Path, PathBuf},
sync::Arc,
};
use dashmap::DashMap;
use futures_util::FutureExt;
use grep_regex::RegexMatcherBuilder;
use grep_searcher::{sinks, BinaryDetection, SearcherBuilder};
use helix_core::{
syntax::{Loader, QueryIterEvent},
Rope, RopeSlice, Selection, Syntax, Uri,
};
use helix_stdx::{
path,
rope::{self, RopeSliceExt},
};
use helix_view::{
align_view,
document::{from_reader, SCRATCH_BUFFER_NAME},
Align, Document, DocumentId, Editor,
};
use ignore::{DirEntry, WalkBuilder, WalkState};
use crate::{
filter_picker_entry,
ui::{
overlay::overlaid,
picker::{Injector, PathOrId},
Picker, PickerColumn,
},
};
use super::Context;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TagKind {
Class,
Constant,
Function,
Interface,
Macro,
Module,
Struct,
Type,
}
impl TagKind {
fn as_str(&self) -> &'static str {
match self {
Self::Class => "class",
Self::Constant => "constant",
Self::Function => "function",
Self::Interface => "interface",
Self::Macro => "macro",
Self::Module => "module",
Self::Struct => "struct",
Self::Type => "type",
}
}
fn from_name(name: &str) -> Option<Self> {
match name {
"class" => Some(TagKind::Class),
"constant" => Some(TagKind::Constant),
"function" => Some(TagKind::Function),
"interface" => Some(TagKind::Interface),
"macro" => Some(TagKind::Macro),
"module" => Some(TagKind::Module),
"struct" => Some(TagKind::Struct),
"type" => Some(TagKind::Type),
_ => None,
}
}
}
// NOTE: Uri is cheap to clone and DocumentId is Copy
#[derive(Debug, Clone)]
enum UriOrDocumentId {
Uri(Uri),
Id(DocumentId),
}
impl UriOrDocumentId {
fn path_or_id(&self) -> Option<PathOrId<'_>> {
match self {
Self::Id(id) => Some(PathOrId::Id(*id)),
Self::Uri(uri) => uri.as_path().map(PathOrId::Path),
}
}
}
#[derive(Debug)]
struct Tag {
kind: TagKind,
name: String,
start: usize,
end: usize,
start_line: usize,
end_line: usize,
doc: UriOrDocumentId,
}
fn tags_iter<'a>(
syntax: &'a Syntax,
loader: &'a Loader,
text: RopeSlice<'a>,
doc: UriOrDocumentId,
pattern: Option<&'a rope::Regex>,
) -> impl Iterator<Item = Tag> + 'a {
let mut tags_iter = syntax.tags(text, loader, ..);
iter::from_fn(move || loop {
let QueryIterEvent::Match(mat) = tags_iter.next()? else {
continue;
};
let query = &loader
.tag_query(tags_iter.current_language())
.expect("must have a tags query to emit matches")
.query;
let Some(kind) = query
.capture_name(mat.capture)
.strip_prefix("definition.")
.and_then(TagKind::from_name)
else {
continue;
};
let range = mat.node.byte_range();
if pattern.is_some_and(|pattern| {
!pattern.is_match(text.regex_input_at_bytes(range.start as usize..range.end as usize))
}) {
continue;
}
let start = text.byte_to_char(range.start as usize);
let end = text.byte_to_char(range.end as usize);
return Some(Tag {
kind,
name: text.slice(start..end).to_string(),
start,
end,
start_line: text.char_to_line(start),
end_line: text.char_to_line(end),
doc: doc.clone(),
});
})
}
pub fn syntax_symbol_picker(cx: &mut Context) {
let doc = doc!(cx.editor);
let Some(syntax) = doc.syntax() else {
cx.editor
.set_error("Syntax tree is not available on this buffer");
return;
};
let doc_id = doc.id();
let text = doc.text().slice(..);
let loader = cx.editor.syn_loader.load();
let tags = tags_iter(syntax, &loader, text, UriOrDocumentId::Id(doc.id()), None);
let columns = vec![
PickerColumn::new("kind", |tag: &Tag, _| tag.kind.as_str().into()),
PickerColumn::new("name", |tag: &Tag, _| tag.name.as_str().into()),
];
let picker = Picker::new(
columns,
1, // name
tags,
(),
move |cx, tag, action| {
cx.editor.switch(doc_id, action);
let view = view_mut!(cx.editor);
let doc = doc_mut!(cx.editor, &doc_id);
doc.set_selection(view.id, Selection::single(tag.start, tag.end));
if action.align_view(view, doc.id()) {
align_view(doc, view, Align::Center)
}
},
)
.with_preview(|_editor, tag| {
Some((tag.doc.path_or_id()?, Some((tag.start_line, tag.end_line))))
})
.truncate_start(false);
cx.push_layer(Box::new(overlaid(picker)));
}
pub fn syntax_workspace_symbol_picker(cx: &mut Context) {
#[derive(Debug)]
struct SearchState {
searcher_builder: SearcherBuilder,
walk_builder: WalkBuilder,
regex_matcher_builder: RegexMatcherBuilder,
rope_regex_builder: rope::RegexBuilder,
search_root: PathBuf,
/// A cache of files that have been parsed in prior searches.
syntax_cache: DashMap<PathBuf, Option<(Rope, Syntax)>>,
}
let mut searcher_builder = SearcherBuilder::new();
searcher_builder.binary_detection(BinaryDetection::quit(b'\x00'));
// Search from the workspace that the currently focused document is within. This behaves like global
// search most of the time but helps when you have two projects open in splits.
let search_root = if let Some(path) = doc!(cx.editor).path() {
helix_loader::find_workspace_in(path).0
} else {
helix_loader::find_workspace().0
};
let absolute_root = search_root
.canonicalize()
.unwrap_or_else(|_| search_root.clone());
let config = cx.editor.config();
let dedup_symlinks = config.file_picker.deduplicate_links;
let mut walk_builder = WalkBuilder::new(&search_root);
walk_builder
.hidden(config.file_picker.hidden)
.parents(config.file_picker.parents)
.ignore(config.file_picker.ignore)
.follow_links(config.file_picker.follow_symlinks)
.git_ignore(config.file_picker.git_ignore)
.git_global(config.file_picker.git_global)
.git_exclude(config.file_picker.git_exclude)
.max_depth(config.file_picker.max_depth)
.filter_entry(move |entry| filter_picker_entry(entry, &absolute_root, dedup_symlinks))
.add_custom_ignore_filename(helix_loader::config_dir().join("ignore"))
.add_custom_ignore_filename(".helix/ignore");
let mut regex_matcher_builder = RegexMatcherBuilder::new();
regex_matcher_builder.case_smart(config.search.smart_case);
let mut rope_regex_builder = rope::RegexBuilder::new();
rope_regex_builder.syntax(rope::Config::new().case_insensitive(config.search.smart_case));
let state = SearchState {
searcher_builder,
walk_builder,
regex_matcher_builder,
rope_regex_builder,
search_root,
syntax_cache: DashMap::default(),
};
let reg = cx.register.unwrap_or('/');
cx.editor.registers.last_search_register = reg;
let columns = vec![
PickerColumn::new("kind", |tag: &Tag, _| tag.kind.as_str().into()),
PickerColumn::new("name", |tag: &Tag, _| tag.name.as_str().into()).without_filtering(),
PickerColumn::new("path", |tag: &Tag, state: &SearchState| {
match &tag.doc {
UriOrDocumentId::Uri(uri) => {
if let Some(path) = uri.as_path() {
let path = if let Ok(stripped) = path.strip_prefix(&state.search_root) {
stripped
} else {
path
};
path.to_string_lossy().into()
} else {
uri.to_string().into()
}
}
// This picker only uses `Id` for scratch buffers for better display.
UriOrDocumentId::Id(_) => SCRATCH_BUFFER_NAME.into(),
}
}),
];
let get_tags = |query: &str,
editor: &mut Editor,
state: Arc<SearchState>,
injector: &Injector<_, _>| {
if query.len() < 3 {
return async { Ok(()) }.boxed();
}
// Attempt to find the tag in any open documents.
let pattern = match state.rope_regex_builder.build(query) {
Ok(pattern) => pattern,
Err(err) => return async { Err(anyhow::anyhow!(err)) }.boxed(),
};
let loader = editor.syn_loader.load();
for doc in editor.documents() {
let Some(syntax) = doc.syntax() else { continue };
let text = doc.text().slice(..);
let uri_or_id = doc
.uri()
.map(UriOrDocumentId::Uri)
.unwrap_or_else(|| UriOrDocumentId::Id(doc.id()));
for tag in tags_iter(syntax, &loader, text.slice(..), uri_or_id, Some(&pattern)) {
if injector.push(tag).is_err() {
return async { Ok(()) }.boxed();
}
}
}
if !state.search_root.exists() {
return async { Err(anyhow::anyhow!("Current working directory does not exist")) }
.boxed();
}
let matcher = match state.regex_matcher_builder.build(query) {
Ok(matcher) => {
// Clear any "Failed to compile regex" errors out of the statusline.
editor.clear_status();
matcher
}
Err(err) => {
log::info!(
"Failed to compile search pattern in workspace symbol search: {}",
err
);
return async { Err(anyhow::anyhow!("Failed to compile regex")) }.boxed();
}
};
let pattern = Arc::new(pattern);
let injector = injector.clone();
let loader = editor.syn_loader.load();
let documents: HashSet<_> = editor
.documents()
.filter_map(Document::path)
.cloned()
.collect();
async move {
let searcher = state.searcher_builder.build();
state.walk_builder.build_parallel().run(|| {
let mut searcher = searcher.clone();
let matcher = matcher.clone();
let injector = injector.clone();
let loader = loader.clone();
let documents = &documents;
let pattern = pattern.clone();
let syntax_cache = &state.syntax_cache;
Box::new(move |entry: Result<DirEntry, ignore::Error>| -> WalkState {
let entry = match entry {
Ok(entry) => entry,
Err(_) => return WalkState::Continue,
};
match entry.file_type() {
Some(entry) if entry.is_file() => {}
// skip everything else
_ => return WalkState::Continue,
};
let path = entry.path();
// If this document is open, skip it because we've already processed it above.
if documents.contains(path) {
return WalkState::Continue;
};
let mut quit = false;
let sink = sinks::UTF8(|_line, _content| {
if !syntax_cache.contains_key(path) {
// Read the file into a Rope and attempt to recognize the language
// and parse it with tree-sitter. Save the Rope and Syntax for future
// queries.
syntax_cache.insert(path.to_path_buf(), syntax_for_path(path, &loader));
};
let entry = syntax_cache.get(path).unwrap();
let Some((text, syntax)) = entry.value() else {
// If the file couldn't be parsed, move on.
return Ok(false);
};
let uri = Uri::from(path::normalize(path));
for tag in tags_iter(
syntax,
&loader,
text.slice(..),
UriOrDocumentId::Uri(uri),
Some(&pattern),
) {
if injector.push(tag).is_err() {
quit = true;
break;
}
}
// Quit after seeing the first regex match. We only care to find files
// that contain the pattern and then we run the tags query within
// those. The location and contents of a match are irrelevant - it's
// only important _if_ a file matches.
Ok(false)
});
if let Err(err) = searcher.search_path(&matcher, path, sink) {
log::info!("Workspace syntax search error: {}, {}", path.display(), err);
}
if quit {
WalkState::Quit
} else {
WalkState::Continue
}
})
});
Ok(())
}
.boxed()
};
let picker = Picker::new(
columns,
1, // name
[],
state,
move |cx, tag, action| {
let doc_id = match &tag.doc {
UriOrDocumentId::Id(id) => *id,
UriOrDocumentId::Uri(uri) => match cx.editor.open(uri.as_path().expect(""), action) {
Ok(id) => id,
Err(e) => {
cx.editor
.set_error(format!("Failed to open file '{uri:?}': {e}"));
return;
}
}
};
let doc = doc_mut!(cx.editor, &doc_id);
let view = view_mut!(cx.editor);
let len_chars = doc.text().len_chars();
if tag.start >= len_chars || tag.end > len_chars {
cx.editor.set_error("The location you jumped to does not exist anymore because the file has changed.");
return;
}
doc.set_selection(view.id, Selection::single(tag.start, tag.end));
if action.align_view(view, doc.id()) {
align_view(doc, view, Align::Center)
}
},
)
.with_dynamic_query(get_tags, Some(275))
.with_preview(move |_editor, tag| {
Some((
tag.doc.path_or_id()?,
Some((tag.start_line, tag.end_line)),
))
})
.truncate_start(false);
cx.push_layer(Box::new(overlaid(picker)));
}
/// Create a Rope and language config for a given existing path without creating a full Document.
fn syntax_for_path(path: &Path, loader: &Loader) -> Option<(Rope, Syntax)> {
let mut file = std::fs::File::open(path).ok()?;
let (rope, _encoding, _has_bom) = from_reader(&mut file, None).ok()?;
let text = rope.slice(..);
let language = loader
.language_for_filename(path)
.or_else(|| loader.language_for_shebang(text))?;
Syntax::new(text, language, loader)
.ok()
.map(|syntax| (rope, syntax))
}

View File

@@ -29,15 +29,6 @@ pub struct TypableCommand {
pub signature: Signature,
}
impl TypableCommand {
fn completer_for_argument_number(&self, n: usize) -> &Completer {
match self.completer.positional_args.get(n) {
Some(completer) => completer,
_ => &self.completer.var_args,
}
}
}
#[derive(Clone)]
pub struct CommandCompleter {
// Arguments with specific completion methods based on their position.
@@ -68,6 +59,13 @@ impl CommandCompleter {
var_args: completer,
}
}
fn for_argument_number(&self, n: usize) -> &Completer {
match self.positional_args.get(n) {
Some(completer) => completer,
_ => &self.var_args,
}
}
}
fn quit(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
@@ -104,6 +102,10 @@ fn open(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow:
return Ok(());
}
open_impl(cx, args, Action::Replace)
}
fn open_impl(cx: &mut compositor::Context, args: Args, action: Action) -> anyhow::Result<()> {
for arg in args {
let (path, pos) = crate::args::parse_file(&arg);
let path = helix_stdx::path::expand_tilde(path);
@@ -113,7 +115,8 @@ fn open(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow:
let callback = async move {
let call: job::Callback = job::Callback::EditorCompositor(Box::new(
move |editor: &mut Editor, compositor: &mut Compositor| {
let picker = ui::file_picker(editor, path.into_owned());
let picker =
ui::file_picker(editor, path.into_owned()).with_default_action(action);
compositor.push(Box::new(overlaid(picker)));
},
));
@@ -122,7 +125,7 @@ fn open(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow:
cx.jobs.callback(callback);
} else {
// Otherwise, just open the file
let _ = cx.editor.open(&path, Action::Replace)?;
let _ = cx.editor.open(&path, action)?;
let (view, doc) = current!(cx.editor);
let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true));
doc.set_selection(view.id, pos);
@@ -1469,7 +1472,14 @@ fn update(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyho
let (_view, doc) = current!(cx.editor);
if doc.is_modified() {
write(cx, args, event)
write_impl(
cx,
None,
WriteOptions {
force: false,
auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name),
},
)
} else {
Ok(())
}
@@ -1808,10 +1818,7 @@ fn vsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyho
if args.is_empty() {
split(cx.editor, Action::VerticalSplit);
} else {
for arg in args {
cx.editor
.open(&PathBuf::from(arg.as_ref()), Action::VerticalSplit)?;
}
open_impl(cx, args, Action::VerticalSplit)?;
}
Ok(())
@@ -1825,10 +1832,7 @@ fn hsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyho
if args.is_empty() {
split(cx.editor, Action::HorizontalSplit);
} else {
for arg in args {
cx.editor
.open(&PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?;
}
open_impl(cx, args, Action::HorizontalSplit)?;
}
Ok(())
@@ -2652,13 +2656,13 @@ const BUFFER_CLOSE_OTHERS_SIGNATURE: Signature = Signature {
// but Signature does not yet allow for var args.
/// This command handles all of its input as-is with no quoting or flags.
const SHELL_SIGNATURE: Signature = Signature {
pub const SHELL_SIGNATURE: Signature = Signature {
positionals: (1, Some(2)),
raw_after: Some(1),
..Signature::DEFAULT
};
const SHELL_COMPLETER: CommandCompleter = CommandCompleter::positional(&[
pub const SHELL_COMPLETER: CommandCompleter = CommandCompleter::positional(&[
// Command name
completers::program,
// Shell argument(s)
@@ -3237,6 +3241,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
completer: CommandCompleter::none(),
signature: Signature {
positionals: (0, Some(0)),
flags: &[WRITE_NO_FORMAT_FLAG],
..Signature::DEFAULT
},
},
@@ -3824,14 +3829,15 @@ fn complete_command_line(editor: &Editor, input: &str) -> Vec<ui::prompt::Comple
.get(command)
.map_or_else(Vec::new, |cmd| {
let args_offset = command.len() + 1;
complete_command_args(editor, cmd, rest, args_offset)
complete_command_args(editor, cmd.signature, &cmd.completer, rest, args_offset)
})
}
}
fn complete_command_args(
pub fn complete_command_args(
editor: &Editor,
command: &TypableCommand,
signature: Signature,
completer: &CommandCompleter,
input: &str,
offset: usize,
) -> Vec<ui::prompt::Completion> {
@@ -3843,7 +3849,7 @@ fn complete_command_args(
let cursor = input.len();
let prefix = &input[..cursor];
let mut tokenizer = Tokenizer::new(prefix, false);
let mut args = Args::new(command.signature, false);
let mut args = Args::new(signature, false);
let mut final_token = None;
let mut is_last_token = true;
@@ -3887,7 +3893,7 @@ fn complete_command_args(
.len()
.checked_sub(1)
.expect("completion state to be positional");
let completer = command.completer_for_argument_number(n);
let completer = completer.for_argument_number(n);
completer(editor, &token.content)
.into_iter()
@@ -3896,7 +3902,7 @@ fn complete_command_args(
}
CompletionState::Flag(_) => fuzzy_match(
token.content.trim_start_matches('-'),
command.signature.flags.iter().map(|flag| flag.name),
signature.flags.iter().map(|flag| flag.name),
false,
)
.into_iter()
@@ -3921,7 +3927,7 @@ fn complete_command_args(
.len()
.checked_sub(1)
.expect("completion state to be positional");
command.completer_for_argument_number(n)
completer.for_argument_number(n)
});
complete_expand(editor, &token, arg_completer, offset + token.content_start)
}

View File

@@ -136,6 +136,11 @@ impl Compositor {
Some(self.layers.remove(idx))
}
pub fn remove_type<T: 'static>(&mut self) {
let type_name = std::any::type_name::<T>();
self.layers
.retain(|component| component.type_name() != type_name);
}
pub fn handle_event(&mut self, event: &Event, cx: &mut Context) -> bool {
// If it is a key event, a macro is being recorded, and a macro isn't being replayed,
// push the key event to the recording.

View File

@@ -8,7 +8,7 @@ use crate::events;
use crate::handlers::auto_save::AutoSaveHandler;
use crate::handlers::signature_help::SignatureHelpHandler;
pub use helix_view::handlers::Handlers;
pub use helix_view::handlers::{word_index, Handlers};
use self::document_colors::DocumentColorsHandler;
@@ -16,6 +16,7 @@ mod auto_save;
pub mod completion;
mod diagnostics;
mod document_colors;
mod prompt;
mod signature_help;
mod snippet;
@@ -26,12 +27,14 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
let signature_hints = SignatureHelpHandler::new().spawn();
let auto_save = AutoSaveHandler::new().spawn();
let document_colors = DocumentColorsHandler::default().spawn();
let word_index = word_index::Handler::spawn();
let handlers = Handlers {
completions: helix_view::handlers::completion::CompletionHandler::new(event_tx),
signature_hints,
auto_save,
document_colors,
word_index,
};
helix_view::handlers::register_hooks(&handlers);
@@ -41,5 +44,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
diagnostics::register_hooks(&handlers);
snippet::register_hooks(&handlers);
document_colors::register_hooks(&handlers);
prompt::register_hooks(&handlers);
handlers
}

View File

@@ -30,6 +30,7 @@ mod item;
mod path;
mod request;
mod resolve;
mod word;
async fn handle_response(
requests: &mut JoinSet<CompletionResponse>,
@@ -82,7 +83,7 @@ async fn replace_completions(
fn show_completion(
editor: &mut Editor,
compositor: &mut Compositor,
items: Vec<CompletionItem>,
mut items: Vec<CompletionItem>,
context: HashMap<CompletionProvider, ResponseContext>,
trigger: Trigger,
) {
@@ -101,6 +102,7 @@ fn show_completion(
if ui.completion.is_some() {
return;
}
word::retain_valid_completions(trigger, doc, view.id, &mut items);
editor.handlers.completions.active_completions = context;
let completion_area = ui.set_completion(editor, items, trigger.pos, size);

View File

@@ -28,6 +28,8 @@ use crate::job::{dispatch, dispatch_blocking};
use crate::ui;
use crate::ui::editor::InsertEvent;
use super::word;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(super) enum TriggerKind {
Auto,
@@ -242,10 +244,15 @@ fn request_completions(
doc.selection(view.id).clone(),
doc,
handle.clone(),
savepoint,
savepoint.clone(),
) {
requests.spawn_blocking(path_completion_request);
}
if let Some(word_completion_request) =
word::completion(editor, trigger, handle.clone(), savepoint)
{
requests.spawn_blocking(word_completion_request);
}
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);

View File

@@ -0,0 +1,134 @@
use std::{borrow::Cow, sync::Arc};
use helix_core::{
self as core, chars::char_is_word, completion::CompletionProvider, movement, Transaction,
};
use helix_event::TaskHandle;
use helix_stdx::rope::RopeSliceExt as _;
use helix_view::{
document::SavePoint, handlers::completion::ResponseContext, Document, Editor, ViewId,
};
use super::{request::TriggerKind, CompletionItem, CompletionItems, CompletionResponse, Trigger};
const COMPLETION_KIND: &str = "word";
pub(super) fn completion(
editor: &Editor,
trigger: Trigger,
handle: TaskHandle,
savepoint: Arc<SavePoint>,
) -> Option<impl FnOnce() -> CompletionResponse> {
if !doc!(editor).word_completion_enabled() {
return None;
}
let config = editor.config().word_completion;
let doc_config = doc!(editor)
.language_config()
.and_then(|config| config.word_completion);
let trigger_length = doc_config
.and_then(|c| c.trigger_length)
.unwrap_or(config.trigger_length)
.get() as usize;
let (view, doc) = current_ref!(editor);
let rope = doc.text().clone();
let word_index = editor.handlers.word_index().clone();
let text = doc.text().slice(..);
let selection = doc.selection(view.id).clone();
let pos = selection.primary().cursor(text);
let cursor = movement::move_prev_word_start(text, core::Range::point(pos), 1);
if cursor.head == pos {
return None;
}
if trigger.kind != TriggerKind::Manual
&& text
.slice(cursor.head..)
.graphemes()
.take(trigger_length)
.take_while(|g| g.chars().all(char_is_word))
.count()
!= trigger_length
{
return None;
}
let typed_word_range = cursor.head..pos;
let typed_word = text.slice(typed_word_range.clone());
let edit_diff = if typed_word
.char(typed_word.len_chars().saturating_sub(1))
.is_whitespace()
{
0
} else {
typed_word.len_chars()
};
if handle.is_canceled() {
return None;
}
let future = move || {
let text = rope.slice(..);
let typed_word: Cow<_> = text.slice(typed_word_range).into();
let items = word_index
.matches(&typed_word)
.into_iter()
.filter(|word| word.as_str() != typed_word.as_ref())
.map(|word| {
let transaction = Transaction::change_by_selection(&rope, &selection, |range| {
let cursor = range.cursor(text);
(cursor - edit_diff, cursor, Some((&word).into()))
});
CompletionItem::Other(core::CompletionItem {
transaction,
label: word.into(),
kind: Cow::Borrowed(COMPLETION_KIND),
documentation: None,
provider: CompletionProvider::Word,
})
})
.collect();
CompletionResponse {
items: CompletionItems::Other(items),
provider: CompletionProvider::Word,
context: ResponseContext {
is_incomplete: false,
priority: 0,
savepoint,
},
}
};
Some(future)
}
pub(super) fn retain_valid_completions(
trigger: Trigger,
doc: &Document,
view_id: ViewId,
items: &mut Vec<CompletionItem>,
) {
if trigger.kind == TriggerKind::Manual {
return;
}
let text = doc.text().slice(..);
let cursor = doc.selection(view_id).primary().cursor(text);
if text
.get_char(cursor.saturating_sub(1))
.is_some_and(|ch| ch.is_whitespace())
{
items.retain(|item| {
!matches!(
item,
CompletionItem::Other(core::CompletionItem {
provider: CompletionProvider::Word,
..
})
)
});
}
}

View File

@@ -0,0 +1,17 @@
use helix_event::register_hook;
use helix_view::events::DocumentFocusLost;
use helix_view::handlers::Handlers;
use crate::job::{self};
use crate::ui;
pub(super) fn register_hooks(_handlers: &Handlers) {
register_hook!(move |_event: &mut DocumentFocusLost<'_>| {
job::dispatch_blocking(move |_, compositor| {
if compositor.find::<ui::Prompt>().is_some() {
compositor.remove_type::<ui::Prompt>();
}
});
Ok(())
});
}

View File

@@ -1,22 +1,33 @@
use crate::config::{Config, ConfigLoadError};
use crossterm::{
style::{Color, StyledContent, Stylize},
tty::IsTty,
};
use helix_core::config::{default_lang_config, user_lang_config};
use helix_loader::grammar::load_runtime_file;
use std::io::Write;
use std::{
collections::HashSet,
io::{IsTerminal, Write},
};
use termina::{
style::{ColorSpec, StyleExt as _, Stylized},
Terminal as _,
};
#[derive(Copy, Clone)]
pub enum TsFeature {
Highlight,
TextObject,
AutoIndent,
Tags,
RainbowBracket,
}
impl TsFeature {
pub fn all() -> &'static [Self] {
&[Self::Highlight, Self::TextObject, Self::AutoIndent]
&[
Self::Highlight,
Self::TextObject,
Self::AutoIndent,
Self::Tags,
Self::RainbowBracket,
]
}
pub fn runtime_filename(&self) -> &'static str {
@@ -24,6 +35,8 @@ impl TsFeature {
Self::Highlight => "highlights.scm",
Self::TextObject => "textobjects.scm",
Self::AutoIndent => "indents.scm",
Self::Tags => "tags.scm",
Self::RainbowBracket => "rainbows.scm",
}
}
@@ -32,6 +45,8 @@ impl TsFeature {
Self::Highlight => "Syntax Highlighting",
Self::TextObject => "Treesitter Textobjects",
Self::AutoIndent => "Auto Indent",
Self::Tags => "Code Navigation Tags",
Self::RainbowBracket => "Rainbow Brackets",
}
}
@@ -40,6 +55,8 @@ impl TsFeature {
Self::Highlight => "Highlight",
Self::TextObject => "Textobject",
Self::AutoIndent => "Indent",
Self::Tags => "Tags",
Self::RainbowBracket => "Rainbow",
}
}
}
@@ -134,6 +151,15 @@ pub fn clipboard() -> std::io::Result<()> {
}
pub fn languages_all() -> std::io::Result<()> {
languages(None)
}
pub fn languages_selection() -> std::io::Result<()> {
let selection = helix_loader::grammar::get_grammar_names().unwrap_or_default();
languages(selection)
}
fn languages(selection: Option<HashSet<String>>) -> std::io::Result<()> {
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
@@ -160,21 +186,24 @@ pub fn languages_all() -> std::io::Result<()> {
headings.push(feat.short_title())
}
let terminal_cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
let terminal_cols = termina::PlatformTerminal::new()
.and_then(|terminal| terminal.get_dimensions())
.map(|size| size.cols)
.unwrap_or(80);
let column_width = terminal_cols as usize / headings.len();
let is_terminal = std::io::stdout().is_tty();
let is_terminal = std::io::stdout().is_terminal();
let fit = |s: &str| -> StyledContent<String> {
let fit = |s: &str| -> Stylized<'static> {
format!(
"{:column_width$}",
s.get(..column_width - 2)
.map(|s| format!("{}", s))
.unwrap_or_else(|| s.to_string())
)
.stylize()
.stylized()
};
let color = |s: StyledContent<String>, c: Color| if is_terminal { s.with(c) } else { s };
let bold = |s: StyledContent<String>| if is_terminal { s.bold() } else { s };
let color = |s: Stylized<'static>, c: ColorSpec| if is_terminal { s.foreground(c) } else { s };
let bold = |s: Stylized<'static>| if is_terminal { s.bold() } else { s };
for heading in headings {
write!(stdout, "{}", bold(fit(heading)))?;
@@ -187,15 +216,22 @@ pub fn languages_all() -> std::io::Result<()> {
let check_binary_with_name = |cmd: Option<(&str, &str)>| match cmd {
Some((name, cmd)) => match helix_stdx::env::which(cmd) {
Ok(_) => color(fit(&format!("{}", name)), Color::Green),
Err(_) => color(fit(&format!("{}", name)), Color::Red),
Ok(_) => color(fit(&format!("{}", name)), ColorSpec::BRIGHT_GREEN),
Err(_) => color(fit(&format!("{}", name)), ColorSpec::BRIGHT_RED),
},
None => color(fit("None"), Color::Yellow),
None => color(fit("None"), ColorSpec::BRIGHT_YELLOW),
};
let check_binary = |cmd: Option<&str>| check_binary_with_name(cmd.map(|cmd| (cmd, cmd)));
for lang in &syn_loader_conf.language {
if selection
.as_ref()
.is_some_and(|s| !s.contains(&lang.language_id))
{
continue;
}
write!(stdout, "{}", fit(&lang.language_id))?;
let mut cmds = lang.language_servers.iter().filter_map(|ls| {
@@ -217,8 +253,8 @@ pub fn languages_all() -> std::io::Result<()> {
for ts_feat in TsFeature::all() {
match load_runtime_file(&lang.language_id, ts_feat.runtime_filename()).is_ok() {
true => write!(stdout, "{}", color(fit(""), Color::Green))?,
false => write!(stdout, "{}", color(fit(""), Color::Red))?,
true => write!(stdout, "{}", color(fit(""), ColorSpec::BRIGHT_GREEN))?,
false => write!(stdout, "{}", color(fit(""), ColorSpec::BRIGHT_RED))?,
}
}
@@ -230,6 +266,14 @@ pub fn languages_all() -> std::io::Result<()> {
}
}
if selection.is_some() {
writeln!(
stdout,
"\nThis list is filtered according to the 'use-grammars' option in languages.toml file.\n\
To see the full list, use the '--health all' or '--health all-languages' option."
)?;
}
Ok(())
}
@@ -391,9 +435,16 @@ fn probe_treesitter_feature(lang: &str, feature: TsFeature) -> std::io::Result<(
pub fn print_health(health_arg: Option<String>) -> std::io::Result<()> {
match health_arg.as_deref() {
Some("languages") => languages_all()?,
Some("languages") => languages_selection()?,
Some("all-languages") => languages_all()?,
Some("clipboard") => clipboard()?,
None | Some("all") => {
None => {
general()?;
clipboard()?;
writeln!(std::io::stdout().lock())?;
languages_selection()?;
}
Some("all") => {
general()?;
clipboard()?;
writeln!(std::io::stdout().lock())?;

View File

@@ -87,6 +87,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
";" => collapse_selection,
"A-;" => flip_selections,
"A-o" | "A-up" => expand_selection,
"A-O" => expand_selection_around,
"A-i" | "A-down" => shrink_selection,
"A-I" | "A-S-down" => select_all_children,
"A-p" | "A-left" => select_prev_sibling,
@@ -120,6 +121,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"e" => goto_prev_entry,
"T" => goto_prev_test,
"p" => goto_prev_paragraph,
"x" => goto_prev_xml_element,
"space" => add_newline_above,
},
"]" => { "Right bracket"
@@ -134,6 +136,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"e" => goto_next_entry,
"T" => goto_next_test,
"p" => goto_next_paragraph,
"x" => goto_next_xml_element,
"space" => add_newline_below,
},
@@ -227,8 +230,8 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"E" => file_explorer_in_current_buffer_directory,
"b" => buffer_picker,
"j" => jumplist_picker,
"s" => symbol_picker,
"S" => workspace_symbol_picker,
"s" => lsp_or_syntax_symbol_picker,
"S" => lsp_or_syntax_workspace_symbol_picker,
"d" => diagnostics_picker,
"D" => workspace_diagnostics_picker,
"g" => changed_file_picker,

View File

@@ -1,5 +1,4 @@
use anyhow::{Context, Error, Result};
use crossterm::event::EventStream;
use helix_loader::VERSION_AND_GIT_HASH;
use helix_term::application::Application;
use helix_term::args::Args;
@@ -63,8 +62,10 @@ FLAGS:
-h, --help Prints help information
--tutor Loads the tutorial
--health [CATEGORY] Checks for potential errors in editor setup
CATEGORY can be a language or one of 'clipboard', 'languages'
or 'all'. 'all' is the default if not specified.
CATEGORY can be a language or one of 'clipboard', 'languages',
'all-languages' or 'all'. 'languages' is filtered according to
user config, 'all-languages' and 'all' are not. If not specified,
the default is the same as 'all', but with languages filtering.
-g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml
-c, --config <file> Specifies a file to use for configuration
-v Increases logging verbosity each use for up to 3 times
@@ -149,8 +150,9 @@ FLAGS:
// TODO: use the thread local executor to spawn the application task separately from the work pool
let mut app = Application::new(args, config, lang_loader).context("unable to start Helix")?;
let mut events = app.event_stream();
let exit_code = app.run(&mut EventStream::new()).await?;
let exit_code = app.run(&mut events).await?;
Ok(exit_code)
}

View File

@@ -127,6 +127,18 @@ impl EditorView {
&text_annotations,
));
if doc
.language_config()
.and_then(|config| config.rainbow_brackets)
.unwrap_or(config.rainbow_brackets)
{
if let Some(overlay) =
Self::doc_rainbow_highlights(doc, view_offset.anchor, inner.height, theme, &loader)
{
overlays.push(overlay);
}
}
Self::doc_diagnostics_highlights_into(doc, theme, &mut overlays);
if is_focused {
@@ -304,6 +316,27 @@ impl EditorView {
text_annotations.collect_overlay_highlights(range)
}
pub fn doc_rainbow_highlights(
doc: &Document,
anchor: usize,
height: u16,
theme: &Theme,
loader: &syntax::Loader,
) -> Option<OverlayHighlights> {
let syntax = doc.syntax()?;
let text = doc.text().slice(..);
let row = text.char_to_line(anchor.min(text.len_chars()));
let visible_range = Self::viewport_byte_range(text, row, height);
let start = syntax::child_for_byte_range(
&syntax.tree().root_node(),
visible_range.start as u32..visible_range.end as u32,
)
.map_or(visible_range.start as u32, |node| node.start_byte());
let range = start..visible_range.end as u32;
Some(syntax.rainbow_highlights(text, theme.rainbow_length(), loader, range))
}
/// Get highlight spans for document diagnostics
pub fn doc_diagnostics_highlights_into(
doc: &Document,
@@ -505,7 +538,7 @@ impl EditorView {
};
spans.push((selection_scope, range.anchor..selection_end));
// add block cursors
// skip primary cursor if terminal is unfocused - crossterm cursor is used in that case
// skip primary cursor if terminal is unfocused - terminal cursor is used in that case
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
spans.push((cursor_scope, cursor_start..range.head));
}
@@ -513,7 +546,7 @@ impl EditorView {
// Reverse case.
let cursor_end = next_grapheme_boundary(text, range.head);
// add block cursors
// skip primary cursor if terminal is unfocused - crossterm cursor is used in that case
// skip primary cursor if terminal is unfocused - terminal cursor is used in that case
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
spans.push((cursor_scope, range.head..cursor_end));
}
@@ -1126,6 +1159,8 @@ impl EditorView {
let editor = &mut cxt.editor;
if let Some((pos, view_id)) = pos_and_view(editor, row, column, true) {
editor.focus(view_id);
let prev_view_id = view!(editor).id;
let doc = doc_mut!(editor, &view!(editor, view_id).doc);
@@ -1149,7 +1184,6 @@ impl EditorView {
self.clear_completion(editor);
}
editor.focus(view_id);
editor.ensure_cursor_in_view(view_id);
return EventResult::Consumed(None);
@@ -1597,7 +1631,7 @@ impl Component for EditorView {
if self.terminal_focused {
(pos, CursorKind::Hidden)
} else {
// use crossterm cursor when terminal loses focus
// use terminal cursor when terminal loses focus
(pos, CursorKind::Underline)
}
}

View File

@@ -356,7 +356,7 @@ fn directory_content(path: &Path) -> Result<Vec<(PathBuf, bool)>, std::io::Error
.map(|entry| {
(
entry.path(),
entry.file_type().is_ok_and(|file_type| file_type.is_dir()),
std::fs::metadata(entry.path()).is_ok_and(|metadata| metadata.is_dir()),
)
})
.collect();

View File

@@ -258,6 +258,7 @@ pub struct Picker<T: 'static + Send + Sync, D: 'static> {
widths: Vec<Constraint>,
callback_fn: PickerCallback<T>,
default_action: Action,
pub truncate_start: bool,
/// Caches paths to documents
@@ -308,7 +309,10 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
F: Fn(&mut Context, &T, Action) + 'static,
{
let columns: Arc<[_]> = columns.into_iter().collect();
let matcher_columns = columns.iter().filter(|col| col.filter).count() as u32;
let matcher_columns = columns
.iter()
.filter(|col: &&Column<T, D>| col.filter)
.count() as u32;
assert!(matcher_columns > 0);
let matcher = Nucleo::new(
Config::DEFAULT,
@@ -382,6 +386,7 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
truncate_start: true,
show_preview: true,
callback_fn: Box::new(callback_fn),
default_action: Action::Replace,
completion_height: 0,
widths,
preview_cache: HashMap::new(),
@@ -424,6 +429,11 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
self
}
pub fn with_initial_cursor(mut self, cursor: u32) -> Self {
self.cursor = cursor;
self
}
pub fn with_dynamic_query(
mut self,
callback: DynQueryCallback<T, D>,
@@ -440,6 +450,11 @@ impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> {
self
}
pub fn with_default_action(mut self, action: Action) -> Self {
self.default_action = action;
self
}
/// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`)
pub fn move_by(&mut self, amount: u32, direction: Direction) {
let len = self.matcher.snapshot().matched_item_count();
@@ -1071,7 +1086,7 @@ impl<I: 'static + Send + Sync, D: 'static + Send + Sync> Component for Picker<I,
key!(Esc) | ctrl!('c') => return close_fn(self),
alt!(Enter) => {
if let Some(option) = self.selection() {
(self.callback_fn)(ctx, option, Action::Replace);
(self.callback_fn)(ctx, option, self.default_action);
}
}
key!(Enter) => {
@@ -1095,7 +1110,7 @@ impl<I: 'static + Send + Sync, D: 'static + Send + Sync> Component for Picker<I,
self.handle_prompt_change(true);
} else {
if let Some(option) = self.selection() {
(self.callback_fn)(ctx, option, Action::Replace);
(self.callback_fn)(ctx, option, self.default_action);
}
if let Some(history_register) = self.prompt.history_register() {
if let Err(err) = ctx

View File

@@ -779,8 +779,7 @@ impl Component for Prompt {
col += self.line[self.cursor..]
.graphemes(true)
.next()
.unwrap()
.width();
.map_or(0, |g| g.width());
}
let line = area.height as usize - 1;

View File

@@ -158,6 +158,7 @@ where
helix_view::editor::StatusLineElement::Spacer => render_spacer,
helix_view::editor::StatusLineElement::VersionControl => render_version_control,
helix_view::editor::StatusLineElement::Register => render_register,
helix_view::editor::StatusLineElement::CurrentWorkingDirectory => render_cwd,
}
}
@@ -573,3 +574,16 @@ where
},
);
}
fn render_cwd<'a, F>(context: &mut RenderContext<'a>, write: F)
where
F: Fn(&mut RenderContext<'a>, Span<'a>) + Copy,
{
let cwd = helix_stdx::env::current_working_dir();
let cwd = cwd
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
write(context, cwd.into())
}

View File

@@ -16,10 +16,119 @@ fn matching_pairs() -> impl Iterator<Item = &'static (char, char)> {
async fn insert_basic() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("#[{}|]#", LINE_END),
"#[\n|]#",
format!("i{}", pair.0),
format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END),
LineFeedHandling::AsIs,
format!("{}#[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_whitespace() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("{}#[|{}]#", pair.0, pair.1),
"i ",
format!("{} #[| ]#{}", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_whitespace_multi() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!(
indoc! {"\
{open}#[|{close}]#
{open}#(|{open})#{close}{close}
{open}{open}#(|{close}{close})#
foo#(|\n)#
"},
open = pair.0,
close = pair.1,
),
"i ",
format!(
indoc! {"\
{open} #[| ]#{close}
{open} #(|{open})#{close}{close}
{open}{open} #(| {close}{close})#
foo #(|\n)#
"},
open = pair.0,
close = pair.1,
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_whitespace_multi() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!(
indoc! {"\
#[|{open}]#{close}
#(|{open})#{open}{close}{close}
#(|{open}{open})#{close}{close}
#(|foo)#
"},
open = pair.0,
close = pair.1,
),
"a ",
format!(
indoc! {"\
#[{open} |]#{close}
#({open} {open}|)#{close}{close}
#({open}{open} |)#{close}{close}
#(foo \n|)#
"},
open = pair.0,
close = pair.1,
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_whitespace_no_pair() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
// sanity check - do not insert extra whitespace unless immediately
// surrounded by a pair
test((
format!("{} #[|{}]#", pair.0, pair.1),
"i ",
format!("{} #[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_whitespace_no_matching_pair() -> anyhow::Result<()> {
for pair in differing_pairs() {
// sanity check - verify whitespace does not insert unless both pairs
// are matches, i.e. no two different openers
test((
format!("{}#[|{}]#", pair.0, pair.0),
"i ",
format!("{} #[|{}]#", pair.0, pair.0),
))
.await?;
}
@@ -567,3 +676,760 @@ async fn append_inside_nested_pair_multi() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_basic() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("#[|{}]#", LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
indoc! {"\
{open}#[|{close}]#
{open}#(|{close})#
{open}#(|{close})#
"},
open = pair.0,
close = pair.1,
),
"i<backspace>",
indoc! {"\
#[|\n]#
#(|\n)#
#(|\n)#
"},
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("{} #[| ]#{}", pair.0, pair.1),
"i<backspace>",
format!("{}#[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace_after_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("foo{} #[| ]#{}", pair.0, pair.1),
"i<backspace>",
format!("foo{}#[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
indoc! {"\
{open} #[| ]#{close}
{open} #(|{open})#{close}{close}
{open}{open} #(| {close}{close})#
foo #(|\n)#
"},
open = pair.0,
close = pair.1,
),
"i<backspace>",
format!(
indoc! {"\
{open}#[|{close}]#
{open}#(|{open})#{close}{close}
{open}{open}#(|{close}{close})#
foo#(|\n)#
"},
open = pair.0,
close = pair.1,
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_whitespace_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
indoc! {"\
#[{open} |]# {close}
#({open} |)#{open}{close}{close}
#({open}{open} |)# {close}{close}
#(foo |)#
"},
open = pair.0,
close = pair.1,
),
"a<backspace>",
format!(
indoc! {"\
#[{open}{close}|]#
#({open}{open}|)#{close}{close}
#({open}{open}{close}|)#{close}
#(foo\n|)#
"},
open = pair.0,
close = pair.1,
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace_no_pair() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("{} #[|{}]#", pair.0, pair.1),
"i<backspace>",
format!("{} #[|{}]#", pair.0, pair.1),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_whitespace_no_matching_pair() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!("{} #[|{}]#", pair.0, pair.0),
"i<backspace>",
format!("{}#[|{}]#", pair.0, pair.0),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_configured_multi_byte_chars() -> anyhow::Result<()> {
// NOTE: these are multi-byte Unicode characters
let pairs = hashmap!('„' => '“', '' => '', '「' => '」');
let config = Config {
editor: helix_view::editor::Config {
auto_pairs: AutoPairConfig::Pairs(pairs.clone()),
..Default::default()
},
..Default::default()
};
for (open, close) in pairs.iter() {
test_with_config(
AppBuilder::new().with_config(config.clone()),
(
format!("{}#[|{}]#{}", open, close, LINE_END),
"i<backspace>",
format!("#[|{}]#", LINE_END),
LineFeedHandling::AsIs,
),
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_after_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
&format!("foo{}#[|{}]#", pair.0, pair.1),
"i<backspace>",
"foo#[|\n]#",
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_then_delete() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
"#[\n|]#\n",
format!("ofoo{}<backspace>", pair.0),
"\nfoo#[\n|]#\n",
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_then_delete_whitespace() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
"foo#[\n|]#",
format!("i{}<space><backspace><backspace>", pair.0),
"foo#[|\n]#",
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn insert_then_delete_multi() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
indoc! {"\
through a day#[\n|]#
in and out of weeks#(\n|)#
over a year#(\n|)#
"},
format!("i{}<space><backspace><backspace>", pair.0),
indoc! {"\
through a day#[|\n]#
in and out of weeks#(|\n)#
over a year#(|\n)#
"},
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_then_delete() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
"fo#[o|]#",
format!("a{}<space><backspace><backspace>", pair.0),
"fo#[o\n|]#",
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn append_then_delete_multi() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
indoc! {"\
#[through a day|]#
#(in and out of weeks|)#
#(over a year|)#
"},
format!("a{}<space><backspace><backspace>", pair.0),
indoc! {"\
#[through a day\n|]#
#(in and out of weeks\n|)#
#(over a year\n|)#
"},
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
// sanity check unclosed pair delete
test((
format!("{}#[|f]#oo{}", pair.0, LINE_END),
"i<backspace>",
format!("#[|f]#oo{}", LINE_END),
))
.await?;
// deleting the closing pair should NOT delete the whole pair
test((
format!("{}{}#[|f]#oo{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("{}#[|f]#oo{}", pair.0, LINE_END),
))
.await?;
// deleting whole pair before word
test((
format!("{}#[|{}]#foo{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("#[|f]#oo{}", LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_word_selection() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
// sanity check unclosed pair delete
test((
format!("{}#[|foo]#{}", pair.0, LINE_END),
"i<backspace>",
format!("#[|foo]#{}", LINE_END),
))
.await?;
// deleting the closing pair should NOT delete the whole pair
test((
format!("{}{}#[|foo]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("{}#[|foo]#{}", pair.0, LINE_END),
))
.await?;
// deleting whole pair before word
test((
format!("{}#[|{}foo]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("#[|foo]#{}", LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_word_selection_trailing_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("foo{}#[|{} wor]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("foo#[| wor]#{}", LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_eol() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"{eol}{open}#[|{close}]#{eol}",
eol = LINE_END,
open = pair.0,
close = pair.1
),
"i<backspace>",
format!("{0}#[|{0}]#", LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_auto_pairs_disabled() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test_with_config(
AppBuilder::new().with_config(Config {
editor: helix_view::editor::Config {
auto_pairs: AutoPairConfig::Enable(false),
..Default::default()
},
..Default::default()
}),
(
format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END),
"i<backspace>",
format!("#[|{}]#{}", pair.1, LINE_END),
),
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_before_multi_code_point_graphemes() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!("hello {}#[|👨‍👩‍👧‍👦]# goodbye{}", pair.1, LINE_END),
"i<backspace>",
format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END),
))
.await?;
test((
format!(
"hello {}{}#[|👨‍👩‍👧‍👦]# goodbye{}",
pair.0, pair.1, LINE_END
),
"i<backspace>",
format!("hello {}#[|👨‍👩‍👧‍👦]# goodbye{}", pair.0, LINE_END),
))
.await?;
test((
format!(
"hello {}#[|{}]#👨‍👩‍👧‍👦 goodbye{}",
pair.0, pair.1, LINE_END
),
"i<backspace>",
format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END),
))
.await?;
test((
format!(
"hello {}#[|{}👨‍👩‍👧‍👦]# goodbye{}",
pair.0, pair.1, LINE_END
),
"i<backspace>",
format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_at_end_of_document() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test(TestCase {
in_text: format!("{}{}{}", LINE_END, pair.0, pair.1),
in_selection: Selection::single(LINE_END.len() + 1, LINE_END.len() + 2),
in_keys: String::from("i<backspace>"),
out_text: String::from(LINE_END),
out_selection: Selection::single(LINE_END.len(), LINE_END.len()),
line_feed_handling: LineFeedHandling::AsIs,
})
.await?;
test(TestCase {
in_text: format!("foo{}{}{}", LINE_END, pair.0, pair.1),
in_selection: Selection::single(LINE_END.len() + 4, LINE_END.len() + 5),
in_keys: String::from("i<backspace>"),
out_text: format!("foo{}", LINE_END),
out_selection: Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()),
line_feed_handling: LineFeedHandling::AsIs,
})
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_nested_open_inside_pair() -> anyhow::Result<()> {
for pair in differing_pairs() {
test((
format!(
"{open}{open}#[|{close}]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"i<backspace>",
format!(
"{open}#[|{close}]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_nested_open_inside_pair_multi() -> anyhow::Result<()> {
for outer_pair in DEFAULT_PAIRS {
for inner_pair in DEFAULT_PAIRS {
if inner_pair.0 == outer_pair.0 {
continue;
}
test((
format!(
"{outer_open}{inner_open}#[|{inner_close}]#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
inner_open = inner_pair.0,
inner_close = inner_pair.1,
eol = LINE_END
),
"i<backspace>",
format!(
"{outer_open}#[|{outer_close}]#{eol}{outer_open}#(|{outer_close})#{eol}{outer_open}#(|{outer_close})#{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
eol = LINE_END
),
))
.await?;
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_basic() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"#[{eol}{open}|]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!("#[{eol}{eol}|]#", eol = LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_multi_range() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"#[ {open}|]#{close}{eol}#( {open}|)#{close}{eol}#( {open}|)#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!("#[ {eol}|]##( {eol}|)##( {eol}|)#", eol = LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_end_of_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"fo#[o{open}|]#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!("fo#[o{}|]#", LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_mixed_dedent() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
indoc! {"\
bar = {}#[|{}]#
#(|\n)#
foo#(|\n)#
"},
pair.0, pair.1,
),
"i<backspace>",
indoc! {"\
bar = #[|\n]#
#(|\n)#
fo#(|\n)#
"},
))
.await?;
test((
format!(
indoc! {"\
bar = {}#[|{}woop]#
#(|word)#
fo#(|o)#
"},
pair.0, pair.1,
),
"i<backspace>",
indoc! {"\
bar = #[|woop]#
#(|word)#
f#(|o)#
"},
))
.await?;
// delete from the right with append
test((
format!(
indoc! {"\
bar = #[|woop{}]#{}
#(| )#word
#(|fo)#o
"},
pair.0, pair.1,
),
"a<backspace>",
indoc! {"\
bar = #[woop\n|]#
#(w|)#ord
#(fo|)#
"},
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_end_of_word_multi() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"fo#[o{open}|]#{close}{eol}fo#(o{open}|)#{close}{eol}fo#(o{open}|)#{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!("fo#[o{eol}|]#fo#(o{eol}|)#fo#(o{eol}|)#", eol = LINE_END),
LineFeedHandling::AsIs,
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_inside_nested_pair() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"f#[oo{open}{open}|]#{close}{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!(
"f#[oo{open}{close}|]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_middle_of_word() -> anyhow::Result<()> {
for pair in DEFAULT_PAIRS {
test((
format!(
"f#[oo{open}{open}|]#{close}{close}{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
"a<backspace>",
format!(
"f#[oo{open}{close}|]#{eol}",
open = pair.0,
close = pair.1,
eol = LINE_END
),
))
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_append_inside_nested_pair_multi() -> anyhow::Result<()> {
for outer_pair in DEFAULT_PAIRS {
for inner_pair in DEFAULT_PAIRS {
if inner_pair.0 == outer_pair.0 {
continue;
}
test((
format!(
"f#[oo{outer_open}{inner_open}|]#{inner_close}{outer_close}{eol}f#(oo{outer_open}{inner_open}|)#{inner_close}{outer_close}{eol}f#(oo{outer_open}{inner_open}|)#{inner_close}{outer_close}{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
inner_open = inner_pair.0,
inner_close = inner_pair.1,
eol = LINE_END
),
"a<backspace>",
format!(
"f#[oo{outer_open}{outer_close}|]#{eol}f#(oo{outer_open}{outer_close}|)#{eol}f#(oo{outer_open}{outer_close}|)#{eol}",
outer_open = outer_pair.0,
outer_close = outer_pair.1,
eol = LINE_END
),
))
.await?;
}
}
Ok(())
}

View File

@@ -4,6 +4,8 @@ use super::*;
mod insert;
mod movement;
mod reverse_selection_contents;
mod rotate_selection_contents;
mod write;
#[tokio::test(flavor = "multi_thread")]

View File

@@ -948,3 +948,198 @@ async fn match_bracket() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn expand_shrink_selection() -> anyhow::Result<()> {
let tests = vec![
// single range
(
indoc! {r##"
Some(#[thing|]#)
"##},
"<A-o><A-o>",
indoc! {r##"
#[Some(thing)|]#
"##},
),
// multi range
(
indoc! {r##"
Some(#[thing|]#)
Some(#(other_thing|)#)
"##},
"<A-o>",
indoc! {r##"
Some#[(thing)|]#
Some#((other_thing)|)#
"##},
),
// multi range collision merges
(
indoc! {r##"
(
Some(#[thing|]#),
Some(#(other_thing|)#),
)
"##},
"<A-o><A-o><A-o>",
indoc! {r##"
#[(
Some(thing),
Some(other_thing),
)|]#
"##},
),
// multi range collision merges, then shrinks back to original
(
indoc! {r##"
(
Some(#[thing|]#),
Some(#(other_thing|)#),
)
"##},
"<A-o><A-o><A-o><A-i>",
indoc! {r##"
(
#[Some(thing)|]#,
#(Some(other_thing)|)#,
)
"##},
),
(
indoc! {r##"
(
Some(#[thing|]#),
Some(#(other_thing|)#),
)
"##},
"<A-o><A-o><A-o><A-i><A-i>",
indoc! {r##"
(
Some#[(thing)|]#,
Some#((other_thing)|)#,
)
"##},
),
(
indoc! {r##"
(
Some(#[thing|]#),
Some(#(other_thing|)#),
)
"##},
"<A-o><A-o><A-o><A-i><A-i><A-i>",
indoc! {r##"
(
Some(#[thing|]#),
Some(#(other_thing|)#),
)
"##},
),
// shrink with no expansion history defaults to first child
(
indoc! {r##"
#[(
Some(thing),
Some(other_thing),
)|]#
"##},
"<A-i>",
indoc! {r##"
(
#[Some(thing)|]#,
Some(other_thing),
)
"##},
),
// any movement cancels selection history and falls back to first child
(
indoc! {r##"
(
Some(#[thing|]#),
Some(#(other_thing|)#),
)
"##},
"<A-o><A-o><A-o>jkvkkk<A-i>",
indoc! {r##"
(
#[|Some(thing)]#,
Some(other_thing),
)
"##},
),
];
for test in tests {
test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn expand_selection_around() -> anyhow::Result<()> {
let tests = vec![
// single cursor stays single cursor, first goes to end of current
// node, then parent
(
indoc! {r##"
Some(#[thing|]#)
"##},
"<A-O><A-O>",
indoc! {r##"
#[Some(|]#thing#()|)#
"##},
),
// shrinking restores previous selection
(
indoc! {r##"
Some(#[thing|]#)
"##},
"<A-O><A-O><A-i><A-i>",
indoc! {r##"
Some(#[thing|]#)
"##},
),
// multi range collision merges expand as normal, except with the
// original selection removed from the result
(
indoc! {r##"
(
Some(#[thing|]#),
Some(#(other_thing|)#),
)
"##},
"<A-O><A-O><A-O>",
indoc! {r##"
#[(
Some(|]#thing#(),
Some(|)#other_thing#(),
)|)#
"##},
),
(
indoc! {r##"
(
Some(#[thing|]#),
Some(#(other_thing|)#),
)
"##},
"<A-O><A-O><A-O><A-i><A-i><A-i>",
indoc! {r##"
(
Some(#[thing|]#),
Some(#(other_thing|)#),
)
"##},
),
];
for test in tests {
test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?;
}
Ok(())
}

View File

@@ -0,0 +1,49 @@
use super::*;
const A: &str = indoc! {"
#(a|)#
#(b|)#
#(c|)#
#[d|]#
#(e|)#"
};
const A_REV: &str = indoc! {"
#(e|)#
#[d|]#
#(c|)#
#(b|)#
#(a|)#"
};
const B: &str = indoc! {"
#(a|)#
#(b|)#
#[c|]#
#(d|)#
#(e|)#"
};
const B_REV: &str = indoc! {"
#(e|)#
#(d|)#
#[c|]#
#(b|)#
#(a|)#"
};
const CMD: &str = "<space>?reverse_selection_contents<ret>";
#[tokio::test(flavor = "multi_thread")]
async fn reverse_selection_contents() -> anyhow::Result<()> {
test((A, CMD, A_REV)).await?;
test((B, CMD, B_REV)).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn reverse_selection_contents_with_count() -> anyhow::Result<()> {
test((B, format!("2{CMD}"), B)).await?;
test((B, format!("3{CMD}"), B_REV)).await?;
test((B, format!("4{CMD}"), B)).await?;
Ok(())
}

View File

@@ -0,0 +1,71 @@
use super::*;
// Progression: A -> B -> C -> D
// as we press `A-)`
const A: &str = indoc! {"
#(a|)#
#(b|)#
#(c|)#
#[d|]#
#(e|)#"
};
const B: &str = indoc! {"
#(e|)#
#(a|)#
#(b|)#
#(c|)#
#[d|]#"
};
const C: &str = indoc! {"
#[d|]#
#(e|)#
#(a|)#
#(b|)#
#(c|)#"
};
const D: &str = indoc! {"
#(c|)#
#[d|]#
#(e|)#
#(a|)#
#(b|)#"
};
#[tokio::test(flavor = "multi_thread")]
async fn rotate_selection_contents_forward_repeated() -> anyhow::Result<()> {
test((A, "<A-)>", B)).await?;
test((B, "<A-)>", C)).await?;
test((C, "<A-)>", D)).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn rotate_selection_contents_forward_with_count() -> anyhow::Result<()> {
test((A, "2<A-)>", C)).await?;
test((A, "3<A-)>", D)).await?;
test((B, "2<A-)>", D)).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn rotate_selection_contents_backward_repeated() -> anyhow::Result<()> {
test((D, "<A-(>", C)).await?;
test((C, "<A-(>", B)).await?;
test((B, "<A-(>", A)).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn rotate_selection_contents_backward_with_count() -> anyhow::Result<()> {
test((D, "2<A-(>", B)).await?;
test((D, "3<A-(>", A)).await?;
test((C, "2<A-(>", A)).await?;
Ok(())
}

View File

@@ -6,11 +6,11 @@ use std::{
};
use anyhow::bail;
use crossterm::event::{Event, KeyEvent};
use helix_core::{diagnostic::Severity, test, Selection, Transaction};
use helix_term::{application::Application, args::Args, config::Config, keymap::merge_keys};
use helix_view::{current_ref, doc, editor::LspConfig, input::parse_macro, Editor};
use tempfile::NamedTempFile;
use termina::event::{Event, KeyEvent};
use tokio_stream::wrappers::UnboundedReceiverStream;
/// Specify how to set up the input text with line feeds

View File

@@ -12,7 +12,7 @@ repository.workspace = true
homepage.workspace = true
[features]
default = ["crossterm"]
default = ["termina"]
[dependencies]
helix-view = { path = "../helix-view", features = ["term"] }
@@ -21,7 +21,7 @@ helix-core = { path = "../helix-core" }
bitflags.workspace = true
cassowary = "0.3"
unicode-segmentation.workspace = true
crossterm = { version = "0.28", optional = true }
termina = { workspace = true, optional = true }
termini = "1.0"
once_cell = "1.21"
log = "~0.4"

View File

@@ -1,465 +0,0 @@
use crate::{backend::Backend, buffer::Cell, terminal::Config};
use crossterm::{
cursor::{Hide, MoveTo, SetCursorStyle, Show},
event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute, queue,
style::{
Attribute as CAttribute, Color as CColor, Colors, Print, SetAttribute, SetBackgroundColor,
SetColors, SetForegroundColor,
},
terminal::{self, Clear, ClearType},
Command,
};
use helix_view::{
editor::Config as EditorConfig,
graphics::{Color, CursorKind, Modifier, Rect, UnderlineStyle},
};
use once_cell::sync::OnceCell;
use std::{
fmt,
io::{self, Write},
};
use termini::TermInfo;
fn term_program() -> Option<String> {
// Some terminals don't set $TERM_PROGRAM
match std::env::var("TERM_PROGRAM") {
Err(_) => std::env::var("TERM").ok(),
Ok(term_program) => Some(term_program),
}
}
fn vte_version() -> Option<usize> {
std::env::var("VTE_VERSION").ok()?.parse().ok()
}
fn reset_cursor_approach(terminfo: TermInfo) -> String {
let mut reset_str = "\x1B[0 q".to_string();
if let Some(termini::Value::Utf8String(se_str)) = terminfo.extended_cap("Se") {
reset_str.push_str(se_str);
};
reset_str.push_str(
terminfo
.utf8_string_cap(termini::StringCapability::CursorNormal)
.unwrap_or(""),
);
reset_str
}
/// Describes terminal capabilities like extended underline, truecolor, etc.
#[derive(Clone, Debug)]
struct Capabilities {
/// Support for undercurled, underdashed, etc.
has_extended_underlines: bool,
/// Support for resetting the cursor style back to normal.
reset_cursor_command: String,
}
impl Default for Capabilities {
fn default() -> Self {
Self {
has_extended_underlines: false,
reset_cursor_command: "\x1B[0 q".to_string(),
}
}
}
impl Capabilities {
/// Detect capabilities from the terminfo database located based
/// on the $TERM environment variable. If detection fails, returns
/// a default value where no capability is supported, or just undercurl
/// if config.undercurl is set.
pub fn from_env_or_default(config: &EditorConfig) -> Self {
match termini::TermInfo::from_env() {
Err(_) => Capabilities {
has_extended_underlines: config.undercurl,
..Capabilities::default()
},
Ok(t) => Capabilities {
// Smulx, VTE: https://unix.stackexchange.com/a/696253/246284
// Su (used by kitty): https://sw.kovidgoyal.net/kitty/underlines
// WezTerm supports underlines but a lot of distros don't properly install its terminfo
has_extended_underlines: config.undercurl
|| t.extended_cap("Smulx").is_some()
|| t.extended_cap("Su").is_some()
|| vte_version() >= Some(5102)
|| matches!(term_program().as_deref(), Some("WezTerm")),
reset_cursor_command: reset_cursor_approach(t),
},
}
}
}
/// Terminal backend supporting a wide variety of terminals
pub struct CrosstermBackend<W: Write> {
buffer: W,
capabilities: Capabilities,
supports_keyboard_enhancement_protocol: OnceCell<bool>,
mouse_capture_enabled: bool,
supports_bracketed_paste: bool,
}
impl<W> CrosstermBackend<W>
where
W: Write,
{
pub fn new(buffer: W, config: &EditorConfig) -> CrosstermBackend<W> {
// helix is not usable without colors, but crossterm will disable
// them by default if NO_COLOR is set in the environment. Override
// this behaviour.
crossterm::style::force_color_output(true);
CrosstermBackend {
buffer,
capabilities: Capabilities::from_env_or_default(config),
supports_keyboard_enhancement_protocol: OnceCell::new(),
mouse_capture_enabled: false,
supports_bracketed_paste: true,
}
}
#[inline]
fn supports_keyboard_enhancement_protocol(&self) -> bool {
*self.supports_keyboard_enhancement_protocol
.get_or_init(|| {
use std::time::Instant;
let now = Instant::now();
let supported = matches!(terminal::supports_keyboard_enhancement(), Ok(true));
log::debug!(
"The keyboard enhancement protocol is {}supported in this terminal (checked in {:?})",
if supported { "" } else { "not " },
Instant::now().duration_since(now)
);
supported
})
}
}
impl<W> Write for CrosstermBackend<W>
where
W: Write,
{
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buffer.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.buffer.flush()
}
}
impl<W> Backend for CrosstermBackend<W>
where
W: Write,
{
fn claim(&mut self, config: Config) -> io::Result<()> {
terminal::enable_raw_mode()?;
execute!(
self.buffer,
terminal::EnterAlternateScreen,
EnableFocusChange
)?;
match execute!(self.buffer, EnableBracketedPaste,) {
Err(err) if err.kind() == io::ErrorKind::Unsupported => {
log::warn!("Bracketed paste is not supported on this terminal.");
self.supports_bracketed_paste = false;
}
Err(err) => return Err(err),
Ok(_) => (),
};
execute!(self.buffer, terminal::Clear(terminal::ClearType::All))?;
if config.enable_mouse_capture {
execute!(self.buffer, EnableMouseCapture)?;
self.mouse_capture_enabled = true;
}
if self.supports_keyboard_enhancement_protocol() {
execute!(
self.buffer,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
)?;
}
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
self.buffer
.write_all(self.capabilities.reset_cursor_command.as_bytes())?;
if config.enable_mouse_capture {
execute!(self.buffer, DisableMouseCapture)?;
}
if self.supports_keyboard_enhancement_protocol() {
execute!(self.buffer, PopKeyboardEnhancementFlags)?;
}
if self.supports_bracketed_paste {
execute!(self.buffer, DisableBracketedPaste,)?;
}
execute!(
self.buffer,
DisableFocusChange,
terminal::LeaveAlternateScreen
)?;
terminal::disable_raw_mode()
}
fn force_restore() -> io::Result<()> {
let mut stdout = io::stdout();
// reset cursor shape
write!(stdout, "\x1B[0 q")?;
// Ignore errors on disabling, this might trigger on windows if we call
// disable without calling enable previously
let _ = execute!(stdout, DisableMouseCapture);
let _ = execute!(stdout, PopKeyboardEnhancementFlags);
let _ = execute!(stdout, DisableBracketedPaste);
execute!(stdout, DisableFocusChange, terminal::LeaveAlternateScreen)?;
terminal::disable_raw_mode()
}
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
let mut fg = Color::Reset;
let mut bg = Color::Reset;
let mut underline_color = Color::Reset;
let mut underline_style = UnderlineStyle::Reset;
let mut modifier = Modifier::empty();
let mut last_pos: Option<(u16, u16)> = None;
for (x, y, cell) in content {
// Move the cursor if the previous location was not (x - 1, y)
if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) {
queue!(self.buffer, MoveTo(x, y))?;
}
last_pos = Some((x, y));
if cell.modifier != modifier {
let diff = ModifierDiff {
from: modifier,
to: cell.modifier,
};
diff.queue(&mut self.buffer)?;
modifier = cell.modifier;
}
if cell.fg != fg || cell.bg != bg {
queue!(
self.buffer,
SetColors(Colors::new(cell.fg.into(), cell.bg.into()))
)?;
fg = cell.fg;
bg = cell.bg;
}
let mut new_underline_style = cell.underline_style;
if self.capabilities.has_extended_underlines {
if cell.underline_color != underline_color {
let color = CColor::from(cell.underline_color);
queue!(self.buffer, SetUnderlineColor(color))?;
underline_color = cell.underline_color;
}
} else {
match new_underline_style {
UnderlineStyle::Reset | UnderlineStyle::Line => (),
_ => new_underline_style = UnderlineStyle::Line,
}
}
if new_underline_style != underline_style {
let attr = CAttribute::from(new_underline_style);
queue!(self.buffer, SetAttribute(attr))?;
underline_style = new_underline_style;
}
queue!(self.buffer, Print(&cell.symbol))?;
}
queue!(
self.buffer,
SetUnderlineColor(CColor::Reset),
SetForegroundColor(CColor::Reset),
SetBackgroundColor(CColor::Reset),
SetAttribute(CAttribute::Reset)
)
}
fn hide_cursor(&mut self) -> io::Result<()> {
execute!(self.buffer, Hide)
}
fn show_cursor(&mut self, kind: CursorKind) -> io::Result<()> {
let shape = match kind {
CursorKind::Block => SetCursorStyle::SteadyBlock,
CursorKind::Bar => SetCursorStyle::SteadyBar,
CursorKind::Underline => SetCursorStyle::SteadyUnderScore,
CursorKind::Hidden => unreachable!(),
};
execute!(self.buffer, Show, shape)
}
fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
crossterm::cursor::position()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))
}
fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
execute!(self.buffer, MoveTo(x, y))
}
fn clear(&mut self) -> io::Result<()> {
execute!(self.buffer, Clear(ClearType::All))
}
fn size(&self) -> io::Result<Rect> {
let (width, height) =
terminal::size().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
Ok(Rect::new(0, 0, width, height))
}
fn flush(&mut self) -> io::Result<()> {
self.buffer.flush()
}
}
#[derive(Debug)]
struct ModifierDiff {
pub from: Modifier,
pub to: Modifier,
}
impl ModifierDiff {
fn queue<W>(&self, mut w: W) -> io::Result<()>
where
W: io::Write,
{
//use crossterm::Attribute;
let removed = self.from - self.to;
if removed.contains(Modifier::REVERSED) {
queue!(w, SetAttribute(CAttribute::NoReverse))?;
}
if removed.contains(Modifier::BOLD) {
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
if self.to.contains(Modifier::DIM) {
queue!(w, SetAttribute(CAttribute::Dim))?;
}
}
if removed.contains(Modifier::ITALIC) {
queue!(w, SetAttribute(CAttribute::NoItalic))?;
}
if removed.contains(Modifier::DIM) {
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
}
if removed.contains(Modifier::CROSSED_OUT) {
queue!(w, SetAttribute(CAttribute::NotCrossedOut))?;
}
if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
queue!(w, SetAttribute(CAttribute::NoBlink))?;
}
if removed.contains(Modifier::HIDDEN) {
queue!(w, SetAttribute(CAttribute::NoHidden))?;
}
let added = self.to - self.from;
if added.contains(Modifier::REVERSED) {
queue!(w, SetAttribute(CAttribute::Reverse))?;
}
if added.contains(Modifier::BOLD) {
queue!(w, SetAttribute(CAttribute::Bold))?;
}
if added.contains(Modifier::ITALIC) {
queue!(w, SetAttribute(CAttribute::Italic))?;
}
if added.contains(Modifier::DIM) {
queue!(w, SetAttribute(CAttribute::Dim))?;
}
if added.contains(Modifier::CROSSED_OUT) {
queue!(w, SetAttribute(CAttribute::CrossedOut))?;
}
if added.contains(Modifier::SLOW_BLINK) {
queue!(w, SetAttribute(CAttribute::SlowBlink))?;
}
if added.contains(Modifier::RAPID_BLINK) {
queue!(w, SetAttribute(CAttribute::RapidBlink))?;
}
if added.contains(Modifier::HIDDEN) {
queue!(w, SetAttribute(CAttribute::Hidden))?;
}
Ok(())
}
}
/// 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.
/// See [this nvim issue](https://github.com/neovim/neovim/issues/9270) for details
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SetUnderlineColor(pub CColor);
impl Command for SetUnderlineColor {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
let color = self.0;
if color == CColor::Reset {
write!(f, "\x1b[59m")?;
return Ok(());
}
f.write_str("\x1b[58:")?;
let res = match color {
CColor::Black => f.write_str("5:0"),
CColor::DarkGrey => f.write_str("5:8"),
CColor::Red => f.write_str("5:9"),
CColor::DarkRed => f.write_str("5:1"),
CColor::Green => f.write_str("5:10"),
CColor::DarkGreen => f.write_str("5:2"),
CColor::Yellow => f.write_str("5:11"),
CColor::DarkYellow => f.write_str("5:3"),
CColor::Blue => f.write_str("5:12"),
CColor::DarkBlue => f.write_str("5:4"),
CColor::Magenta => f.write_str("5:13"),
CColor::DarkMagenta => f.write_str("5:5"),
CColor::Cyan => f.write_str("5:14"),
CColor::DarkCyan => f.write_str("5:6"),
CColor::White => f.write_str("5:15"),
CColor::Grey => f.write_str("5:7"),
CColor::Rgb { r, g, b } => write!(f, "2::{}:{}:{}", r, g, b),
CColor::AnsiValue(val) => write!(f, "5:{}", val),
_ => Ok(()),
};
res?;
write!(f, "m")?;
Ok(())
}
#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"SetUnderlineColor not supported by winapi.",
))
}
}

View File

@@ -6,10 +6,10 @@ use crate::{buffer::Cell, terminal::Config};
use helix_view::graphics::{CursorKind, Rect};
#[cfg(feature = "crossterm")]
mod crossterm;
#[cfg(feature = "crossterm")]
pub use self::crossterm::CrosstermBackend;
#[cfg(feature = "termina")]
mod termina;
#[cfg(feature = "termina")]
pub use self::termina::TerminaBackend;
mod test;
pub use self::test::TestBackend;
@@ -17,13 +17,11 @@ pub use self::test::TestBackend;
/// Representation of a terminal backend.
pub trait Backend {
/// Claims the terminal for TUI use.
fn claim(&mut self, config: Config) -> Result<(), io::Error>;
fn claim(&mut self) -> Result<(), io::Error>;
/// Update terminal configuration.
fn reconfigure(&mut self, config: Config) -> Result<(), io::Error>;
/// Restores the terminal to a normal state, undoes `claim`
fn restore(&mut self, config: Config) -> Result<(), io::Error>;
/// Forcibly resets the terminal, ignoring errors and configuration
fn force_restore() -> Result<(), io::Error>;
fn restore(&mut self) -> Result<(), io::Error>;
/// Draws styled text to the terminal
fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error>
where
@@ -32,8 +30,6 @@ pub trait Backend {
fn hide_cursor(&mut self) -> Result<(), io::Error>;
/// Sets the cursor to the given shape
fn show_cursor(&mut self, kind: CursorKind) -> Result<(), io::Error>;
/// Gets the current position of the cursor
fn get_cursor(&mut self) -> Result<(u16, u16), io::Error>;
/// Sets the cursor to the given position
fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>;
/// Clears the terminal
@@ -42,4 +38,5 @@ pub trait Backend {
fn size(&self) -> Result<Rect, io::Error>;
/// Flushes the terminal buffer
fn flush(&mut self) -> Result<(), io::Error>;
fn supports_true_color(&self) -> bool;
}

View File

@@ -0,0 +1,629 @@
use std::io::{self, Write as _};
use helix_view::{
editor::KittyKeyboardProtocolConfig,
graphics::{CursorKind, Rect, UnderlineStyle},
theme::{Color, Modifier},
};
use termina::{
escape::{
csi::{self, Csi, SgrAttributes, SgrModifiers},
dcs::{self, Dcs},
},
style::{CursorStyle, RgbColor},
Event, OneBased, PlatformTerminal, Terminal as _, WindowSize,
};
use crate::{buffer::Cell, terminal::Config};
use super::Backend;
// These macros are helpers to set/unset modes like bracketed paste or enter/exit the alternate
// screen.
macro_rules! decset {
($mode:ident) => {
Csi::Mode(csi::Mode::SetDecPrivateMode(csi::DecPrivateMode::Code(
csi::DecPrivateModeCode::$mode,
)))
};
}
macro_rules! decreset {
($mode:ident) => {
Csi::Mode(csi::Mode::ResetDecPrivateMode(csi::DecPrivateMode::Code(
csi::DecPrivateModeCode::$mode,
)))
};
}
fn term_program() -> Option<String> {
// Some terminals don't set $TERM_PROGRAM
match std::env::var("TERM_PROGRAM") {
Err(_) => std::env::var("TERM").ok(),
Ok(term_program) => Some(term_program),
}
}
fn vte_version() -> Option<usize> {
std::env::var("VTE_VERSION").ok()?.parse().ok()
}
#[derive(Debug, Default, Clone, Copy)]
struct Capabilities {
kitty_keyboard: KittyKeyboardSupport,
synchronized_output: bool,
true_color: bool,
extended_underlines: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum KittyKeyboardSupport {
/// The terminal doesn't support the protocol.
#[default]
None,
/// The terminal supports the protocol but we haven't checked yet whether it has full or
/// partial support for the flags we require.
Some,
/// The terminal only supports some of the flags we require.
Partial,
/// The terminal supports all flags require.
Full,
}
#[derive(Debug)]
pub struct TerminaBackend {
terminal: PlatformTerminal,
config: Config,
capabilities: Capabilities,
reset_cursor_command: String,
is_synchronized_output_set: bool,
}
impl TerminaBackend {
pub fn new(config: Config) -> io::Result<Self> {
let mut terminal = PlatformTerminal::new()?;
let (capabilities, reset_cursor_command) =
Self::detect_capabilities(&mut terminal, &config)?;
// In the case of a panic, reset the terminal eagerly. If we didn't do this and instead
// relied on `Drop`, the backtrace would be lost because it is printed before we would
// clear and exit the alternate screen.
let hook_reset_cursor_command = reset_cursor_command.clone();
terminal.set_panic_hook(move |term| {
let _ = write!(
term,
"{}{}{}{}{}{}{}{}{}{}{}",
Csi::Keyboard(csi::Keyboard::PopFlags(1)),
decreset!(MouseTracking),
decreset!(ButtonEventMouse),
decreset!(AnyEventMouse),
decreset!(RXVTMouse),
decreset!(SGRMouse),
&hook_reset_cursor_command,
decreset!(BracketedPaste),
decreset!(FocusTracking),
Csi::Edit(csi::Edit::EraseInDisplay(csi::EraseInDisplay::EraseDisplay)),
decreset!(ClearAndEnableAlternateScreen),
);
});
Ok(Self {
terminal,
config,
capabilities,
reset_cursor_command,
is_synchronized_output_set: false,
})
}
pub fn terminal(&self) -> &PlatformTerminal {
&self.terminal
}
fn detect_capabilities(
terminal: &mut PlatformTerminal,
config: &Config,
) -> io::Result<(Capabilities, String)> {
use std::time::{Duration, Instant};
// Colibri "midnight"
const TEST_COLOR: RgbColor = RgbColor::new(59, 34, 76);
terminal.enter_raw_mode()?;
let mut capabilities = Capabilities::default();
let start = Instant::now();
capabilities.kitty_keyboard = match config.kitty_keyboard_protocol {
KittyKeyboardProtocolConfig::Disabled => KittyKeyboardSupport::None,
KittyKeyboardProtocolConfig::Enabled => KittyKeyboardSupport::Full,
KittyKeyboardProtocolConfig::Auto => {
write!(terminal, "{}", Csi::Keyboard(csi::Keyboard::QueryFlags))?;
KittyKeyboardSupport::None
}
};
// Many terminal extensions can be detected by querying the terminal for the state of the
// extension and then sending a request for the primary device attributes (which is
// consistently supported by all terminals). If we receive the status of the feature (for
// example the current Kitty keyboard flags) then we know that the feature is supported.
// If we only receive the device attributes then we know it is not.
write!(
terminal,
"{}{}{}{}{}{}",
// Synchronized output
Csi::Mode(csi::Mode::QueryDecPrivateMode(csi::DecPrivateMode::Code(
csi::DecPrivateModeCode::SynchronizedOutput
))),
// True color and while we're at it, extended underlines:
// <https://github.com/termstandard/colors?tab=readme-ov-file#querying-the-terminal>
Csi::Sgr(csi::Sgr::Background(TEST_COLOR.into())),
Csi::Sgr(csi::Sgr::UnderlineColor(TEST_COLOR.into())),
Dcs::Request(dcs::DcsRequest::GraphicRendition),
Csi::Sgr(csi::Sgr::Reset),
// Finally request the primary device attributes
Csi::Device(csi::Device::RequestPrimaryDeviceAttributes),
)?;
terminal.flush()?;
let device_attributes = |event: &Event| {
matches!(
event,
Event::Csi(Csi::Device(csi::Device::DeviceAttributes(_)))
)
};
// TODO: tune this poll constant? Does it need to be longer when on an SSH connection?
let poll_duration = Duration::from_millis(100);
if terminal.poll(device_attributes, Some(poll_duration))? {
while terminal.poll(Event::is_escape, Some(Duration::ZERO))? {
match terminal.read(Event::is_escape)? {
Event::Csi(Csi::Keyboard(csi::Keyboard::ReportFlags(_))) => {
capabilities.kitty_keyboard = KittyKeyboardSupport::Some;
}
Event::Csi(Csi::Mode(csi::Mode::ReportDecPrivateMode {
mode: csi::DecPrivateMode::Code(csi::DecPrivateModeCode::SynchronizedOutput),
setting: csi::DecModeSetting::Set | csi::DecModeSetting::Reset,
})) => {
capabilities.synchronized_output = true;
}
Event::Dcs(dcs::Dcs::Response {
value: dcs::DcsResponse::GraphicRendition(sgrs),
..
}) => {
capabilities.true_color =
sgrs.contains(&csi::Sgr::Background(TEST_COLOR.into()));
capabilities.extended_underlines =
sgrs.contains(&csi::Sgr::UnderlineColor(TEST_COLOR.into()));
}
_ => (),
}
}
let end = Instant::now();
log::debug!(
"Detected terminal capabilities in {:?}: {capabilities:?}",
end.duration_since(start)
);
} else {
log::debug!("Failed to detect terminal capabilities within {poll_duration:?}. Using default capabilities only");
}
capabilities.extended_underlines |= config.force_enable_extended_underlines;
let mut reset_cursor_command =
Csi::Cursor(csi::Cursor::CursorStyle(CursorStyle::Default)).to_string();
if let Ok(t) = termini::TermInfo::from_env() {
capabilities.extended_underlines |= t.extended_cap("Smulx").is_some()
|| t.extended_cap("Su").is_some()
|| vte_version() >= Some(5102)
// HACK: once WezTerm can support DECRQSS/DECRPSS for SGR we can remove this line.
// <https://github.com/wezterm/wezterm/pull/6856>
|| matches!(term_program().as_deref(), Some("WezTerm"));
if let Some(termini::Value::Utf8String(se_str)) = t.extended_cap("Se") {
reset_cursor_command.push_str(se_str);
};
reset_cursor_command.push_str(
t.utf8_string_cap(termini::StringCapability::CursorNormal)
.unwrap_or(""),
);
log::debug!(
"Cursor reset escape sequence detected from terminfo: {reset_cursor_command:?}"
);
} else {
log::debug!("terminfo could not be read, using default cursor reset escape sequence: {reset_cursor_command:?}");
}
terminal.enter_cooked_mode()?;
Ok((capabilities, reset_cursor_command))
}
fn enable_mouse_capture(&mut self) -> io::Result<()> {
if self.config.enable_mouse_capture {
write!(
self.terminal,
"{}{}{}{}{}",
decset!(MouseTracking),
decset!(ButtonEventMouse),
decset!(AnyEventMouse),
decset!(RXVTMouse),
decset!(SGRMouse),
)?;
}
Ok(())
}
fn disable_mouse_capture(&mut self) -> io::Result<()> {
if self.config.enable_mouse_capture {
write!(
self.terminal,
"{}{}{}{}{}",
decreset!(MouseTracking),
decreset!(ButtonEventMouse),
decreset!(AnyEventMouse),
decreset!(RXVTMouse),
decreset!(SGRMouse),
)?;
}
Ok(())
}
fn enable_extensions(&mut self) -> io::Result<()> {
const KEYBOARD_FLAGS: csi::KittyKeyboardFlags =
csi::KittyKeyboardFlags::DISAMBIGUATE_ESCAPE_CODES
.union(csi::KittyKeyboardFlags::REPORT_ALTERNATE_KEYS);
match self.capabilities.kitty_keyboard {
KittyKeyboardSupport::None | KittyKeyboardSupport::Partial => (),
KittyKeyboardSupport::Full => {
write!(
self.terminal,
"{}",
Csi::Keyboard(csi::Keyboard::PushFlags(KEYBOARD_FLAGS))
)?;
}
KittyKeyboardSupport::Some => {
write!(
self.terminal,
"{}{}",
// Enable the flags we need.
Csi::Keyboard(csi::Keyboard::PushFlags(KEYBOARD_FLAGS)),
// Then request the current flags. We need to check if the terminal enabled
// all of the flags we require.
Csi::Keyboard(csi::Keyboard::QueryFlags),
)?;
self.terminal.flush()?;
let event = self.terminal.read(|event| {
matches!(
event,
Event::Csi(Csi::Keyboard(csi::Keyboard::ReportFlags(_)))
)
})?;
let Event::Csi(Csi::Keyboard(csi::Keyboard::ReportFlags(flags))) = event else {
unreachable!();
};
if flags != KEYBOARD_FLAGS {
log::info!("Turning off enhanced keyboard support because the terminal enabled different flags. Requested {KEYBOARD_FLAGS:?} but got {flags:?}");
write!(
self.terminal,
"{}",
Csi::Keyboard(csi::Keyboard::PopFlags(1))
)?;
self.terminal.flush()?;
self.capabilities.kitty_keyboard = KittyKeyboardSupport::Partial;
} else {
log::debug!(
"The terminal fully supports the requested keyboard enhancement flags"
);
self.capabilities.kitty_keyboard = KittyKeyboardSupport::Full;
}
}
}
Ok(())
}
fn disable_extensions(&mut self) -> io::Result<()> {
if self.capabilities.kitty_keyboard == KittyKeyboardSupport::Full {
write!(
self.terminal,
"{}",
Csi::Keyboard(csi::Keyboard::PopFlags(1))
)?;
}
Ok(())
}
// See <https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036>.
// Synchronized output sequences tell the terminal when we are "starting to render" and
// stopping, enabling to make better choices about when it draws a frame. This avoids all
// kinds of ugly visual artifacts like tearing and flashing (i.e. the background color
// after clearing the terminal).
fn start_synchronized_render(&mut self) -> io::Result<()> {
if self.capabilities.synchronized_output && !self.is_synchronized_output_set {
write!(self.terminal, "{}", decset!(SynchronizedOutput))?;
self.is_synchronized_output_set = true;
}
Ok(())
}
fn end_sychronized_render(&mut self) -> io::Result<()> {
if self.is_synchronized_output_set {
write!(self.terminal, "{}", decreset!(SynchronizedOutput))?;
self.is_synchronized_output_set = false;
}
Ok(())
}
}
impl Backend for TerminaBackend {
fn claim(&mut self) -> io::Result<()> {
self.terminal.enter_raw_mode()?;
write!(
self.terminal,
"{}{}{}{}",
// Enter an alternate screen.
decset!(ClearAndEnableAlternateScreen),
decset!(BracketedPaste),
decset!(FocusTracking),
// Clear the buffer. `ClearAndEnableAlternateScreen` **should** do this but some
// things like mosh are buggy. See <https://github.com/helix-editor/helix/pull/1944>.
Csi::Edit(csi::Edit::EraseInDisplay(csi::EraseInDisplay::EraseDisplay)),
)?;
self.enable_mouse_capture()?;
self.enable_extensions()?;
Ok(())
}
fn reconfigure(&mut self, mut config: Config) -> io::Result<()> {
std::mem::swap(&mut self.config, &mut config);
if self.config.enable_mouse_capture != config.enable_mouse_capture {
if self.config.enable_mouse_capture {
self.enable_mouse_capture()?;
} else {
self.disable_mouse_capture()?;
}
}
self.capabilities.extended_underlines |= self.config.force_enable_extended_underlines;
Ok(())
}
fn restore(&mut self) -> io::Result<()> {
self.disable_extensions()?;
self.disable_mouse_capture()?;
write!(
self.terminal,
"{}{}{}{}",
&self.reset_cursor_command,
decreset!(BracketedPaste),
decreset!(FocusTracking),
decreset!(ClearAndEnableAlternateScreen),
)?;
self.terminal.flush()?;
self.terminal.enter_cooked_mode()?;
Ok(())
}
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
self.start_synchronized_render()?;
let mut fg = Color::Reset;
let mut bg = Color::Reset;
let mut underline_color = Color::Reset;
let mut underline_style = UnderlineStyle::Reset;
let mut modifier = Modifier::empty();
let mut last_pos: Option<(u16, u16)> = None;
for (x, y, cell) in content {
// Move the cursor if the previous location was not (x - 1, y)
if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) {
write!(
self.terminal,
"{}",
Csi::Cursor(csi::Cursor::Position {
col: OneBased::from_zero_based(x),
line: OneBased::from_zero_based(y),
})
)?;
}
last_pos = Some((x, y));
let mut attributes = SgrAttributes::default();
if cell.fg != fg {
attributes.foreground = Some(cell.fg.into());
fg = cell.fg;
}
if cell.bg != bg {
attributes.background = Some(cell.bg.into());
bg = cell.bg;
}
if cell.modifier != modifier {
attributes.modifiers = diff_modifiers(modifier, cell.modifier);
modifier = cell.modifier;
}
// Set underline style and color separately from SgrAttributes. Some terminals seem
// to not like underline colors and styles being intermixed with other SGRs.
let mut new_underline_style = cell.underline_style;
if self.capabilities.extended_underlines {
if cell.underline_color != underline_color {
write!(
self.terminal,
"{}",
Csi::Sgr(csi::Sgr::UnderlineColor(cell.underline_color.into()))
)?;
underline_color = cell.underline_color;
}
} else {
match new_underline_style {
UnderlineStyle::Reset | UnderlineStyle::Line => (),
_ => new_underline_style = UnderlineStyle::Line,
}
}
if new_underline_style != underline_style {
write!(
self.terminal,
"{}",
Csi::Sgr(csi::Sgr::Underline(new_underline_style.into()))
)?;
underline_style = new_underline_style;
}
// `attributes` will be empty if nothing changed between two cells. Empty
// `SgrAttributes` behave the same as a `Sgr::Reset` rather than a 'no-op' though so
// we should avoid writing them if they're empty.
if !attributes.is_empty() {
write!(
self.terminal,
"{}",
Csi::Sgr(csi::Sgr::Attributes(attributes))
)?;
}
write!(self.terminal, "{}", &cell.symbol)?;
}
write!(self.terminal, "{}", Csi::Sgr(csi::Sgr::Reset))?;
self.end_sychronized_render()?;
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
write!(self.terminal, "{}", decreset!(ShowCursor))?;
self.flush()
}
fn show_cursor(&mut self, kind: CursorKind) -> io::Result<()> {
let style = match kind {
CursorKind::Block => CursorStyle::SteadyBlock,
CursorKind::Bar => CursorStyle::SteadyBar,
CursorKind::Underline => CursorStyle::SteadyUnderline,
CursorKind::Hidden => unreachable!(),
};
write!(
self.terminal,
"{}{}",
decset!(ShowCursor),
Csi::Cursor(csi::Cursor::CursorStyle(style)),
)?;
self.flush()
}
fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
let col = OneBased::from_zero_based(x);
let line = OneBased::from_zero_based(y);
write!(
self.terminal,
"{}",
Csi::Cursor(csi::Cursor::Position { line, col })
)?;
self.flush()
}
fn clear(&mut self) -> io::Result<()> {
self.start_synchronized_render()?;
write!(
self.terminal,
"{}",
Csi::Edit(csi::Edit::EraseInDisplay(csi::EraseInDisplay::EraseDisplay))
)?;
self.flush()
}
fn size(&self) -> io::Result<Rect> {
let WindowSize { rows, cols, .. } = self.terminal.get_dimensions()?;
Ok(Rect::new(0, 0, cols, rows))
}
fn flush(&mut self) -> io::Result<()> {
self.terminal.flush()
}
fn supports_true_color(&self) -> bool {
self.capabilities.true_color
}
}
impl Drop for TerminaBackend {
fn drop(&mut self) {
// Avoid resetting the terminal while panicking because we set a panic hook above in
// `Self::new`.
if !std::thread::panicking() {
let _ = self.disable_extensions();
let _ = self.disable_mouse_capture();
let _ = write!(
self.terminal,
"{}{}{}{}",
&self.reset_cursor_command,
decreset!(BracketedPaste),
decreset!(FocusTracking),
decreset!(ClearAndEnableAlternateScreen),
);
// NOTE: Drop for Platform terminal resets the mode and flushes the buffer when not
// panicking.
}
}
}
fn diff_modifiers(from: Modifier, to: Modifier) -> SgrModifiers {
let mut modifiers = SgrModifiers::default();
let removed = from - to;
if removed.contains(Modifier::REVERSED) {
modifiers |= SgrModifiers::NO_REVERSE;
}
if removed.contains(Modifier::BOLD) && !to.contains(Modifier::DIM) {
modifiers |= SgrModifiers::INTENSITY_NORMAL;
}
if removed.contains(Modifier::DIM) {
modifiers |= SgrModifiers::INTENSITY_NORMAL;
}
if removed.contains(Modifier::ITALIC) {
modifiers |= SgrModifiers::NO_ITALIC;
}
if removed.contains(Modifier::CROSSED_OUT) {
modifiers |= SgrModifiers::NO_STRIKE_THROUGH;
}
if removed.contains(Modifier::HIDDEN) {
modifiers |= SgrModifiers::NO_INVISIBLE;
}
if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
modifiers |= SgrModifiers::BLINK_NONE;
}
let added = to - from;
if added.contains(Modifier::REVERSED) {
modifiers |= SgrModifiers::REVERSE;
}
if added.contains(Modifier::BOLD) {
modifiers |= SgrModifiers::INTENSITY_BOLD;
}
if added.contains(Modifier::DIM) {
modifiers |= SgrModifiers::INTENSITY_DIM;
}
if added.contains(Modifier::ITALIC) {
modifiers |= SgrModifiers::ITALIC;
}
if added.contains(Modifier::CROSSED_OUT) {
modifiers |= SgrModifiers::STRIKE_THROUGH;
}
if added.contains(Modifier::HIDDEN) {
modifiers |= SgrModifiers::INVISIBLE;
}
if added.contains(Modifier::SLOW_BLINK) {
modifiers |= SgrModifiers::BLINK_SLOW;
}
if added.contains(Modifier::RAPID_BLINK) {
modifiers |= SgrModifiers::BLINK_RAPID;
}
modifiers
}

View File

@@ -107,7 +107,7 @@ impl TestBackend {
}
impl Backend for TestBackend {
fn claim(&mut self, _config: Config) -> Result<(), io::Error> {
fn claim(&mut self) -> Result<(), io::Error> {
Ok(())
}
@@ -115,11 +115,7 @@ impl Backend for TestBackend {
Ok(())
}
fn restore(&mut self, _config: Config) -> Result<(), io::Error> {
Ok(())
}
fn force_restore() -> Result<(), io::Error> {
fn restore(&mut self) -> Result<(), io::Error> {
Ok(())
}
@@ -143,10 +139,6 @@ impl Backend for TestBackend {
Ok(())
}
fn get_cursor(&mut self) -> Result<(u16, u16), io::Error> {
Ok(self.pos)
}
fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error> {
self.pos = (x, y);
Ok(())
@@ -164,4 +156,8 @@ impl Backend for TestBackend {
fn flush(&mut self) -> Result<(), io::Error> {
Ok(())
}
fn supports_true_color(&self) -> bool {
false
}
}

View File

@@ -1,133 +1,3 @@
//! [tui](https://github.com/fdehau/tui-rs) is a library used to build rich
//! terminal users interfaces and dashboards.
//!
//! ![](https://raw.githubusercontent.com/fdehau/tui-rs/master/assets/demo.gif)
//!
//! # Get started
//!
//! ## Adding `tui` as a dependency
//!
//! ```toml
//! [dependencies]
//! tui = "0.15"
//! crossterm = "0.19"
//! ```
//!
//! The same logic applies for all other available backends.
//!
//! ## Creating a `Terminal`
//!
//! Every application using `tui` should start by instantiating a `Terminal`. It is a light
//! abstraction over available backends that provides basic functionalities such as clearing the
//! screen, hiding the cursor, etc.
//!
//! ```rust,no_run
//! use std::io;
//! use helix_tui::Terminal;
//! use helix_tui::backend::CrosstermBackend;
//! use helix_view::editor::Config;
//!
//! fn main() -> Result<(), io::Error> {
//! let stdout = io::stdout();
//! let config = Config::default();
//! let backend = CrosstermBackend::new(stdout, &config);
//! let mut terminal = Terminal::new(backend)?;
//! Ok(())
//! }
//! ```
//!
//! You may also refer to the examples to find out how to create a `Terminal` for each available
//! backend.
//!
//! ## Building a User Interface (UI)
//!
//! Every component of your interface will be implementing the `Widget` trait. The library comes
//! with a predefined set of widgets that should meet most of your use cases. You are also free to
//! implement your own.
//!
//! Each widget follows a builder pattern API providing a default configuration along with methods
//! to customize them. The widget is then rendered using the `Frame::render_widget` which take
//! your widget instance an area to draw to.
//!
//! The following example renders a block of the size of the terminal:
//!
//! ```rust,no_run
//! use std::io;
//! use crossterm::terminal;
//! use helix_tui::Terminal;
//! use helix_tui::backend::CrosstermBackend;
//! use helix_tui::widgets::{Widget, Block, Borders};
//! use helix_tui::layout::{Layout, Constraint, Direction};
//! use helix_view::editor::Config;
//!
//! fn main() -> Result<(), io::Error> {
//! terminal::enable_raw_mode().unwrap();
//! let stdout = io::stdout();
//! let config = Config::default();
//! let backend = CrosstermBackend::new(stdout, &config);
//! let mut terminal = Terminal::new(backend)?;
//! // terminal.draw(|f| {
//! // let size = f.size();
//! // let block = Block::default()
//! // .title("Block")
//! // .borders(Borders::ALL);
//! // f.render_widget(block, size);
//! // })?;
//! Ok(())
//! }
//! ```
//!
//! ## Layout
//!
//! The library comes with a basic yet useful layout management object called `Layout`. As you may
//! see below and in the examples, the library makes heavy use of the builder pattern to provide
//! full customization. And `Layout` is no exception:
//!
//! ```rust,no_run
//! use std::io;
//! use crossterm::terminal;
//! use helix_tui::Terminal;
//! use helix_tui::backend::CrosstermBackend;
//! use helix_tui::widgets::{Widget, Block, Borders};
//! use helix_tui::layout::{Layout, Constraint, Direction};
//! use helix_view::editor::Config;
//!
//! fn main() -> Result<(), io::Error> {
//! terminal::enable_raw_mode().unwrap();
//! let stdout = io::stdout();
//! let config = Config::default();
//! let backend = CrosstermBackend::new(stdout, &config);
//! let mut terminal = Terminal::new(backend)?;
//! // terminal.draw(|f| {
//! // let chunks = Layout::default()
//! // .direction(Direction::Vertical)
//! // .margin(1)
//! // .constraints(
//! // [
//! // Constraint::Percentage(10),
//! // Constraint::Percentage(80),
//! // Constraint::Percentage(10)
//! // ].as_ref()
//! // )
//! // .split(f.size());
//! // let block = Block::default()
//! // .title("Block")
//! // .borders(Borders::ALL);
//! // f.render_widget(block, chunks[0]);
//! // let block = Block::default()
//! // .title("Block 2")
//! // .borders(Borders::ALL);
//! // f.render_widget(block, chunks[1]);
//! // })?;
//! Ok(())
//! }
//! ```
//!
//! This let you describe responsive terminal UI by nesting layouts. You should note that by
//! default the computed layout tries to fill the available space completely. So if for any reason
//! you might need a blank space somewhere, try to pass an additional constraint and don't use the
//! corresponding area.
pub mod backend;
pub mod buffer;
pub mod layout;

View File

@@ -2,7 +2,7 @@
//! Frontend for [Backend]
use crate::{backend::Backend, buffer::Buffer};
use helix_view::editor::Config as EditorConfig;
use helix_view::editor::{Config as EditorConfig, KittyKeyboardProtocolConfig};
use helix_view::graphics::{CursorKind, Rect};
use std::io;
@@ -24,12 +24,16 @@ pub struct Viewport {
#[derive(Debug)]
pub struct Config {
pub enable_mouse_capture: bool,
pub force_enable_extended_underlines: bool,
pub kitty_keyboard_protocol: KittyKeyboardProtocolConfig,
}
impl From<EditorConfig> for Config {
fn from(config: EditorConfig) -> Self {
impl From<&EditorConfig> for Config {
fn from(config: &EditorConfig) -> Self {
Self {
enable_mouse_capture: config.mouse,
force_enable_extended_underlines: config.undercurl,
kitty_keyboard_protocol: config.kitty_keyboard_protocol,
}
}
}
@@ -102,16 +106,16 @@ where
})
}
pub fn claim(&mut self, config: Config) -> io::Result<()> {
self.backend.claim(config)
pub fn claim(&mut self) -> io::Result<()> {
self.backend.claim()
}
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)
pub fn restore(&mut self) -> io::Result<()> {
self.backend.restore()
}
// /// Get a Frame object which provides a consistent view into the terminal state for rendering.
@@ -218,10 +222,6 @@ where
Ok(())
}
pub fn get_cursor(&mut self) -> io::Result<(u16, u16)> {
self.backend.get_cursor()
}
pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
self.backend.set_cursor(x, y)
}

View File

@@ -374,6 +374,30 @@ impl<'a> Text<'a> {
self.lines.len()
}
/// Patch text with a new style. Only updates fields that are in the new style.
///
/// # Examples
///
/// ```rust
/// # use helix_tui::text::Text;
/// # use helix_view::graphics::{Color, Style};
/// let style1 = Style::default().fg(Color::Yellow);
/// let style2 = Style::default().fg(Color::Yellow).bg(Color::Black);
/// let mut half_styled_text = Text::styled(String::from("The first line\nThe second line"), style1);
/// let full_styled_text = Text::styled(String::from("The first line\nThe second line"), style2);
/// assert_ne!(half_styled_text, full_styled_text);
///
/// half_styled_text.patch_style(Style::default().bg(Color::Black));
/// assert_eq!(half_styled_text, full_styled_text);
/// ```
pub fn patch_style(&mut self, style: Style) {
for line in &mut self.lines {
for span in &mut line.0 {
span.style = span.style.patch(style);
}
}
}
/// Apply a new style to existing text.
///
/// # Examples
@@ -386,13 +410,13 @@ impl<'a> Text<'a> {
/// let styled_text = Text::styled(String::from("The first line\nThe second line"), style);
/// assert_ne!(raw_text, styled_text);
///
/// raw_text.patch_style(style);
/// raw_text.set_style(style);
/// assert_eq!(raw_text, styled_text);
/// ```
pub fn patch_style(&mut self, style: Style) {
pub fn set_style(&mut self, style: Style) {
for line in &mut self.lines {
for span in &mut line.0 {
span.style = span.style.patch(style);
span.style = style;
}
}
}

View File

@@ -38,8 +38,15 @@ impl Cell<'_> {
/// Set the `Style` of this cell.
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self.content.set_style(style);
self
}
/// Set the `Style` of this cell.
pub fn set_style(&mut self, style: Style) {
self.style = style;
self.content.set_style(style);
}
}
impl<'a, T> From<T> for Cell<'a>
@@ -453,6 +460,9 @@ impl Table<'_> {
};
if is_selected {
buf.set_style(table_row_area, self.highlight_style);
for cell in &mut table_row.cells {
cell.set_style(self.highlight_style);
}
}
let mut col = table_row_start_col;
for (width, cell) in columns_widths.iter().zip(table_row.cells.iter()) {

128
helix-tui/tests/text.rs Normal file
View File

@@ -0,0 +1,128 @@
use helix_tui::text::{Span, Spans, StyledGrapheme, Text};
use helix_view::graphics::{Color, Modifier, Style};
// Text
#[test]
fn text_width() {
let text = Text::from("The first line\nThe second line");
assert_eq!(15, text.width());
}
#[test]
fn text_height() {
let text = Text::from("The first line\nThe second line");
assert_eq!(2, text.height());
}
#[test]
fn patch_style() {
let style1 = Style::default().fg(Color::Yellow);
let style2 = Style::default().fg(Color::Yellow).bg(Color::Black);
let mut half_styled_text =
Text::styled(String::from("The first line\nThe second line"), style1);
let full_styled_text = Text::styled(String::from("The first line\nThe second line"), style2);
assert_ne!(half_styled_text, full_styled_text);
half_styled_text.patch_style(Style::default().bg(Color::Black));
assert_eq!(half_styled_text, full_styled_text);
}
#[test]
fn set_style() {
let style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC);
let mut raw_text = Text::raw("The first line\nThe second line");
let styled_text = Text::styled(String::from("The first line\nThe second line"), style);
assert_ne!(raw_text, styled_text);
raw_text.set_style(style);
assert_eq!(raw_text, styled_text);
}
#[test]
fn text_extend() {
let style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC);
let mut text = Text::from("The first line\nThe second line");
assert_eq!(2, text.height());
// Adding two more unstyled lines
text.extend(Text::raw("These are two\nmore lines!"));
assert_eq!(4, text.height());
// Adding a final two styled lines
text.extend(Text::styled("Some more lines\nnow with more style!", style));
assert_eq!(6, text.height());
}
// Span
#[test]
fn styled_graphemes() {
let style = Style::default().fg(Color::Yellow);
let span = Span::styled("Text", style);
let style = Style::default().fg(Color::Green).bg(Color::Black);
let styled_graphemes = span.styled_graphemes(style);
assert_eq!(
vec![
StyledGrapheme {
symbol: "T",
style: Style {
fg: Some(Color::Yellow),
bg: Some(Color::Black),
underline_color: None,
underline_style: None,
add_modifier: Modifier::empty(),
sub_modifier: Modifier::empty(),
},
},
StyledGrapheme {
symbol: "e",
style: Style {
fg: Some(Color::Yellow),
bg: Some(Color::Black),
underline_color: None,
underline_style: None,
add_modifier: Modifier::empty(),
sub_modifier: Modifier::empty(),
},
},
StyledGrapheme {
symbol: "x",
style: Style {
fg: Some(Color::Yellow),
bg: Some(Color::Black),
underline_color: None,
underline_style: None,
add_modifier: Modifier::empty(),
sub_modifier: Modifier::empty(),
},
},
StyledGrapheme {
symbol: "t",
style: Style {
fg: Some(Color::Yellow),
bg: Some(Color::Black),
underline_color: None,
underline_style: None,
add_modifier: Modifier::empty(),
sub_modifier: Modifier::empty(),
},
},
],
styled_graphemes.collect::<Vec<StyledGrapheme>>()
);
}
// Spans
#[test]
fn spans_width() {
let spans = Spans::from(vec![
Span::styled("My", Style::default().fg(Color::Yellow)),
Span::raw(" text"),
]);
assert_eq!(7, spans.width());
}

View File

@@ -17,7 +17,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "p
parking_lot.workspace = true
arc-swap = { version = "1.7.1" }
gix = { version = "0.72.1", features = ["attributes", "status"], default-features = false, optional = true }
gix = { version = "0.73.0", features = ["attributes", "status"], default-features = false, optional = true }
imara-diff = "0.2.0"
anyhow = "1"

View File

@@ -12,7 +12,7 @@ homepage.workspace = true
[features]
default = []
term = ["crossterm"]
term = ["termina"]
unicode-lines = []
[dependencies]
@@ -26,7 +26,7 @@ helix-vcs = { path = "../helix-vcs" }
bitflags.workspace = true
anyhow = "1"
crossterm = { version = "0.28", optional = true }
termina = { workspace = true, optional = true }
tempfile.workspace = true
@@ -46,12 +46,14 @@ chardetng = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
toml.workspace = true
log = "~0.4"
parking_lot.workspace = true
thiserror.workspace = true
kstring = "2.0"
[target.'cfg(windows)'.dependencies]
clipboard-win = { version = "5.4", features = ["std"] }

View File

@@ -109,7 +109,7 @@ impl InlineDiagnosticsConfig {
impl Default for InlineDiagnosticsConfig {
fn default() -> Self {
InlineDiagnosticsConfig {
cursor_line: DiagnosticFilter::Disable,
cursor_line: DiagnosticFilter::Enable(Severity::Warning),
other_lines: DiagnosticFilter::Disable,
min_diagnostic_width: 40,
prefix_len: 1,

View File

@@ -1,163 +0,0 @@
// A minimal base64 implementation to keep from pulling in a crate for just that. It's based on
// https://github.com/marshallpierce/rust-base64 but without all the customization options.
// The biggest portion comes from
// https://github.com/marshallpierce/rust-base64/blob/a675443d327e175f735a37f574de803d6a332591/src/engine/naive.rs#L42
// Thanks, rust-base64!
// The MIT License (MIT)
// Copyright (c) 2015 Alice Maz
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
use std::ops::{BitAnd, BitOr, Shl, Shr};
const PAD_BYTE: u8 = b'=';
const ENCODE_TABLE: &[u8] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".as_bytes();
const LOW_SIX_BITS: u32 = 0x3F;
pub fn encode(input: &[u8]) -> String {
let rem = input.len() % 3;
let complete_chunks = input.len() / 3;
let remainder_chunk = usize::from(rem != 0);
let encoded_size = (complete_chunks + remainder_chunk) * 4;
let mut output = vec![0; encoded_size];
// complete chunks first
let complete_chunk_len = input.len() - rem;
let mut input_index = 0_usize;
let mut output_index = 0_usize;
while input_index < complete_chunk_len {
let chunk = &input[input_index..input_index + 3];
// populate low 24 bits from 3 bytes
let chunk_int: u32 =
(chunk[0] as u32).shl(16) | (chunk[1] as u32).shl(8) | (chunk[2] as u32);
// encode 4x 6-bit output bytes
output[output_index] = ENCODE_TABLE[chunk_int.shr(18) as usize];
output[output_index + 1] = ENCODE_TABLE[chunk_int.shr(12_u8).bitand(LOW_SIX_BITS) as usize];
output[output_index + 2] = ENCODE_TABLE[chunk_int.shr(6_u8).bitand(LOW_SIX_BITS) as usize];
output[output_index + 3] = ENCODE_TABLE[chunk_int.bitand(LOW_SIX_BITS) as usize];
input_index += 3;
output_index += 4;
}
// then leftovers
if rem == 2 {
let chunk = &input[input_index..input_index + 2];
// high six bits of chunk[0]
output[output_index] = ENCODE_TABLE[chunk[0].shr(2) as usize];
// bottom 2 bits of [0], high 4 bits of [1]
output[output_index + 1] = ENCODE_TABLE
[(chunk[0].shl(4_u8).bitor(chunk[1].shr(4_u8)) as u32).bitand(LOW_SIX_BITS) as usize];
// bottom 4 bits of [1], with the 2 bottom bits as zero
output[output_index + 2] =
ENCODE_TABLE[(chunk[1].shl(2_u8) as u32).bitand(LOW_SIX_BITS) as usize];
output[output_index + 3] = PAD_BYTE;
} else if rem == 1 {
let byte = input[input_index];
output[output_index] = ENCODE_TABLE[byte.shr(2) as usize];
output[output_index + 1] =
ENCODE_TABLE[(byte.shl(4_u8) as u32).bitand(LOW_SIX_BITS) as usize];
output[output_index + 2] = PAD_BYTE;
output[output_index + 3] = PAD_BYTE;
}
String::from_utf8(output).expect("Invalid UTF8")
}
#[cfg(test)]
mod tests {
fn compare_encode(expected: &str, target: &[u8]) {
assert_eq!(expected, super::encode(target));
}
#[test]
fn encode_rfc4648_0() {
compare_encode("", b"");
}
#[test]
fn encode_rfc4648_1() {
compare_encode("Zg==", b"f");
}
#[test]
fn encode_rfc4648_2() {
compare_encode("Zm8=", b"fo");
}
#[test]
fn encode_rfc4648_3() {
compare_encode("Zm9v", b"foo");
}
#[test]
fn encode_rfc4648_4() {
compare_encode("Zm9vYg==", b"foob");
}
#[test]
fn encode_rfc4648_5() {
compare_encode("Zm9vYmE=", b"fooba");
}
#[test]
fn encode_rfc4648_6() {
compare_encode("Zm9vYmFy", b"foobar");
}
#[test]
fn encode_all_ascii() {
let mut ascii = Vec::<u8>::with_capacity(128);
for i in 0..128 {
ascii.push(i);
}
compare_encode(
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7P\
D0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8\
=",
&ascii,
);
}
#[test]
fn encode_all_bytes() {
let mut bytes = Vec::<u8>::with_capacity(256);
for i in 0..255 {
bytes.push(i);
}
bytes.push(255); //bug with "overflowing" ranges?
compare_encode(
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7P\
D0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\
+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6\
/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==",
&bytes,
);
}
}

View File

@@ -193,7 +193,7 @@ mod external {
Self::Wayland => builtin_name("wayland", &WL_CLIPBOARD),
Self::XClip => builtin_name("x-clip", &XCLIP),
Self::XSel => builtin_name("x-sel", &XSEL),
Self::Win32Yank => builtin_name("win-32-yank", &WIN32),
Self::Win32Yank => builtin_name("win32-yank", &WIN32),
Self::Tmux => builtin_name("tmux", &TMUX),
Self::Termux => builtin_name("termux", &TERMUX),
#[cfg(windows)]
@@ -292,10 +292,17 @@ mod external {
},
#[cfg(feature = "term")]
Self::Termcode => {
crossterm::queue!(
std::io::stdout(),
osc52::SetClipboardCommand::new(content, clipboard_type)
)?;
use std::io::Write;
use termina::escape::osc::{self, Osc};
let selection = match clipboard_type {
ClipboardType::Clipboard => osc::Selection::CLIPBOARD,
ClipboardType::Selection => osc::Selection::PRIMARY,
};
// NOTE: it would be ideal to have the terminal execute this but it _should_
// work to send this over stdout instead.
let mut stdout = std::io::stdout().lock();
write!(stdout, "{}", Osc::SetSelection(selection, content))?;
stdout.flush()?;
Ok(())
}
Self::Custom(command_provider) => match clipboard_type {
@@ -400,43 +407,6 @@ mod external {
paste => "termux-clipboard-set";
}
#[cfg(feature = "term")]
mod osc52 {
use {super::ClipboardType, crate::base64};
pub struct SetClipboardCommand {
encoded_content: String,
clipboard_type: ClipboardType,
}
impl SetClipboardCommand {
pub fn new(content: &str, clipboard_type: ClipboardType) -> Self {
Self {
encoded_content: base64::encode(content.as_bytes()),
clipboard_type,
}
}
}
impl crossterm::Command for SetClipboardCommand {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
let kind = match &self.clipboard_type {
ClipboardType::Clipboard => "c",
ClipboardType::Selection => "p",
};
// Send an OSC 52 set command: https://terminalguide.namepad.de/seq/osc-52/
write!(f, "\x1b]52;{};{}\x1b\\", kind, &self.encoded_content)
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::result::Result<(), std::io::Error> {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"OSC clipboard codes not supported by winapi.",
))
}
}
}
fn execute_command(
cmd: &Command,
input: Option<&str>,

View File

@@ -1320,15 +1320,27 @@ impl Document {
Ok(())
}
/// Select text within the [`Document`].
pub fn set_selection(&mut self, view_id: ViewId, selection: Selection) {
/// Select text within the [`Document`], optionally clearing the previous selection state.
pub fn set_selection_clear(&mut self, view_id: ViewId, selection: Selection, clear_prev: bool) {
// TODO: use a transaction?
self.selections
.insert(view_id, selection.ensure_invariants(self.text().slice(..)));
helix_event::dispatch(SelectionDidChange {
doc: self,
view: view_id,
})
});
if clear_prev {
self.view_data
.entry(view_id)
.and_modify(|view_data| view_data.object_selections.clear());
}
}
/// Select text within the [`Document`].
pub fn set_selection(&mut self, view_id: ViewId, selection: Selection) {
self.set_selection_clear(view_id, selection, true);
}
/// Find the origin selection of the text in a document, i.e. where
@@ -1520,6 +1532,12 @@ impl Document {
apply_inlay_hint_changes(padding_after_inlay_hints);
}
// clear out all associated view object selections, as they are no
// longer valid
self.view_data
.values_mut()
.for_each(|view_data| view_data.object_selections.clear());
helix_event::dispatch(DocumentDidChange {
doc: self,
view: view_id,
@@ -1810,6 +1828,12 @@ impl Document {
self.version
}
pub fn word_completion_enabled(&self) -> bool {
self.language_config()
.and_then(|lang_config| lang_config.word_completion.and_then(|c| c.enable))
.unwrap_or_else(|| self.config.load().word_completion.enable)
}
pub fn path_completion_enabled(&self) -> bool {
self.language_config()
.and_then(|lang_config| lang_config.path_completion)
@@ -1956,13 +1980,13 @@ impl Document {
&self.selections
}
fn view_data(&self, view_id: ViewId) -> &ViewData {
pub fn view_data(&self, view_id: ViewId) -> &ViewData {
self.view_data
.get(&view_id)
.expect("This should only be called after ensure_view_init")
}
fn view_data_mut(&mut self, view_id: ViewId) -> &mut ViewData {
pub fn view_data_mut(&mut self, view_id: ViewId) -> &mut ViewData {
self.view_data.entry(view_id).or_default()
}
@@ -2280,9 +2304,13 @@ impl Document {
}
}
/// Stores data needed for views that are tied to this specific Document.
#[derive(Debug, Default)]
pub struct ViewData {
view_position: ViewPosition,
/// used to store previous selections of tree-sitter objects
pub object_selections: HashMap<&'static str, Vec<Selection>>,
}
#[derive(Clone, Debug)]
@@ -2333,6 +2361,7 @@ mod test {
Arc::new(ArcSwap::from_pointee(syntax::Loader::default())),
);
let view = ViewId::default();
doc.ensure_view_init(view);
doc.set_selection(view, Selection::single(0, 0));
let transaction =
@@ -2371,7 +2400,9 @@ mod test {
Arc::new(ArcSwap::new(Arc::new(Config::default()))),
Arc::new(ArcSwap::from_pointee(syntax::Loader::default())),
);
let view = ViewId::default();
doc.ensure_view_init(view);
doc.set_selection(view, Selection::single(5, 5));
// insert

View File

@@ -278,6 +278,9 @@ pub struct Config {
/// either absolute or relative to the current opened document or current working directory (if the buffer is not yet saved).
/// Defaults to true.
pub path_completion: bool,
/// Configures completion of words from open buffers.
/// Defaults to enabled with a trigger length of 7.
pub word_completion: WordCompletion,
/// Automatic formatting on save. Defaults to true.
pub auto_format: bool,
/// Default register used for yank/paste. Defaults to '"'
@@ -376,6 +379,19 @@ pub struct Config {
/// Whether to read settings from [EditorConfig](https://editorconfig.org) files. Defaults to
/// `true`.
pub editor_config: bool,
/// Whether to render rainbow colors for matching brackets. Defaults to `false`.
pub rainbow_brackets: bool,
/// Whether to enable Kitty Keyboard Protocol
pub kitty_keyboard_protocol: KittyKeyboardProtocolConfig,
}
#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum KittyKeyboardProtocolConfig {
#[default]
Auto,
Disabled,
Enabled,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
@@ -625,6 +641,9 @@ pub enum StatusLineElement {
/// Indicator for selected register
Register,
/// The base of current working directory
CurrentWorkingDirectory,
}
// Cursor shape is read and used on every rendered frame and so needs
@@ -974,6 +993,22 @@ pub enum PopupBorderConfig {
Menu,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct WordCompletion {
pub enable: bool,
pub trigger_length: NonZeroU8,
}
impl Default for WordCompletion {
fn default() -> Self {
Self {
enable: true,
trigger_length: NonZeroU8::new(7).unwrap(),
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
@@ -993,6 +1028,7 @@ impl Default for Config {
auto_pairs: AutoPairConfig::default(),
auto_completion: true,
path_completion: true,
word_completion: WordCompletion::default(),
auto_format: true,
default_yank_register: '"',
auto_save: AutoSave::default(),
@@ -1032,9 +1068,11 @@ impl Default for Config {
indent_heuristic: IndentationHeuristic::default(),
jump_label_alphabet: ('a'..='z').collect(),
inline_diagnostics: InlineDiagnosticsConfig::default(),
end_of_line_diagnostics: DiagnosticFilter::Disable,
end_of_line_diagnostics: DiagnosticFilter::Enable(Severity::Hint),
clipboard_provider: ClipboardProvider::default(),
editor_config: true,
rainbow_brackets: false,
kitty_keyboard_protocol: Default::default(),
}
}
}
@@ -1961,28 +1999,29 @@ impl Editor {
}
pub fn focus(&mut self, view_id: ViewId) {
let prev_id = std::mem::replace(&mut self.tree.focus, view_id);
// if leaving the view: mode should reset and the cursor should be
// within view
if prev_id != view_id {
self.enter_normal_mode();
self.ensure_cursor_in_view(view_id);
// Update jumplist selections with new document changes.
for (view, _focused) in self.tree.views_mut() {
let doc = doc_mut!(self, &view.doc);
view.sync_changes(doc);
}
let view = view!(self, view_id);
let doc = doc_mut!(self, &view.doc);
doc.mark_as_focused();
let focus_lost = self.tree.get(prev_id).doc;
dispatch(DocumentFocusLost {
editor: self,
doc: focus_lost,
});
if self.tree.focus == view_id {
return;
}
// Reset mode to normal and ensure any pending changes are committed in the old document.
self.enter_normal_mode();
let (view, doc) = current!(self);
doc.append_changes_to_history(view);
self.ensure_cursor_in_view(view_id);
// Update jumplist selections with new document changes.
for (view, _focused) in self.tree.views_mut() {
let doc = doc_mut!(self, &view.doc);
view.sync_changes(doc);
}
let prev_id = std::mem::replace(&mut self.tree.focus, view_id);
doc_mut!(self).mark_as_focused();
let focus_lost = self.tree.get(prev_id).doc;
dispatch(DocumentFocusLost {
editor: self,
doc: focus_lost,
});
}
pub fn focus_next(&mut self) {

View File

@@ -33,6 +33,10 @@ pub enum Variable {
BufferName,
/// A string containing the line-ending of the currently focused document.
LineEnding,
/// Curreng working directory
CurrentWorkingDirectory,
/// Nearest ancestor directory of the current working directory that contains `.git`, `.svn`, `jj` or `.helix`
WorkspaceDirectory,
// The name of current buffers language as set in `languages.toml`
Language,
// Primary selection
@@ -49,6 +53,8 @@ impl Variable {
Self::CursorColumn,
Self::BufferName,
Self::LineEnding,
Self::CurrentWorkingDirectory,
Self::WorkspaceDirectory,
Self::Language,
Self::Selection,
Self::SelectionLineStart,
@@ -61,6 +67,8 @@ impl Variable {
Self::CursorColumn => "cursor_column",
Self::BufferName => "buffer_name",
Self::LineEnding => "line_ending",
Self::CurrentWorkingDirectory => "current_working_directory",
Self::WorkspaceDirectory => "workspace_directory",
Self::Language => "language",
Self::Selection => "selection",
Self::SelectionLineStart => "selection_line_start",
@@ -74,6 +82,8 @@ impl Variable {
"cursor_column" => Some(Self::CursorColumn),
"buffer_name" => Some(Self::BufferName),
"line_ending" => Some(Self::LineEnding),
"workspace_directory" => Some(Self::WorkspaceDirectory),
"current_working_directory" => Some(Self::CurrentWorkingDirectory),
"language" => Some(Self::Language),
"selection" => Some(Self::Selection),
"selection_line_start" => Some(Self::SelectionLineStart),
@@ -235,6 +245,17 @@ fn expand_variable(editor: &Editor, variable: Variable) -> Result<Cow<'static, s
}
}
Variable::LineEnding => Ok(Cow::Borrowed(doc.line_ending.as_str())),
Variable::CurrentWorkingDirectory => Ok(std::borrow::Cow::Owned(
helix_stdx::env::current_working_dir()
.to_string_lossy()
.to_string(),
)),
Variable::WorkspaceDirectory => Ok(std::borrow::Cow::Owned(
helix_loader::find_workspace()
.0
.to_string_lossy()
.to_string(),
)),
Variable::Language => Ok(match doc.language_name() {
Some(lang) => Cow::Owned(lang.to_owned()),
None => Cow::Borrowed("text"),

View File

@@ -289,30 +289,28 @@ impl Color {
}
#[cfg(feature = "term")]
impl From<Color> for crossterm::style::Color {
impl From<Color> for termina::style::ColorSpec {
fn from(color: Color) -> Self {
use crossterm::style::Color as CColor;
match color {
Color::Reset => CColor::Reset,
Color::Black => CColor::Black,
Color::Red => CColor::DarkRed,
Color::Green => CColor::DarkGreen,
Color::Yellow => CColor::DarkYellow,
Color::Blue => CColor::DarkBlue,
Color::Magenta => CColor::DarkMagenta,
Color::Cyan => CColor::DarkCyan,
Color::Gray => CColor::DarkGrey,
Color::LightRed => CColor::Red,
Color::LightGreen => CColor::Green,
Color::LightBlue => CColor::Blue,
Color::LightYellow => CColor::Yellow,
Color::LightMagenta => CColor::Magenta,
Color::LightCyan => CColor::Cyan,
Color::LightGray => CColor::Grey,
Color::White => CColor::White,
Color::Indexed(i) => CColor::AnsiValue(i),
Color::Rgb(r, g, b) => CColor::Rgb { r, g, b },
Color::Reset => Self::Reset,
Color::Black => Self::BLACK,
Color::Red => Self::RED,
Color::Green => Self::GREEN,
Color::Yellow => Self::YELLOW,
Color::Blue => Self::BLUE,
Color::Magenta => Self::MAGENTA,
Color::Cyan => Self::CYAN,
Color::Gray => Self::BRIGHT_BLACK,
Color::White => Self::BRIGHT_WHITE,
Color::LightRed => Self::BRIGHT_RED,
Color::LightGreen => Self::BRIGHT_GREEN,
Color::LightBlue => Self::BRIGHT_BLUE,
Color::LightYellow => Self::BRIGHT_YELLOW,
Color::LightMagenta => Self::BRIGHT_MAGENTA,
Color::LightCyan => Self::BRIGHT_CYAN,
Color::LightGray => Self::WHITE,
Color::Indexed(i) => Self::PaletteIndex(i),
Color::Rgb(r, g, b) => termina::style::RgbColor::new(r, g, b).into(),
}
}
}
@@ -343,15 +341,15 @@ impl FromStr for UnderlineStyle {
}
#[cfg(feature = "term")]
impl From<UnderlineStyle> for crossterm::style::Attribute {
impl From<UnderlineStyle> for termina::style::Underline {
fn from(style: UnderlineStyle) -> Self {
match style {
UnderlineStyle::Line => crossterm::style::Attribute::Underlined,
UnderlineStyle::Curl => crossterm::style::Attribute::Undercurled,
UnderlineStyle::Dotted => crossterm::style::Attribute::Underdotted,
UnderlineStyle::Dashed => crossterm::style::Attribute::Underdashed,
UnderlineStyle::DoubleLine => crossterm::style::Attribute::DoubleUnderlined,
UnderlineStyle::Reset => crossterm::style::Attribute::NoUnderline,
UnderlineStyle::Reset => Self::None,
UnderlineStyle::Line => Self::Single,
UnderlineStyle::Curl => Self::Curly,
UnderlineStyle::Dotted => Self::Dotted,
UnderlineStyle::Dashed => Self::Dashed,
UnderlineStyle::DoubleLine => Self::Double,
}
}
}

View File

@@ -9,6 +9,7 @@ pub mod completion;
pub mod dap;
pub mod diagnostics;
pub mod lsp;
pub mod word_index;
#[derive(Debug)]
pub enum AutoSaveEvent {
@@ -22,6 +23,7 @@ pub struct Handlers {
pub signature_hints: Sender<lsp::SignatureHelpEvent>,
pub auto_save: Sender<AutoSaveEvent>,
pub document_colors: Sender<lsp::DocumentColorsEvent>,
pub word_index: word_index::Handler,
}
impl Handlers {
@@ -46,8 +48,13 @@ impl Handlers {
};
send_blocking(&self.signature_hints, event)
}
pub fn word_index(&self) -> &word_index::WordIndex {
&self.word_index.index
}
}
pub fn register_hooks(handlers: &Handlers) {
lsp::register_hooks(handlers);
word_index::register_hooks(handlers);
}

View File

@@ -0,0 +1,509 @@
//! Indexing of words from open buffers.
//!
//! This provides an eventually consistent set of words used in any open buffers. This set is
//! later used for lexical completion.
use std::{borrow::Cow, collections::HashMap, iter, mem, sync::Arc, time::Duration};
use helix_core::{
chars::char_is_word, fuzzy::fuzzy_match, movement, ChangeSet, Range, Rope, RopeSlice,
};
use helix_event::{register_hook, AsyncHook};
use helix_stdx::rope::RopeSliceExt as _;
use parking_lot::RwLock;
use tokio::{sync::mpsc, time::Instant};
use crate::{
events::{ConfigDidChange, DocumentDidChange, DocumentDidClose, DocumentDidOpen},
DocumentId,
};
use super::Handlers;
#[derive(Debug)]
struct Change {
old_text: Rope,
text: Rope,
changes: ChangeSet,
}
#[derive(Debug)]
enum Event {
Insert(Rope),
Update(DocumentId, Change),
Delete(DocumentId, Rope),
/// Clear the entire word index.
/// This is used to clear memory when the feature is turned off.
Clear,
}
#[derive(Debug)]
pub struct Handler {
pub(super) index: WordIndex,
/// A sender into an async hook which debounces updates to the index.
hook: mpsc::Sender<Event>,
/// A sender to a tokio task which coordinates the indexing of documents.
///
/// See [WordIndex::run]. A supervisor-like task is in charge of spawning tasks to update the
/// index. This ensures that consecutive edits to a document trigger the correct order of
/// insertions and deletions into the word set.
coordinator: mpsc::UnboundedSender<Event>,
}
impl Handler {
pub fn spawn() -> Self {
let index = WordIndex::default();
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(index.clone().run(rx));
Self {
hook: Hook {
changes: HashMap::default(),
coordinator: tx.clone(),
}
.spawn(),
index,
coordinator: tx,
}
}
}
#[derive(Debug)]
struct Hook {
changes: HashMap<DocumentId, Change>,
coordinator: mpsc::UnboundedSender<Event>,
}
const DEBOUNCE: Duration = Duration::from_secs(1);
impl AsyncHook for Hook {
type Event = Event;
fn handle_event(&mut self, event: Self::Event, timeout: Option<Instant>) -> Option<Instant> {
match event {
Event::Insert(_) => unreachable!("inserts are sent to the worker directly"),
Event::Update(doc, change) => {
if let Some(pending_change) = self.changes.get_mut(&doc) {
// If there is already a change waiting for this document, merge the two
// changes together by composing the changesets and saving the new `text`.
pending_change.changes =
mem::take(&mut pending_change.changes).compose(change.changes);
pending_change.text = change.text;
Some(Instant::now() + DEBOUNCE)
} else if !is_changeset_significant(&change.changes) {
// If the changeset is fairly large, debounce before updating the index.
self.changes.insert(doc, change);
Some(Instant::now() + DEBOUNCE)
} else {
// Otherwise if the change is small, queue the update to the index immediately.
self.coordinator.send(Event::Update(doc, change)).unwrap();
timeout
}
}
Event::Delete(doc, text) => {
// If there are pending changes that haven't been indexed since the last debounce,
// forget them and delete the old text.
if let Some(change) = self.changes.remove(&doc) {
self.coordinator
.send(Event::Delete(doc, change.old_text))
.unwrap();
} else {
self.coordinator.send(Event::Delete(doc, text)).unwrap();
}
timeout
}
Event::Clear => unreachable!("clear is sent to the worker directly"),
}
}
fn finish_debounce(&mut self) {
for (doc, change) in self.changes.drain() {
self.coordinator.send(Event::Update(doc, change)).unwrap();
}
}
}
/// Minimum number of grapheme clusters required to include a word in the index
const MIN_WORD_GRAPHEMES: usize = 3;
/// Maximum word length allowed (in chars)
const MAX_WORD_LEN: usize = 50;
type Word = kstring::KString;
#[derive(Debug, Default)]
struct WordIndexInner {
/// Reference counted storage for words.
///
/// Words are very likely to be reused many times. Instead of storing duplicates we keep a
/// reference count of times a word is used. When the reference count drops to zero the word
/// is removed from the index.
words: HashMap<Word, u32>,
}
impl WordIndexInner {
fn words(&self) -> impl Iterator<Item = &Word> {
self.words.keys()
}
fn insert(&mut self, word: RopeSlice) {
let word: Cow<str> = word.into();
if let Some(rc) = self.words.get_mut(word.as_ref()) {
*rc = rc.saturating_add(1);
} else {
let word = match word {
Cow::Owned(s) => Word::from_string(s),
Cow::Borrowed(s) => Word::from_ref(s),
};
self.words.insert(word, 1);
}
}
fn remove(&mut self, word: RopeSlice) {
let word: Cow<str> = word.into();
match self.words.get_mut(word.as_ref()) {
Some(1) => {
self.words.remove(word.as_ref());
}
Some(n) => *n -= 1,
None => (),
}
}
fn clear(&mut self) {
std::mem::take(&mut self.words);
}
}
#[derive(Debug, Default, Clone)]
pub struct WordIndex {
inner: Arc<RwLock<WordIndexInner>>,
}
impl WordIndex {
pub fn matches(&self, pattern: &str) -> Vec<String> {
let inner = self.inner.read();
let mut matches = fuzzy_match(pattern, inner.words(), false);
matches.sort_unstable_by_key(|(_, score)| *score);
matches
.into_iter()
.map(|(word, _)| word.to_string())
.collect()
}
fn add_document(&self, text: &Rope) {
let mut inner = self.inner.write();
for word in words(text.slice(..)) {
inner.insert(word);
}
}
fn update_document(&self, old_text: &Rope, text: &Rope, changes: &ChangeSet) {
let mut inner = self.inner.write();
for (old_window, new_window) in changed_windows(old_text.slice(..), text.slice(..), changes)
{
for word in words(new_window) {
inner.insert(word);
}
for word in words(old_window) {
inner.remove(word);
}
}
}
fn remove_document(&self, text: &Rope) {
let mut inner = self.inner.write();
for word in words(text.slice(..)) {
inner.remove(word);
}
}
fn clear(&self) {
let mut inner = self.inner.write();
inner.clear();
}
/// Coordinate the indexing of documents.
///
/// This task wraps a MPSC queue and spawns blocking tasks which update the index. Updates
/// are applied one-by-one to ensure that changes to the index are **serialized**:
/// updates to each document must be applied in-order.
async fn run(self, mut events: mpsc::UnboundedReceiver<Event>) {
while let Some(event) = events.recv().await {
let this = self.clone();
tokio::task::spawn_blocking(move || match event {
Event::Insert(text) => {
this.add_document(&text);
}
Event::Update(
_doc,
Change {
old_text,
text,
changes,
..
},
) => {
this.update_document(&old_text, &text, &changes);
}
Event::Delete(_doc, text) => {
this.remove_document(&text);
}
Event::Clear => {
this.clear();
}
})
.await
.unwrap();
}
}
}
fn words(text: RopeSlice) -> impl Iterator<Item = RopeSlice> {
let mut cursor = Range::point(0);
if text
.get_char(cursor.anchor)
.is_some_and(|ch| !ch.is_whitespace())
{
let cursor_word_end = movement::move_next_word_end(text, cursor, 1);
if cursor_word_end.anchor == 0 {
cursor = cursor_word_end;
}
}
iter::from_fn(move || {
while cursor.head <= text.len_chars() {
let mut word = None;
if text
.slice(..cursor.head)
.graphemes_rev()
.take(MIN_WORD_GRAPHEMES)
.take_while(|g| g.chars().all(char_is_word))
.count()
== MIN_WORD_GRAPHEMES
{
cursor.anchor += text
.chars_at(cursor.anchor)
.take_while(|&c| !char_is_word(c))
.count();
let slice = cursor.slice(text);
if slice.len_chars() <= MAX_WORD_LEN {
word = Some(slice);
}
}
let head = cursor.head;
cursor = movement::move_next_word_end(text, cursor, 1);
if cursor.head == head {
cursor.head = usize::MAX;
}
if word.is_some() {
return word;
}
}
None
})
}
/// Finds areas of the old and new texts around each operation in `changes`.
///
/// The window is larger than the changed area and can encompass multiple insert/delete operations
/// if they are grouped closely together.
///
/// The ranges of the old and new text should usually be of different sizes. For example a
/// deletion of "foo" surrounded by large retain sections would give a longer window into the
/// `old_text` and shorter window of `new_text`. Vice-versa for an insertion. A full replacement
/// of a word though would give two slices of the same size.
fn changed_windows<'a>(
old_text: RopeSlice<'a>,
new_text: RopeSlice<'a>,
changes: &'a ChangeSet,
) -> impl Iterator<Item = (RopeSlice<'a>, RopeSlice<'a>)> {
use helix_core::Operation::*;
let mut operations = changes.changes().iter().peekable();
let mut old_pos = 0;
let mut new_pos = 0;
iter::from_fn(move || loop {
let operation = operations.next()?;
let old_start = old_pos;
let new_start = new_pos;
let len = operation.len_chars();
match operation {
Retain(_) => {
old_pos += len;
new_pos += len;
continue;
}
Insert(_) => new_pos += len,
Delete(_) => old_pos += len,
}
// Scan ahead until a `Retain` is found which would end a window.
while let Some(o) = operations.next_if(|op| !matches!(op, Retain(n) if *n > MAX_WORD_LEN)) {
let len = o.len_chars();
match o {
Retain(_) => {
old_pos += len;
new_pos += len;
}
Delete(_) => old_pos += len,
Insert(_) => new_pos += len,
}
}
let old_window = old_start.saturating_sub(MAX_WORD_LEN)
..(old_pos + MAX_WORD_LEN).min(old_text.len_chars());
let new_window = new_start.saturating_sub(MAX_WORD_LEN)
..(new_pos + MAX_WORD_LEN).min(new_text.len_chars());
return Some((old_text.slice(old_window), new_text.slice(new_window)));
})
}
/// Estimates whether a changeset is significant or small.
fn is_changeset_significant(changes: &ChangeSet) -> bool {
use helix_core::Operation::*;
let mut diff = 0;
for operation in changes.changes() {
match operation {
Retain(_) => continue,
Delete(_) | Insert(_) => diff += operation.len_chars(),
}
}
// This is arbitrary and could be tuned further:
diff > 1_000
}
pub(crate) fn register_hooks(handlers: &Handlers) {
let coordinator = handlers.word_index.coordinator.clone();
register_hook!(move |event: &mut DocumentDidOpen<'_>| {
let doc = doc!(event.editor, &event.doc);
if doc.word_completion_enabled() {
coordinator.send(Event::Insert(doc.text().clone())).unwrap();
}
Ok(())
});
let tx = handlers.word_index.hook.clone();
register_hook!(move |event: &mut DocumentDidChange<'_>| {
if !event.ghost_transaction && event.doc.word_completion_enabled() {
helix_event::send_blocking(
&tx,
Event::Update(
event.doc.id(),
Change {
old_text: event.old_text.clone(),
text: event.doc.text().clone(),
changes: event.changes.clone(),
},
),
);
}
Ok(())
});
let tx = handlers.word_index.hook.clone();
register_hook!(move |event: &mut DocumentDidClose<'_>| {
if event.doc.word_completion_enabled() {
helix_event::send_blocking(
&tx,
Event::Delete(event.doc.id(), event.doc.text().clone()),
);
}
Ok(())
});
let coordinator = handlers.word_index.coordinator.clone();
register_hook!(move |event: &mut ConfigDidChange<'_>| {
// The feature has been turned off. Clear the index and reclaim any used memory.
if event.old.word_completion.enable && !event.new.word_completion.enable {
coordinator.send(Event::Clear).unwrap();
}
// The feature has been turned on. Index open documents.
if !event.old.word_completion.enable && event.new.word_completion.enable {
for doc in event.editor.documents() {
if doc.word_completion_enabled() {
coordinator.send(Event::Insert(doc.text().clone())).unwrap();
}
}
}
Ok(())
});
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
use helix_core::diff::compare_ropes;
impl WordIndex {
fn words(&self) -> HashSet<String> {
let inner = self.inner.read();
inner.words().map(|w| w.to_string()).collect()
}
}
#[track_caller]
fn assert_words<I: ToString, T: IntoIterator<Item = I>>(text: &str, expected: T) {
let text = Rope::from_str(text);
let index = WordIndex::default();
index.add_document(&text);
let actual = index.words();
let expected: HashSet<_> = expected.into_iter().map(|i| i.to_string()).collect();
assert_eq!(expected, actual);
}
#[test]
fn parse() {
assert_words("one two three", ["one", "two", "three"]);
assert_words("a foo c", ["foo"]);
}
#[track_caller]
fn assert_diff<S, R, I>(before: &str, after: &str, expect_removed: R, expect_inserted: I)
where
S: ToString,
R: IntoIterator<Item = S>,
I: IntoIterator<Item = S>,
{
let before = Rope::from_str(before);
let after = Rope::from_str(after);
let diff = compare_ropes(&before, &after);
let expect_removed: HashSet<_> =
expect_removed.into_iter().map(|i| i.to_string()).collect();
let expect_inserted: HashSet<_> =
expect_inserted.into_iter().map(|i| i.to_string()).collect();
let index = WordIndex::default();
index.add_document(&before);
let words_before = index.words();
index.update_document(&before, &after, diff.changes());
let words_after = index.words();
let actual_removed = words_before.difference(&words_after).cloned().collect();
let actual_inserted = words_after.difference(&words_before).cloned().collect();
eprintln!("\"{before}\" {words_before:?} => \"{after}\" {words_after:?}");
assert_eq!(
expect_removed, actual_removed,
"expected {expect_removed:?} to be removed, instead {actual_removed:?} was"
);
assert_eq!(
expect_inserted, actual_inserted,
"expected {expect_inserted:?} to be inserted, instead {actual_inserted:?} was"
);
}
#[test]
fn diff() {
assert_diff("one two three", "one five three", ["two"], ["five"]);
assert_diff("one two three", "one to three", ["two"], []);
assert_diff("one two three", "one three", ["two"], []);
assert_diff("one two three", "one t{o three", ["two"], []);
assert_diff("one foo three", "one fooo three", ["foo"], ["fooo"]);
}
}

View File

@@ -1,4 +1,4 @@
//! Input event handling, currently backed by crossterm.
//! Input event handling, currently backed by termina.
use anyhow::{anyhow, Error};
use helix_core::unicode::{segmentation::UnicodeSegmentation, width::UnicodeWidthStr};
use serde::de::{self, Deserialize, Deserializer};
@@ -65,7 +65,7 @@ pub enum MouseButton {
pub struct KeyEvent {
pub code: KeyCode,
pub modifiers: KeyModifiers,
// TODO: crossterm now supports kind & state if terminal supports kitty's extended protocol
// TODO: termina now supports kind & state if terminal supports kitty's extended protocol
}
impl KeyEvent {
@@ -459,28 +459,31 @@ impl<'de> Deserialize<'de> for KeyEvent {
}
#[cfg(feature = "term")]
impl From<crossterm::event::Event> for Event {
fn from(event: crossterm::event::Event) -> Self {
impl From<termina::event::Event> for Event {
fn from(event: termina::event::Event) -> Self {
match event {
crossterm::event::Event::Key(key) => Self::Key(key.into()),
crossterm::event::Event::Mouse(mouse) => Self::Mouse(mouse.into()),
crossterm::event::Event::Resize(w, h) => Self::Resize(w, h),
crossterm::event::Event::FocusGained => Self::FocusGained,
crossterm::event::Event::FocusLost => Self::FocusLost,
crossterm::event::Event::Paste(s) => Self::Paste(s),
termina::event::Event::Key(key) => Self::Key(key.into()),
termina::event::Event::Mouse(mouse) => Self::Mouse(mouse.into()),
termina::event::Event::WindowResized(termina::WindowSize { rows, cols, .. }) => {
Self::Resize(cols, rows)
}
termina::event::Event::FocusIn => Self::FocusGained,
termina::event::Event::FocusOut => Self::FocusLost,
termina::event::Event::Paste(s) => Self::Paste(s),
_ => unreachable!(),
}
}
}
#[cfg(feature = "term")]
impl From<crossterm::event::MouseEvent> for MouseEvent {
impl From<termina::event::MouseEvent> for MouseEvent {
fn from(
crossterm::event::MouseEvent {
termina::event::MouseEvent {
kind,
column,
row,
modifiers,
}: crossterm::event::MouseEvent,
}: termina::event::MouseEvent,
) -> Self {
Self {
kind: kind.into(),
@@ -492,40 +495,40 @@ impl From<crossterm::event::MouseEvent> for MouseEvent {
}
#[cfg(feature = "term")]
impl From<crossterm::event::MouseEventKind> for MouseEventKind {
fn from(kind: crossterm::event::MouseEventKind) -> Self {
impl From<termina::event::MouseEventKind> for MouseEventKind {
fn from(kind: termina::event::MouseEventKind) -> Self {
match kind {
crossterm::event::MouseEventKind::Down(button) => Self::Down(button.into()),
crossterm::event::MouseEventKind::Up(button) => Self::Up(button.into()),
crossterm::event::MouseEventKind::Drag(button) => Self::Drag(button.into()),
crossterm::event::MouseEventKind::Moved => Self::Moved,
crossterm::event::MouseEventKind::ScrollDown => Self::ScrollDown,
crossterm::event::MouseEventKind::ScrollUp => Self::ScrollUp,
crossterm::event::MouseEventKind::ScrollLeft => Self::ScrollLeft,
crossterm::event::MouseEventKind::ScrollRight => Self::ScrollRight,
termina::event::MouseEventKind::Down(button) => Self::Down(button.into()),
termina::event::MouseEventKind::Up(button) => Self::Up(button.into()),
termina::event::MouseEventKind::Drag(button) => Self::Drag(button.into()),
termina::event::MouseEventKind::Moved => Self::Moved,
termina::event::MouseEventKind::ScrollDown => Self::ScrollDown,
termina::event::MouseEventKind::ScrollUp => Self::ScrollUp,
termina::event::MouseEventKind::ScrollLeft => Self::ScrollLeft,
termina::event::MouseEventKind::ScrollRight => Self::ScrollRight,
}
}
}
#[cfg(feature = "term")]
impl From<crossterm::event::MouseButton> for MouseButton {
fn from(button: crossterm::event::MouseButton) -> Self {
impl From<termina::event::MouseButton> for MouseButton {
fn from(button: termina::event::MouseButton) -> Self {
match button {
crossterm::event::MouseButton::Left => MouseButton::Left,
crossterm::event::MouseButton::Right => MouseButton::Right,
crossterm::event::MouseButton::Middle => MouseButton::Middle,
termina::event::MouseButton::Left => MouseButton::Left,
termina::event::MouseButton::Right => MouseButton::Right,
termina::event::MouseButton::Middle => MouseButton::Middle,
}
}
}
#[cfg(feature = "term")]
impl From<crossterm::event::KeyEvent> for KeyEvent {
impl From<termina::event::KeyEvent> for KeyEvent {
fn from(
crossterm::event::KeyEvent {
termina::event::KeyEvent {
code, modifiers, ..
}: crossterm::event::KeyEvent,
}: termina::event::KeyEvent,
) -> Self {
if code == crossterm::event::KeyCode::BackTab {
if code == termina::event::KeyCode::BackTab {
// special case for BackTab -> Shift-Tab
let mut modifiers: KeyModifiers = modifiers.into();
modifiers.insert(KeyModifiers::SHIFT);
@@ -543,24 +546,24 @@ impl From<crossterm::event::KeyEvent> for KeyEvent {
}
#[cfg(feature = "term")]
impl From<KeyEvent> for crossterm::event::KeyEvent {
impl From<KeyEvent> for termina::event::KeyEvent {
fn from(KeyEvent { code, modifiers }: KeyEvent) -> Self {
if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
// special case for Shift-Tab -> BackTab
let mut modifiers = modifiers;
modifiers.remove(KeyModifiers::SHIFT);
crossterm::event::KeyEvent {
code: crossterm::event::KeyCode::BackTab,
termina::event::KeyEvent {
code: termina::event::KeyCode::BackTab,
modifiers: modifiers.into(),
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
kind: termina::event::KeyEventKind::Press,
state: termina::event::KeyEventState::NONE,
}
} else {
crossterm::event::KeyEvent {
termina::event::KeyEvent {
code: code.into(),
modifiers: modifiers.into(),
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
kind: termina::event::KeyEventKind::Press,
state: termina::event::KeyEventState::NONE,
}
}
}

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