Compare commits

...

372 commits

Author SHA1 Message Date
d26f1b9d41 Merge remote-tracking branch 'SofusA/pull-diagnostics'
Some checks failed
Build / Check (msrv) (push) Has been cancelled
Build / Test Suite (push) Has been cancelled
Build / Lints (push) Has been cancelled
Build / Docs (push) Has been cancelled
Cachix / Publish Flake (push) Has been cancelled
GitHub Pages / deploy (push) Has been cancelled
2025-04-02 21:09:56 +02:00
1974fe9033 Merge remote-tracking branch 'upstream/master' 2025-04-02 21:05:51 +02:00
2c19d0934a Merge remote-tracking branch 'nik-rev/welcome-screen' 2025-04-02 21:05:40 +02:00
8229e746ae Merge remote-tracking branch 'nik-rev/gix-blame' 2025-04-02 21:04:54 +02:00
Nik Revenco
7c7bd20159 chore: incorrect comment fix 2025-04-02 16:10:10 +01:00
Nik Revenco
761a62df86 refactor: add comments explaining various variables + rename variables 2025-04-02 16:08:37 +01:00
Nik Revenco
3c0fcb0679 chore: justify welcome! macro with comment 2025-04-02 16:02:40 +01:00
Nik Revenco
b1c7bd91b9 refactor: do not pass argument unnecessarily 2025-04-02 16:00:49 +01:00
Sofus Addington
f32f5fddba
Pull diagnostics 2025-04-02 08:57:34 +02:00
Nik Revenco
c74fec4c6e fix?: do not block on the main thread when acquiring diff handle
not sure if this will work as I can't reproduce this
but let's see!
2025-04-02 00:12:38 +01:00
53ed151a9f Merge remote-tracking branch 'nik-rev/gix-blame' 2025-04-01 22:44:02 +02:00
7db2913019 Merge remote-tracking branch 'fabian1409/feat/border_type' 2025-04-01 22:41:11 +02:00
Nik Revenco
e8d7e76c73
fix: spelling error
Co-authored-by: Sebastian Klähn <39526136+Septias@users.noreply.github.com>
2025-04-01 12:17:48 +01:00
dependabot[bot]
7ebf650029
build(deps): bump once_cell in the rust-dependencies group (#13244)
Bumps the rust-dependencies group with 1 update: [once_cell](https://github.com/matklad/once_cell).


Updates `once_cell` from 1.21.1 to 1.21.3
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.21.1...v1.21.3)

---
updated-dependencies:
- dependency-name: once_cell
  dependency-version: 1.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 13:55:16 +09:00
Sri Senthil Balaji J
db187c4870
feat: add ConsoleOnly to desktop entry categories (#13236) 2025-03-31 09:28:50 -05:00
Michael Davis
e148d8b311
editor: Remove closed Document after updating Views
When closing a document we must wait until all views have been updated
first - either replacing their current document or closing the view -
before we remove the document from the `documents` map. The
`Editor::_refresh` helper is called by `Editor::close`. It accesses each
View's Document to sync changes and ensure that the cursor is in view.
When closing multiple Views at once, `Editor::_refresh` will attempt
to access the closing Document while refreshing a to-be-closed View.
2025-03-30 11:01:17 -04:00
Nik Revenco
1a0dad36b7 perf: only render inline blame for visible lines when all-lines is set
Previously, we rendereded the inline blame for lines in 3X the range of
the viewport

This is not necessary because when we scroll down, the rendering will
occur before we see the new content
2025-03-30 11:42:43 +01:00
Nik Revenco
95344a9585 perf: use string preallocations for string concatenation 2025-03-30 00:04:16 +00:00
Andrea Novellini
fb815e2c6f
Add peachpuff theme (#13225) 2025-03-29 14:44:55 -05:00
Gavin Morrow
e735485277
Remove bg from tokyonight text (#13216) 2025-03-29 14:43:33 -05:00
Keir Lawson
bb96a535fc
Add ui.text.directory to spacebones (#13213) 2025-03-29 14:41:43 -05:00
RoloEdits
01fce51c45
fix(keymap): point to proper MappableCommand instead of Command (#13214) 2025-03-28 08:51:36 -05:00
f79099a418 Fix bork merge 2025-03-27 21:49:15 +01:00
6ad3624f84 Merge remote-tracking branch 'RoloEdits/icons-v2' 2025-03-27 21:38:40 +01:00
2ea120d205 Merge remote-tracking branch 'nik-rev/merge-statusline-cmdline' 2025-03-27 21:37:36 +01:00
096201540b Merge remote-tracking branch 'SofusA/pull-diagnostics' 2025-03-27 21:37:28 +01:00
bf032afa79 Merge remote-tracking branch 'nik-rev/gix-blame' 2025-03-27 21:36:23 +01:00
b4ba2dc83d Merge remote-tracking branch 'nik-rev/welcome-screen' 2025-03-27 21:35:52 +01:00
Keir Lawson
7929c0719d
Track progress title an display in place of internal token (#13180)
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-03-27 12:00:23 -05:00
Nik Revenco
a51334f00b feat: increase padding by 1 2025-03-27 16:42:01 +00:00
Nik Revenco
55ef555f87 feat: improve wording in start menu 2025-03-27 16:38:50 +00:00
Nik Revenco
603d95f5d3 feat: recommend <space>e over <space>f
_
2025-03-27 16:35:13 +00:00
Alexandre Legent
68d7308e25
Update golangci-lint command for v2 (#13204) 2025-03-27 11:27:25 -05:00
Daniel Fichtinger
a2c580c4ae
feat: add tmTheme as XML filetype (#13202) 2025-03-27 08:39:10 -05:00
Steven Vancoillie
cf7eb5517f
Add earl_grey theme (#13203) 2025-03-27 08:37:32 -05:00
Nik Revenco
593504bb27 feat: left padding 2 -> 1 2025-03-27 11:03:56 +00:00
Nik Revenco
3471d82f87 refactor: make padding more naive
no need for such a complex solution, this is already pretty good
2025-03-27 11:02:02 +00:00
Nik Revenco
1e2f1363bc feat: update welcome screen
1. remove `type ` prefix
2. add `<tab>` to themes point
2025-03-27 10:48:37 +00:00
Nik Revenco
b68aa35bf4 feat: change text of :theme in welcome message
_
2025-03-26 19:10:46 +00:00
Nik Revenco
871f12b751 fix: 2 panics
_

_
2025-03-26 15:04:15 +00:00
Nik Revenco
7e9bf8a3bb feat: shift the type to the left
_
2025-03-26 13:38:36 +00:00
Nik Revenco
810ac6023e feat: update dashboard 2025-03-26 13:34:20 +00:00
Nik Revenco
5640161e38 feat: Welcome Screen
_

_
2025-03-26 02:53:55 +00:00
Nik Revenco
af3b670de6 refactor: move expression
_
2025-03-25 21:52:54 +00:00
Nik Revenco
b3b1c88d27 refactor: pass the Style instead of Theme 2025-03-25 21:34:09 +00:00
Nik Revenco
c101f37298 style: fmt 2025-03-25 21:31:08 +00:00
Nik Revenco
082ba4d741 refactor: match over if 2025-03-25 21:27:14 +00:00
Nik Revenco
ab5663891c refactor: render inline blame in a separate Editor function 2025-03-25 21:24:33 +00:00
Nik Revenco
00d168a78d fix: funny boolean inversion 2025-03-25 21:14:02 +00:00
Nik Revenco
a8097f1cdc perf: use Vec<T> instead of HashMap<usize, T> 2025-03-25 20:02:05 +00:00
Nik Revenco
22f9571687 feat: split inline-blame.behaviour into two options
_

_
2025-03-25 19:11:46 +00:00
Michael Davis
388a3b78e3
Avoid removing modified documents in Editor::close_document
This fixes a regression from 6da1a79d80. `:buffer-close` on an
unmodified document would cause later panics since the document should
not have been removed. Instead of eagerly removing the document on the
first line we need to wait until we've checked that it's unmodified.
2025-03-25 09:03:32 -04:00
Nik Revenco
b9f8226208 refactor: remove new_config from EditorConfigDidChange event
There is no need for it because we have access to `Editor::config()`
2025-03-25 13:03:20 +00:00
Michael Davis
d43de14807
LSP: Avoid requesting document colors for ghost transactions
The point of ghost transactions is to avoid notifying language servers
about changes since the change is meant to be temporary. This is used
for completion while selecting items in the menu: updating the language
server would mess up incomplete completions.

When a document is changed by a ghost transaction the language server
will not be notified so its understanding of the document will not be
synchronized and any positions it sends may be out-of-date. So we should
avoid triggering a request for new document color information when a
document is changed by a ghost transaction.
2025-03-25 08:52:47 -04:00
Nik Revenco
d34074af1b perf: do not render inline blame on invisible lines 2025-03-25 12:51:47 +00:00
dependabot[bot]
04d1180a0c
build(deps): bump the rust-dependencies group with 4 updates (#13190)
Bumps the rust-dependencies group with 4 updates: [tempfile](https://github.com/Stebalien/tempfile), [log](https://github.com/rust-lang/log), [rustix](https://github.com/bytecodealliance/rustix) and [cc](https://github.com/rust-lang/cc-rs).


Updates `tempfile` from 3.19.0 to 3.19.1
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/compare/v3.19.0...v3.19.1)

Updates `log` from 0.4.26 to 0.4.27
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.26...0.4.27)

Updates `rustix` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/bytecodealliance/rustix/releases)
- [Changelog](https://github.com/bytecodealliance/rustix/blob/main/CHANGES.md)
- [Commits](https://github.com/bytecodealliance/rustix/compare/v1.0.2...v1.0.3)

Updates `cc` from 1.2.16 to 1.2.17
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.16...cc-v1.2.17)

---
updated-dependencies:
- dependency-name: tempfile
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: log
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rustix
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 07:40:40 -05:00
Nik Revenco
ac0e677fae chore: appease clippy 2025-03-25 12:34:01 +00:00
Nik Revenco
76a92aff2f feat: all-lines option for inline blame 2025-03-25 12:25:12 +00:00
Nik Revenco
7478d9e688 refactor: extract as variable 2025-03-25 11:55:05 +00:00
Nik Revenco
f54fdef099 refactor: remove extra layer of sync 2025-03-25 11:46:36 +00:00
Nick
5adb4b7413
Allow :theme to show current theme (#13192)
Updates the signature for the command to take 0 arguments. This probably
regressed during 0efa8207d8.
2025-03-25 18:43:26 +09:00
Nik Revenco
8f0721f00a use format! instead of preallocating
this is more efficient apparently
2025-03-24 16:07:38 +00:00
Sebastian Dörner
13b2dc31f5
Book: Add a section with links to "Helix mode" in non-Helix software. (#12258) 2025-03-24 08:21:37 -05:00
Sofus Addington
8bce378bad
Pull diagnostics 2025-03-24 08:33:52 +01:00
Nik Revenco
07c69c1e74 fix: update blame when editing config 2025-03-24 04:00:02 +00:00
Nik Revenco
647615ddec perf: optimize obtaining blame for the same line
_

fix: blame_line_impl

_

_

_

_

_
2025-03-24 03:27:25 +00:00
Nik Revenco
29f442887a feat: Inline Git Blame
fix: use relative path when finding file

style: cargo fmt

_

chore: better error message

refactor: rename to `blame_line`

fix: use line of primary cursor for git blame

feat: basic implementation of blocking Blame handler

feat: implement basic virtual text (end of line blame)

feat: figure out how to draw stuff at the end of lines

feat: implement end of line virtual text for the current line

feat: implement inline git blame

chore: clean up

chore: remove unused import

_

chore: set `blame` to `false` by default

docs: document `[editor.vcs.blame]`

chore: add progress

perf: use background task for worker

_

chore: remove unnecessary panic!s

chore: remove commented code

refactor: remove some layers of abstraction

refactor: remove nesting

feat: [editor.vcs] -> [editor.version-control]

fix: account for inserted and deleted lines

_

refactor: extract into a `blame` module

feat: allow using custom commit format

feat: allow more customizability for inline blame

test: add tests for custom inline commit parsser

refactor: rename `blame` -> `blame_line`

_

_

test: create helper macros for tests

test: make test syntax more expressive. Allow specifying line numbers
that just got added

test: with interspersed lines

feat: add `line_blame` static command

_

test: add an extra test case

test: add ability to have `delete`d lines

test: fix on windows (?)

test: `delete` test case

test: add extra step to test case

test: add documentation for macro

refactor: use `hashmap!` macro

refactor: collapse match arm

fix: remove panic

perf: update inline git blame every 150 milliseconds instead of on each
command

test: add attributes on blocks

style: move function earlier in the file

perf: cache blame results in a hashma

chore: remove log statements

chore: clean up.

ALSO: removes checking for inline blame every N seconds.

_

perf: use mspc instead of busy-wait

docs: add information why we don't optimize the repo

_

test: add back the commented out tests

chore: comment out cfg(not(windows))

test: add extra history to blame test

docs: remove incorrect static command

_

test: disable test on windows

feat: send inline blame event update when reloading or saving the
document

feat: rename `version-control` -> `inline-blame`

feat: update theme key used for inline-blame

chore: remove unused #![allow]

chore:

style: remove accidental formatting

docs: remove incorrect key

perf: Use a single `ThreadSafeRepository` instead of re-constructing it
each time

feat: add `inline_blame` static command bound to `space + B`

style: revert formatting in keymap.md

chore: do not compute blame for document when changing config option

This isn't needed anymore because the inline-blame will be computed
regardless if `inline_blame.enable` is set or not

style: remove newline

refactor: use `fold` instead of loop

chore: clean up

feat: log error forl line blame when it happens

feat: improve message when we don't have the blame

We know that we don't have it because we're still calculating it.

feat: do not render inline blame for empty lines

_

feat: do not show blame output when we are on a hunk that was added

refactor: remove additional wrapper methods

fix

_

feat: more readable time for git blame

chr

feat:

feat: improved error handling

fix: path separator on Windows

test: disable on windows

refactor: move pretty date function formatter into `helix-stdx`

perf: do not use a syscall on each render

chore: add TODO comment to update gix version

chore: use `gix::path` conversion from Path -> BString

_

_

chore: do not update file blame on document save

This is not needed because when we write the file, we don't make a new
commit so the blame will not change.

refactor: use statics to get time elapsed instead of editor state

refactor: do not use custom event, use handler instead

fix: do not spawn a new handler

docs: correct examples for `editor.inline-blame.format`

docs: correct static command name

refactor: add comments, and improve variable names

I didn't really understand this function when I made it. Was just
copy-pasted from end of line diagnostics

I wanted to know what this is actually doing, so I investigated and
while doing this also added comments and improved names of variables
so others can understand too

fix: time in future is accounted for

perf: inline some functions that are called in only 1 place, during a
render loop

perf: add option to disable requesting inline blame in the background

fix: request blame again when document is reloaded

chore: inline blame is disabled with request on demand

feat: when requesting line blame with "blame on demand", show blame in
status

perf: use less allocations

perf: less allocations in `format_relative_time`

_

_

_

_

docs: correct name of command

_

feat: improve error message

_

feat: rename enum variants for inline blame behaviour

docs: improve description of behaviour field
2025-03-24 00:32:29 +00:00
Rolo
578a81f146 feat: add support for basic icons 2025-03-23 15:18:10 -07:00
Nik Revenco
0ee5850016
Color swatches ( 🟩 green 🟥 #ffaaaa ) (#12308) 2025-03-23 16:07:02 -05:00
Asta Halkjær From
8ff544757f
Make goto_word highlights visible (same fix as #12904) (#13174) 2025-03-23 10:27:58 -05:00
Branch Vincent
f07c1c1b29
add ui.text.directory to onedark (#13166) 2025-03-23 09:33:23 -05:00
RoloEdits
8ad6e53b1f
build(grammar): remove -fPIC flag from windows build (#13169)
Even though there is a check for `is_like_msvc`, when setting `CXX` to
`clang++` this will miss that check and try to use `-fPIC`, which is an
invlaid flag for the target.
2025-03-23 09:32:56 -05:00
Ahmir Postell
6bedca8064
Add focus_nova theme (#13144) 2025-03-22 16:24:16 -05:00
Michael Davis
7e7a98560e
LSP: Eagerly decode request results in the client
Previously the `call` helper (and its related functions) returned a
`serde_json::Value` which was then decoded either later in the client
(see signature help and hover) or by the client's caller. This led to
some unnecessary boilerplate in the client:

    let resp = self.call::<MyRequest>(params);
    Some(async move { Ok(serde_json::from_value(resp.await?)?) })

and in the caller. It also allowed for mistakes with the types. The
workspace symbol request's calling code for example mistakenly decoded a
`lsp::WorkspaceSymbolResponse` as `Vec<lsp::SymbolInformation>` - one of
the untagged enum members (so it parsed successfully) but not the
correct type.

With this change, the `call` helper eagerly decodes the response to a
request as the `lsp::request::Request::Result` trait item. This is
similar to the old helper `request` (which has become redundant and has
been eliminated) but all work is done within the same async block which
avoids some awkward lifetimes. The return types of functions like
`Client::text_document_range_inlay_hints` are now more verbose but it is
no longer possible to accidentally decode as an incorrect type.

Additionally `Client::resolve_code_action` now uses the `call_with_ref`
helper to avoid an unnecessary clone.
2025-03-22 14:40:29 -04:00
Michael Davis
6da1a79d80
Add document and LSP lifecycle events, move some callbacks into hooks
This adds events for:

* a document being opened
* a document being closed
* a language server sending the initialized notification
* a language server exiting

and also moves some handling done for these scenarios into hooks,
generally moving more into helix-view. A hook is also added on
`DocumentDidChange` which sends the `text_document_did_change`
notification - this resolves a TODO in `document`.
2025-03-22 11:41:50 -04:00
Michael Davis
2cc33b5c47
Add pull diagnostics identifier to LSP diagnostic provider
This includes a change to lsp-types to store the identifier as an Arc
since it will be cloned for each diagnostic.
2025-03-22 09:25:29 -04:00
Michael Davis
683fac65e7
Refactor DiagnosticProvider as an enum
This resolves a TODO in the core diagnostic module to refactor this
type. It was originally an alias of `LanguageServerId` for simplicity.
Refactoring as an enum is a necessary step towards introducing
"internal" diagnostics - diagnostics emitted by core features such as
a spell checker. Fully supporting this use-case will require further
larger changes to the diagnostic type, but the change to the provider
can be made first.

Note that `Copy` is not derived for `DiagnosticProvider` (as it was
previously because `LanguageServerId` is `Copy`). In the child commits
we will add the `identifier` used in LSP pull diagnostics which is a
string - not `Copy`.
2025-03-22 09:25:29 -04:00
Michael Davis
2d4c2a170c
commands: Allow any number of arguments in :bc, :bc!
Limiting to zero arguments was incorrect - a set of buffers can be
specified.
2025-03-22 09:17:30 -04:00
Michael Davis
14cab4ba62
LSP: Gracefully handle partial failures in multi-server LSP requests
This is the same change as 1c9a5bd366 but for:

* document symbols
* workspace symbols
* goto definition/declaration/.../references
* hover

Instead of bailing when one server fails, we log an error and continue
gathering items from the other responses.
2025-03-22 08:54:22 -04:00
Michael Davis
3a63e85b6a
Support EditorConfig (#13056) 2025-03-22 16:06:41 +09:00
RoloEdits
f6cb90593d
chore(worker): remove unused lifetime on EventAccumulator (#13158) 2025-03-22 16:00:39 +09:00
Ian Hobson
1c9a5bd366
Show successfully requested code actions after a failed request (#13156)
When requesting code actions from multiple LSP servers,
rather than bailing as soon as an error is encountered,
instead log the error and then keep going so that successful
requests can be presented to the user.
2025-03-21 09:10:24 -05:00
Michael Davis
1dee64f7ec
minor: Accept impl AsRef<Path> in loader's runtime_file helper
This is purely for ergonomics: we should be able to pass strings for
example

    crate::runtime_file(format!("namespace/{foo}/{bar}.txt"))

(Note that this works on Windows, see the `Path` documentation.)
2025-03-20 22:05:23 -04:00
Jan
b7d735ffe6
Switch from reddish-orange to orangeish-yellow for Solarized diff.delta (#13121) 2025-03-20 08:59:23 -05:00
Jan
7ca916ab73
Switch from reddish-orange to orangeish-yellow for Solarized diff.delta (#13121) 2025-03-20 08:55:06 -05:00
may
8e65077065
queries(scheme): consider the first argument of λ to be a variable (#13143) 2025-03-20 08:54:26 -05:00
Freddie Gilbraith
d6cacb2731
add werk language and highlights (#13136) 2025-03-20 08:04:52 -05:00
Rishikanth Chandrasekaran
ccf9564123
Adds Carbon theme for helix editor (#13067) 2025-03-19 08:54:10 -05:00
Max Milton
e7c82a34a5
language: Extend ini with more systemd file-types (#13139) 2025-03-19 08:44:37 -05:00
Michael Davis
33c17d48ff
minor: Move 'execute_lsp_command' helper into helix-view
This is a minor move that will make future refactors of code actions
simpler. We should be able to move nearly all code action functionality
into `helix-view`, save UI stuff like the `menu::Item` implementation
and dealings with the compositor.
2025-03-19 09:41:18 -04:00
Zeger Van de Vannet
6f463dbeb3
feat: add indents for starlark (#13126) 2025-03-18 09:18:45 -05:00
dependabot[bot]
70a60efcbe
build(deps): bump the rust-dependencies group with 5 updates (#13131) 2025-03-18 12:52:22 +09:00
trevershick
9d31e4df11
fix: adjust spelling of simlink->symlink (#13128) 2025-03-18 08:03:25 +09:00
Lens0021 / Leslie
27ca9d2c33
Add '///' to Amber comment-token configuration (#13122) 2025-03-16 11:03:03 -05:00
Ben Brown
e56d3abb0a
languages: Also include gitconfig as an extension (#13115)
This is useful for maintaining syntax highlighting when editing git
config files which have been included via `include` or `includeIf`.
2025-03-15 13:10:24 -05:00
Michael Davis
9574e551cf
commands: Allow zero or one arguments in :reflow
`:reflow` can optionally be passed a width.
2025-03-14 10:36:02 -04:00
Michael Davis
44bddf51b7
minor: Use parking_lot workspace dependency in helix-vcs
This is a follow-up from the parent commit - I accidentally didn't write
the buffer with this change before committing.
2025-03-13 12:43:33 -04:00
Michael Davis
b47c9da3a1
minor: Use a workspace dependency for parking_lot 2025-03-13 12:34:40 -04:00
VESSE Léo
fdaf12a35d
feat(tlaplus) : added tlaplus config + grammar (#13081) 2025-03-13 08:59:17 -05:00
SadMachinesP86
0d84bd563c
Fix Ruby highlights (#13055) 2025-03-13 08:48:13 -05:00
Michael Davis
1bd7a3901c
queries: Add JSON injection for Rust json!({..}) macros
Note that this injection doesn't work currently because precedence is
not handled by the current syntax highlighter. The switch to tree-house
will properly handle the precedence of this pattern.
2025-03-12 17:46:17 -04:00
may
694b61514f
queries: Inject into string content in Rust injections
This change also recognizes `RegexBuilder::new` calls for the regex
injection.
2025-03-12 17:31:50 -04:00
Michael Davis
7f416704b1
Fix precedence of JSON highlight queries for keys 2025-03-12 17:28:11 -04:00
Mikhail Katychev
430ce9c46b
chore: Point OpenSCAD grammar to official repo (#13033) 2025-03-12 16:10:38 -05:00
iximeow
d1e0891260
warn when configured theme is unusable for color reasons (#13058)
if `config.toml` either does not have `editor.true-color` or sets
it to false, many (most?) themes stop being usable. when loading such a
theme, Helix falls back to the default theme, but didn't mention this
anywhere - even in `~/.cache/helix/helix.log` when run with `-v`.

if this occurs when reloading a theme at runtime with `:theme`, there's
a fairly helpful error about

> `theme requires true color support`

seems worth logging about this if it happens during startup too.
2025-03-12 15:52:07 -05:00
Michael Davis
e74956fa4d
minor: Add a helper function for setting the configured theme
This block was duplicated in `Application::new` and in another helper
`Application::refresh_theme`. This change adds a helper to cover both
cases.
2025-03-12 16:32:52 -04:00
Chris44442
8d590e8aee
update vhdl tree-sitter (#13091) 2025-03-12 09:47:37 -05:00
Egor Afanasin
63ed85bc62
Sunset theme: version 2.0 (#13086) 2025-03-12 09:39:55 -05:00
Constantin Angheloiu
9bd3cecd49
Update base16_transparent.toml ui.linenr (#13080) 2025-03-12 09:18:40 -05:00
Daniel Fichtinger
8df58b2e17
feat(ini): bumped grammar version to include support for global parameters (#13088) 2025-03-11 15:28:53 -05:00
dependabot[bot]
f9360fb27e
build(deps): bump rustix from 0.38.44 to 1.0.2 (#13071)
* build(deps): bump rustix from 0.38.44 to 1.0.2

Bumps [rustix](https://github.com/bytecodealliance/rustix) from 0.38.44 to 1.0.2.
- [Release notes](https://github.com/bytecodealliance/rustix/releases)
- [Changelog](https://github.com/bytecodealliance/rustix/blob/main/CHANGES.md)
- [Commits](https://github.com/bytecodealliance/rustix/compare/v0.38.44...v1.0.2)

---
updated-dependencies:
- dependency-name: rustix
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Drop unnecessary unsafe blocks for rustix Uid and Gid types

* Revert spurious downgrade of windows-sys

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-03-11 09:34:08 -05:00
dependabot[bot]
88a254d8bf
build(deps): bump cachix/install-nix-action from 30 to 31 (#13073)
Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 30 to 31.
- [Release notes](https://github.com/cachix/install-nix-action/releases)
- [Commits](https://github.com/cachix/install-nix-action/compare/v30...v31)

---
updated-dependencies:
- dependency-name: cachix/install-nix-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 08:49:18 -05:00
Jonathan Davies
c5c9e65cc4
Update install instructions (#13079) 2025-03-11 08:41:35 -05:00
dependabot[bot]
9db6c534a3
build(deps): bump cachix/cachix-action from 15 to 16 (#13074)
Bumps [cachix/cachix-action](https://github.com/cachix/cachix-action) from 15 to 16.
- [Release notes](https://github.com/cachix/cachix-action/releases)
- [Commits](https://github.com/cachix/cachix-action/compare/v15...v16)

---
updated-dependencies:
- dependency-name: cachix/cachix-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 08:30:17 -05:00
dependabot[bot]
ff558f9105
build(deps): bump the rust-dependencies group with 5 updates (#13070)
Bumps the rust-dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [tempfile](https://github.com/Stebalien/tempfile) | `3.17.1` | `3.18.0` |
| [once_cell](https://github.com/matklad/once_cell) | `1.20.3` | `1.21.0` |
| [serde](https://github.com/serde-rs/serde) | `1.0.218` | `1.0.219` |
| [tokio](https://github.com/tokio-rs/tokio) | `1.43.0` | `1.44.0` |
| [indexmap](https://github.com/indexmap-rs/indexmap) | `2.7.1` | `2.8.0` |


Updates `tempfile` from 3.17.1 to 3.18.0
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/compare/v3.17.1...v3.18.0)

Updates `once_cell` from 1.20.3 to 1.21.0
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.20.3...v1.21.0)

Updates `serde` from 1.0.218 to 1.0.219
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.218...v1.0.219)

Updates `tokio` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.43.0...tokio-1.44.0)

Updates `indexmap` from 2.7.1 to 2.8.0
- [Changelog](https://github.com/indexmap-rs/indexmap/blob/main/RELEASES.md)
- [Commits](https://github.com/indexmap-rs/indexmap/compare/2.7.1...2.8.0)

---
updated-dependencies:
- dependency-name: tempfile
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: once_cell
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tokio
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: indexmap
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 08:29:38 -05:00
Michael Davis
b38eae1f98
minor: Fix native line-ending handling in trimming integration tests 2025-03-10 11:09:46 -04:00
Michael Davis
67879a1e5b
Avoid inserting final newlines in empty files
This matches the behavior described by the EditorConfig spec for its
`insert_final_newline` option:

> Editors must not insert newlines in empty files when saving those
> files, even if `insert_final_newline = true`.

Co-authored-by: Axlefublr <101342105+Axlefublr@users.noreply.github.com>
2025-03-10 10:38:11 -04:00
Michael Davis
aa20eb8e7f
Add config for trimming trailing whitespace and newlines on write
These match the equivalent options in VSCode. `trim_trailing_whitespace`
is also the name used by EditorConfig.

* `trim-final-newlines` trims any extra line endings after the final one
* `trim-trailing-whitespace` trims any trailing whitespace (but not
  empty lines)
2025-03-10 10:18:55 -04:00
Michael Davis
ee9db440ce
minor: Trim trailing whitespace in languages.toml 2025-03-10 10:18:55 -04:00
Lauri Gustafsson
296eb9be83
languages.toml: Change wgsl_analyzer to wgsl-analyzer (#13063)
The binary name was changed in wgsl-analyzer commit
4c56b1435d30cd45d8aee52297bbf68ed5bb3beb and released in 0.9.7.
2025-03-10 08:22:49 -05:00
suza
dc4761ad3a
feat: Add SourcePawn language support (#13028) 2025-03-07 11:55:48 -06:00
Noel Cower
2d3b75a8c5
fix: render rulers before the cursor
Render rulers before the cursor to ensure that the cursor, when over
a ruler, is not hidden from view. Without this, you typically end up
with 1) foreground text that is the same as the background if the
ruler doesn't already have a foreground and 2) no visible cursor,
because the ruler's background color took precedence. By moving the
rulers before the cursor, this ensures that the theme is still rendered
more or less the way one would visually expect things to turn out.
2025-03-07 12:44:28 -05:00
Michael Davis
8da226f0b4
flake: Revert devShell linker to lld
`mold` does not appear to work on macOS as stated in the parent commit.
2025-03-07 12:06:33 -05:00
Michael Davis
fab08c0981
flake: Use mold for linking in devShell
Our `lld` was a bit out of date. Mold seems to be slightly faster
anyways and seems to work well on both Linux & macOS.
2025-03-07 09:41:48 -05:00
Michael Davis
b6e58c0fa4
flake: Split platform and common RUSTFLAGS in devShell
The `--no-rosegment` is not supported on macOS but the other flag
configurations can be used on both macOS and Linux.
2025-03-07 09:36:58 -05:00
Michael Davis
19558839b7
flake: Avoid setting HELIX_RUNTIME in devShell
The runtime directory should be correctly set without the need to set
HELIX_RUNTIME manually because we check for a runtime directory within
CARGO_MANIFEST_DIR.

This change also filters the runtime directory out of the source file
set passed to buildRustPackage since the runtime directory is not needed
at compilation time.
2025-03-06 18:40:35 -05:00
Michael Davis
c4d314d7ba
LSP: Fix offset encoding test case
Co-authored-by: Isaac Mills <rooster0055@protonmail.com>
2025-03-06 17:13:38 -05:00
Daniel Fichtinger
b423ed42f1
feat: add harper-ls LSP configuration (#13029) 2025-03-06 15:59:22 -06:00
Michael Davis
a3fa65880e
flake: Copy logo.svg in postInstall hook 2025-03-04 14:52:17 -05:00
Christopher Smyth
b1ee4ab5c6
Fix the git hash missing and add some more comments. (#13024) 2025-03-04 13:12:48 -06:00
Michael Davis
fbc0f956b3
minor: Move json deserialization into text_document_hover future
This follows a pattern used in the signature help request for example.
Moving the json deserialization into the return future of
`text_document_hover` makes the types easier for callers to work with.
2025-03-04 12:01:07 -05:00
Michael Davis
486f4297b7
Set cargoLock.allowBuiltinFetchGit in Nix package 2025-03-04 11:47:17 -05:00
Michael Davis
28e69f09fc
direnv: Watch changes to default.nix
Now that the package definition lives in default.nix we need direnv to
watch that file to get automatic reloads.
2025-03-04 11:31:54 -05:00
Michael Davis
ab56f9e26b
minor: Tweak some verbose LSP logs
The info log within `process_request_response` duplicated the body of
the JSON message printed earlier by the transport which was confusing.

The error log in the completion handler was easy to hit during normal
use and is not actually an error - dropping is the graceful way to
handle changes occurring while completion requests are in flight.
2025-03-04 11:25:11 -05:00
Christopher Smyth
1d453785e5
Clean up Nix Flake & make it easier to customize (#12831) 2025-03-04 10:23:28 -06:00
Erwin de Keijzer
671a6036b3
Add beans theme (#12963) 2025-03-04 10:03:46 -06:00
Alexander Brassel
82f8ac208f
Improve %% escaping error message (#13018) 2025-03-04 10:03:11 -06:00
dependabot[bot]
9440feae7c
build(deps): bump the rust-dependencies group with 11 updates (#13017)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 18:09:43 -06:00
luetage
1a28999002
Kanagawa: fix palette and attempt at a markdown compromise (#12895) 2025-03-02 11:06:59 -06:00
Michael Davis
0efa8207d8
Rewrite command line parsing, add flags and expansions (#12527)
Co-authored-by: Pascal Kuthe <pascalkuthe@pm.me>
2025-02-26 19:50:15 -06:00
may
e1c7a1ed77
remove unnecessary allocations in switch_case (#12786) 2025-02-26 19:03:29 -06:00
Michael Davis
7bebe0a70e
Highlight file picker directories with 'ui.text.directory'
This applies the same styling as the parent commit to the file pickers.
2025-02-26 19:19:37 -05:00
Nik Revenco
682967d328
feat: Improve look of Global Search Picker (#12855)
Co-authored-by: Poliorcetics <poliorcetics@users.noreply.github.com>
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-02-26 18:09:57 -06:00
Mykyta
1e8774a030
Added missing CSS highlight Tree Sitter Scopes (#12497) 2025-02-26 17:51:36 -06:00
Daniel Fichtinger
c36408457a
feat(kdl): add kdlfmt as formatter for kdl (#12967) 2025-02-26 17:50:36 -06:00
Branch Vincent
1dd8a19ad6
Add pkl-lsp (#12962) 2025-02-26 17:45:10 -06:00
tshaynik
43eab10a4c
languages.toml: add starpls as Starlark language server (#12958) 2025-02-26 17:43:16 -06:00
Daniel Fichtinger
83d4ca41cc
feat: added comment textobject to toml (#12952) 2025-02-26 17:40:34 -06:00
SofusA
534d0907d3
Update c-sharp queries (#12948) 2025-02-26 17:40:16 -06:00
Daniel Fichtinger
bb3af143f1
feat: language support for mail files (#12945) 2025-02-26 17:33:36 -06:00
wcampbell
26cb3c20e7
Accept more scons as python language (#12943) 2025-02-26 17:31:23 -06:00
Bang Lee
69f25a85da
Update languages.toml to add astro-ls (#12939) 2025-02-26 17:30:55 -06:00
Dmitriy Sokolov
8cb0d869e6
feat(lsp): add protobuf language servers (#12936) 2025-02-26 17:30:26 -06:00
David Vogt
c98302a543
feat(lsp): add container name as a column in the symbol pickers (#12930) 2025-02-26 17:28:34 -06:00
Roberto Vidal
0ba2e05a6f
fix: escape percent character when yanking to search register (#12886)
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-02-26 17:22:41 -06:00
Michael Davis
e1060a2785
queries: Fix precedence in Erlang highlights 2025-02-26 10:55:00 -05:00
Michael Davis
fcddd50325
Set theme before opening documents
This is not consequential now but when we switch to the new highlighter
we will want the theme to be set (and the loader's `scopes` to be set
based on the theme) before parsing a document. Previously `set_theme`
came after the loading of documents, so documents would be missing
locals highlights after being loaded and before the first edit.
2025-02-26 10:49:36 -05:00
dependabot[bot]
35575b0b0f
build(deps): bump the rust-dependencies group with 6 updates (#12956)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 18:05:24 -06:00
Michael Davis
7e87a36e93
queries: Fix module highlights in koto 2025-02-24 12:49:43 -05:00
Michael Davis
6182bdc860
xtask: Inline query and theme checks into main module
This reverts cosmetic changes from <https://redirect.github.com/helix-editor/helix/pull/3234>:
that PR split the tasks into separate modules but the query and theme
check tasks are small enough that splitting them into separate files (or
modules) adds unnecessary friction.

This change also adds `theme-check` to the help message for the xtask
crate.
2025-02-24 10:51:28 -05:00
Michael Davis
e1c26ebfc7
queries: Reverse precedence for git-config highlights 2025-02-24 10:41:09 -05:00
Michael Davis
3683cd9ea3
queries: Remove unknown predicates 2025-02-22 14:26:41 -05:00
Sebastian Dörner
0deb8bbce6
Add indents and textobjects for Kotlin (#12925) 2025-02-20 11:58:27 -06:00
Michael Davis
3d7e2730e7
Read language servers from config in :lsp-restart
`:lsp-stop` should consider only the set of active language servers for
a document. `:lsp-restart` though may be used to start up a language
server that crashed or was manually stopped, so it needs to consider the
language servers in config instead.

This change inlines the `valid_lang_servers` function into `:lsp-stop`
and `:lsp-restart` and changes `:lsp-restart` to check the doc's config
rather than active language servers. `:lsp-restart` now also does not
need to clone the language server names as strings since it borrows from
the config and arguments rather than `Document`. The completer has also
been split into two - one matching active language servers, used by
`:lsp-stop`, and the other matching configured language servers, used by
`:lsp-restart`.

This also removes the part of `:lsp-restart` which bailed if a language
server failed to be restarted (for example because it is not installed).
There might be multiple language servers configured for a language and
only one installed. In that case the `:lsp-restart` should be considered
successful even if not all servers could be started. Bailing prevented
any language servers which could start from being attached to the
document. Instead errors are collected and emitted at the end.
2025-02-20 12:50:09 -05:00
J. Dekker
6304e7b2a7
languages/xml: add mpd & smil extensions (#12916) 2025-02-19 10:58:29 -06:00
Michael Davis
d031260180
Avoid cloning configured env vars when starting a language server
The clone of the hashmap can be avoided by passing a ref instead. This
commit also changes the `server_environment` type to match the bounds
expected by `Command::envs` - this will avoid future refactoring if the
underlying type changes (for example switching to `hashbrown::HashMap`).
2025-02-19 10:30:06 -05:00
Michael Davis
e0da129727
Use custom titles for register select info boxes
Previously all register selection info boxes had "Registers" as the
title. That was particularly confusing for `copy_between_registers`
which presents two info boxes back-to-back.
2025-02-19 10:29:15 -05:00
Michael Davis
b8912adbbf
Use a Cow<'static, str> for the Info component title
Some uses of the component (like for register) provide a static title.
We can trivially avoid the title allocation in those cases.
2025-02-19 10:10:55 -05:00
oxcrow
1c0b36b1b4
Improve jump label colors for github_light theme (#12907) 2025-02-18 08:40:42 -06:00
Michael Davis
e35d420199
application: Eliminate duplicate theme and syntax loader clones
The application held onto these since their introduction in ce97a2f0 but
the Arcs are duplicated between Application and Editor - we can store it
only on Editor without issue.
2025-02-17 19:01:54 -05:00
dependabot[bot]
48194825b9
build(deps): bump the rust-dependencies group with 3 updates (#12903)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-17 17:36:50 -06:00
Michael Davis
82f07fe6d1
Migrate helix-event to foldhash
This is following `hashbrown`'s switch in v0.15 from ahash to foldhash
for its `default-haster` feature, applied only to helix-event for now.

I don't have a strong preference between the two. Benchmarks in
Spellbook, which is particularly sensitive to hashers and hash table
performance, show no perceptible difference. Foldhash is dependency-free
though.

Once we migrate to the new tree-sitter bindings and highlighter we
should be able to eliminate the remaining dependencies on ahash.
2025-02-17 17:35:00 -05:00
Mike Boutin
1c47aec30c
Improve onedarker theme contrast cursorline/selection (#12833) 2025-02-17 13:04:15 -06:00
Nik Revenco
ef375d690e
feat: highlight rust repetition pattern (#12871)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-02-17 12:54:49 -06:00
Axlefublr
0445062d27
fix :yank-joined disrespecting default-yank-register option (#12890) 2025-02-17 10:06:29 -06:00
Abderrahmane TAHRI JOUTI
46728046fd
Cyan Theme : fix popup not having any background (#12891) 2025-02-17 10:05:15 -06:00
dependabot[bot]
7275b7f850
build(deps): bump pulldown-cmark from 0.12.2 to 0.13.0 in the rust-dependencies group (#12865)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-13 08:31:38 -06:00
Michael Davis
ed3bc2b294
Remove unused dependencies
The dependabot file was matching on tree-sitter crates - that's a relic
from v0.6.0 and lower where grammars were regular dependencies.

The remaining changes are unused crates that were forgotten about during
shuffles like moving path canonicalization from helix-core to
helix-loader (and then again from helix-loader to helix-stdx).
2025-02-13 08:41:46 -05:00
Abderrahmane TAHRI JOUTI
3ccf8d58de
Cyan light UI grays and directory prompt (#12864) 2025-02-13 07:21:37 -06:00
RoloEdits
efb44e0b22
feat(sql): update tree-sitter files (#12837) 2025-02-13 07:16:27 -06:00
Michael Davis
144a4f402f
queries: Fix html highlight precedence ordering 2025-02-12 20:58:09 -05:00
Harishankar G
df752bbd45
Prevent auto-format in auto-save (#12817)
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-02-12 12:11:01 -06:00
T. Li
d8c4c7c26f
fix: check and print remaining language servers (#12841) 2025-02-12 09:57:25 -06:00
Michael Davis
c3e9a0d607
Replace 'pkgbuild-language-server' with 'termux-language-server'
`pkgbuild-language-server` no longer exists and the PKGBUILD
functionality has moved to `termux-language-server`.
2025-02-12 10:51:07 -05:00
Jean-Louis Fuchs
258e3e1136
feat: Add support for the Ink programming language (#12773) 2025-02-12 09:46:50 -06:00
Michael Davis
5a66270c00
Remove typst-lsp config
typst-lsp has been deprecated in favor of tinymist.
2025-02-12 10:39:10 -05:00
Roberto Vidal
6aa82bb3f8
mark xsl files as XML (#12834) 2025-02-11 09:09:53 -06:00
dependabot[bot]
518d054fcb
build(deps): bump the rust-dependencies group with 4 updates (#12832)
Bumps the rust-dependencies group with 4 updates: [once_cell](https://github.com/matklad/once_cell), [toml](https://github.com/toml-rs/toml), [cc](https://github.com/rust-lang/cc-rs) and [which](https://github.com/harryfei/which-rs).


Updates `once_cell` from 1.20.2 to 1.20.3
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.20.2...v1.20.3)

Updates `toml` from 0.8.19 to 0.8.20
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.19...toml-v0.8.20)

Updates `cc` from 1.2.11 to 1.2.13
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.11...cc-v1.2.13)

Updates `which` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/harryfei/which-rs/releases)
- [Changelog](https://github.com/harryfei/which-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harryfei/which-rs/compare/7.0.1...7.0.2)

---
updated-dependencies:
- dependency-name: once_cell
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: toml
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: which
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-11 14:53:29 +09:00
Matthew Mark Ibbetson
35faa73be1
Add Djot support (#12562) 2025-02-10 15:36:01 -06:00
Abhi
7a3470c48d
Add support for yara language (#12753) 2025-02-10 15:32:28 -06:00
Nikita Revenco
199dc05a04
fix: Align Markdown styles with tree sitter highlights (#12696)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-02-10 14:58:39 -06:00
Michael Davis
5e2501da30
Reapply "Re-enable Hare by default (#11507)"
This reverts commit 151caeacc6.
2025-02-10 15:51:50 -05:00
Milo Moisson
a03becf021
nix: add indent TS query (#12829)
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-02-10 14:47:31 -06:00
Kristoffer Plagborg Bak Sørensen
a6f94e04e6
feat: add mising pkgs.writers.write* nix tree-sitter injections (#12774) 2025-02-10 14:46:53 -06:00
Kristoffer Plagborg Bak Sørensen
2197b3cfa0
feat: add mising builtins.fromTOML nix tree-sitter injection (#12776) 2025-02-10 14:46:23 -06:00
Jaakko Paju
a19c95a0a7
Add CSV language and syntax highlighting (#11973) 2025-02-10 10:51:06 -06:00
Xubai Wang
ff012e844f
Fix Bash completion space regression (#12828) 2025-02-10 10:42:45 -06:00
Poliorcetics
fcfa70e66c
just: bump grammar support to handle more kind of shebang injections (#12818) 2025-02-10 09:55:27 -06:00
Nikita Revenco
da577cf1ac feat: always render the statusline 2025-02-07 21:04:56 +00:00
Nikita Revenco
1b89f998e8
fix: Rust highlights (regression from the reverse-query-precedence PR) (#12795)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-02-07 08:51:22 -06:00
Nikita Revenco
df96f0c122 feat: merge-with-commandline false by default 2025-02-06 17:04:18 +00:00
Nikita Revenco
355402e210 fix: number indicators rendering on top of statusline 2025-02-06 16:57:49 +00:00
Jonas Köhnen
c36ed6ad92
feat(themes): add ui.text.directory to gruber-darker (#12797) 2025-02-06 09:31:36 -06:00
Michael Davis
18b9eb9e06
Update tree-sitter-erlang
This is for packaging reasons, previously the license identifier in
`package.json` accidentally listed MIT instead of Apache-2.0
2025-02-05 20:22:38 -05:00
Gabriel Dinner-David
b0e1eaf50d
reverse zig highlights (#12777) 2025-02-04 20:38:15 -06:00
Drew Zemke
a36730cb21
add support for the FGA language (#12763) 2025-02-04 11:06:22 -06:00
uncenter
75abc23428
Add Tera templating language support (#12756) 2025-02-04 10:56:36 -06:00
Michael Davis
313a6479b1
LSP: Properly discard out-of-date diagnostics
Previously the `filter` caused the diagnostics to not be attached to the
document - which is good - but the out-of-date diagnostics were still
inserted into the global (editor-wide) diagnostic set. Instead we should
completely discard out-of-date diagnostics.
2025-02-04 10:39:49 -05:00
Michael Davis
62625eda46
LSP: Move diagnostic handling from Application to Editor
There is no functional change to the move - it's just moving the code
into helix-view under a new method `Editor::handle_lsp_diagnostics` -
thought there is a typo fix, the removal of an unnecessary clone (for
the document's language config) and the removal of some nesting.

Co-authored-by: Sofus Addington <sofus@addington.dk>
2025-02-04 10:39:42 -05:00
Michael Davis
16ff06370f
queries: Remove (ERROR) from all highlights
We do not highlight `(ERROR)` nodes since the highlighting is quite
noisy while typing. Also see todo comments in `syntax.rs` - we could
introduce configuration in the future to prepend `(ERROR)` to a
language's highlights query.
2025-02-04 09:35:38 -05:00
Robin Heggelund Hansen
ee33a84489
Update highlights.scm for Gren language (#12769) 2025-02-04 08:34:59 -06:00
Niklas Wallgren
1258111394
Print full error chain when failing to load grammar (#12744) 2025-02-04 08:18:54 -06:00
Michael Davis
26db54155e
DAP: Drain pending requests on recv failure
This matches <https://redirect.github.com/helix-editor/helix/pull/4852>
for the DAP transport: when there is a failure to receive a message from
the debugger we should drain all pending requests and respond to them
with the StreamClosed error.

This improves the behavior when a debugger fails to initialize, for
example starting debugpy without debugpy installed. Previously the UI
would freeze until the request timed out. Now it instantly prints a
statusline error saying that the debugger failed to start up.
2025-02-04 09:09:54 -05:00
Michael Davis
d456377821
minor: Remove double BufReader wrapper in DAP client
`reader` is already a `BufReader` so there's no need to wrap it in
another `BufReader`. This is a typo/mistake made possible by the type
erasure (a `Box<BufReader<BufReader<T: Read>>>` is also a boxed reader).
2025-02-04 08:52:09 -05:00
Michael Davis
d0d16931e3
DAP: Configure child process stderr as piped
By default this is `Stdio::inherit` which sends stderr from the child
process to Helix. Instead we should use `Stdio::piped` which allows us
to read the piped output.

We can also expect that the stderr opens now (it should similarly to
stdout), so that we always start a reader for stderr like the LSP
client.
2025-02-04 08:51:46 -05:00
dependabot[bot]
8995ccaae2
build(deps): bump the rust-dependencies group with 4 updates (#12766)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-03 17:37:46 -06:00
Michael Davis
066e938ba0
Add copy_between_registers for interactive register copying 2025-02-02 20:49:25 -05:00
Michael Davis
e882a750ea
commands: Eagerly clear autoinfo in select_register, insert_register
This causes the infobox to disappear even when you type a non-character
key like escape. For example `"<esc>` now clears the infobox where
before it was left hanging.
2025-02-02 20:45:25 -05:00
Michael Davis
ebdab86ce6
minor: Prefer stable core::num::abs_diff to polyfill
This function was made stable in Rust 1.60.0 so we no longer need to
polyfill.
2025-02-02 20:42:55 -05:00
jack
ab6a92ed49
update(theme): add virtual-inlay hint highlight to snazzy theme (#11089) 2025-02-02 19:02:48 -06:00
Viktor Szépe
e22bbf5489
Fix typos (#12690) 2025-02-02 18:58:29 -06:00
Leo Unglaub
0ab403d428
Add block comment configuration for PHP 2025-02-02 19:37:13 -05:00
Michael Davis
b8bfc44e42
queries: Improve Rust const generic and '_' type highlighting
You may pass constants as type arguments the const generics feature.
This is used in spellbook for example as a poor man's enum, for example
`self.strip_suffix_only::<FULL_WORD>(word, hidden_homonym)`. With this
change that `FULL_WORD` part is highlighted as a constant instead of
a type.

This change also highlight the underscore in type placeholders - this
is similar to the highlighting done for bindings in Elixir or Erlang
for example. In `Vec<_>` the underscore is highlighted the same as a
comment.
2025-02-02 19:28:01 -05:00
Michael Davis
5952d564d1
Reverse highlight precedence ordering (#9458)
Co-authored-by: postsolar <120750161+postsolar@users.noreply.github.com>
Co-authored-by: Iorvethe <58810330+Iorvethe@users.noreply.github.com>
Co-authored-by: Blaž Hrastnik <blaz@mxxn.io>
Co-authored-by: gabydd <gabydinnerdavid@gmail.com>
2025-02-02 18:17:10 -06:00
Michael Davis
382401020c
queries: Add 'not-eq' and 'not-match' predicates to TSQ highlights 2025-02-02 18:39:07 -05:00
Michael Davis
93fa990e56
queries: Fix module/namespace highlight in Unison 2025-02-02 18:39:07 -05:00
Michael Davis
70d452db3e
core: Make completion item documentation optional
Path completion items always have documentation but future core (i.e.
non-LSP) completions may not always have documentation - for example
word completion from the current buffer.
2025-02-01 21:24:25 -05:00
Michael Davis
369f2bb93d
ui: Expose the 'prompt' module
The prompt Completion type alias is otherwise private. This will be
used in <https://redirect.github.com/helix-editor/helix/pull/12527>
to refactor some functions to return prompt completions.
2025-02-01 21:12:20 -05:00
Doug Kelkhoff
0f594c35f2
feat(lang:r): Add roxygen header comment token (#12748) 2025-02-01 19:44:07 -06:00
Remo Senekowitsch
de11273857
Document installation of rust-analyzer via rustup (#12618) 2025-02-01 19:43:20 -06:00
Pascal Kuthe
5c1f3f814f
implement incomplete completion requests 2025-02-01 19:36:10 -05:00
Pascal Kuthe
4e0fc0efc6
Add a completion handler type in helix-view for tracking responses
This will replace the `Sender<CompletionEvent>` in the child commits.
It tracks sender alongside extra metadata about the responses received
from providers - namely whether a request is incomplete or not - which
can be reused between subsequent requests to the provider.
2025-02-01 19:35:58 -05:00
Michael Davis
1ab35ade2d
minor: Move CompletionEvent to a new completion handler module
Completions are not specific to LSP anymore. In the child commits we
will expand on the types in this module so this refactor is done
eagerly to minimize changes later.
2025-02-01 19:32:37 -05:00
Pascal Kuthe
018081a5b1
core: Add a provider type to track the origin of a completion 2025-02-01 19:32:37 -05:00
Michael Davis
f0fa905622
LSP: Eagerly send requests in Client::request
This is a similar change to the parent commit but for `request`. The
requests should be sent eagerly so that the ordering stays consistent.

Co-authored-by: Pascal Kuthe <pascalkuthe@pm.me>
2025-02-01 19:32:37 -05:00
Michael Davis
5532ef35d9
LSP: Remove future wrapper from Client::notify, Client::reply
Previously LSP notifications were sent within a future and most callers
used a `tokio::spawn` to send the notification, silently discarding any
failures like problems serializing parameters or sending on the channel.
It's possible that tokio could schedule futures out of intended order
though which could cause notifications where order is important, like
document synchronization, to become partially shuffled. This change
removes the future wrapper and logs all internal failures.

Also included in this commit is the same change for `Client::reply`
which was also unnecessarily wrapped in a future.

Co-authored-by: Pascal Kuthe <pascalkuthe@pm.me>
2025-02-01 19:32:37 -05:00
Pascal Kuthe
0ea401d2d7
Use the slotmap workspace dependency in helix-view
This workspace dependency is already used in `helix-core` and
`helix-lsp`. This change uses it in `helix-view` as well for
consistency.
2025-02-01 19:32:23 -05:00
uncenter
e70f8833e2
Highlight $ template literals as shell commands (#12751) 2025-02-01 18:18:08 -06:00
Kristoffer Plagborg Bak Sørensen
30616344d7
Recognize .sublime-* files (#12750) 2025-02-01 17:44:06 -06:00
rhogenson
17ffa38a5a
Use the first char in a grapheme for classification (#12483)
Co-authored-by: Rose Hogenson <rosehogenson@posteo.net>
2025-02-01 17:09:45 -06:00
Michael Davis
c3620b7116
Join input and wait tasks in external formatter Tokio command
This matches the layout of `shell_impl_async` in `commands.rs` and
avoids a hang or maybe deadlock in `to_writer`'s calls to
`tokio::io::AsyncWriteExt::write_all`. I don't really understand the
underlying cause of the hang but it seems it's necessary to spawn a
new tokio task to provide input to stdin. This is shown in an example
in `tokio::process::Child::wait` but not documented explicitly.
2025-02-01 10:58:03 -05:00
Michael Davis
e9c16b7fc5
Use typable command doc when keybind provides no arguments
This improves the display of the keymap popup for example, so that if
you bind a key like `C-x = ":buffer-close"` under the `<space>` menu,
the infobox shows "Close the current buffer." rather than `:buffer-close
[]`.
2025-02-01 09:10:04 -05:00
Michael Davis
8439ce5683
Hover UI: Eagerly convert hover response to Markdown
This simplifies the hover component by eagerly converting all
`lsp::Hover` responses into `Markdown`s. Previously we cached the
current `Markdown` and created a new `Markdown` when switching the
active response. Instead we can consume the `lsp::Hover` and avoid some
clones of its inner types.
2025-01-31 17:34:56 -05:00
Nikita Revenco
6edff24c81
fix: add comment token for svelte files (#12743)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-01-31 15:13:09 -06:00
Michael Davis
47f84d04ea
Set a statusline error for formatter errors in :format 2025-01-31 14:07:22 -05:00
Michael Davis
2367b20318
Remove popup_border calculations from LSP Hover UI component
The Hover component is used as the inner contents of a Popup. The Popup
should be doing calculations based on whether popup_borders is
configured and not Hover. This fixes an issue with hover rendering when
the popup border option is enabled for popups.

Fixes #12742
2025-01-31 12:01:33 -05:00
Michael Davis
28047fed7f
config: Deny unknown fields in [editor.smart-tab]
Previously a typo like "enabled" would silently be discarded. Instead
we should error when a field is configured which doesn't exist.

Fixes #12739
2025-01-31 08:34:30 -05:00
RoloEdits
025719c1d8
perf(ropey): enable simd feature for stdx (#12735) 2025-01-30 19:51:34 -05:00
renshyle
80dbe030a1
Do not record keys pressed by macros while recording a macro (#12733)
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-01-30 18:14:04 -06:00
John Kelly
6906164177
Properly prevent crossterm features being used when feature = "term" not enabled in helix-view (#12734) 2025-01-30 17:38:07 -06:00
Michael Davis
d285a8a9e5
LSP: Fix option handling in goto definition/references commands
The language server may return `None` for a definition/reference
request. The parent commits introduced a regression for these commands
when a server did not provide locations. With this change a server may
respond with `null` and its locations will instead not be considered.

Fixes #12732
2025-01-30 11:59:02 -05:00
Michael Davis
1a821ac726
LSP: Support multiple langauge servers for goto references
This refactors goto_reference like the parent commit to query all
language servers supporting the feature.
2025-01-30 10:37:01 -05:00
Michael Davis
f7394d53fd
LSP: Support multiple language servers for goto definition
This covers all goto-definition-like commands: declaration, definition,
type definition and implementation.

Closes #11689

Co-authored-by: j <junglerobba@jngl.one>
2025-01-30 10:36:03 -05:00
Michael Davis
ba116b47a0
LSP commands: Move offset encoding onto the Location type
<https://github.com/helix-editor/helix/pull/11486> introduced a Location
type in the LSP commands module which unified helpers like
`jump_to_location`. This change moves `OffsetEncoding` onto that type.

`SymbolInformationItem` and `PickerDiagnostic` already had fields for
carrying the offset encoding. We would want a similar setup for goto
definition/references as well (for supporting multiple language servers
with that feature) but those use the `Location` type. By moving
`OffsetEncoding` onto `Location` we make future changes to allow
mulitple language servers possible for goto definition/references
features and also simplify some calls for symbols and diagnostics.
2025-01-29 17:25:12 -05:00
Michael Davis
c9dc940428
Fix byte/char indexing mix-up in path completion
The positions passed to `Transaction::change_by_selection` should be
character indexes. `edit_diff` is meant to track the number of
characters that should be deleted to erase the file name that has been
typed so far (if any). Mistakenly this was using `str::len` which is
the byte count. This fixes a bug that could cause more text to be
deleted than intended or a panic when completing a directory with
multi-byte characters like 'éclair'.

This change also moves the `edit_diff` binding out of the loop since
it's now performing some non-trivial work (counting characters, where
before it was just accessing the pre-computed number of bytes).
2025-01-29 10:05:21 -05:00
Gareth Widlansky
8328c422b7
Add ghostty configuration support (#12703) 2025-01-29 08:56:08 -06:00
0xLucqs
6049f2035b
chore(grammar): update cairo + queries (#12712) 2025-01-28 08:19:33 -06:00
Gabriel Dinner-David
8d6efaf350
fix zig highlight query use of #lua-match (#12708) 2025-01-28 00:19:43 -05:00
dependabot[bot]
98ddbf0086
build(deps): bump the rust-dependencies group with 2 updates (#12707)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 17:19:26 -06:00
Zi How Poh
0c8f0c0334
goto_diag: avoid wraparound by default (#12704) 2025-01-27 14:41:34 -06:00
Michael Davis
fec5101a41
DAP: Refactor handling of Event and Request protocol types
This change refactors the DAP `Event` type, the `helix_dap` module and
the `helix_dap::transport` module to be closer to the equivalent
implementations in `helix_lsp`. The DAP `Event` type is similar to LSP's
`Notification` so this change rewrites the enum as a trait which is
implemented by empty types (for example `enum Initialized {}`).

`Event` is then reintroduced as an enum in `helix_dap` with a helper
function to convert from the `Event` as the transport knows it. By
separating the definitions of `Event` between lib and transport, we can
handle incoming events which are not known to our `Event` enum. For
example debugpy sends `"debugpySockets"` which is unknown. With this
change, unknown events are discarded with an info-level log message.

The `Request` type was already a trait but did not have an enum with the
known values. This change also introduces a `helix_dap::Request` enum
similar to `helix_dap::Event`. This resolves a TODO comment about
avoiding `unwrap` when parsing the request's arguments.
2025-01-27 15:27:35 -05:00
Michael Davis
9bc63c1c59
DAP: Move module ID tests out of events module
These were mistakenly added to the events module, they should be part of
the types module.
2025-01-27 15:25:22 -05:00
Michael Davis
20151a5594
Move rope grapheme iterators from core to stdx 2025-01-27 09:24:40 -05:00
Michael Davis
51832b02c9
core: Remove unused byte index grapheme functions 2025-01-27 09:24:40 -05:00
Michael Davis
39b72329b4
stdx: Add floor/ceil/is grapheme boundary functions to RopeSliceExt
These functions are the equivalent of 23b424a46 for grapheme clusters.
In order to add the `is_grapheme_boundary` function we also need to
query whether a byte index lies on a character boundary, so this change
also adds `is_char_boundary`.
2025-01-27 09:24:40 -05:00
Michael Davis
0364521dca
goto_word: Skip keys with modifiers in both on-next-key blocks 2025-01-27 09:24:40 -05:00
Michael Davis
f5f9f499cf
goto_word: Reject jump label characters with modifiers
Previously you could use `<A-a><A-b>` to jump to a label "ab". We should
not treat characters with modifiers the same as characters without.
With this change the `<A-a>` input exits out of the jumping on-next-key.

Fixes #12695
2025-01-27 08:49:03 -05:00
Poliorcetics
b00b475dfe
just: bump grammar support to Just 1.39.0 (#12692) 2025-01-26 20:10:27 -06:00
kyfanc
9829ac0c02
Cycle through hover results from multiple language servers (#10122)
Co-authored-by: Vladyslav Karasov <36513243+cotneit@users.noreply.github.com>
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-01-26 11:24:50 -06:00
Kristoffer Plagborg Bak Sørensen
7c907e66f4
feat: print helpful suggestion when using :{,r}sort incorrectly (#12585) 2025-01-26 10:39:19 -06:00
Kristoffer Plagborg Bak Sørensen
259be07f05
feat: add asm-lsp for assembly dialects (#12684) 2025-01-26 10:28:40 -06:00
Michael Davis
360c6bb061
stdx: Replace RopeSliceExt::byte_to_next_char with ceil_char_boundary
The new `RopeSliceExt::ceil_char_boundary` from the parent commits can
be used to implement `RopeSliceExt::byte_to_next_char` when used with
`RopeSlice::byte_to_char`. That function had only one caller and that
caller will eventually disappear when we switch to Ropey v2 and drop
character indexing, so we can drop `byte_to_next_char` now and replace
its caller with `byte_to_char` plus `ceil_char_boundary`.

This change keeps the unit tests for `byte_to_next_char` and checks them
against a polyfill of `byte_to_char` plus `ceil_char_boundary` to ensure
that `byte_to_next_char`'s intended behavior is not changed.
2025-01-26 11:11:53 -05:00
Michael Davis
4919058e90
Use RopeSliceExt floor/ceil functions for goto_file_impl search cap
This is a good example use-case of the `floor_char_boundary` and
`ceil_char_boundary` functions added in the parent commit. In the
single-width, single-selection case in `goto_file` we cap the search
to either the current line or 1000 bytes before or after the cursor
(whichever case comes earlier). That byte index might not lie on a
character boundary so it needs to be fixed to either the prior or
later boundary.
2025-01-26 11:10:50 -05:00
Michael Davis
23b424a46d
stdx: Add floor/ceil char boundary functions to RopeSliceExt
These functions mimic `str::floor_char_boundary` and
`str::floor_char_boundary` (currently unstable under
`round_char_boundary`). They're useful for correcting a byte index
which may not lie on a character boundary. For example you might limit
a search within a slice to some fixed number of bytes. The fixed number
might not lie on a boundary though so it needs to be corrected to
either the earlier (floor) or later (ceil) boundary.
2025-01-26 11:10:24 -05:00
Joel Dueck
aac0ce5fd1
Update install.md: fix link to lang server install instructions (#12675) 2025-01-26 14:21:13 +09:00
Michael Davis
899afad4a6
flake: Revert update of nixpkgs 2025-01-25 13:52:14 -05:00
magackame
3fdd98979c
fix: goto_file_impl incorrect use of slice instead of byte_slice (#12673) 2025-01-25 12:38:35 -06:00
Alexis Mousset
de738bac6a
Small refinements for modus themes (#12670) 2025-01-25 20:30:43 +09:00
Darshan Kumawat
81708b70e6
doc: Document mdm and mrm for popup help (#12650) 2025-01-24 11:46:19 -06:00
Michael Davis
8bf9adf7b6
Update tree-sitter-elixir 2025-01-24 12:37:48 -05:00
Kevin Danne
9088f8a599
fix: HELIX_RUNTIME environment path for windows on building-from-source book page (#12658)
Co-authored-by: Kevin Danne <kevin.danne@triluxds.com>
2025-01-24 09:03:49 -06:00
Fabian Gruber
f8e9a75633 feat: added rounded-corners option to draw rounded borders 2025-01-24 08:46:46 +01:00
Nikita Revenco
a63a2ad281
feat: specify custom lang server(s) for :lsp-stop and :lsp-restart (#12578)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-01-23 18:14:35 -06:00
RoloEdits
4ded712dbd
perf(syntax): short-circuit if name matches language_id (#12407) 2025-01-23 17:49:14 -06:00
Michael Davis
151caeacc6
Revert "Re-enable Hare by default (#11507)"
This reverts commit 2c09a35ccf.

Temporarily reverting this to unblock any builds during SourceHut's
ongoing DDoS: <https://status.sr.ht/issues/2025-01-23-git.sr.ht-ddos/>
2025-01-23 18:43:13 -05:00
Michael Davis
d4ade40983
Rename "file browser" => "file explorer"
Connects #11285
2025-01-23 18:17:56 -05:00
Michael Davis
0b9701e899
tui buffer: Handle multi-width graphemes in set_string_anchored
We should skip zero-width graphemes and reset only long (more than
1-width) graphemes.

Connects #12036
2025-01-23 18:12:20 -05:00
Michael Davis
9d6ea773e9
prompt: Cap anchor to line length in cursor calculation
This prevents a panic when using `C-w` on a long single-word prompt line
for example.

Connects #12036
2025-01-23 17:33:28 -05:00
Denys Rybalka
6b044aeb29
Add file browser (#11285) 2025-01-23 16:28:18 -06:00
Yomain
8af33108f6
fix: better display of prompts on long inputs (#12036) 2025-01-23 15:56:34 -06:00
Michael Davis
1afa63d457
rust: Highlight / and ! within comments as comments 2025-01-23 16:17:44 -05:00
Khang Nguyen Duy
5f62c5c24c
Update to more up-to-date zig tree-sitter repo (#11980)
Co-authored-by: Khang Nguyen Duy <iceghost@users.noreply.github.com>
Co-authored-by: Khang Nguyen Duy <os@ndykhang.net>
2025-01-23 14:36:24 -06:00
TornaxO7
fa27ae16a7
Add path completion for multiple cursors (#12550) 2025-01-23 14:31:12 -06:00
Michael Davis
8986f8b953
Update nix flake inputs
$ nix flake update
    • Updated input 'crane':
        'github:ipetkov/crane/37e4f9f0976cb9281cd3f0c70081e5e0ecaee93f?narHash=sha256-WD0//20h%2B2/yPGkO88d2nYbb23WMWYvnRyDQ9Dx4UHg%3D' (2024-10-03)
      → 'github:ipetkov/crane/849376434956794ebc7a6b487d31aace395392ba?narHash=sha256-GLJvkOG29XCynQm8XWPyykMRqIhxKcBARVu7Ydrz02M%3D' (2025-01-22)
    • Updated input 'flake-utils':
        'github:numtide/flake-utils/c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a?narHash=sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ%3D' (2024-09-17)
      → 'github:numtide/flake-utils/11707dc2f618dd54ca8739b309ec4fc024de578b?narHash=sha256-l0KFg5HjrsfsO/JpG%2Br7fRrqm12kzFHyUHqHCVpMMbI%3D' (2024-11-13)
    • Updated input 'nixpkgs':
        'github:nixos/nixpkgs/bc947f541ae55e999ffdb4013441347d83b00feb?narHash=sha256-NOiTvBbRLIOe5F6RbHaAh6%2B%2BBNjsb149fGZd1T4%2BKBg%3D' (2024-10-04)
      → 'github:nixos/nixpkgs/9e4d5190a9482a1fb9d18adf0bdb83c6e506eaab?narHash=sha256-nmKOgAU48S41dTPIXAq0AHZSehWUn6ZPrUKijHAMmIk%3D' (2025-01-21)
    • Updated input 'rust-overlay':
        'github:oxalica/rust-overlay/25685cc2c7054efc31351c172ae77b21814f2d42?narHash=sha256-lJMFnMO4maJuNO6PQ5fZesrTmglze3UFTTBuKGwR1Nw%3D' (2024-10-07)
      → 'github:oxalica/rust-overlay/38374302ae9edf819eac666d1f276d62c712dd06?narHash=sha256-S2rHCrQWCDVp63XxL/AQbGr1g5M8Zx14C7Jooa4oM8o%3D' (2025-01-23)
2025-01-23 15:18:37 -05:00
Rolo
650af50c13 fix: typos 2025-01-23 15:18:16 -05:00
Rolo
c1d382a532 fix(lints): clippy 1.84 2025-01-23 15:18:16 -05:00
Nikita Revenco
168b11e091
feat: passing multile of the same files in the arguments places a cursor at each position (#12192)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-01-23 14:04:02 -06:00
uncenter
f70923c448
Restrict tagged template language injections for ecma (#12217) 2025-01-23 13:45:35 -06:00
Branch Vincent
8f1585a097 Add ghostty/config and hgrc file types 2025-01-23 14:23:48 -05:00
Branch Vincent
0d5f6f04c9 Add fish-lsp and bump tree-sitter-fish 2025-01-23 14:23:48 -05:00
Roman Frołow
122bbea7cf
tutor: flips selections -> flips direction of selection (#12638) 2025-01-23 13:22:28 -06:00
Roman Frołow
088ba58af5
docs: force creating symbolic link if it exists (#12637) 2025-01-23 13:17:06 -06:00
Remo Senekowitsch
ce348d84f6
book: Document language.rulers config option (#12627) 2025-01-23 13:16:37 -06:00
Remo Senekowitsch
def6139abd
docs: Fix broken link (#12623) 2025-01-23 13:15:54 -06:00
Fraser Li
cf7b36f0bf
Add beancount-language-server (#12610) 2025-01-23 13:14:10 -06:00
robb
c81e0136c5
Update languages.toml to fix cl-lsp (#12615) 2025-01-23 13:13:30 -06:00
Rock Boynton
dca235c5c8
Update tree-sitter-rust (#12607)
Co-authored-by: Rock Boynton <rboynton@anduril.com>
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-01-23 13:11:07 -06:00
Michael Davis
fcf981bbd7
Recognize bun.lock as JSONC
Fixes #12651
2025-01-23 14:03:10 -05:00
Jose Alvarez
d123193902
Replace current file when loading in background from picker (#12605) 2025-01-23 12:49:20 -06:00
Pig Fang
430414979d
add language server for wat (#12581) 2025-01-23 12:46:15 -06:00
FITAHIANA Nomeniavo joe
6593969f8d
add ruby-lsp as candidate for the ruby lsp (#12511) 2025-01-23 12:09:46 -06:00
Michael Davis
cb0f201d0e
snippets: Discard placeholder text for the $0 tabstop 2025-01-23 09:50:19 -05:00
Michael Davis
032dadaf37
snippets: Add a test case for parsing ${0:placeholder}
This is an example snippet sent by older versions of clangd.
2025-01-23 09:50:19 -05:00
Michael Davis
7dea2b0ddd
CI: Cache tree-sitter grammars in all jobs
This change adds tree-sitter grammar caching to Check, Lints and Docs
jobs which all previously downloaded grammars in the `helix-term` build
script fresh per job. This should increase reliability and mitigate the
effects of an ongoing SourceHut outage
(<https://status.sr.ht/issues/2025-01-23-git.sr.ht-ddos/>).

This is also a nice speed boost for these jobs:

| Job name | Example time before | Example time after |
|---       |---                  |---                 |
| Check    | 2m20s               | 47s                |
| Lints    | 2m56s               | 1m10s              |
| Docs     | 4m56s               | 2m35s              |
2025-01-23 09:47:51 -05:00
Michael Davis
76a8682c4d syntax: Prefer RopeSlice for non-id language injection markers
The `Name` variant's inner type can be switched to `RopeSlice` since
the parent commit removed the usage of `&str`. In doing this we need to
switch from a regular `Regex` to a `rope::Regex`, which is mostly a
matter of renaming the type.

The `Filename` and `Shebang` variants can also switch to `RopeSlice`
which avoids allocations in cases where the text doesn't reside on
different chunks of the rope. Previously `Filename`'s `Cow` was always
the owned variant because of the conversion to a `PathBuf`.
2025-01-23 11:01:35 +09:00
Michael Davis
060255344c syntax: Lookup up (#set! injection.language "name") props by ID
This splits the `InjectionLanguageMarker::Name` into two: one that
preforms the previous behavior (using the language configurations'
`injection_regex` fields and performing a match) and a new variant that
looks up directly by `language_id` with equality.

The old variant is used when capturing the injection language like we
do in the markdown queries for codefences. That captured text is part of
the document being highlighted so we might need a regex to recognize a
language like JavaScript as either "js" or "javascript". But the text
passed in the `(#set! injection.language "name")` property can be
looked up directly. This property is in the query code so there's no
need to be flexible in what we accept: we can require that the
`(#set! injection.language ..)` properties refer to languages by their
configured ID. This should save a noticeable amount of work for the
common case of injections: `(#set! injection.language)` is used much
more often than `@injection.language`.
2025-01-23 11:01:35 +09:00
dependabot[bot]
09b2f6ab5f
build(deps): bump the rust-dependencies group with 5 updates (#12614)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2025-01-21 08:59:05 -06:00
Remo Senekowitsch
64aca8b350
Fix indent unit of git-rebase config (#12617) 2025-01-21 08:14:04 -06:00
Michael Davis
ccdb710431
minor: Rename '*' at eof integration test to be more specific 2025-01-21 09:08:36 -05:00
Nikita Revenco
ba4793fca0
fix: panic when pressing * after the end of the file (#12611)
* fix: panic when pressing `*` at the end of the file

chore: remove incorrect additions

* docs: add info comment

* test: add new syntax to add a selection at the final character

* test: `*` panics when after the last char

* test: move into a more appopriate module

* test: fix failing

* test: account for Windows test suite

* test: choose a different strategy for custom syntax

* test: do not modify the syntax

* style: remove newline

---------

Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-01-21 16:05:15 +09:00
Michael Davis
e7ac2fcdec
Add changelog notes for 25.01.1 (#12560)
Some checks failed
Github Pages / deploy (push) Has been cancelled
Release / Fetch Grammars (push) Has been cancelled
Release / Dist (push) Has been cancelled
Release / Publish (push) Has been cancelled
2025-01-19 14:50:39 -06:00
Christoph Heiss
7cc93eb1c5
feat: add MERGE_MSG file glob for git-commit (#12589) 2025-01-18 12:50:03 -06:00
Michael Davis
2c09a35ccf
Re-enable Hare by default (#11507) 2025-01-18 09:48:51 -06:00
Frans Skarman
954c97f2b5
Bump Spade grammar (#12583) 2025-01-18 09:43:45 -06:00
Nikita Revenco
076d8bd173
fix: surprising behaviour when changing line above a comment (#12575)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-01-17 15:29:39 -06:00
Michael Davis
343397391f
Remove unused variant from FormatterError
This seems to be a relic from the change which added external formatting
commands - initially it worked by writing the file in place and
reloading it. Now this error type is not possible and can be removed.
2025-01-17 11:00:37 -05:00
Michael Davis
69068770c8
Add extra logging for external formatters and formatting errors
This should help debug formatting failures when using external
formatters in the future. Previously we didn't log anything when an
external formatter failed despite having a custom error type for it.
2025-01-17 11:00:34 -05:00
Michael Davis
4c41c5250c
Eliminate an unnecessary clone in insert_newline 2025-01-17 10:23:36 -05:00
Michael Davis
ffdfb59033
Fix slicing panic in path completion variable expansion (#12556) 2025-01-16 18:10:30 -06:00
janos-r
d4ee22b483
Generate a .deb file (#12453) 2025-01-16 17:37:53 -06:00
Valentin B.
29dda1403f
chore(solidity): update to newest grammar and fix queries (#12457) 2025-01-16 09:11:18 -06:00
Michael Davis
19f7bc9ecb
book: Add missing TOC entries to editor config page 2025-01-16 08:14:09 -05:00
Michael Davis
3318953bf6
minor: Use more exact allocations in insert_newline
This is partially a style commit:

* Pull more bindings out the `change_by_selection` closure like the
  line-ending string and the comment tokens used for continuation.
* Prefer `Editor::config` to `Document`'s config.

The rest is changes to places where `insert_newline` may allocate.

The first is to move `new_text` out of the `change_by_selection`
closure, reusing it between iterations. This is not necessarily always
an improvement as we need to clone the text for the return type of the
closure. `SmartString`'s `From<String>` implementation reuses the
allocation when the string is too long to inline and drops it if it is
short enough to inline though which can be wasteful. `From<&String>`
clones the string's allocation only when it is too long to be inlined,
so we save on allocations for any `new_text` short enough to be inlined.

The rest is changes to `new_text.reserve_exact`. Previously calls to
this function in this block mixed up character and byte indexing by
treating the length of the line-ending as 1. `reserve_exact` takes a
number of bytes to reserve and that may be 2 when `line_ending` is a
CRLF. A call to `reserve_exact` is also added to the branch used when
continuing line comments.
2025-01-15 10:57:03 -05:00
Michael Davis
4bd17e542e
Fix offset tracking in insert_newline
#12177 changed `insert_newline`'s behavior to trim any trailing
whitespace on a line which came before a cursor. `insert_newline` would
previously never delete text. Even the whitespace stripping behavior in
#4854 worked by inserting text - a line ending at the beginning of the
line. `global_offs`, a variable that tracks the number of characters
inserted between iterations over the existing selection ranges, was not
updated to also account for text deleted by the trimming behavior,
causing cursors to be offset by the amount of trailing space deleted
and causing panics in some cases.

To fix this we need to subtract the number of trimmed whitespace
characters from `global_offs`. `global_offs` must become an `isize`
(was a `usize`) because it may become negative in cases where a lot of
trailing whitespace is trimmed. Integration tests have been added for
each of these cases.

Fixes #12461
Fixes #12495
Fixes #12539
2025-01-15 10:36:29 -05:00
Michael Davis
99d33c741a
Add '///' to Dart comment-token configuration
Fixes #12537
2025-01-15 08:33:33 -05:00
David Else
ca19496eed
Improve dark_plus theme: Change special, ui.text.directory and ui.virtual.wrap (#12530) 2025-01-14 12:55:01 -06:00
Robin Heggelund Hansen
f69659c5be
Add support for the Gren programming language (#12525) 2025-01-14 08:26:56 -06:00
Michael Davis
27bb2447db
Use a workspace dependency for bitflags 2025-01-13 18:26:31 -05:00
dependabot[bot]
3d772afc8b
build(deps): bump the rust-dependencies group with 6 updates
Bumps the rust-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [thiserror](https://github.com/dtolnay/thiserror) | `2.0.9` | `2.0.11` |
| [bitflags](https://github.com/bitflags/bitflags) | `2.6.0` | `2.7.0` |
| [serde_json](https://github.com/serde-rs/json) | `1.0.134` | `1.0.135` |
| [tokio](https://github.com/tokio-rs/tokio) | `1.42.0` | `1.43.0` |
| [rustix](https://github.com/bytecodealliance/rustix) | `0.38.42` | `0.38.43` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.7` | `1.2.9` |


Updates `thiserror` from 2.0.9 to 2.0.11
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.9...2.0.11)

Updates `bitflags` from 2.6.0 to 2.7.0
- [Release notes](https://github.com/bitflags/bitflags/releases)
- [Changelog](https://github.com/bitflags/bitflags/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bitflags/bitflags/compare/2.6.0...2.7.0)

Updates `serde_json` from 1.0.134 to 1.0.135
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.134...v1.0.135)

Updates `tokio` from 1.42.0 to 1.43.0
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.42.0...tokio-1.43.0)

Updates `rustix` from 0.38.42 to 0.38.43
- [Release notes](https://github.com/bytecodealliance/rustix/releases)
- [Changelog](https://github.com/bytecodealliance/rustix/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bytecodealliance/rustix/compare/v0.38.42...v0.38.43)

Updates `cc` from 1.2.7 to 1.2.9
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.7...cc-v1.2.9)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bitflags
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_json
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tokio
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rustix
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-13 23:09:54 +00:00
TornaxO7
60bff8feee
Fix open_{below, above} behaviour with multiple cursors (#12465) 2025-01-13 08:14:30 -06:00
Taylor C. Richberger
134aebf8cc
add rockspec to lua file types (#12516) 2025-01-13 07:45:38 -06:00
Álan Crístoffer
367ccc1c64
Fix a bug in matlab indentation and updates the grammar commit hash to latest (#12518) 2025-01-13 07:43:02 -06:00
Nikita Revenco
e01775a667
fix: unable to detect Color completion item hex code for some LSPs (#12501)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-01-12 09:40:19 -06:00
Michael Davis
0f2ce303c5
Add directory name to :cd errors
For example `:cd README.md` would say "Not a directory" but would not
print the directory name. Now the error message includes some context
about the operation and requested directory.
2025-01-11 20:39:44 -05:00
meator
b05971f178
Add .clang-tidy highlighting (#12498) 2025-01-11 15:12:46 -06:00
Nikita Revenco
a539199666
feat(highlights): add more built-in functions for ecma, rust and haskell (#12488)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-01-11 14:59:03 -06:00
Kirawi
e440e54e79
pin to ubuntu-22.04 for releases (#12464) 2025-01-11 10:52:13 -06:00
Nikita Revenco
8f5f818c88
fix(highlights): recognize ! as the never type (#12485)
Co-authored-by: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com>
2025-01-11 10:49:39 -06:00
Michael Davis
b26903cd13
Add comment tokens for JSONC
Fixes #12491
2025-01-11 08:42:17 -05:00
Evan Richter
9721144e03
language support: CodeQL (#12470) 2025-01-10 09:03:04 -06:00
Rob Gonnella
a83c23bb03
Run formatter from Document directory (#12315)
Co-authored-by: Rob Gonnella <rob.gonnella@papayapay.com>
2025-01-08 12:36:40 -06:00
rhogenson
931dd9c1dc
Fix a typo in join_selections (#12452)
Co-authored-by: Rose Hogenson <rosehogenson@posteo.net>
2025-01-08 08:42:41 -06:00
Michael Davis
917174e546
Fix blank buffer picker preview on doc with no views
Reproduction:

* `hx`
* Open any file in a split (`<space>f` and choose anything with `<C-v>`)
* Close the split with `<C-w>q`
* Open up the buffer picker and look the file you opened previously

Previously the preview was empty in this case because the Document's
`selections` hashmap was empty and we returned early, giving `None`
instead of a FileLocation. Instead when the Document is not currently
open in any view we can show the document but with no range highlighted.
2025-01-07 15:11:15 -05:00
Michael Davis
a0bd39d40e
book: Document editor.lsp.display-progress-messages config option
Connects #5535
2025-01-07 09:17:59 -05:00
dependabot[bot]
e698b20245
build(deps): bump the rust-dependencies group with 3 updates (#12437) 2025-01-06 19:34:21 -05:00
Michael Davis
5616f1d66d
changelog: Add missing breaking change for display-messages config 2025-01-06 14:18:26 -05:00
Michael Davis
217818681e
Revert "refactor(shellwords)!: change arg handling strategy (#11149)"
This reverts commit 64b38d1a28.
2025-01-06 12:39:53 -05:00
Michael Davis
03f35af9c1
Format '--version' calendar version as 'YY.0M'
We use calendar versioning which isn't supported by Cargo, so we need
to add an extra leading zero to the month for releases between January
and September to match our usual 'YY.0M' formatting.

Closes #12414
2025-01-06 10:34:25 -05:00
uncenter
6c9619d094
Improve markdown heading highlights (#12417) 2025-01-05 16:35:09 -06:00
uncenter
e856dde738
Use @attribute scope for JSX attributes (#12416) 2025-01-05 16:33:08 -06:00
RoloEdits
f80ae997f2
perf: cache Documents relative path (#12385) 2025-01-05 16:29:16 -06:00
RoloEdits
64b38d1a28
refactor(shellwords)!: change arg handling strategy (#11149) 2025-01-05 12:18:30 -06:00
Seigo Mori
377e36908a
Add cursorline color to iceberg theme (#12404) 2025-01-05 10:58:31 -06:00
Nikita Revenco
fa4aa0fb42
docs: catppuccin themes should not be directly edited here (#12400)
Co-authored-by: uncenter <47499684+uncenter@users.noreply.github.com>
2025-01-05 10:55:28 -06:00
Nikita Revenco
2b8f8df1af
feat: correct Swift highlights (#12409)
- Adds injections for the `comment` language
- Correct highlight of the `nil` value. Same highlight as `null` in javascript, java and others
- Recognize `<` and `>` as punctuation, used in generics (same color as the syntax used in other languages)
- `protocol` function methods are recognized
- When accessing object properties, like `hello.world`, the `world` is properly recognized as being a member
- Recognize the `\` as an operator
2025-01-05 10:54:45 -06:00
Nikita Revenco
eed052e86b
feat: highlight : as a delimiter in Rust (#12408) 2025-01-05 10:51:33 -06:00
Erasin Wang
0654a1f058
Update onelight theme (#12399) 2025-01-05 10:27:38 -06:00
RoloEdits
353176082e
doc: generate lang-support.md for teal (#12402) 2025-01-05 01:11:10 +09:00
André L. Alvares
b47b946c47
Fix Teal LSP name. (#12395) 2025-01-04 11:49:44 +09:00
Will Faught
827deba74c
Change Cwd to Cmd (#12396) 2025-01-03 20:23:21 -05:00
Nikita Revenco
f44be74435 feat: merge-with-commandline config option 2024-12-06 15:14:25 +00:00
Nikita Revenco
13d2cd1293 feat: option to Merge statusline and Command line into 1 line 2024-12-06 14:51:13 +00:00
373 changed files with 19140 additions and 7414 deletions

3
.envrc
View file

@ -1,7 +1,8 @@
watch_file shell.nix
watch_file default.nix
watch_file flake.lock
watch_file rust-toolchain.toml
# try to use flakes, if it fails use normal nix (ie. shell.nix)
use flake || use nix
eval "$shellHook"
eval "$shellHook"

View file

@ -8,9 +8,6 @@ updates:
schedule:
interval: "weekly"
groups:
tree-sitter:
patterns:
- "tree-sitter*"
rust-dependencies:
update-types:
- "minor"

View file

@ -10,6 +10,8 @@ on:
env:
MSRV: "1.76"
# This key can be changed to bust the cache of tree-sitter grammars.
GRAMMAR_CACHE_VERSION: ""
jobs:
check:
@ -29,6 +31,13 @@ jobs:
with:
shared-key: "build"
- name: Cache tree-sitter grammars
uses: actions/cache@v4
with:
path: runtime/grammars
key: ${{ runner.os }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-
- name: Run cargo check
run: cargo check
@ -52,12 +61,12 @@ jobs:
with:
shared-key: "build"
- name: Cache test tree-sitter grammar
- name: Cache tree-sitter grammars
uses: actions/cache@v4
with:
path: runtime/grammars
key: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-stable-v${{ env.CACHE_VERSION }}-tree-sitter-grammars-
key: ${{ runner.os }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-
- name: Run cargo test
run: cargo test --workspace
@ -87,6 +96,13 @@ jobs:
with:
shared-key: "build"
- name: Cache tree-sitter grammars
uses: actions/cache@v4
with:
path: runtime/grammars
key: ${{ runner.os }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-
- name: Run cargo fmt
run: cargo fmt --all --check
@ -115,6 +131,13 @@ jobs:
with:
shared-key: "build"
- name: Cache tree-sitter grammars
uses: actions/cache@v4
with:
path: runtime/grammars
key: ${{ runner.os }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-${{ hashFiles('languages.toml') }}
restore-keys: ${{ runner.os }}-stable-v${{ env.GRAMMAR_CACHE_VERSION }}-tree-sitter-grammars-
- name: Validate queries
run: cargo xtask query-check

View file

@ -14,10 +14,10 @@ jobs:
uses: actions/checkout@v4
- name: Install nix
uses: cachix/install-nix-action@v30
uses: cachix/install-nix-action@v31
- name: Authenticate with Cachix
uses: cachix/cachix-action@v15
uses: cachix/cachix-action@v16
with:
name: helix
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

View file

@ -1,4 +1,4 @@
name: Github Pages
name: GitHub Pages
on:
push:

View file

@ -61,17 +61,17 @@ jobs:
build: [x86_64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc
include:
- build: x86_64-linux
os: ubuntu-latest
os: ubuntu-22.04
rust: stable
target: x86_64-unknown-linux-gnu
cross: false
- build: aarch64-linux
os: ubuntu-latest
os: ubuntu-22.04
rust: stable
target: aarch64-unknown-linux-gnu
cross: true
# - build: riscv64-linux
# os: ubuntu-latest
# os: ubuntu-22.04
# rust: stable
# target: riscv64gc-unknown-linux-gnu
# cross: true
@ -147,16 +147,8 @@ jobs:
if: "!matrix.skip_tests"
run: ${{ env.CARGO }} test --release --locked --target ${{ matrix.target }} --workspace
- name: Set profile.release.strip = true
shell: bash
run: |
cat >> .cargo/config.toml <<EOF
[profile.release]
strip = true
EOF
- name: Build release binary
run: ${{ env.CARGO }} build --release --locked --target ${{ matrix.target }}
run: ${{ env.CARGO }} build --profile opt --locked --target ${{ matrix.target }}
- name: Build AppImage
shell: bash
@ -183,7 +175,7 @@ jobs:
mkdir -p "$APP.AppDir"/usr/{bin,lib/helix}
cp "target/${{ matrix.target }}/release/hx" "$APP.AppDir/usr/bin/hx"
cp "target/${{ matrix.target }}/opt/hx" "$APP.AppDir/usr/bin/hx"
rm -rf runtime/grammars/sources
cp -r runtime "$APP.AppDir/usr/lib/helix/runtime"
@ -206,14 +198,25 @@ jobs:
mv "$APP-$VERSION-$ARCH.AppImage" \
"$APP-$VERSION-$ARCH.AppImage.zsync" dist
- name: Build Deb
shell: bash
if: matrix.build == 'x86_64-linux'
run: |
cargo install cargo-deb
mkdir -p target/release
cp target/${{ matrix.target }}/opt/hx target/release/
cargo deb --no-build
mkdir -p dist
mv target/debian/*.deb dist/
- name: Build archive
shell: bash
run: |
mkdir -p dist
if [ "${{ matrix.os }}" = "windows-2019" ]; then
cp "target/${{ matrix.target }}/release/hx.exe" "dist/"
cp "target/${{ matrix.target }}/opt/hx.exe" "dist/"
else
cp "target/${{ matrix.target }}/release/hx" "dist/"
cp "target/${{ matrix.target }}/opt/hx" "dist/"
fi
if [ -d runtime/grammars/sources ]; then
rm -rf runtime/grammars/sources
@ -241,6 +244,7 @@ jobs:
set -ex
source="$(pwd)"
tag=${GITHUB_REF_NAME//\//}
mkdir -p runtime/grammars/sources
tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources
rm -rf grammars
@ -254,7 +258,7 @@ jobs:
if [[ $platform =~ "windows" ]]; then
exe=".exe"
fi
pkgname=helix-$GITHUB_REF_NAME-$platform
pkgname=helix-$tag-$platform
mkdir -p $pkgname
cp $source/LICENSE $source/README.md $pkgname
mkdir $pkgname/contrib
@ -265,6 +269,7 @@ jobs:
if [[ "$platform" = "x86_64-linux" ]]; then
mv bins-$platform/helix-*.AppImage* dist/
mv bins-$platform/*.deb dist/
fi
if [ "$exe" = "" ]; then
@ -274,7 +279,7 @@ jobs:
fi
done
tar cJf dist/helix-$GITHUB_REF_NAME-source.tar.xz -C $source .
tar cJf dist/helix-$tag-source.tar.xz -C $source .
mv dist $source/
- name: Upload binaries to release

View file

@ -20,10 +20,67 @@ Updated languages and queries:
Packaging:
-->
# 25.01.1 (2025-01-19)
25.01.1 is a patch release focusing on fixing bugs and panics from changes in 25.01.
Usability improvements:
* Run external formatters from the document's directory ([#12315](https://github.com/helix-editor/helix/pull/12315))
Fixes:
* Fix blank buffer picker preview on doc with no views ([917174e](https://github.com/helix-editor/helix/commit/917174e))
* Fix `join_selections` behavior on tabs ([#12452](https://github.com/helix-editor/helix/pull/12452))
* Fix recognition for color LSP completion hex codes for some language servers ([#12501](https://github.com/helix-editor/helix/pull/12501))
* Fix offsets to selections updated by `open_below`/`open_above` (`o`/`O`) in multi-cursor scenarios ([#12465](https://github.com/helix-editor/helix/pull/12465))
* Fix offsets to selections updated by `insert_newline` when trimming whitespace in multi-cursor scenarios ([4bd17e5](https://github.com/helix-editor/helix/commit/4bd17e5))
* Fix panic in path completion from resolving variables like `${HOME:-$HOME}` ([#12556](https://github.com/helix-editor/helix/pull/12556))
* Prevent line comment continuation when using `change_selection` (`c`) on a line above a comment ([#12575](https://github.com/helix-editor/helix/pull/12575))
Themes:
* Update `onelight` ([#12399](https://github.com/helix-editor/helix/pull/12399))
* Add cursorline color to iceberg themes ([#12404](https://github.com/helix-editor/helix/pull/12404))
* Update `special`, `ui.text.directory` and `ui.virtual.wrap` in `dark_plus` ([#12530](https://github.com/helix-editor/helix/pull/12530))
New languages:
* CodeQL ([#12470](https://github.com/helix-editor/helix/pull/12470))
* Gren ([#12525](https://github.com/helix-editor/helix/pull/12525))
Updated languages and queries:
* Fix Teal LSP name ([#12395](https://github.com/helix-editor/helix/pull/12395))
* Highlight `:` in Rust as a delimiter ([#12408](https://github.com/helix-editor/helix/pull/12408))
* Update Swift highlights ([#12409](https://github.com/helix-editor/helix/pull/12409))
* Highlight JSX attributes as `@attribute` ([#12416](https://github.com/helix-editor/helix/pull/12416))
* Improve markdown heading highlights ([#12417](https://github.com/helix-editor/helix/pull/12417))
* Add comment tokens configuration for JSONC ([b26903c](https://github.com/helix-editor/helix/commit/b26903c))
* Highlight the never type `!` as a type in Rust ([#12485](https://github.com/helix-editor/helix/pull/12485))
* Expand builtin function highlights for ECMA languages, Rust and Haskell ([#12488](https://github.com/helix-editor/helix/pull/12488))
* Recognize `.clang-tidy` as YAML ([#12498](https://github.com/helix-editor/helix/pull/12498))
* Update MATLAB grammar and indent queries ([#12518](https://github.com/helix-editor/helix/pull/12518))
* Recognize `rockspec` as Lua ([#12516](https://github.com/helix-editor/helix/pull/12516))
* Add `///` to Dart comment tokens configuration ([99d33c7](https://github.com/helix-editor/helix/commit/99d33c7))
* Update Solidity grammar and queries ([#12457](https://github.com/helix-editor/helix/pull/12457))
* Update Spade grammar and queries ([#12583](https://github.com/helix-editor/helix/pull/12583))
* Re-enable Hare fetching and building by default ([#11507](https://github.com/helix-editor/helix/pull/11507))
Packaging:
* `--version` now prints a leading zero for single-digit months, for example `25.01` (03f35af)
* Pin the Ubuntu GitHub Actions runners used for releases to `ubuntu-22.04` ([#12464](https://github.com/helix-editor/helix/pull/12464))
* Produce a Debian package (`.deb` file) in the release GitHub Actions workflow ([#12453](https://github.com/helix-editor/helix/pull/12453))
# 25.01 (2025-01-03)
As always, a big thank you to all of the contributors! This release saw changes from 171 contributors.
Breaking changes:
* The `editor.lsp.display-messages` key now controls messages sent with the LSP `window/showMessage` notification rather than progress messages. If you want to enable progress messages you should now enable the `editor.lsp.display-progress-messages` key instead. ([#5535](https://github.com/helix-editor/helix/pull/5535))
Features:
* Big refactor for `Picker`s ([#9647](https://github.com/helix-editor/helix/pull/9647), [#11209](https://github.com/helix-editor/helix/pull/11209), [#11216](https://github.com/helix-editor/helix/pull/11216), [#11211](https://github.com/helix-editor/helix/pull/11211), [#11343](https://github.com/helix-editor/helix/pull/11343), [#11406](https://github.com/helix-editor/helix/pull/11406))
@ -34,7 +91,7 @@ Features:
* Continue line comments in `o`/`O` and on `<ret>` in insert mode ([#10996](https://github.com/helix-editor/helix/pull/10996), [#12213](https://github.com/helix-editor/helix/pull/12213), [#12215](https://github.com/helix-editor/helix/pull/12215))
* Allow configuring and switching clipboard providers at runtime ([#10839](https://github.com/helix-editor/helix/pull/10839), [b855cd0](https://github.com/helix-editor/helix/commit/b855cd0), [467fad5](https://github.com/helix-editor/helix/commit/467fad5), [191b0f0](https://github.com/helix-editor/helix/commit/191b0f0))
* Add support for path completion ([#2608](https://github.com/helix-editor/helix/pull/2608))
* Support bindings with the Super (Cwd/Win/Meta) modifier ([#6592](https://github.com/helix-editor/helix/pull/6592))
* Support bindings with the Super (Cmd/Win/Meta) modifier ([#6592](https://github.com/helix-editor/helix/pull/6592))
* Support rendering and jumping between tabstops in snippet completions ([#9801](https://github.com/helix-editor/helix/pull/9801))
* Allow theming directory completions ([#12205](https://github.com/helix-editor/helix/pull/12205), [#12295](https://github.com/helix-editor/helix/pull/12295))
@ -678,7 +735,7 @@ Updated languages and queries:
- Recognize common Dockerfile file types ([#9772](https://github.com/helix-editor/helix/pull/9772))
- Recognize NUON files as Nu ([#9839](https://github.com/helix-editor/helix/pull/9839))
- Add textobjects for Java native functions and constructors ([#9806](https://github.com/helix-editor/helix/pull/9806))
- Fix "braket" typeo in JSX highlights ([#9910](https://github.com/helix-editor/helix/pull/9910))
- Fix "braket" typo in JSX highlights ([#9910](https://github.com/helix-editor/helix/pull/9910))
- Update tree-sitter-hurl ([#9775](https://github.com/helix-editor/helix/pull/9775))
- Add textobjects queries for Vala ([#8541](https://github.com/helix-editor/helix/pull/8541))
- Update tree-sitter-git-config ([9610254](https://github.com/helix-editor/helix/commit/9610254))
@ -885,7 +942,7 @@ Updated languages and queries:
- Add Fortran comment injections ([#7305](https://github.com/helix-editor/helix/pull/7305))
- Switch Vue language server to `vue-language-server` ([#7312](https://github.com/helix-editor/helix/pull/7312))
- Update tree-sitter-sql ([#7387](https://github.com/helix-editor/helix/pull/7387), [#8464](https://github.com/helix-editor/helix/pull/8464))
- Replace the MATLAB tre-sitter grammar ([#7388](https://github.com/helix-editor/helix/pull/7388), [#7442](https://github.com/helix-editor/helix/pull/7442), [#7491](https://github.com/helix-editor/helix/pull/7491), [#7493](https://github.com/helix-editor/helix/pull/7493), [#7511](https://github.com/helix-editor/helix/pull/7511), [#7532](https://github.com/helix-editor/helix/pull/7532), [#8040](https://github.com/helix-editor/helix/pull/8040))
- Replace the MATLAB tree-sitter grammar ([#7388](https://github.com/helix-editor/helix/pull/7388), [#7442](https://github.com/helix-editor/helix/pull/7442), [#7491](https://github.com/helix-editor/helix/pull/7491), [#7493](https://github.com/helix-editor/helix/pull/7493), [#7511](https://github.com/helix-editor/helix/pull/7511), [#7532](https://github.com/helix-editor/helix/pull/7532), [#8040](https://github.com/helix-editor/helix/pull/8040))
- Highlight TOML table headers ([#7441](https://github.com/helix-editor/helix/pull/7441))
- Recognize `cppm` file-type as C++ ([#7492](https://github.com/helix-editor/helix/pull/7492))
- Refactor ecma language queries into private and public queries ([#7207](https://github.com/helix-editor/helix/pull/7207))
@ -1372,7 +1429,7 @@ Features:
- Support underline styles and colors ([#4061](https://github.com/helix-editor/helix/pull/4061), [98c121c](https://github.com/helix-editor/helix/commit/98c121c))
- Inheritance for themes ([#3067](https://github.com/helix-editor/helix/pull/3067), [#4096](https://github.com/helix-editor/helix/pull/4096))
- Cursorcolumn ([#4084](https://github.com/helix-editor/helix/pull/4084))
- Overhauled system for writing files and quiting ([#2267](https://github.com/helix-editor/helix/pull/2267), [#4397](https://github.com/helix-editor/helix/pull/4397))
- Overhauled system for writing files and quitting ([#2267](https://github.com/helix-editor/helix/pull/2267), [#4397](https://github.com/helix-editor/helix/pull/4397))
- Autosave when terminal loses focus ([#3178](https://github.com/helix-editor/helix/pull/3178))
- Use OSC52 as a fallback for the system clipboard ([#3220](https://github.com/helix-editor/helix/pull/3220))
- Show git diffs in the gutter ([#3890](https://github.com/helix-editor/helix/pull/3890), [#5012](https://github.com/helix-editor/helix/pull/5012), [#4995](https://github.com/helix-editor/helix/pull/4995))
@ -1527,7 +1584,7 @@ Themes:
- Update `pop-dark` ([#4323](https://github.com/helix-editor/helix/pull/4323))
- Update `rose_pine` ([#4221](https://github.com/helix-editor/helix/pull/4221))
- Add `kanagawa` ([#4300](https://github.com/helix-editor/helix/pull/4300))
- Add `hex_steel`, `hex_toxic` and `hex_lavendar` ([#4367](https://github.com/helix-editor/helix/pull/4367), [#4990](https://github.com/helix-editor/helix/pull/4990))
- Add `hex_steel`, `hex_toxic` and `hex_lavender` ([#4367](https://github.com/helix-editor/helix/pull/4367), [#4990](https://github.com/helix-editor/helix/pull/4990))
- Update `tokyonight` and `tokyonight_storm` ([#4415](https://github.com/helix-editor/helix/pull/4415))
- Update `gruvbox` ([#4626](https://github.com/helix-editor/helix/pull/4626))
- Update `dark_plus` ([#4661](https://github.com/helix-editor/helix/pull/4661), [#4678](https://github.com/helix-editor/helix/pull/4678))
@ -1694,7 +1751,7 @@ Usability improvements and fixes:
- Introduce `keyword.storage` highlight scope ([#2731](https://github.com/helix-editor/helix/pull/2731))
- Handle symlinks more consistently ([#2718](https://github.com/helix-editor/helix/pull/2718))
- Improve markdown list rendering ([#2687](https://github.com/helix-editor/helix/pull/2687))
- Update auto-pairs and idle-timout settings when the config is reloaded ([#2736](https://github.com/helix-editor/helix/pull/2736))
- Update auto-pairs and idle-timeout settings when the config is reloaded ([#2736](https://github.com/helix-editor/helix/pull/2736))
- Fix panic on closing last buffer ([#2658](https://github.com/helix-editor/helix/pull/2658))
- Prevent modifying jumplist until jumping to a reference ([#2670](https://github.com/helix-editor/helix/pull/2670))
- Ensure `:quit` and `:quit!` take no arguments ([#2654](https://github.com/helix-editor/helix/pull/2654))

720
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -22,13 +22,12 @@ default-members = [
[profile.release]
lto = "thin"
# debug = true
[profile.opt]
inherits = "release"
lto = "fat"
codegen-units = 1
# strip = "debuginfo" # TODO: or strip = true
strip = true
opt-level = 3
[profile.integration]
@ -42,9 +41,15 @@ tree-sitter = { version = "0.22" }
nucleo = "0.5.0"
slotmap = "1.0.7"
thiserror = "2.0"
tempfile = "3.19.1"
bitflags = "2.9"
unicode-segmentation = "1.2"
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
foldhash = "0.1"
parking_lot = "0.12"
[workspace.package]
version = "25.1.0"
version = "25.1.1"
edition = "2021"
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
categories = ["editor"]

View file

@ -12,9 +12,12 @@
- [Syntax aware motions](./syntax-aware-motions.md)
- [Pickers](./pickers.md)
- [Keymap](./keymap.md)
- [Command line](./command-line.md)
- [Commands](./commands.md)
- [Language support](./lang-support.md)
- [Migrating from Vim](./from-vim.md)
- [Ecosystem](./ecosystem.md)
- [Migrating from Vim](./from-vim.md)
- [Helix mode in other software](./other-software.md)
- [Configuration](./configuration.md)
- [Editor](./editor.md)
- [Themes](./themes.md)

View file

@ -7,6 +7,7 @@
- [Note to packagers](#note-to-packagers)
- [Validating the installation](#validating-the-installation)
- [Configure the desktop shortcut](#configure-the-desktop-shortcut)
- [Building the Debian package](#building-the-debian-package)
Requirements:
@ -63,11 +64,9 @@ export HELIX_RUNTIME=~/src/helix/runtime
Or, create a symbolic link:
```sh
ln -Ts $PWD/runtime ~/.config/helix/runtime
ln -Tsf $PWD/runtime ~/.config/helix/runtime
```
If the above command fails to create a symbolic link because the file exists either move `~/.config/helix/runtime` to a new location or delete it, then run the symlink command above again.
#### Windows
Either set the `HELIX_RUNTIME` environment variable to point to the runtime files using the Windows setting (search for
@ -75,7 +74,7 @@ Either set the `HELIX_RUNTIME` environment variable to point to the runtime file
Cmd:
```sh
setx HELIX_RUNTIME "%userprofile%\source\repos\helix\runtime"
setx HELIX_RUNTIME "%userprofile%\src\helix\runtime"
```
> 💡 `%userprofile%` resolves to your user directory like
@ -162,3 +161,39 @@ file. For example, to use `kitty`:
sed -i "s|Exec=hx %F|Exec=kitty hx %F|g" ~/.local/share/applications/Helix.desktop
sed -i "s|Terminal=true|Terminal=false|g" ~/.local/share/applications/Helix.desktop
```
### Building the Debian package
If the `.deb` file provided on the release page uses a `libc` version higher
than that used by your Debian, Ubuntu, or Mint system, you can build the package
from source to match your system's dependencies.
Install `cargo-deb`, the tool used for building the `.deb` file:
```sh
cargo install cargo-deb
```
After cloning and entering the Helix repository as previously described,
use the following command to build the release binary and package it into a `.deb` file in a single step.
```sh
cargo deb -- --locked
```
> 💡 This locks you into the `--release` profile. But you can also build helix in any way you like.
> As long as you leave a `target/release/hx` file, it will get packaged with `cargo deb --no-build`
> 💡 Don't worry about the repeated
> ```
> warning: Failed to find dependency specification
> ```
> warnings. Cargo deb just reports which packaged files it didn't derive dependencies for. But
> so far the dependency deriving seams very good, even if some of the grammar files are skipped.
You can find the resulted `.deb` in `target/debian/`. It should contain everything it needs, including the
- completions for bash, fish, zsh
- .desktop file
- icon (though desktop environments might use their own since the name of the package is correctly `helix`)
- launcher to the binary with the runtime

82
book/src/command-line.md Normal file
View file

@ -0,0 +1,82 @@
# Command line
- [Quoting](#quoting)
- [Flags](#flags)
- [Expansions](#expansions)
- [Exceptions](#exceptions)
The command line is used for executing [typable commands](./commands.md#typable-commands) like `:write` or `:quit`. Press `:` to activate the command line.
Typable commands optionally accept arguments. `:write` for example accepts an optional path to write the file contents. The command line also supports a quoting syntax for arguments, flags to modify command behaviors, and _expansions_ - a way to insert values from the editor. Most commands support these features but some have custom parsing rules (see the [exceptions](#exceptions) below).
## Quoting
By default, command arguments are split on tabs and space characters. `:open README.md CHANGELOG.md` for example should open two files, `README.md` and `CHANGELOG.md`. Arguments that contain spaces can be surrounded in single quotes (`'`) or backticks (`` ` ``) to prevent the space from separating the argument, like `:open 'a b.txt'`.
Double quotes may be used the same way, but double quotes _expand_ their inner content. `:echo "%{cursor_line}"` for example may print `1` because of the expansion for the `cursor_line` variable. `:echo '%{cursor_line}'` though prints `%{cursor_line}` literally: content within single quotes or backticks is interpreted as-is.
On Unix systems the backslash character may be used to escape certain characters depending on where it is used. Within an argument which isn't surround in quotes, the backslash can be used to escape the space or tab characters: `:open a\ b.txt` is equivalent to `:open 'a b.txt'`. The backslash may also be used to escape quote characters (`'`, `` ` ``, `"`) or the percent token (`%`) when used at the beginning of an argument. `:echo \%%sh{foo}` for example prints `%sh{foo}` instead of invoking a `foo` shell command and `:echo \"quote` prints `"quote`. The backslash character is treated literally in any other situation on Unix systems and always on Windows: `:echo \n` always prints `\n`.
## Flags
Command flags are optional switches that can be used to alter the behavior of a command. For example the `:sort` command accepts an optional `--reverse` (or `-r` for short) flag which causes the sort command to reverse the sorting direction. Typing the `-` character shows completions for the current command's flags, if any.
The `--` flag specifies the end of flags. All arguments after `--` are treated as positional arguments: `:open -- -a.txt` opens a file called `-a.txt`.
## Expansions
Expansions are patterns that Helix recognizes and replaces within the command line. Helix recognizes anything starting with a percent token (`%`) as an expansion, for example `%sh{echo hi!}`. Expansions are particularly useful when used in commands like `:echo` or `:noop` for executing simple scripts. For example:
```toml
[keys.normal]
# Print the current line's git blame information to the statusline.
space.B = ":echo %sh{git blame -L %{cursor_line},+1 %{buffer_name}}"
```
Expansions take the form `%[<kind>]<open><contents><close>`. In `%sh{echo hi!}`, for example, the kind is `sh` - the shell expansion - and the contents are "echo hi!", with `{` and `}` acting as opening and closing delimiters. The following open/close characters are recognized as expansion delimiter pairs: `(`/`)`, `[`/`]`, `{`/`}` and `<`/`>`. Plus the single characters `'`, `"` or `|` may be used instead: `%{cursor_line}` is equivalent to `%<cursor_line>`, `%[cursor_line]` or `%|cursor_line|`.
To escape a percent character instead of treating it as an expansion, use two percent characters consecutively. To execute a shell command like `date -u +'%Y-%m-%d'`, double the percent characters: `:echo %sh{date -u +'%%Y-%%m-%%d'}`.
When no `<kind>` is provided, Helix will expand a **variable**. For example `%{cursor_line}` can be used as in argument to insert the line number. `:echo %{cursor_line}` for instance may print `1` to the statusline.
The following variables are supported:
| Name | Description |
|--- |--- |
| `cursor_line` | The line number of the primary cursor in the currently focused document, starting at 1. |
| `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. |
Aside from editor variables, the following expansions may be used:
* Unicode `%u{..}`. The contents may contain up to six hexadecimal numbers corresponding to a Unicode codepoint value. For example `:echo %u{25CF}` prints `●` to the statusline.
* Shell `%sh{..}`. The contents are passed to the configured shell command. For example `:echo %sh{echo "20 * 5" | bc}` may print `100` on the statusline on when using a shell with `echo` and the `bc` calculator installed. Shell expansions are evaluated recursively. `%sh{echo '%{buffer_name}:%{cursor_line}'}` for example executes a command like `echo 'README.md:1'`: the variables within the `%sh{..}` expansion are evaluated before executing the shell command.
As mentioned above, double quotes can be used to surround arguments containing spaces but also support expansions within the quoted content unlike singe quotes or backticks. For example `:echo "circle: %u{25CF}"` prints `circle: ●` to the statusline while `:echo 'circle: %u{25CF}'` prints `circle: %u{25CF}`.
Note that expansions are only evaluated once the Enter key is pressed in command mode.
## Exceptions
The following commands support expansions but otherwise pass the given argument directly to the shell program without interpreting quotes:
* `:insert-output`
* `:append-output`
* `:pipe`
* `:pipe-to`
* `:run-shell-command`
For example executing `:sh echo "%{buffer_name}:%{cursor_column}"` would pass text like `echo "README.md:1"` as an argument to the shell program: the expansions are evaluated but not the quotes. As mentioned above, percent characters can be used in shell commands by doubling the percent character. To insert the output of a command like `date -u +'%Y-%m-%d'` use `:insert-output date -u +'%%Y-%%m-%%d'`.
The `:set-option` and `:toggle-option` commands use regular parsing for the first argument - the config option name - and parse the rest depending on the config option's type. `:set-option` interprets the second argument as a string for string config options and parses everything else as JSON.
`:toggle-option`'s behavior depends on the JSON type of the config option supplied as the first argument:
* Booleans: only the config option name should be provided. For example `:toggle-option auto-format` will flip the `auto-format` option.
* Strings: the rest of the command line is parsed with regular quoting rules. For example `:toggle-option indent-heuristic hybrid tree-sitter simple` cycles through "hybrid", "tree-sitter" and "simple" values on each invocation of the command.
* Numbers, arrays and objects: the rest of the command line is parsed as a stream of JSON values. For example `:toggle-option rulers [81] [51, 73]` cycles through `[81]` and `[51, 73]`.
When providing multiple values to `:toggle-option` there should be no duplicates. `:toggle-option indent-heuristic hybrid simple tree-sitter simple` for example would only toggle between "hybrid" and "tree-sitter" values.
`:lsp-workspace-command` works similarly to `:toggle-option`. The first argument (if present) is parsed according to normal rules. The rest of the line is parsed as JSON values. Unlike `:toggle-option`, string arguments for a command must be quoted. For example `:lsp-workspace-command lsp.Command "foo" "bar"`.

3
book/src/ecosystem.md Normal file
View file

@ -0,0 +1,3 @@
# Ecosystem
This section has information related to the wider Helix ecosystem.

View file

@ -1,11 +1,14 @@
## Editor
- [`[editor]` Section](#editor-section)
- [`[editor.clipboard-provider]` Section](#editorclipboard-provider-section)
- [`[editor.statusline]` Section](#editorstatusline-section)
- [`[editor.lsp]` Section](#editorlsp-section)
- [`[editor.inline-blame]` Section](#editorinlineblame-section)
- [`[editor.cursor-shape]` Section](#editorcursor-shape-section)
- [`[editor.file-picker]` Section](#editorfile-picker-section)
- [`[editor.auto-pairs]` Section](#editorauto-pairs-section)
- [`[editor.auto-save]` Section](#editorauto-save-section)
- [`[editor.search]` Section](#editorsearch-section)
- [`[editor.whitespace]` Section](#editorwhitespace-section)
- [`[editor.indent-guides]` Section](#editorindent-guides-section)
@ -51,11 +54,16 @@
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
| `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` |
| `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` |
| `trim-final-newlines` | Whether to automatically remove line-endings after the final one on write | `false` |
| `trim-trailing-whitespace` | Whether to automatically remove whitespace preceding line endings on write | `false` |
| `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` |
| `rounded-corners` | Set to `true` to draw rounded border corners | `false` |
| `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. |
| `editor-config` | Whether to read settings from [EditorConfig](https://editorconfig.org) files | `true` |
| `welcome-screen` | Whether to enable the welcome screen | `true` |
### `[editor.clipboard-provider]` Section
@ -68,7 +76,7 @@ For instance, setting it to use OSC 52 termcodes, the configuration would be:
clipboard-provider = "termcode"
```
Alternatively, Helix can be configured to use arbitary commands for clipboard integration:
Alternatively, Helix can be configured to use arbitrary commands for clipboard integration:
```toml
[editor.clipboard-provider.custom]
@ -111,6 +119,7 @@ The `[editor.statusline]` key takes the following sub-keys:
| `mode.normal` | The text shown in the `mode` element for normal mode | `"NOR"` |
| `mode.insert` | The text shown in the `mode` element for insert mode | `"INS"` |
| `mode.select` | The text shown in the `mode` element for select mode | `"SEL"` |
| `merge-with-commandline` | If set, the command line and statusline will merge into a single line. Status text will replace the statusline briefly | `false` |
The following statusline elements can be configured:
@ -143,9 +152,11 @@ The following statusline elements can be configured:
| Key | Description | Default |
| --- | ----------- | ------- |
| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` |
| `display-messages` | Display LSP progress messages below statusline[^1] | `false` |
| `display-messages` | Display LSP `window/showMessage` messages below statusline[^1] | `true` |
| `display-progress-messages` | Display LSP progress messages below statusline[^1] | `false` |
| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` |
| `display-inlay-hints` | Display inlay hints[^2] | `false` |
| `display-color-swatches` | Show color swatches next to colors | `true` |
| `display-signature-help-docs` | Display docs under signature help popup | `true` |
| `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` |
| `goto-reference-include-declaration` | Include declaration in the goto references popup. | `true` |
@ -154,6 +165,50 @@ The following statusline elements can be configured:
[^2]: You may also have to activate them in the language server config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them!
### `[editor.inline-blame]` Section
Inline blame is virtual text that appears at the end of a line, displaying information about the most recent commit that affected this line.
| Key | Description | Default |
| ------- | ------------------------------------------ | ------- |
| `behaviour` | Choose when to show inline blame | `"hidden"` |
| `compute` | Choose when inline blame should be computed | `"on-demand"` |
| `format` | The format in which to show the inline blame | `"{author}, {time-ago} • {message} • {commit}"` |
The `behaviour` can be one of the following:
- `"all-lines"`: Inline blame is on every line.
- `"cursor-line"`: Inline blame is only on the line of the primary cursor.
- `"hidden"`: Inline blame is not shown.
Inline blame will only show if the blame for the file has already been computed.
The `compute` key determines under which circumstances the blame is computed, and can be one of the following:
- `"on-demand"`: Blame for the file is computed only when explicitly requested, such as when using `space + B` to blame the line of the cursor. There may be a little delay when loading the blame. When opening new files, even with `behaviour` not set to `"hidden"`, the inline blame won't show. It needs to be computed first in order to become available. This computation can be manually triggered by requesting it with `space + B`.
- `"background"`: Blame for the file is loaded in the background. This will have zero effect on performance of the Editor, but will use a little bit extra resources. Directly requesting the blame with `space + B` will be instant. Inline blame will show as soon as the blame is available when loading new files.
`inline-blame-format` allows customization of the blame message, and can be set to any string. Variables can be used like so: `{variable}`. These are the available variables:
- `author`: The author of the commit
- `date`: When the commit was made
- `time-ago`: How long ago the commit was made
- `message`: The message of the commit, excluding the body
- `body`: The body of the commit
- `commit`: The short hex SHA1 hash of the commit
- `email`: The email of the author of the commit
Any of the variables can potentially be empty.
In this case, the content before the variable will not be included in the string.
If the variable is at the beginning of the string, the content after the variable will not be included.
Some examples, using the default value `format` value:
- If `author` is empty: `"{time-ago} • {message} • {commit}"`
- If `time-ago` is empty: `"{author} • {message} • {commit}"`
- If `message` is empty: `"{author}, {time-ago} • {commit}"`
- If `commit` is empty: `"{author}, {time-ago} • {message}"`
- If `time-ago` and `message` is empty: `"{author} • {commit}"`
- If `author` and `message` is empty: `"{time-ago} • {commit}"`
### `[editor.cursor-shape]` Section
Defines the shape of cursor in each mode.

View file

@ -4,11 +4,11 @@
| adl | ✓ | ✓ | ✓ | |
| agda | ✓ | | | |
| amber | ✓ | | | |
| astro | ✓ | | | |
| astro | ✓ | | | `astro-ls` |
| awk | ✓ | ✓ | | `awk-language-server` |
| bash | ✓ | ✓ | ✓ | `bash-language-server` |
| bass | ✓ | | | `bass` |
| beancount | ✓ | | | |
| beancount | ✓ | | | `beancount-language-server` |
| bibtex | ✓ | | | `texlab` |
| bicep | ✓ | | | `bicep-langserver` |
| bitbake | ✓ | | | `bitbake-language-server` |
@ -23,12 +23,14 @@
| circom | ✓ | | | `circom-lsp` |
| clojure | ✓ | | | `clojure-lsp` |
| cmake | ✓ | ✓ | ✓ | `cmake-language-server` |
| codeql | ✓ | ✓ | | `codeql` |
| comment | ✓ | | | |
| common-lisp | ✓ | | ✓ | `cl-lsp` |
| cpon | ✓ | | ✓ | |
| cpp | ✓ | ✓ | ✓ | `clangd` |
| crystal | ✓ | ✓ | | `crystalline` |
| css | ✓ | | ✓ | `vscode-css-language-server` |
| csv | ✓ | | | |
| cue | ✓ | | | `cuelsp` |
| cylc | ✓ | ✓ | ✓ | |
| d | ✓ | ✓ | ✓ | `serve-d` |
@ -37,6 +39,7 @@
| devicetree | ✓ | | | |
| dhall | ✓ | ✓ | | `dhall-lsp-server` |
| diff | ✓ | | | |
| djot | ✓ | | | |
| docker-compose | ✓ | ✓ | ✓ | `docker-compose-langserver`, `yaml-language-server` |
| dockerfile | ✓ | ✓ | | `docker-langserver` |
| dot | ✓ | | | `dot-language-server` |
@ -54,15 +57,17 @@
| erb | ✓ | | | |
| erlang | ✓ | ✓ | | `erlang_ls`, `elp` |
| esdl | ✓ | | | |
| fga | ✓ | ✓ | ✓ | |
| fidl | ✓ | | | |
| fish | ✓ | ✓ | ✓ | |
| fish | ✓ | ✓ | ✓ | `fish-lsp` |
| forth | ✓ | | | `forth-lsp` |
| fortran | ✓ | | ✓ | `fortls` |
| fsharp | ✓ | | | `fsautocomplete` |
| gas | ✓ | ✓ | | |
| gas | ✓ | ✓ | | `asm-lsp` |
| gdscript | ✓ | ✓ | ✓ | |
| gemini | ✓ | | | |
| gherkin | ✓ | | | |
| ghostty | ✓ | | | |
| git-attributes | ✓ | | | |
| git-commit | ✓ | ✓ | | |
| git-config | ✓ | ✓ | | |
@ -80,6 +85,7 @@
| gowork | ✓ | | | `gopls` |
| gpr | ✓ | | | `ada_language_server` |
| graphql | ✓ | ✓ | | `graphql-lsp` |
| gren | ✓ | ✓ | | |
| groovy | ✓ | | | |
| gts | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` |
| hare | ✓ | | | |
@ -97,6 +103,7 @@
| idris | | | | `idris2-lsp` |
| iex | ✓ | | | |
| ini | ✓ | | | |
| ink | ✓ | | | |
| inko | ✓ | ✓ | ✓ | |
| janet | ✓ | | | |
| java | ✓ | ✓ | ✓ | `jdtls` |
@ -114,7 +121,7 @@
| just | ✓ | ✓ | ✓ | |
| kdl | ✓ | ✓ | ✓ | |
| koka | ✓ | | ✓ | `koka` |
| kotlin | ✓ | | | `kotlin-language-server` |
| kotlin | ✓ | | | `kotlin-language-server` |
| koto | ✓ | ✓ | ✓ | `koto-ls` |
| latex | ✓ | ✓ | | `texlab` |
| ld | ✓ | | ✓ | |
@ -127,6 +134,7 @@
| log | ✓ | | | |
| lpf | ✓ | | | |
| lua | ✓ | ✓ | ✓ | `lua-language-server` |
| mail | ✓ | ✓ | | |
| make | ✓ | | ✓ | |
| markdoc | ✓ | | | `markdoc-ls` |
| markdown | ✓ | | | `marksman`, `markdown-oxide` |
@ -138,12 +146,12 @@
| mojo | ✓ | ✓ | ✓ | `magic` |
| move | ✓ | | | |
| msbuild | ✓ | | ✓ | |
| nasm | ✓ | ✓ | | |
| nasm | ✓ | ✓ | | `asm-lsp` |
| nestedtext | ✓ | ✓ | ✓ | |
| nginx | ✓ | | | |
| nickel | ✓ | | ✓ | `nls` |
| nim | ✓ | ✓ | ✓ | `nimlangserver` |
| nix | ✓ | ✓ | | `nil`, `nixd` |
| nix | ✓ | ✓ | | `nil`, `nixd` |
| nu | ✓ | | | `nu` |
| nunjucks | ✓ | | | |
| ocaml | ✓ | | ✓ | `ocamllsp` |
@ -160,15 +168,15 @@
| pest | ✓ | ✓ | ✓ | `pest-language-server` |
| php | ✓ | ✓ | ✓ | `intelephense` |
| php-only | ✓ | | | |
| pkgbuild | ✓ | ✓ | ✓ | `pkgbuild-language-server`, `bash-language-server` |
| pkl | ✓ | | ✓ | |
| pkgbuild | ✓ | ✓ | ✓ | `termux-language-server`, `bash-language-server` |
| pkl | ✓ | | ✓ | `pkl-lsp` |
| po | ✓ | ✓ | | |
| pod | ✓ | | | |
| ponylang | ✓ | ✓ | ✓ | |
| powershell | ✓ | | | |
| prisma | ✓ | ✓ | | `prisma-language-server` |
| prolog | | | | `swipl` |
| protobuf | ✓ | ✓ | ✓ | `bufls`, `pb` |
| protobuf | ✓ | ✓ | ✓ | `buf`, `pb`, `protols` |
| prql | ✓ | | | |
| purescript | ✓ | ✓ | | `purescript-language-server` |
| python | ✓ | ✓ | ✓ | `ruff`, `jedi-language-server`, `pylsp` |
@ -183,7 +191,7 @@
| robot | ✓ | | | `robotframework_ls` |
| ron | ✓ | | ✓ | |
| rst | ✓ | | | |
| ruby | ✓ | ✓ | ✓ | `solargraph` |
| ruby | ✓ | ✓ | ✓ | `ruby-lsp`, `solargraph` |
| rust | ✓ | ✓ | ✓ | `rust-analyzer` |
| sage | ✓ | ✓ | | |
| scala | ✓ | ✓ | ✓ | `metals` |
@ -195,11 +203,12 @@
| sml | ✓ | | | |
| snakemake | ✓ | | ✓ | `pylsp` |
| solidity | ✓ | ✓ | | `solc` |
| sourcepawn | ✓ | ✓ | | `sourcepawn-studio` |
| spade | ✓ | | ✓ | `spade-language-server` |
| spicedb | ✓ | | | |
| sql | ✓ | ✓ | | |
| sshclientconfig | ✓ | | | |
| starlark | ✓ | ✓ | | |
| starlark | ✓ | ✓ | | `starpls` |
| strace | ✓ | | | |
| supercollider | ✓ | | | |
| svelte | ✓ | | ✓ | `svelteserver` |
@ -210,11 +219,13 @@
| tact | ✓ | ✓ | ✓ | |
| task | ✓ | | | |
| tcl | ✓ | | ✓ | |
| teal | ✓ | | | |
| teal | ✓ | | | `teal-language-server` |
| templ | ✓ | | | `templ` |
| tera | ✓ | | | |
| textproto | ✓ | ✓ | ✓ | |
| tfvars | ✓ | | ✓ | `terraform-ls` |
| thrift | ✓ | | | |
| tlaplus | ✓ | | | |
| todotxt | ✓ | | | |
| toml | ✓ | ✓ | | `taplo` |
| tsq | ✓ | | | `ts_query_ls` |
@ -222,7 +233,7 @@
| twig | ✓ | | | |
| typescript | ✓ | ✓ | ✓ | `typescript-language-server` |
| typespec | ✓ | ✓ | ✓ | `tsp-server` |
| typst | ✓ | | | `tinymist`, `typst-lsp` |
| typst | ✓ | | | `tinymist` |
| ungrammar | ✓ | | | |
| unison | ✓ | ✓ | ✓ | |
| uxntal | ✓ | | | |
@ -234,14 +245,16 @@
| vhs | ✓ | | | |
| vue | ✓ | | | `vue-language-server` |
| wast | ✓ | | | |
| wat | ✓ | | | |
| wat | ✓ | | | `wat_server` |
| webc | ✓ | | | |
| wgsl | ✓ | | | `wgsl_analyzer` |
| werk | ✓ | | | |
| wgsl | ✓ | | | `wgsl-analyzer` |
| wit | ✓ | | ✓ | |
| wren | ✓ | ✓ | ✓ | |
| xit | ✓ | | | |
| xml | ✓ | | ✓ | |
| xtc | ✓ | | | |
| yaml | ✓ | ✓ | ✓ | `yaml-language-server`, `ansible-language-server` |
| yara | ✓ | | | `yls` |
| yuck | ✓ | | | |
| zig | ✓ | ✓ | ✓ | `zls` |

View file

@ -100,6 +100,9 @@
| `file_picker` | Open file picker | normal: `` <space>f ``, select: `` <space>f `` |
| `file_picker_in_current_buffer_directory` | Open file picker at current buffer's directory | |
| `file_picker_in_current_directory` | Open file picker at current working directory | normal: `` <space>F ``, select: `` <space>F `` |
| `file_explorer` | Open file explorer in workspace root | normal: `` <space>e ``, select: `` <space>e `` |
| `file_explorer_in_current_buffer_directory` | Open file explorer at current buffer's directory | normal: `` <space>E ``, select: `` <space>E `` |
| `file_explorer_in_current_directory` | Open file explorer at current working directory | |
| `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 `` |
@ -236,6 +239,7 @@
| `wonly` | Close windows except current | normal: `` <C-w>o ``, `` <space>wo ``, `` <C-w><C-o> ``, `` <space>w<C-o> ``, select: `` <C-w>o ``, `` <space>wo ``, `` <C-w><C-o> ``, `` <space>w<C-o> `` |
| `select_register` | Select register | normal: `` " ``, select: `` " `` |
| `insert_register` | Insert register | insert: `` <C-r> `` |
| `copy_between_registers` | Copy between two registers | |
| `align_view_middle` | Align view middle | normal: `` Zm ``, `` zm ``, select: `` Zm ``, `` zm `` |
| `align_view_top` | Align view top | normal: `` Zt ``, `` zt ``, select: `` Zt ``, `` zt `` |
| `align_view_center` | Align view center | normal: `` Zc ``, `` Zz ``, `` zc ``, `` zz ``, select: `` Zc ``, `` Zz ``, `` zc ``, `` zz `` |
@ -294,3 +298,4 @@
| `extend_to_word` | Extend to a two-character label | select: `` gw `` |
| `goto_next_tabstop` | goto next snippet placeholder | |
| `goto_prev_tabstop` | goto next snippet placeholder | |
| `blame_line` | Show blame for the current line | normal: `` <space>B ``, select: `` <space>B `` |

View file

@ -52,8 +52,8 @@
| `:reload-all`, `:rla` | Discard changes and reload all documents from the source files. |
| `:update`, `:u` | Write changes only if the file has been modified. |
| `:lsp-workspace-command` | Open workspace command picker |
| `:lsp-restart` | Restarts the language servers used by the current doc |
| `:lsp-stop` | Stops the language servers that are used by the current doc |
| `:lsp-restart` | Restarts the given language servers, or all language servers that are used by the current file if no arguments are supplied |
| `:lsp-stop` | Stops the given language servers, or all language servers that are used by the current file if no arguments are supplied |
| `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. |
| `:tree-sitter-highlight-name` | Display name of tree-sitter highlight scope under the cursor. |
| `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. |
@ -67,10 +67,9 @@
| `:goto`, `:g` | Goto line number. |
| `:set-language`, `:lang` | Set the language of current buffer (show current language if no value specified). |
| `:set-option`, `:set` | Set a config option at runtime.<br>For example to disable smart case search, use `:set search.smart-case false`. |
| `:toggle-option`, `:toggle` | Toggle a boolean config option at runtime.<br>For example to toggle smart case search, use `:toggle search.smart-case`. |
| `:toggle-option`, `:toggle` | Toggle a config option at runtime.<br>For example to toggle smart case search, use `:toggle search.smart-case`. |
| `:get-option`, `:get` | Get the current value of a config option. |
| `:sort` | Sort ranges in selection. |
| `:rsort` | Sort ranges in selection in reverse order. |
| `:reflow` | Hard-wrap the current selection of lines to a given width. |
| `:tree-sitter-subtree`, `:ts-subtree` | Display the smallest tree-sitter subtree that spans the primary selection, primarily for debugging queries. |
| `:config-reload` | Refresh user config. |
@ -88,3 +87,5 @@
| `:move`, `:mv` | Move the current buffer and its corresponding file to a different path |
| `:yank-diagnostic` | Yank diagnostic(s) under primary cursor to register, or clipboard by default |
| `:read`, `:r` | Load a file into buffer |
| `:echo` | Prints the given arguments to the statusline. |
| `:noop` | Does nothing. |

View file

@ -38,12 +38,6 @@ below.
for more information on writing queries.
4. A list of highlight captures can be found [on the themes page](https://docs.helix-editor.com/themes.html#scopes).
> 💡 In Helix, the first matching query takes precedence when evaluating
> queries, which is different from other editors such as Neovim where the last
> matching query supersedes the ones before it. See
> [this issue](https://github.com/helix-editor/helix/pull/1170#issuecomment-997294090)
> for an example.
## Common issues
- If you encounter errors when running Helix after switching branches, you may

View file

@ -1,6 +1,7 @@
# Installing Helix
To install Helix, follow the instructions specific to your operating system.
The typical way to install Helix is via [your operating system's package manager](./package-managers.md).
Note that:
- To get the latest nightly version of Helix, you need to
@ -8,7 +9,7 @@ Note that:
- To take full advantage of Helix, install the language servers for your
preferred programming languages. See the
[wiki](https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers)
[wiki](https://github.com/helix-editor/helix/wiki/Language-Server-Configurations)
for instructions.
## Pre-built binaries

View file

@ -309,6 +309,7 @@ This layer is a kludge of mappings, mostly pickers.
| `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` |
| `/` | Global search in workspace folder | `global_search` |
| `?` | Open command palette | `command_palette` |
| `B` | Show blame for the current line | `blame_line` |
> 💡 Global search displays results in a fuzzy picker, use `Space + '` to bring it back up after opening a file.

View file

@ -67,8 +67,9 @@ These configuration keys are available:
| `language-servers` | The Language Servers used for this language. See below for more information in the section [Configuring Language Servers for a language](#configuring-language-servers-for-a-language) |
| `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) |
| `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout |
| `soft-wrap` | [editor.softwrap](./configuration.md#editorsoft-wrap-section)
| `soft-wrap` | [editor.softwrap](./editor.md#editorsoft-wrap-section)
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` |
| `rulers` | Overrides the `editor.rulers` config key for the language. |
| `path-completion` | Overrides the `editor.path-completion` config key 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.

View file

@ -0,0 +1,32 @@
# Helix mode in other software
Helix' keymap and interaction model ([Using Helix](#usage.md)) is easier to adopt if it can be used consistently in many editing contexts. Yet, certain use cases cannot easily be addressed directly in Helix. Similar to vim, this leads to the creation of "Helix mode" in various other software products, allowing Helix-style editing for a greater variety of use cases.
"Helix mode" is frequently still in early stages or missing entirely. For such cases, we also link to relevant bugs or discussions.
## Other editors
| Editor | Plugin or feature providing Helix editing | Comments
| --- | --- | --- |
| [Vim](https://www.vim.org/) | [helix.vim](https://github.com/chtenb/helix.vim) config |
| [IntelliJ IDEA](https://www.jetbrains.com/idea/) / [Android Studio](https://developer.android.com/studio)| [IdeaVim](https://plugins.jetbrains.com/plugin/164-ideavim) plugin + [helix.idea.vim](https://github.com/chtenb/helix.vim) config | Minimum recommended version is IdeaVim 2.19.0.
| [Visual Studio Code](https://code.visualstudio.com/) | [Dance](https://marketplace.visualstudio.com/items?itemName=gregoire.dance) extension, or its [Helix fork](https://marketplace.visualstudio.com/items?itemName=kend.dancehelixkey) | The Helix fork has diverged. You can also use the original Dance and tweak its keybindings directly (try [this config](https://github.com/71/dance/issues/299#issuecomment-1655509531)).
| [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) |
## Shells
| Shell | Plugin or feature providing Helix editing
| --- | ---
| Fish | [Feature Request](https://github.com/fish-shell/fish-shell/issues/7748)
| Fish | [fish-helix](https://github.com/sshilovsky/fish-helix/tree/main)
| Zsh | [helix-zsh](https://github.com/john-h-k/helix-zsh)
| Nushell | [Feature Request](https://github.com/nushell/reedline/issues/639)
## Other software
| Software | Plugin or feature providing Helix editing. | Comments
| --- | --- | --- |
| [Obsidian](https://obsidian.md/) | [Obsidian-Helix](https://github.com/Sinono3/obsidian-helix) | Uses `codemirror-helix` listed above.

View file

@ -1,7 +1,8 @@
## Package managers
- [Linux](#linux)
- [Ubuntu](#ubuntu)
- [Ubuntu/Debian](#ubuntudebian)
- [Ubuntu (PPA)](#ubuntu-ppa)
- [Fedora/RHEL](#fedorarhel)
- [Arch Linux extra](#arch-linux-extra)
- [NixOS](#nixos)
@ -23,7 +24,14 @@
The following third party repositories are available:
### Ubuntu
### Ubuntu/Debian
Install the Debian package from the release page.
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:

View file

@ -314,6 +314,7 @@ These scopes are used for theming the editor interface:
| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (language servers are not required to set a kind) |
| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) |
| `ui.virtual.jump-label` | Style for virtual jump labels |
| `ui.virtual.inline-blame` | Inline blame indicator (see the [`editor.inline-blame` config][editor-section]) |
| `ui.menu` | Code and command completion menus |
| `ui.menu.selected` | Selected autocomplete item |
| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar |
@ -336,5 +337,6 @@ These scopes are used for theming the editor interface:
| `diagnostic.error` | Diagnostics error (editing area) |
| `diagnostic.unnecessary` | Diagnostics with unnecessary tag (editing area) |
| `diagnostic.deprecated` | Diagnostics with deprecated tag (editing area) |
| `tabstop` | Snippet placeholder |
[editor-section]: ./configuration.md#editor-section

View file

@ -47,6 +47,9 @@
<content_rating type="oars-1.1" />
<releases>
<release version="25.01.1" date="2025-01-19">
<url>https://github.com/helix-editor/helix/releases/tag/25.01.1</url>
</release>
<release version="25.01" date="2025-01-03">
<url>https://helix-editor.com/news/release-25-01-highlights/</url>
</release>

View file

@ -86,6 +86,6 @@ Keywords[ru]=текст;текстовый редактор;
Keywords[sr]=Текст;едитор;
Keywords[tr]=Metin;düzenleyici;
Icon=helix
Categories=Utility;TextEditor;
Categories=Utility;TextEditor;ConsoleOnly
StartupNotify=false
MimeType=text/english;text/plain;text/x-makefile;text/x-c++hdr;text/x-c++src;text/x-chdr;text/x-csrc;text/x-java;text/x-moc;text/x-pascal;text/x-tcl;text/x-tex;application/x-shellscript;text/x-c;text/x-c++;

View file

@ -9,23 +9,23 @@ _hx() {
case "$prev" in
-g | --grammar)
COMPREPLY=($(compgen -W 'fetch build' -- "$cur"))
mapfile -t COMPREPLY < <(compgen -W 'fetch build' -- "$cur")
return 0
;;
--health)
languages=$(hx --health | tail -n '+7' | awk '{print $1}' | sed 's/\x1b\[[0-9;]*m//g')
COMPREPLY=($(compgen -W """$languages""" -- "$cur"))
mapfile -t COMPREPLY < <(compgen -W """$languages""" -- "$cur")
return 0
;;
esac
case "$2" in
-*)
COMPREPLY=($(compgen -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config --log" -- """$2"""))
mapfile -t COMPREPLY < <(compgen -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config --log" -- """$2""")
return 0
;;
*)
COMPREPLY=($(compgen -fd -- """$2"""))
mapfile -t COMPREPLY < <(compgen -fd -- """$2""")
return 0
;;
esac

View file

@ -20,7 +20,7 @@ var config = [ "--config" "-c" ]
set edit:completion:arg-completer[hx] = {|@args|
var n = (count $args)
if (>= $n 3) {
# Stop completions if passed arg will take presedence
# Stop completions if passed arg will take precedence
# and invalidate further input
if (has-value $skips $args[-2]) {
return

View file

@ -2,7 +2,7 @@
#
# NOTE: the `+N` syntax is not supported in Nushell (https://github.com/nushell/nushell/issues/13418)
# so it has not been specified here and will not be proposed in the autocompletion of Nushell.
# The help message won't be overriden though, so it will still be present here
# 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 }

3
contrib/hx_launcher.sh Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env sh
HELIX_RUNTIME=/usr/lib/helix/runtime exec /usr/lib/helix/hx "$@"

View file

@ -1,8 +1,84 @@
# Flake's default package for non-flake-enabled nix instances
let
compat = builtins.fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/b4a34015c698c7793d592d66adbab377907a2be8.tar.gz";
sha256 = "sha256:1qc703yg0babixi6wshn5wm2kgl5y1drcswgszh4xxzbrwkk9sv7";
};
{
lib,
rustPlatform,
callPackage,
runCommand,
installShellFiles,
git,
gitRev ? null,
...
}: let
fs = lib.fileset;
src = fs.difference (fs.gitTracked ./.) (fs.unions [
./.envrc
./rustfmt.toml
./screenshot.png
./book
./docs
./runtime
./flake.lock
(fs.fileFilter (file: lib.strings.hasInfix ".git" file.name) ./.)
(fs.fileFilter (file: file.hasExt "svg") ./.)
(fs.fileFilter (file: file.hasExt "md") ./.)
(fs.fileFilter (file: file.hasExt "nix") ./.)
]);
# Next we actually need to build the grammars and the runtime directory
# that they reside in. It is built by calling the derivation in the
# grammars.nix file, then taking the runtime directory in the git repo
# and hooking symlinks up to it.
grammars = callPackage ./grammars.nix {};
runtimeDir = runCommand "helix-runtime" {} ''
mkdir -p $out
ln -s ${./runtime}/* $out
rm -r $out/grammars
ln -s ${grammars} $out/grammars
'';
in
(import compat {src = ./.;}).defaultNix
rustPlatform.buildRustPackage (self: {
cargoLock = {
lockFile = ./Cargo.lock;
# This is not allowed in nixpkgs but is very convenient here: it allows us to
# avoid specifying `outputHashes` here for any git dependencies we might take
# on temporarily.
allowBuiltinFetchGit = true;
};
nativeBuildInputs = [
installShellFiles
git
];
buildType = "release";
name = with builtins; (fromTOML (readFile ./helix-term/Cargo.toml)).package.name;
src = fs.toSource {
root = ./.;
fileset = src;
};
# Helix attempts to reach out to the network and get the grammars. Nix doesn't allow this.
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";
# So Helix knows what rev it is.
HELIX_NIX_BUILD_REV = gitRev;
doCheck = false;
strictDeps = true;
# Sets the Helix runtime dir to the grammars
env.HELIX_DEFAULT_RUNTIME = "${runtimeDir}";
# Get all the application stuff in the output directory.
postInstall = ''
mkdir -p $out/lib
installShellCompletion ${./contrib/completion}/hx.{bash,fish,zsh}
mkdir -p $out/share/{applications,icons/hicolor/{256x256,scalable}/apps}
cp ${./contrib/Helix.desktop} $out/share/applications
cp ${./logo.svg} $out/share/icons/hicolor/scalable/apps/helix.svg
cp ${./contrib/helix.png} $out/share/icons/hicolor/256x256/apps
'';
meta.mainProgram = "hx";
})

View file

@ -15,6 +15,8 @@ Some suggestions to get started:
- If your preferred language is missing, integrating a tree-sitter grammar for
it and defining syntax highlight queries for it is straight forward and
doesn't require much knowledge of the internals.
- If you don't use the Nix development shell and are getting your rust-analyzer binary from rustup, you may need to run `rustup component add rust-analyzer`.
This is because `rust-toolchain.toml` selects our MSRV for the development toolchain but doesn't download the matching rust-analyzer automatically.
We provide an [architecture.md][architecture.md] that should give you
a good overview of the internals.

34
flake.lock generated
View file

@ -1,30 +1,15 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1727974419,
"narHash": "sha256-WD0//20h+2/yPGkO88d2nYbb23WMWYvnRyDQ9Dx4UHg=",
"owner": "ipetkov",
"repo": "crane",
"rev": "37e4f9f0976cb9281cd3f0c70081e5e0ecaee93f",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@ -35,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1728018373,
"narHash": "sha256-NOiTvBbRLIOe5F6RbHaAh6++BNjsb149fGZd1T4+KBg=",
"lastModified": 1740560979,
"narHash": "sha256-Vr3Qi346M+8CjedtbyUevIGDZW8LcA1fTG0ugPY/Hic=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "bc947f541ae55e999ffdb4013441347d83b00feb",
"rev": "5135c59491985879812717f4c9fea69604e7f26f",
"type": "github"
},
"original": {
@ -51,7 +36,6 @@
},
"root": {
"inputs": {
"crane": "crane",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
@ -64,11 +48,11 @@
]
},
"locked": {
"lastModified": 1728268235,
"narHash": "sha256-lJMFnMO4maJuNO6PQ5fZesrTmglze3UFTTBuKGwR1Nw=",
"lastModified": 1740623427,
"narHash": "sha256-3SdPQrZoa4odlScFDUHd4CUPQ/R1gtH4Mq9u8CBiK8M=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "25685cc2c7054efc31351c172ae77b21814f2d42",
"rev": "d342e8b5fd88421ff982f383c853f0fc78a847ab",
"type": "github"
},
"original": {

206
flake.nix
View file

@ -8,183 +8,79 @@
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
crane.url = "github:ipetkov/crane";
};
outputs = {
self,
nixpkgs,
crane,
flake-utils,
rust-overlay,
...
}:
}: let
gitRev = self.rev or self.dirtyRev or null;
in
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
overlays = [(import rust-overlay)];
};
mkRootPath = rel:
builtins.path {
path = "${toString ./.}/${rel}";
name = rel;
};
filteredSource = let
pathsToIgnore = [
".envrc"
".ignore"
".github"
".gitignore"
"logo_dark.svg"
"logo_light.svg"
"rust-toolchain.toml"
"rustfmt.toml"
"runtime"
"screenshot.png"
"book"
"docs"
"README.md"
"CHANGELOG.md"
"shell.nix"
"default.nix"
"grammars.nix"
"flake.nix"
"flake.lock"
];
ignorePaths = path: type: let
inherit (nixpkgs) lib;
# split the nix store path into its components
components = lib.splitString "/" path;
# drop off the `/nix/hash-source` section from the path
relPathComponents = lib.drop 4 components;
# reassemble the path components
relPath = lib.concatStringsSep "/" relPathComponents;
in
lib.all (p: ! (lib.hasPrefix p relPath)) pathsToIgnore;
in
builtins.path {
name = "helix-source";
path = toString ./.;
# filter out unnecessary paths
filter = ignorePaths;
};
makeOverridableHelix = old: config: let
grammars = pkgs.callPackage ./grammars.nix config;
runtimeDir = pkgs.runCommand "helix-runtime" {} ''
mkdir -p $out
ln -s ${mkRootPath "runtime"}/* $out
rm -r $out/grammars
ln -s ${grammars} $out/grammars
'';
helix-wrapped =
pkgs.runCommand
old.name
{
inherit (old) pname version;
meta = old.meta or {};
passthru =
(old.passthru or {})
// {
unwrapped = old;
};
nativeBuildInputs = [pkgs.makeWrapper];
makeWrapperArgs = config.makeWrapperArgs or [];
}
''
cp -rs --no-preserve=mode,ownership ${old} $out
wrapProgram "$out/bin/hx" ''${makeWrapperArgs[@]} --set HELIX_RUNTIME "${runtimeDir}"
'';
in
helix-wrapped
// {
override = makeOverridableHelix old;
passthru =
helix-wrapped.passthru
// {
wrapper = old: makeOverridableHelix old config;
};
};
stdenv =
if pkgs.stdenv.isLinux
then pkgs.stdenv
else pkgs.clangStdenv;
rustFlagsEnv = pkgs.lib.optionalString stdenv.isLinux "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment --cfg tokio_unstable";
rustToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
craneLibMSRV = (crane.mkLib pkgs).overrideToolchain rustToolchain;
craneLibStable = (crane.mkLib pkgs).overrideToolchain pkgs.pkgsBuildHost.rust-bin.stable.latest.default;
commonArgs = {
inherit stdenv;
inherit (craneLibMSRV.crateNameFromCargoToml {cargoToml = ./helix-term/Cargo.toml;}) pname;
inherit (craneLibMSRV.crateNameFromCargoToml {cargoToml = ./Cargo.toml;}) version;
src = filteredSource;
# disable fetching and building of tree-sitter grammars in the helix-term build.rs
HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";
buildInputs = [stdenv.cc.cc.lib];
nativeBuildInputs = [pkgs.installShellFiles];
# disable tests
doCheck = false;
meta.mainProgram = "hx";
# Get Helix's MSRV toolchain to build with by default.
msrvToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
msrvPlatform = pkgs.makeRustPlatform {
cargo = msrvToolchain;
rustc = msrvToolchain;
};
cargoArtifacts = craneLibMSRV.buildDepsOnly commonArgs;
in {
packages = {
helix-unwrapped = craneLibStable.buildPackage (commonArgs
// {
cargoArtifacts = craneLibStable.buildDepsOnly commonArgs;
postInstall = ''
mkdir -p $out/share/applications $out/share/icons/hicolor/scalable/apps $out/share/icons/hicolor/256x256/apps
cp contrib/Helix.desktop $out/share/applications
cp logo.svg $out/share/icons/hicolor/scalable/apps/helix.svg
cp contrib/helix.png $out/share/icons/hicolor/256x256/apps
installShellCompletion contrib/completion/hx.{bash,fish,zsh}
'';
# set git revision for nix flake builds, see 'git_hash' in helix-loader/build.rs
HELIX_NIX_BUILD_REV = self.rev or self.dirtyRev or null;
});
helix = makeOverridableHelix self.packages.${system}.helix-unwrapped {};
default = self.packages.${system}.helix;
packages = rec {
helix = pkgs.callPackage ./default.nix {inherit gitRev;};
/**
The default Helix build. Uses the latest stable Rust toolchain, and unstable
nixpkgs.
The build inputs can be overriden with the following:
packages.${system}.default.override { rustPlatform = newPlatform; };
Overriding a derivation attribute can be done as well:
packages.${system}.default.overrideAttrs { buildType = "debug"; };
*/
default = helix;
};
checks = {
# Build the crate itself
inherit (self.packages.${system}) helix;
clippy = craneLibMSRV.cargoClippy (commonArgs
// {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
});
fmt = craneLibMSRV.cargoFmt commonArgs;
doc = craneLibMSRV.cargoDoc (commonArgs
// {
inherit cargoArtifacts;
});
test = craneLibMSRV.cargoTest (commonArgs
// {
inherit cargoArtifacts;
});
checks.helix = self.outputs.packages.${system}.helix.override {
buildType = "debug";
rustPlatform = msrvPlatform;
};
devShells.default = pkgs.mkShell {
inputsFrom = builtins.attrValues self.checks.${system};
nativeBuildInputs = with pkgs;
[lld_13 cargo-flamegraph rust-analyzer]
++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) pkgs.cargo-tarpaulin)
++ (lib.optional stdenv.isLinux pkgs.lldb)
++ (lib.optional stdenv.isDarwin pkgs.darwin.apple_sdk.frameworks.CoreFoundation);
shellHook = ''
export HELIX_RUNTIME="$PWD/runtime"
export RUST_BACKTRACE="1"
export RUSTFLAGS="''${RUSTFLAGS:-""} ${rustFlagsEnv}"
'';
};
# Devshell behavior is preserved.
devShells.default = let
commonRustFlagsEnv = "-C link-arg=-fuse-ld=lld -C target-cpu=native --cfg tokio_unstable";
platformRustFlagsEnv = pkgs.lib.optionalString pkgs.stdenv.isLinux "-Clink-arg=-Wl,--no-rosegment";
in
pkgs.mkShell
{
inputsFrom = [self.checks.${system}.helix];
nativeBuildInputs = with pkgs;
[
lld
cargo-flamegraph
rust-bin.nightly.latest.rust-analyzer
]
++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin)
++ (lib.optional stdenv.isLinux lldb)
++ (lib.optional stdenv.isDarwin darwin.apple_sdk.frameworks.CoreFoundation);
shellHook = ''
export RUST_BACKTRACE="1"
export RUSTFLAGS="''${RUSTFLAGS:-""} ${commonRustFlagsEnv} ${platformRustFlagsEnv}"
'';
};
})
// {
overlays.default = final: prev: {
inherit (self.packages.${final.system}) helix;
helix = final.callPackage ./default.nix {inherit gitRev;};
};
};

View file

@ -32,10 +32,10 @@
# If `use-grammars.except` is set, use all other grammars.
# Otherwise use all grammars.
useGrammar = grammar:
if languagesConfig?use-grammars.only then
builtins.elem grammar.name languagesConfig.use-grammars.only
else if languagesConfig?use-grammars.except then
!(builtins.elem grammar.name languagesConfig.use-grammars.except)
if languagesConfig ? use-grammars.only
then builtins.elem grammar.name languagesConfig.use-grammars.only
else if languagesConfig ? use-grammars.except
then !(builtins.elem grammar.name languagesConfig.use-grammars.except)
else true;
grammarsToUse = builtins.filter useGrammar languagesConfig.grammar;
gitGrammars = builtins.filter isGitGrammar grammarsToUse;
@ -66,10 +66,10 @@
version = grammar.source.rev;
src = source;
sourceRoot = if builtins.hasAttr "subpath" grammar.source then
"source/${grammar.source.subpath}"
else
"source";
sourceRoot =
if builtins.hasAttr "subpath" grammar.source
then "source/${grammar.source.subpath}"
else "source";
dontConfigure = true;
@ -116,17 +116,21 @@
'';
};
grammarsToBuild = builtins.filter includeGrammarIf gitGrammars;
builtGrammars = builtins.map (grammar: {
inherit (grammar) name;
value = buildGrammar grammar;
}) grammarsToBuild;
builtGrammars =
builtins.map (grammar: {
inherit (grammar) name;
value = buildGrammar grammar;
})
grammarsToBuild;
extensibleGrammars =
lib.makeExtensible (self: builtins.listToAttrs builtGrammars);
overlayedGrammars = lib.pipe extensibleGrammars
overlaidGrammars =
lib.pipe extensibleGrammars
(builtins.map (overlay: grammar: grammar.extend overlay) grammarOverlays);
grammarLinks = lib.mapAttrsToList
grammarLinks =
lib.mapAttrsToList
(name: artifact: "ln -s ${artifact}/${name}.so $out/${name}.so")
(lib.filterAttrs (n: v: lib.isDerivation v) overlayedGrammars);
(lib.filterAttrs (n: v: lib.isDerivation v) overlaidGrammars);
in
runCommand "consolidated-helix-grammars" {} ''
mkdir -p $out

View file

@ -20,10 +20,10 @@ helix-stdx = { path = "../helix-stdx" }
helix-loader = { path = "../helix-loader" }
helix-parsec = { path = "../helix-parsec" }
ropey = { version = "1.6.1", default-features = false, features = ["simd"] }
smallvec = "1.13"
ropey.workspace = true
smallvec = "1.14"
smartstring = "1.0.1"
unicode-segmentation = "1.12"
unicode-segmentation.workspace = true
# unicode-width is changing width definitions
# that both break our logic and disagree with common
# width definitions in terminals, we need to replace it.
@ -33,13 +33,12 @@ unicode-width = "=0.1.12"
unicode-general-category = "1.0"
slotmap.workspace = true
tree-sitter.workspace = true
once_cell = "1.20"
once_cell = "1.21"
arc-swap = "1"
regex = "1"
bitflags = "2.6"
bitflags.workspace = true
ahash = "0.8.11"
hashbrown = { version = "0.14.5", features = ["raw"] }
dunce = "1.0"
url = "2.5.4"
log = "0.4"
@ -48,20 +47,19 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
imara-diff = "0.1.7"
imara-diff = "0.1.8"
encoding_rs = "0.8"
chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] }
etcetera = "0.8"
textwrap = "0.16.1"
textwrap = "0.16.2"
nucleo.workspace = true
parking_lot = "0.12"
globset = "0.4.15"
regex-cursor = "0.1.4"
parking_lot.workspace = true
globset = "0.4.16"
regex-cursor = "0.1.5"
[dev-dependencies]
quickcheck = { version = "1", default-features = false }
indoc = "2.0.5"
indoc = "2.0.6"

File diff suppressed because it is too large Load diff

View file

@ -204,13 +204,9 @@ pub fn find_block_comments(
range: *range,
start_pos,
end_pos,
start_margin: selection_slice
.get_char(after_start)
.map_or(false, |c| c == ' '),
start_margin: selection_slice.get_char(after_start) == Some(' '),
end_margin: after_start != before_end
&& selection_slice
.get_char(before_end)
.map_or(false, |c| c == ' '),
&& (selection_slice.get_char(before_end) == Some(' ')),
start_token: start_token.to_string(),
end_token: end_token.to_string(),
});

View file

@ -1,6 +1,6 @@
use std::borrow::Cow;
use crate::Transaction;
use crate::{diagnostic::LanguageServerId, Transaction};
#[derive(Debug, PartialEq, Clone)]
pub struct CompletionItem {
@ -8,5 +8,18 @@ pub struct CompletionItem {
pub label: Cow<'static, str>,
pub kind: Cow<'static, str>,
/// Containing Markdown
pub documentation: String,
pub documentation: Option<String>,
pub provider: CompletionProvider,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum CompletionProvider {
Lsp(LanguageServerId),
Path,
}
impl From<LanguageServerId> for CompletionProvider {
fn from(id: LanguageServerId) -> Self {
CompletionProvider::Lsp(id)
}
}

View file

@ -1,5 +1,5 @@
//! LSP diagnostic utility types.
use std::fmt;
use std::{fmt, sync::Arc};
pub use helix_stdx::range::Range;
use serde::{Deserialize, Serialize};
@ -50,8 +50,35 @@ pub struct Diagnostic {
pub data: Option<serde_json::Value>,
}
// TODO turn this into an enum + feature flag when lsp becomes optional
pub type DiagnosticProvider = LanguageServerId;
/// The source of a diagnostic.
///
/// This type is cheap to clone: all data is either `Copy` or wrapped in an `Arc`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum DiagnosticProvider {
Lsp {
/// The ID of the language server which sent the diagnostic.
server_id: LanguageServerId,
/// An optional identifier under which diagnostics are managed by the client.
///
/// `identifier` is a field from the LSP "Pull Diagnostics" feature meant to provide an
/// optional "namespace" for diagnostics: a language server can respond to a diagnostics
/// pull request with an identifier and these diagnostics should be treated as separate
/// from push diagnostics. Rust-analyzer uses this feature for example to provide Cargo
/// diagnostics with push and internal diagnostics with pull. The push diagnostics should
/// not clear the pull diagnostics and vice-versa.
identifier: Option<Arc<str>>,
},
// Future internal features can go here...
}
impl DiagnosticProvider {
pub fn language_server_id(&self) -> Option<LanguageServerId> {
match self {
Self::Lsp { server_id, .. } => Some(*server_id),
// _ => None,
}
}
}
// while I would prefer having this in helix-lsp that necessitates a bunch of
// conversions I would rather not add. I think its fine since this just a very

View file

@ -19,10 +19,12 @@ mod test;
use unicode_segmentation::{Graphemes, UnicodeSegmentation};
use helix_stdx::rope::{RopeGraphemes, RopeSliceExt};
use crate::graphemes::{Grapheme, GraphemeStr};
use crate::syntax::Highlight;
use crate::text_annotations::TextAnnotations;
use crate::{Position, RopeGraphemes, RopeSlice};
use crate::{Position, RopeSlice};
/// TODO make Highlight a u32 to reduce the size of this enum to a single word.
#[derive(Debug, Clone, Copy)]
@ -219,7 +221,7 @@ impl<'t> DocumentFormatter<'t> {
text_fmt,
annotations,
visual_pos: Position { row: 0, col: 0 },
graphemes: RopeGraphemes::new(text.slice(block_char_idx..)),
graphemes: text.slice(block_char_idx..).graphemes(),
char_pos: block_char_idx,
exhausted: false,
indent_level: None,
@ -370,8 +372,8 @@ impl<'t> DocumentFormatter<'t> {
match col.cmp(&(self.text_fmt.viewport_width as usize)) {
// The EOF char and newline chars are always selectable in helix. That means
// that wrapping happens "too-early" if a word fits a line perfectly. This
// is intentional so that all selectable graphemes are always visisble (and
// therefore the cursor never dissapears). However if the user manually set a
// is intentional so that all selectable graphemes are always visible (and
// therefore the cursor never disappears). However if the user manually set a
// lower softwrap width then this is undesirable. Just increasing the viewport-
// width by one doesn't work because if a line is wrapped multiple times then
// some words may extend past the specified width.
@ -380,9 +382,10 @@ impl<'t> DocumentFormatter<'t> {
// by a newline/eof character here.
Ordering::Equal
if self.text_fmt.soft_wrap_at_text_width
&& self.peek_grapheme(col, char_pos).map_or(false, |grapheme| {
grapheme.is_newline() || grapheme.is_eof()
}) => {}
&& self
.peek_grapheme(col, char_pos)
.is_some_and(|grapheme| grapheme.is_newline() || grapheme.is_eof()) => {
}
Ordering::Equal if word_width > self.text_fmt.max_wrap as usize => return,
Ordering::Greater if word_width > self.text_fmt.max_wrap as usize => {
self.peeked_grapheme = self.word_buf.pop();

View file

@ -102,6 +102,14 @@ fn long_word_softwrap() {
);
}
#[test]
fn softwrap_multichar_grapheme() {
assert_eq!(
softwrap_text("xxxx xxxx xxx a\u{0301}bc\n"),
"xxxx xxxx xxx \n.ábc \n "
)
}
fn softwrap_text_at_text_width(text: &str) -> String {
let mut text_fmt = TextFormat::new_test(true);
text_fmt.soft_wrap_at_text_width = true;

View file

@ -0,0 +1,333 @@
//! Support for [EditorConfig](https://EditorConfig.org) configuration loading.
//!
//! EditorConfig is an editor-agnostic format for specifying configuration in an INI-like, human
//! friendly syntax in `.editorconfig` files (which are intended to be checked into VCS). This
//! module provides functions to search for all `.editorconfig` files that apply to a given path
//! and returns an `EditorConfig` type containing any specified configuration options.
//!
//! At time of writing, this module follows the [spec](https://spec.editorconfig.org/) at
//! version 0.17.2.
use std::{
collections::HashMap,
fs,
num::{NonZeroU16, NonZeroU8},
path::Path,
str::FromStr,
};
use encoding_rs::Encoding;
use globset::{GlobBuilder, GlobMatcher};
use crate::{
indent::{IndentStyle, MAX_INDENT},
LineEnding,
};
/// Configuration declared for a path in `.editorconfig` files.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct EditorConfig {
pub indent_style: Option<IndentStyle>,
pub tab_width: Option<NonZeroU8>,
pub line_ending: Option<LineEnding>,
pub encoding: Option<&'static Encoding>,
// pub spelling_language: Option<SpellingLanguage>,
pub trim_trailing_whitespace: Option<bool>,
pub insert_final_newline: Option<bool>,
pub max_line_length: Option<NonZeroU16>,
}
impl EditorConfig {
/// Finds any configuration in `.editorconfig` files which applies to the given path.
///
/// If no configuration applies then `EditorConfig::default()` is returned.
pub fn find(path: &Path) -> Self {
let mut configs = Vec::new();
// <https://spec.editorconfig.org/#file-processing>
for ancestor in path.ancestors() {
let editor_config_file = ancestor.join(".editorconfig");
let Ok(contents) = fs::read_to_string(&editor_config_file) else {
continue;
};
let ini = match contents.parse::<Ini>() {
Ok(ini) => ini,
Err(err) => {
log::warn!("Ignoring EditorConfig file at '{editor_config_file:?}' because a glob failed to compile: {err}");
continue;
}
};
let is_root = ini.pairs.get("root").map(AsRef::as_ref) == Some("true");
configs.push((ini, ancestor));
// > The search shall stop if an EditorConfig file is found with the `root` key set to
// > `true` in the preamble or when reaching the root filesystem directory.
if is_root {
break;
}
}
let mut pairs = Pairs::new();
// Reverse the configuration stack so that the `.editorconfig` files closest to `path`
// are applied last and overwrite settings in files closer to the search ceiling.
//
// > If multiple EditorConfig files have matching sections, the pairs from the closer
// > EditorConfig file are read last, so pairs in closer files take precedence.
for (config, dir) in configs.into_iter().rev() {
let relative_path = path.strip_prefix(dir).expect("dir is an ancestor of path");
for section in config.sections {
if section.glob.is_match(relative_path) {
log::info!(
"applying EditorConfig from section '{}' in file {:?}",
section.glob.glob(),
dir.join(".editorconfig")
);
pairs.extend(section.pairs);
}
}
}
Self::from_pairs(pairs)
}
fn from_pairs(pairs: Pairs) -> Self {
enum IndentSize {
Tab,
Spaces(NonZeroU8),
}
// <https://spec.editorconfig.org/#supported-pairs>
let indent_size = pairs.get("indent_size").and_then(|value| {
if value.as_ref() == "tab" {
Some(IndentSize::Tab)
} else if let Ok(spaces) = value.parse::<NonZeroU8>() {
Some(IndentSize::Spaces(spaces))
} else {
None
}
});
let tab_width = pairs
.get("tab_width")
.and_then(|value| value.parse::<NonZeroU8>().ok())
.or(match indent_size {
Some(IndentSize::Spaces(spaces)) => Some(spaces),
_ => None,
});
let indent_style = pairs
.get("indent_style")
.and_then(|value| match value.as_ref() {
"tab" => Some(IndentStyle::Tabs),
"space" => {
let spaces = match indent_size {
Some(IndentSize::Spaces(spaces)) => spaces.get(),
Some(IndentSize::Tab) => tab_width.map(|n| n.get()).unwrap_or(4),
None => 4,
};
Some(IndentStyle::Spaces(spaces.clamp(1, MAX_INDENT)))
}
_ => None,
});
let line_ending = pairs
.get("end_of_line")
.and_then(|value| match value.as_ref() {
"lf" => Some(LineEnding::LF),
"crlf" => Some(LineEnding::Crlf),
#[cfg(feature = "unicode-lines")]
"cr" => Some(LineEnding::CR),
_ => None,
});
let encoding = pairs.get("charset").and_then(|value| match value.as_ref() {
"latin1" => Some(encoding_rs::WINDOWS_1252),
"utf-8" => Some(encoding_rs::UTF_8),
// `utf-8-bom` is intentionally ignored.
// > `utf-8-bom` is discouraged.
"utf-16le" => Some(encoding_rs::UTF_16LE),
"utf-16be" => Some(encoding_rs::UTF_16BE),
_ => None,
});
let trim_trailing_whitespace =
pairs
.get("trim_trailing_whitespace")
.and_then(|value| match value.as_ref() {
"true" => Some(true),
"false" => Some(false),
_ => None,
});
let insert_final_newline = pairs
.get("insert_final_newline")
.and_then(|value| match value.as_ref() {
"true" => Some(true),
"false" => Some(false),
_ => None,
});
// This option is not in the spec but is supported by some editors.
// <https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#max_line_length>
let max_line_length = pairs
.get("max_line_length")
.and_then(|value| value.parse::<NonZeroU16>().ok());
Self {
indent_style,
tab_width,
line_ending,
encoding,
trim_trailing_whitespace,
insert_final_newline,
max_line_length,
}
}
}
type Pairs = HashMap<Box<str>, Box<str>>;
#[derive(Debug)]
struct Section {
glob: GlobMatcher,
pairs: Pairs,
}
#[derive(Debug, Default)]
struct Ini {
pairs: Pairs,
sections: Vec<Section>,
}
impl FromStr for Ini {
type Err = globset::Error;
fn from_str(source: &str) -> Result<Self, Self::Err> {
// <https://spec.editorconfig.org/#file-format>
let mut ini = Ini::default();
// > EditorConfig files are in an INI-like file format. To read an EditorConfig file, take
// > one line at a time, from beginning to end. For each line:
for full_line in source.lines() {
// > 1. Remove all leading and trailing whitespace.
let line = full_line.trim();
// > 2. Process the remaining text as specified for its type below.
// > The types of lines are:
// > * Blank: contains nothing. Blank lines are ignored.
if line.is_empty() {
continue;
}
// > * Comment: starts with a ';' or '#'. Comment lines are ignored.
if line.starts_with([';', '#']) {
continue;
}
if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
// > * Section Header: starts with a `[` and ends with a `]`. These lines define
// > globs...
// <https://spec.editorconfig.org/#glob-expressions>
// We need to modify the glob string slightly since EditorConfig's glob flavor
// doesn't match `globset`'s exactly. `globset` only allows '**' at the beginning
// or end of a glob or between two '/'s. (This replacement is not very fancy but
// should cover most practical cases.)
let mut glob_str = section.replace("**.", "**/*.");
if !is_glob_relative(section) {
glob_str.insert_str(0, "**/");
}
let glob = GlobBuilder::new(&glob_str)
.literal_separator(true)
.backslash_escape(true)
.build()?;
ini.sections.push(Section {
glob: glob.compile_matcher(),
pairs: Pairs::new(),
});
} else if let Some((key, value)) = line.split_once('=') {
// > * Key-Value Pair (or Pair): contains a key and a value, separated by an `=`.
// > * Key: The part before the first `=` on the line.
// > * Value: The part, if any, after the first `=` on the line.
// > * Keys and values are trimmed of leading and trailing whitespace, but
// > include any whitespace that is between non-whitespace characters.
// > * If a value is not provided, then the value is an empty string.
let key = key.trim().to_lowercase().into_boxed_str();
let value = value.trim().to_lowercase().into_boxed_str();
if let Some(section) = ini.sections.last_mut() {
section.pairs.insert(key, value);
} else {
ini.pairs.insert(key, value);
}
}
}
Ok(ini)
}
}
/// Determines whether a glob is relative to the directory of the config file.
fn is_glob_relative(source: &str) -> bool {
// > If the glob contains a path separator (a `/` not inside square brackets), then the
// > glob is relative to the directory level of the particular `.editorconfig` file itself.
let mut idx = 0;
while let Some(open) = source[idx..].find('[').map(|open| idx + open) {
if source[..open].contains('/') {
return true;
}
idx = source[open..]
.find(']')
.map_or(source.len(), |close| idx + close);
}
source[idx..].contains('/')
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn is_glob_relative_test() {
assert!(is_glob_relative("subdir/*.c"));
assert!(!is_glob_relative("*.txt"));
assert!(!is_glob_relative("[a/b].c"));
}
fn editor_config(path: impl AsRef<Path>, source: &str) -> EditorConfig {
let path = path.as_ref();
let ini = source.parse::<Ini>().unwrap();
let pairs = ini
.sections
.into_iter()
.filter(|section| section.glob.is_match(path))
.fold(Pairs::new(), |mut acc, section| {
acc.extend(section.pairs);
acc
});
EditorConfig::from_pairs(pairs)
}
#[test]
fn parse_test() {
let source = r#"
[*]
indent_style = space
[Makefile]
indent_style = tab
[docs/**.txt]
insert_final_newline = true
"#;
assert_eq!(
editor_config("a.txt", source),
EditorConfig {
indent_style: Some(IndentStyle::Spaces(4)),
..Default::default()
}
);
assert_eq!(
editor_config("pkg/Makefile", source),
EditorConfig {
indent_style: Some(IndentStyle::Tabs),
..Default::default()
}
);
assert_eq!(
editor_config("docs/config/editor.txt", source),
EditorConfig {
indent_style: Some(IndentStyle::Spaces(4)),
insert_final_newline: Some(true),
..Default::default()
}
);
}
}

View file

@ -1,7 +1,7 @@
//! Utility functions to traverse the unicode graphemes of a `Rope`'s text contents.
//!
//! Based on <https://github.com/cessen/led/blob/c4fa72405f510b7fd16052f90a598c429b3104a6/src/graphemes.rs>
use ropey::{iter::Chunks, str_utils::byte_to_char_idx, RopeSlice};
use ropey::{str_utils::byte_to_char_idx, RopeSlice};
use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete};
use unicode_width::UnicodeWidthStr;
@ -64,7 +64,7 @@ impl<'a> Grapheme<'a> {
}
pub fn is_whitespace(&self) -> bool {
!matches!(&self, Grapheme::Other { g } if !g.chars().all(char_is_whitespace))
!matches!(&self, Grapheme::Other { g } if !g.chars().next().is_some_and(char_is_whitespace))
}
// TODO currently word boundaries are used for softwrapping.
@ -72,7 +72,7 @@ impl<'a> Grapheme<'a> {
// This could however be improved in the future by considering unicode
// character classes but
pub fn is_word_boundary(&self) -> bool {
!matches!(&self, Grapheme::Other { g,.. } if g.chars().all(char_is_word))
!matches!(&self, Grapheme::Other { g,.. } if g.chars().next().is_some_and(char_is_word))
}
}
@ -119,6 +119,9 @@ pub fn grapheme_width(g: &str) -> usize {
}
}
// NOTE: for byte indexing versions of these functions see `RopeSliceExt`'s
// `floor_grapheme_boundary` and `ceil_grapheme_boundary` and the rope grapheme iterators.
#[must_use]
pub fn nth_prev_grapheme_boundary(slice: RopeSlice, char_idx: usize, n: usize) -> usize {
// Bounds check
@ -208,43 +211,6 @@ pub fn nth_next_grapheme_boundary(slice: RopeSlice, char_idx: usize, n: usize) -
chunk_char_idx + tmp
}
#[must_use]
pub fn nth_next_grapheme_boundary_byte(slice: RopeSlice, mut byte_idx: usize, n: usize) -> usize {
// Bounds check
debug_assert!(byte_idx <= slice.len_bytes());
// Get the chunk with our byte index in it.
let (mut chunk, mut chunk_byte_idx, mut _chunk_char_idx, _) = slice.chunk_at_byte(byte_idx);
// Set up the grapheme cursor.
let mut gc = GraphemeCursor::new(byte_idx, slice.len_bytes(), true);
// Find the nth next grapheme cluster boundary.
for _ in 0..n {
loop {
match gc.next_boundary(chunk, chunk_byte_idx) {
Ok(None) => return slice.len_bytes(),
Ok(Some(n)) => {
byte_idx = n;
break;
}
Err(GraphemeIncomplete::NextChunk) => {
chunk_byte_idx += chunk.len();
let (a, _, _c, _) = slice.chunk_at_byte(chunk_byte_idx);
chunk = a;
// chunk_char_idx = c;
}
Err(GraphemeIncomplete::PreContext(n)) => {
let ctx_chunk = slice.chunk_at_byte(n - 1).0;
gc.provide_context(ctx_chunk, n - ctx_chunk.len());
}
_ => unreachable!(),
}
}
}
byte_idx
}
/// Finds the next grapheme boundary after the given char position.
#[must_use]
#[inline(always)]
@ -252,13 +218,6 @@ pub fn next_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> usize {
nth_next_grapheme_boundary(slice, char_idx, 1)
}
/// Finds the next grapheme boundary after the given byte position.
#[must_use]
#[inline(always)]
pub fn next_grapheme_boundary_byte(slice: RopeSlice, byte_idx: usize) -> usize {
nth_next_grapheme_boundary_byte(slice, byte_idx, 1)
}
/// Returns the passed char index if it's already a grapheme boundary,
/// or the next grapheme boundary char index if not.
#[must_use]
@ -311,187 +270,6 @@ pub fn is_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> bool {
}
}
/// Returns whether the given byte position is a grapheme boundary.
#[must_use]
pub fn is_grapheme_boundary_byte(slice: RopeSlice, byte_idx: usize) -> bool {
// Bounds check
debug_assert!(byte_idx <= slice.len_bytes());
// Get the chunk with our byte index in it.
let (chunk, chunk_byte_idx, _, _) = slice.chunk_at_byte(byte_idx);
// Set up the grapheme cursor.
let mut gc = GraphemeCursor::new(byte_idx, slice.len_bytes(), true);
// Determine if the given position is a grapheme cluster boundary.
loop {
match gc.is_boundary(chunk, chunk_byte_idx) {
Ok(n) => return n,
Err(GraphemeIncomplete::PreContext(n)) => {
let (ctx_chunk, ctx_byte_start, _, _) = slice.chunk_at_byte(n - 1);
gc.provide_context(ctx_chunk, ctx_byte_start);
}
Err(_) => unreachable!(),
}
}
}
/// An iterator over the graphemes of a `RopeSlice`.
#[derive(Clone)]
pub struct RopeGraphemes<'a> {
text: RopeSlice<'a>,
chunks: Chunks<'a>,
cur_chunk: &'a str,
cur_chunk_start: usize,
cursor: GraphemeCursor,
}
impl fmt::Debug for RopeGraphemes<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RopeGraphemes")
.field("text", &self.text)
.field("chunks", &self.chunks)
.field("cur_chunk", &self.cur_chunk)
.field("cur_chunk_start", &self.cur_chunk_start)
// .field("cursor", &self.cursor)
.finish()
}
}
impl RopeGraphemes<'_> {
#[must_use]
pub fn new(slice: RopeSlice) -> RopeGraphemes {
let mut chunks = slice.chunks();
let first_chunk = chunks.next().unwrap_or("");
RopeGraphemes {
text: slice,
chunks,
cur_chunk: first_chunk,
cur_chunk_start: 0,
cursor: GraphemeCursor::new(0, slice.len_bytes(), true),
}
}
}
impl<'a> Iterator for RopeGraphemes<'a> {
type Item = RopeSlice<'a>;
fn next(&mut self) -> Option<RopeSlice<'a>> {
let a = self.cursor.cur_cursor();
let b;
loop {
match self
.cursor
.next_boundary(self.cur_chunk, self.cur_chunk_start)
{
Ok(None) => {
return None;
}
Ok(Some(n)) => {
b = n;
break;
}
Err(GraphemeIncomplete::NextChunk) => {
self.cur_chunk_start += self.cur_chunk.len();
self.cur_chunk = self.chunks.next().unwrap_or("");
}
Err(GraphemeIncomplete::PreContext(idx)) => {
let (chunk, byte_idx, _, _) = self.text.chunk_at_byte(idx.saturating_sub(1));
self.cursor.provide_context(chunk, byte_idx);
}
_ => unreachable!(),
}
}
if a < self.cur_chunk_start {
Some(self.text.byte_slice(a..b))
} else {
let a2 = a - self.cur_chunk_start;
let b2 = b - self.cur_chunk_start;
Some((&self.cur_chunk[a2..b2]).into())
}
}
}
/// An iterator over the graphemes of a `RopeSlice` in reverse.
#[derive(Clone)]
pub struct RevRopeGraphemes<'a> {
text: RopeSlice<'a>,
chunks: Chunks<'a>,
cur_chunk: &'a str,
cur_chunk_start: usize,
cursor: GraphemeCursor,
}
impl fmt::Debug for RevRopeGraphemes<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RevRopeGraphemes")
.field("text", &self.text)
.field("chunks", &self.chunks)
.field("cur_chunk", &self.cur_chunk)
.field("cur_chunk_start", &self.cur_chunk_start)
// .field("cursor", &self.cursor)
.finish()
}
}
impl RevRopeGraphemes<'_> {
#[must_use]
pub fn new(slice: RopeSlice) -> RevRopeGraphemes {
let (mut chunks, mut cur_chunk_start, _, _) = slice.chunks_at_byte(slice.len_bytes());
chunks.reverse();
let first_chunk = chunks.next().unwrap_or("");
cur_chunk_start -= first_chunk.len();
RevRopeGraphemes {
text: slice,
chunks,
cur_chunk: first_chunk,
cur_chunk_start,
cursor: GraphemeCursor::new(slice.len_bytes(), slice.len_bytes(), true),
}
}
}
impl<'a> Iterator for RevRopeGraphemes<'a> {
type Item = RopeSlice<'a>;
fn next(&mut self) -> Option<RopeSlice<'a>> {
let a = self.cursor.cur_cursor();
let b;
loop {
match self
.cursor
.prev_boundary(self.cur_chunk, self.cur_chunk_start)
{
Ok(None) => {
return None;
}
Ok(Some(n)) => {
b = n;
break;
}
Err(GraphemeIncomplete::PrevChunk) => {
self.cur_chunk = self.chunks.next().unwrap_or("");
self.cur_chunk_start -= self.cur_chunk.len();
}
Err(GraphemeIncomplete::PreContext(idx)) => {
let (chunk, byte_idx, _, _) = self.text.chunk_at_byte(idx.saturating_sub(1));
self.cursor.provide_context(chunk, byte_idx);
}
_ => unreachable!(),
}
}
if a >= self.cur_chunk_start + self.cur_chunk.len() {
Some(self.text.byte_slice(b..a))
} else {
let a2 = a - self.cur_chunk_start;
let b2 = b - self.cur_chunk_start;
Some((&self.cur_chunk[b2..a2]).into())
}
}
}
/// A highly compressed Cow<'a, str> that holds
/// atmost u31::MAX bytes and is readonly
pub struct GraphemeStr<'a> {

View file

@ -8,7 +8,7 @@ use crate::{
graphemes::{grapheme_width, tab_width_at},
syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax},
tree_sitter::Node,
Position, Rope, RopeGraphemes, RopeSlice, Tendril,
Position, Rope, RopeSlice, Tendril,
};
/// Enum representing indentation style.
@ -200,7 +200,7 @@ pub fn indent_level_for_line(line: RopeSlice, tab_width: usize, indent_width: us
/// Create a string of tabs & spaces that has the same visual width as the given RopeSlice (independent of the tab width).
fn whitespace_with_same_width(text: RopeSlice) -> String {
let mut s = String::new();
for grapheme in RopeGraphemes::new(text) {
for grapheme in text.graphemes() {
if grapheme == "\t" {
s.push('\t');
} else {
@ -456,7 +456,7 @@ struct IndentQueryResult<'a> {
fn get_node_start_line(node: Node, new_line_byte_pos: Option<usize>) -> usize {
let mut node_line = node.start_position().row;
// Adjust for the new line that will be inserted
if new_line_byte_pos.map_or(false, |pos| node.start_byte() >= pos) {
if new_line_byte_pos.is_some_and(|pos| node.start_byte() >= pos) {
node_line += 1;
}
node_line
@ -464,7 +464,7 @@ fn get_node_start_line(node: Node, new_line_byte_pos: Option<usize>) -> usize {
fn get_node_end_line(node: Node, new_line_byte_pos: Option<usize>) -> usize {
let mut node_line = node.end_position().row;
// Adjust for the new line that will be inserted (with a strict inequality since end_byte is exclusive)
if new_line_byte_pos.map_or(false, |pos| node.end_byte() > pos) {
if new_line_byte_pos.is_some_and(|pos| node.end_byte() > pos) {
node_line += 1;
}
node_line

View file

@ -3,12 +3,14 @@ pub use encoding_rs as encoding;
pub mod auto_pairs;
pub mod case_conversion;
pub mod chars;
pub mod command_line;
pub mod comment;
pub mod completion;
pub mod config;
pub mod diagnostic;
pub mod diff;
pub mod doc_formatter;
pub mod editor_config;
pub mod fuzzy;
pub mod graphemes;
pub mod history;
@ -22,7 +24,6 @@ pub mod object;
mod position;
pub mod search;
pub mod selection;
pub mod shellwords;
pub mod snippets;
pub mod surround;
pub mod syntax;
@ -54,7 +55,6 @@ pub type Tendril = SmartString<smartstring::LazyCompact>;
#[doc(inline)]
pub use {regex, tree_sitter};
pub use graphemes::RopeGraphemes;
pub use position::{
char_idx_at_visual_offset, coords_at_pos, pos_at_coords, softwrapped_dimensions,
visual_offset_from_anchor, visual_offset_from_block, Position, VisualOffsetError,

View file

@ -4,10 +4,12 @@ use std::{
ops::{Add, AddAssign, Sub, SubAssign},
};
use helix_stdx::rope::RopeSliceExt;
use crate::{
chars::char_is_line_ending,
doc_formatter::{DocumentFormatter, TextFormat},
graphemes::{ensure_grapheme_boundary_prev, grapheme_width, RopeGraphemes},
graphemes::{ensure_grapheme_boundary_prev, grapheme_width},
line_ending::line_end_char_index,
text_annotations::TextAnnotations,
RopeSlice,
@ -101,7 +103,7 @@ pub fn coords_at_pos(text: RopeSlice, pos: usize) -> Position {
let line_start = text.line_to_char(line);
let pos = ensure_grapheme_boundary_prev(text, pos);
let col = RopeGraphemes::new(text.slice(line_start..pos)).count();
let col = text.slice(line_start..pos).graphemes().count();
Position::new(line, col)
}
@ -126,7 +128,7 @@ pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Po
let mut col = 0;
for grapheme in RopeGraphemes::new(text.slice(line_start..pos)) {
for grapheme in text.slice(line_start..pos).graphemes() {
if grapheme == "\t" {
col += tab_width - (col % tab_width);
} else {
@ -275,7 +277,7 @@ pub fn pos_at_coords(text: RopeSlice, coords: Position, limit_before_line_ending
};
let mut col_char_offset = 0;
for (i, g) in RopeGraphemes::new(text.slice(line_start..line_end)).enumerate() {
for (i, g) in text.slice(line_start..line_end).graphemes().enumerate() {
if i == col {
break;
}
@ -306,7 +308,7 @@ pub fn pos_at_visual_coords(text: RopeSlice, coords: Position, tab_width: usize)
let mut col_char_offset = 0;
let mut cols_remaining = col;
for grapheme in RopeGraphemes::new(text.slice(line_start..line_end)) {
for grapheme in text.slice(line_start..line_end).graphemes() {
let grapheme_width = if grapheme == "\t" {
tab_width - ((col - cols_remaining) % tab_width)
} else {

View file

@ -9,7 +9,7 @@ use crate::{
},
line_ending::get_line_ending,
movement::Direction,
Assoc, ChangeSet, RopeGraphemes, RopeSlice,
Assoc, ChangeSet, RopeSlice,
};
use helix_stdx::range::is_subset;
use helix_stdx::rope::{self, RopeSliceExt};
@ -379,7 +379,7 @@ impl Range {
/// Returns true if this Range covers a single grapheme in the given text
pub fn is_single_grapheme(&self, doc: RopeSlice) -> bool {
let mut graphemes = RopeGraphemes::new(doc.slice(self.from()..self.to()));
let mut graphemes = doc.slice(self.from()..self.to()).graphemes();
let first = graphemes.next();
let second = graphemes.next();
first.is_some() && second.is_none()
@ -619,7 +619,6 @@ impl Selection {
self
}
// TODO: consume an iterator or a vec to reduce allocations?
#[must_use]
pub fn new(ranges: SmallVec<[Range; 1]>, primary_index: usize) -> Self {
assert!(!ranges.is_empty());
@ -721,6 +720,12 @@ impl IntoIterator for Selection {
}
}
impl FromIterator<Range> for Selection {
fn from_iter<T: IntoIterator<Item = Range>>(ranges: T) -> Self {
Self::new(ranges.into_iter().collect(), 0)
}
}
impl From<Range> for Selection {
fn from(range: Range) -> Self {
Self {

View file

@ -1,350 +0,0 @@
use std::borrow::Cow;
/// Auto escape for shellwords usage.
pub fn escape(input: Cow<str>) -> Cow<str> {
if !input.chars().any(|x| x.is_ascii_whitespace()) {
input
} else if cfg!(unix) {
Cow::Owned(input.chars().fold(String::new(), |mut buf, c| {
if c.is_ascii_whitespace() {
buf.push('\\');
}
buf.push(c);
buf
}))
} else {
Cow::Owned(format!("\"{}\"", input))
}
}
enum State {
OnWhitespace,
Unquoted,
UnquotedEscaped,
Quoted,
QuoteEscaped,
Dquoted,
DquoteEscaped,
}
pub struct Shellwords<'a> {
state: State,
/// Shellwords where whitespace and escapes has been resolved.
words: Vec<Cow<'a, str>>,
/// The parts of the input that are divided into shellwords. This can be
/// used to retrieve the original text for a given word by looking up the
/// same index in the Vec as the word in `words`.
parts: Vec<&'a str>,
}
impl<'a> From<&'a str> for Shellwords<'a> {
fn from(input: &'a str) -> Self {
use State::*;
let mut state = Unquoted;
let mut words = Vec::new();
let mut parts = Vec::new();
let mut escaped = String::with_capacity(input.len());
let mut part_start = 0;
let mut unescaped_start = 0;
let mut end = 0;
for (i, c) in input.char_indices() {
state = match state {
OnWhitespace => match c {
'"' => {
end = i;
Dquoted
}
'\'' => {
end = i;
Quoted
}
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
UnquotedEscaped
} else {
OnWhitespace
}
}
c if c.is_ascii_whitespace() => {
end = i;
OnWhitespace
}
_ => Unquoted,
},
Unquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
UnquotedEscaped
} else {
Unquoted
}
}
c if c.is_ascii_whitespace() => {
end = i;
OnWhitespace
}
_ => Unquoted,
},
UnquotedEscaped => Unquoted,
Quoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
QuoteEscaped
} else {
Quoted
}
}
'\'' => {
end = i;
OnWhitespace
}
_ => Quoted,
},
QuoteEscaped => Quoted,
Dquoted => match c {
'\\' => {
if cfg!(unix) {
escaped.push_str(&input[unescaped_start..i]);
unescaped_start = i + 1;
DquoteEscaped
} else {
Dquoted
}
}
'"' => {
end = i;
OnWhitespace
}
_ => Dquoted,
},
DquoteEscaped => Dquoted,
};
let c_len = c.len_utf8();
if i == input.len() - c_len && end == 0 {
end = i + c_len;
}
if end > 0 {
let esc_trim = escaped.trim();
let inp = &input[unescaped_start..end];
if !(esc_trim.is_empty() && inp.trim().is_empty()) {
if esc_trim.is_empty() {
words.push(inp.into());
parts.push(inp);
} else {
words.push([escaped, inp.into()].concat().into());
parts.push(&input[part_start..end]);
escaped = "".to_string();
}
}
unescaped_start = i + 1;
part_start = i + 1;
end = 0;
}
}
debug_assert!(words.len() == parts.len());
Self {
state,
words,
parts,
}
}
}
impl<'a> Shellwords<'a> {
/// Checks that the input ends with a whitespace character which is not escaped.
///
/// # Examples
///
/// ```rust
/// use helix_core::shellwords::Shellwords;
/// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true);
/// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true);
/// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true);
/// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false);
/// #[cfg(unix)]
/// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false);
/// #[cfg(unix)]
/// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false);
/// ```
pub fn ends_with_whitespace(&self) -> bool {
matches!(self.state, State::OnWhitespace)
}
/// Returns the list of shellwords calculated from the input string.
pub fn words(&self) -> &[Cow<'a, str>] {
&self.words
}
/// Returns a list of strings which correspond to [`Self::words`] but represent the original
/// text in the input string - including escape characters - without separating whitespace.
pub fn parts(&self) -> &[&'a str] {
&self.parts
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
#[cfg(windows)]
fn test_normal() {
let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó"),
Cow::from("wörds"),
Cow::from("\\three\\"),
Cow::from("\\"),
Cow::from("with\\ escaping\\\\"),
];
// TODO test is_owned and is_borrowed, once they get stabilized.
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_normal() {
let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó"),
Cow::from("wörds"),
Cow::from(r#"three "with escaping\"#),
];
// TODO test is_owned and is_borrowed, once they get stabilized.
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_quoted() {
let quoted =
r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#;
let shellwords = Shellwords::from(quoted);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó wörds"),
Cow::from(r#"three' "with escaping\"#),
Cow::from("quote incomplete"),
];
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_dquoted() {
let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#;
let shellwords = Shellwords::from(dquoted);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó wörds"),
Cow::from(r#"three' "with escaping\"#),
Cow::from("dquote incomplete"),
];
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_mixed() {
let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#;
let shellwords = Shellwords::from(dquoted);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":o"),
Cow::from("single_word"),
Cow::from("twó wörds"),
Cow::from("three' \"with escaping\\"),
Cow::from("no space before"),
Cow::from("and after"),
Cow::from("$#%^@"),
Cow::from("%^&(%^"),
Cow::from(")(*&^%"),
Cow::from(r#"a\\b"#),
//last ' just changes to quoted but since we dont have anything after it, it should be ignored
];
assert_eq!(expected, result);
}
#[test]
fn test_lists() {
let input =
r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "quotes"]'"#;
let shellwords = Shellwords::from(input);
let result = shellwords.words().to_vec();
let expected = vec![
Cow::from(":set"),
Cow::from("statusline.center"),
Cow::from(r#"["file-type","file-encoding"]"#),
Cow::from(r#"["list", "in", "quotes"]"#),
];
assert_eq!(expected, result);
}
#[test]
#[cfg(unix)]
fn test_escaping_unix() {
assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
assert_eq!(escape("foo bar".into()), Cow::Borrowed("foo\\ bar"));
assert_eq!(escape("foo\tbar".into()), Cow::Borrowed("foo\\\tbar"));
}
#[test]
#[cfg(windows)]
fn test_escaping_windows() {
assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\""));
}
#[test]
#[cfg(unix)]
fn test_parts() {
assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\ "]);
}
#[test]
#[cfg(windows)]
fn test_parts() {
assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\"]);
}
#[test]
fn test_multibyte_at_end() {
assert_eq!(Shellwords::from("𒀀").parts(), &["𒀀"]);
assert_eq!(
Shellwords::from(":sh echo 𒀀").parts(),
&[":sh", "echo", "𒀀"]
);
assert_eq!(
Shellwords::from(":sh echo 𒀀 hello world𒀀").parts(),
&[":sh", "echo", "𒀀", "hello", "world𒀀"]
);
}
}

View file

@ -252,4 +252,21 @@ mod tests {
snippet.map(edit.changes());
assert!(!snippet.is_valid(&Selection::point(4)))
}
#[test]
fn tabstop_zero_with_placeholder() {
// The `$0` tabstop should not have placeholder text. When we receive a snippet like this
// (from older versions of clangd for example) we should discard the placeholder text.
let snippet = Snippet::parse("sizeof(${0:expression-or-type})").unwrap();
let mut doc = Rope::from("\n");
let (transaction, _, snippet) = snippet.render(
&doc,
&Selection::point(0),
|_| (0, 0),
&mut SnippetRenderCtx::test_ctx(),
);
assert!(transaction.apply(&mut doc));
assert_eq!(doc, "sizeof()\n");
assert!(ActiveSnippet::new(snippet).is_none());
}
}

View file

@ -178,9 +178,16 @@ impl Snippet {
&mut self,
idx: usize,
parent: Option<TabstopIdx>,
default: Vec<parser::SnippetElement>,
mut default: Vec<parser::SnippetElement>,
) -> TabstopIdx {
let idx = TabstopIdx::elaborate(idx);
if idx == LAST_TABSTOP_IDX && !default.is_empty() {
// Older versions of clangd for example may send a snippet like `${0:placeholder}`
// which is considered by VSCode to be a misuse of the `$0` tabstop.
log::warn!("Discarding placeholder text for the `$0` tabstop ({default:?}). \
The `$0` tabstop signifies the final cursor position and should not include placeholder text.");
default.clear();
}
let default = self.elaborate(default, Some(idx));
self.tabstops.push(Tabstop {
idx,

View file

@ -361,7 +361,20 @@ mod test {
Text(")".into()),
]),
parse("match(${1:Arg1})")
)
);
// The `$0` tabstop should not have placeholder text. The parser should handle this case
// normally and then the placeholder text should be discarded during elaboration.
assert_eq!(
Ok(vec![
Text("sizeof(".into()),
Placeholder {
tabstop: 0,
value: vec![Text("expression-or-type".into())],
},
Text(")".into()),
]),
parse("sizeof(${0:expression-or-type})")
);
}
#[test]

View file

@ -36,12 +36,12 @@ use helix_loader::grammar::{get_language, load_runtime_file};
pub use tree_cursor::TreeCursor;
fn deserialize_regex<'de, D>(deserializer: D) -> Result<Option<Regex>, D::Error>
fn deserialize_regex<'de, D>(deserializer: D) -> Result<Option<rope::Regex>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer)?
.map(|buf| Regex::new(&buf).map_err(serde::de::Error::custom))
.map(|buf| rope::Regex::new(&buf).map_err(serde::de::Error::custom))
.transpose()
}
@ -135,7 +135,7 @@ pub struct LanguageConfiguration {
// content_regex
#[serde(default, skip_serializing, deserialize_with = "deserialize_regex")]
pub injection_regex: Option<Regex>,
pub injection_regex: Option<rope::Regex>,
// first_line_regex
//
#[serde(skip)]
@ -332,8 +332,10 @@ pub enum LanguageServerFeature {
WorkspaceSymbols,
// Symbols, use bitflags, see above?
Diagnostics,
PullDiagnostics,
RenameSymbol,
InlayHints,
DocumentColors,
}
impl Display for LanguageServerFeature {
@ -355,8 +357,10 @@ impl Display for LanguageServerFeature {
DocumentSymbols => "document-symbols",
WorkspaceSymbols => "workspace-symbols",
Diagnostics => "diagnostics",
PullDiagnostics => "pull-diagnostics",
RenameSymbol => "rename-symbol",
InlayHints => "inlay-hints",
DocumentColors => "document-colors",
};
write!(f, "{feature}",)
}
@ -756,7 +760,7 @@ impl LanguageConfiguration {
let language = get_language(self.grammar.as_deref().unwrap_or(&self.language_id))
.map_err(|err| {
log::error!(
"Failed to load tree-sitter parser for language {:?}: {}",
"Failed to load tree-sitter parser for language {:?}: {:#}",
self.language_id,
err
)
@ -994,21 +998,32 @@ impl Loader {
.cloned()
}
pub fn language_config_for_language_id(&self, id: &str) -> Option<Arc<LanguageConfiguration>> {
pub fn language_config_for_language_id(
&self,
id: impl PartialEq<String>,
) -> Option<Arc<LanguageConfiguration>> {
self.language_configs
.iter()
.find(|config| config.language_id == id)
.find(|config| id.eq(&config.language_id))
.cloned()
}
/// Unlike language_config_for_language_id, which only returns Some for an exact id, this
/// Unlike `language_config_for_language_id`, which only returns Some for an exact id, this
/// function will perform a regex match on the given string to find the closest language match.
pub fn language_config_for_name(&self, name: &str) -> Option<Arc<LanguageConfiguration>> {
pub fn language_config_for_name(&self, slice: RopeSlice) -> Option<Arc<LanguageConfiguration>> {
// PERF: If the name matches up with the id, then this saves the need to do expensive regex.
let shortcircuit = self.language_config_for_language_id(slice);
if shortcircuit.is_some() {
return shortcircuit;
}
// If the name did not match up with a known id, then match on injection regex.
let mut best_match_length = 0;
let mut best_match_position = None;
for (i, configuration) in self.language_configs.iter().enumerate() {
if let Some(injection_regex) = &configuration.injection_regex {
if let Some(mat) = injection_regex.find(name) {
if let Some(mat) = injection_regex.find(slice.regex_input()) {
let length = mat.end() - mat.start();
if length > best_match_length {
best_match_position = Some(i);
@ -1026,12 +1041,18 @@ impl Loader {
capture: &InjectionLanguageMarker,
) -> Option<Arc<LanguageConfiguration>> {
match capture {
InjectionLanguageMarker::Name(string) => self.language_config_for_name(string),
InjectionLanguageMarker::Filename(file) => self.language_config_for_file_name(file),
InjectionLanguageMarker::Shebang(shebang) => self
.language_config_ids_by_shebang
.get(shebang)
.and_then(|&id| self.language_configs.get(id).cloned()),
InjectionLanguageMarker::LanguageId(id) => self.language_config_for_language_id(*id),
InjectionLanguageMarker::Name(name) => self.language_config_for_name(*name),
InjectionLanguageMarker::Filename(file) => {
let path_str: Cow<str> = (*file).into();
self.language_config_for_file_name(Path::new(path_str.as_ref()))
}
InjectionLanguageMarker::Shebang(shebang) => {
let shebang_str: Cow<str> = (*shebang).into();
self.language_config_ids_by_shebang
.get(shebang_str.as_ref())
.and_then(|&id| self.language_configs.get(id).cloned())
}
}
}
@ -2030,12 +2051,13 @@ impl HighlightConfiguration {
for capture in query_match.captures {
let index = Some(capture.index);
if index == self.injection_language_capture_index {
let name = byte_range_to_str(capture.node.byte_range(), source);
injection_capture = Some(InjectionLanguageMarker::Name(name));
injection_capture = Some(InjectionLanguageMarker::Name(
source.byte_slice(capture.node.byte_range()),
));
} else if index == self.injection_filename_capture_index {
let name = byte_range_to_str(capture.node.byte_range(), source);
let path = Path::new(name.as_ref()).to_path_buf();
injection_capture = Some(InjectionLanguageMarker::Filename(path.into()));
injection_capture = Some(InjectionLanguageMarker::Filename(
source.byte_slice(capture.node.byte_range()),
));
} else if index == self.injection_shebang_capture_index {
let node_slice = source.byte_slice(capture.node.byte_range());
@ -2054,7 +2076,7 @@ impl HighlightConfiguration {
.captures_iter(lines.regex_input())
.map(|cap| {
let cap = lines.byte_slice(cap.get_group(1).unwrap().range());
InjectionLanguageMarker::Shebang(cap.into())
InjectionLanguageMarker::Shebang(cap)
})
.next()
} else if index == self.injection_content_capture_index {
@ -2085,8 +2107,8 @@ impl HighlightConfiguration {
"injection.language" if injection_capture.is_none() => {
injection_capture = prop
.value
.as_ref()
.map(|s| InjectionLanguageMarker::Name(s.as_ref().into()));
.as_deref()
.map(InjectionLanguageMarker::LanguageId);
}
// By default, injections do not include the *children* of an
@ -2484,15 +2506,17 @@ impl Iterator for HighlightIter<'_> {
}
}
// Once a highlighting pattern is found for the current node, skip over
// any later highlighting patterns that also match this node. Captures
// Use the last capture found for the current node, skipping over any
// highlight patterns that also match this node. Captures
// for a given node are ordered by pattern index, so these subsequent
// captures are guaranteed to be for highlighting, not injections or
// local variables.
while let Some((next_match, next_capture_index)) = captures.peek() {
let next_capture = next_match.captures[*next_capture_index];
if next_capture.node == capture.node {
captures.next();
match_.remove();
capture = next_capture;
match_ = captures.next().unwrap().0;
} else {
break;
}
@ -2521,9 +2545,20 @@ impl Iterator for HighlightIter<'_> {
#[derive(Debug, Clone)]
pub enum InjectionLanguageMarker<'a> {
Name(Cow<'a, str>),
Filename(Cow<'a, Path>),
Shebang(String),
/// The language is specified by `LanguageConfiguration`'s `language_id` field.
///
/// This marker is used when a pattern sets the `injection.language` property, for example
/// `(#set! injection.language "rust")`.
LanguageId(&'a str),
/// The language is specified in the document and captured by `@injection.language`.
///
/// This is used for markdown code fences for example. While the `LanguageId` variant can be
/// looked up by finding the language config that sets an `language_id`, this variant contains
/// text from the document being highlighted, so the text is checked against each language's
/// `injection_regex`.
Name(RopeSlice<'a>),
Filename(RopeSlice<'a>),
Shebang(RopeSlice<'a>),
}
const SHEBANG: &str = r"#!\s*(?:\S*[/\\](?:env\s+(?:\-\S+\s+)*)?)?([^\s\.\d]+)";

View file

@ -119,6 +119,7 @@ impl Client {
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// make sure the process is reaped on drop
.kill_on_drop(true)
.spawn();
@ -128,16 +129,12 @@ impl Client {
// TODO: do we need bufreader/writer here? or do we use async wrappers on unblock?
let writer = BufWriter::new(process.stdin.take().expect("Failed to open stdin"));
let reader = BufReader::new(process.stdout.take().expect("Failed to open stdout"));
let errors = process.stderr.take().map(BufReader::new);
let stderr = BufReader::new(process.stderr.take().expect("Failed to open stderr"));
Self::streams(
Box::new(BufReader::new(reader)),
Box::new(reader),
Box::new(writer),
// errors.map(|errors| Box::new(BufReader::new(errors))),
match errors {
Some(errors) => Some(Box::new(BufReader::new(errors))),
None => None,
},
Some(Box::new(stderr)),
id,
Some(process),
)

View file

@ -3,10 +3,11 @@ mod transport;
mod types;
pub use client::{Client, ConnectionType};
pub use events::Event;
pub use transport::{Payload, Response, Transport};
pub use types::*;
use serde::de::DeserializeOwned;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
@ -18,9 +19,84 @@ pub enum Error {
Timeout(u64),
#[error("server closed the stream")]
StreamClosed,
#[error("Unhandled")]
Unhandled,
#[error(transparent)]
ExecutableNotFound(#[from] helix_stdx::env::ExecutableNotFoundError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Request {
RunInTerminal(<requests::RunInTerminal as types::Request>::Arguments),
}
impl Request {
pub fn parse(command: &str, arguments: Option<serde_json::Value>) -> Result<Self> {
use crate::types::Request as _;
let arguments = arguments.unwrap_or_default();
let request = match command {
requests::RunInTerminal::COMMAND => Self::RunInTerminal(parse_value(arguments)?),
_ => return Err(Error::Unhandled),
};
Ok(request)
}
}
#[derive(Debug)]
pub enum Event {
Initialized(<events::Initialized as events::Event>::Body),
Stopped(<events::Stopped as events::Event>::Body),
Continued(<events::Continued as events::Event>::Body),
Exited(<events::Exited as events::Event>::Body),
Terminated(<events::Terminated as events::Event>::Body),
Thread(<events::Thread as events::Event>::Body),
Output(<events::Output as events::Event>::Body),
Breakpoint(<events::Breakpoint as events::Event>::Body),
Module(<events::Module as events::Event>::Body),
LoadedSource(<events::LoadedSource as events::Event>::Body),
Process(<events::Process as events::Event>::Body),
Capabilities(<events::Capabilities as events::Event>::Body),
// ProgressStart(),
// ProgressUpdate(),
// ProgressEnd(),
// Invalidated(),
Memory(<events::Memory as events::Event>::Body),
}
impl Event {
pub fn parse(event: &str, body: Option<serde_json::Value>) -> Result<Self> {
use crate::events::Event as _;
let body = body.unwrap_or_default();
let event = match event {
events::Initialized::EVENT => Self::Initialized(parse_value(body)?),
events::Stopped::EVENT => Self::Stopped(parse_value(body)?),
events::Continued::EVENT => Self::Continued(parse_value(body)?),
events::Exited::EVENT => Self::Exited(parse_value(body)?),
events::Terminated::EVENT => Self::Terminated(parse_value(body)?),
events::Thread::EVENT => Self::Thread(parse_value(body)?),
events::Output::EVENT => Self::Output(parse_value(body)?),
events::Breakpoint::EVENT => Self::Breakpoint(parse_value(body)?),
events::Module::EVENT => Self::Module(parse_value(body)?),
events::LoadedSource::EVENT => Self::LoadedSource(parse_value(body)?),
events::Process::EVENT => Self::Process(parse_value(body)?),
events::Capabilities::EVENT => Self::Capabilities(parse_value(body)?),
events::Memory::EVENT => Self::Memory(parse_value(body)?),
_ => return Err(Error::Unhandled),
};
Ok(event)
}
}
fn parse_value<T>(value: serde_json::Value) -> Result<T>
where
T: DeserializeOwned,
{
serde_json::from_value(value).map_err(|err| err.into())
}

View file

@ -1,4 +1,4 @@
use crate::{Error, Event, Result};
use crate::{Error, Result};
use anyhow::Context;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
@ -32,11 +32,17 @@ pub struct Response {
pub body: Option<Value>,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
pub struct Event {
pub event: String,
pub body: Option<Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Payload {
// type = "event"
Event(Box<Event>),
Event(Event),
// type = "response"
Response(Response),
// type = "request"
@ -230,25 +236,37 @@ impl Transport {
}
}
async fn recv_inner(
async fn recv(
transport: Arc<Self>,
mut server_stdout: Box<dyn AsyncBufRead + Unpin + Send>,
client_tx: UnboundedSender<Payload>,
) -> Result<()> {
) {
let mut recv_buffer = String::new();
loop {
let msg = Self::recv_server_message(&mut server_stdout, &mut recv_buffer).await?;
transport.process_server_message(&client_tx, msg).await?;
}
}
match Self::recv_server_message(&mut server_stdout, &mut recv_buffer).await {
Ok(msg) => match transport.process_server_message(&client_tx, msg).await {
Ok(_) => (),
Err(err) => {
error!("err: <- {err:?}");
break;
}
},
Err(err) => {
if !matches!(err, Error::StreamClosed) {
error!("Exiting after unexpected error: {err:?}");
}
async fn recv(
transport: Arc<Self>,
server_stdout: Box<dyn AsyncBufRead + Unpin + Send>,
client_tx: UnboundedSender<Payload>,
) {
if let Err(err) = Self::recv_inner(transport, server_stdout, client_tx).await {
error!("err: <- {:?}", err);
// Close any outstanding requests.
for (id, tx) in transport.pending_requests.lock().await.drain() {
match tx.send(Err(Error::StreamClosed)).await {
Ok(_) => (),
Err(_) => {
error!("Could not close request on a closed channel (id={id})");
}
}
}
}
}
}
}

View file

@ -759,33 +759,30 @@ pub mod requests {
pub mod events {
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(tag = "event", content = "body")]
// seq is omitted as unused and is not sent by some implementations
pub enum Event {
Initialized(Option<DebuggerCapabilities>),
Stopped(Stopped),
Continued(Continued),
Exited(Exited),
Terminated(Option<Terminated>),
Thread(Thread),
Output(Output),
Breakpoint(Breakpoint),
Module(Module),
LoadedSource(LoadedSource),
Process(Process),
Capabilities(Capabilities),
// ProgressStart(),
// ProgressUpdate(),
// ProgressEnd(),
// Invalidated(),
Memory(Memory),
pub trait Event {
type Body: serde::de::DeserializeOwned + serde::Serialize;
const EVENT: &'static str;
}
#[derive(Debug)]
pub enum Initialized {}
impl Event for Initialized {
type Body = Option<DebuggerCapabilities>;
const EVENT: &'static str = "initialized";
}
#[derive(Debug)]
pub enum Stopped {}
impl Event for Stopped {
type Body = StoppedBody;
const EVENT: &'static str = "stopped";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Stopped {
pub struct StoppedBody {
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
@ -801,37 +798,77 @@ pub mod events {
pub hit_breakpoint_ids: Option<Vec<usize>>,
}
#[derive(Debug)]
pub enum Continued {}
impl Event for Continued {
type Body = ContinuedBody;
const EVENT: &'static str = "continued";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Continued {
pub struct ContinuedBody {
pub thread_id: ThreadId,
#[serde(skip_serializing_if = "Option::is_none")]
pub all_threads_continued: Option<bool>,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Exited {
pub exit_code: usize,
#[derive(Debug)]
pub enum Exited {}
impl Event for Exited {
type Body = ExitedBody;
const EVENT: &'static str = "exited";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Terminated {
pub struct ExitedBody {
pub exit_code: usize,
}
#[derive(Debug)]
pub enum Terminated {}
impl Event for Terminated {
type Body = Option<TerminatedBody>;
const EVENT: &'static str = "terminated";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TerminatedBody {
#[serde(skip_serializing_if = "Option::is_none")]
pub restart: Option<Value>,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Thread {
pub reason: String,
pub thread_id: ThreadId,
#[derive(Debug)]
pub enum Thread {}
impl Event for Thread {
type Body = ThreadBody;
const EVENT: &'static str = "thread";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Output {
pub struct ThreadBody {
pub reason: String,
pub thread_id: ThreadId,
}
#[derive(Debug)]
pub enum Output {}
impl Event for Output {
type Body = OutputBody;
const EVENT: &'static str = "output";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OutputBody {
pub output: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
@ -849,30 +886,62 @@ pub mod events {
pub data: Option<Value>,
}
#[derive(Debug)]
pub enum Breakpoint {}
impl Event for Breakpoint {
type Body = BreakpointBody;
const EVENT: &'static str = "breakpoint";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Breakpoint {
pub struct BreakpointBody {
pub reason: String,
pub breakpoint: super::Breakpoint,
}
#[derive(Debug)]
pub enum Module {}
impl Event for Module {
type Body = ModuleBody;
const EVENT: &'static str = "module";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Module {
pub struct ModuleBody {
pub reason: String,
pub module: super::Module,
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadedSource {
pub reason: String,
pub source: super::Source,
#[derive(Debug)]
pub enum LoadedSource {}
impl Event for LoadedSource {
type Body = LoadedSourceBody;
const EVENT: &'static str = "loadedSource";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Process {
pub struct LoadedSourceBody {
pub reason: String,
pub source: super::Source,
}
#[derive(Debug)]
pub enum Process {}
impl Event for Process {
type Body = ProcessBody;
const EVENT: &'static str = "process";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessBody {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_process_id: Option<usize>,
@ -884,39 +953,55 @@ pub mod events {
pub pointer_size: Option<usize>,
}
#[derive(Debug)]
pub enum Capabilities {}
impl Event for Capabilities {
type Body = CapabilitiesBody;
const EVENT: &'static str = "capabilities";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Capabilities {
pub struct CapabilitiesBody {
pub capabilities: super::DebuggerCapabilities,
}
// #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
// #[serde(rename_all = "camelCase")]
// pub struct Invalidated {
// pub struct InvalidatedBody {
// pub areas: Vec<InvalidatedArea>,
// pub thread_id: Option<ThreadId>,
// pub stack_frame_id: Option<usize>,
// }
#[derive(Debug)]
pub enum Memory {}
impl Event for Memory {
type Body = MemoryBody;
const EVENT: &'static str = "memory";
}
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Memory {
pub struct MemoryBody {
pub memory_reference: String,
pub offset: usize,
pub count: usize,
}
#[test]
fn test_deserialize_module_id_from_number() {
let raw = r#"{"id": 0, "name": "Name"}"#;
let module: super::Module = serde_json::from_str(raw).expect("Error!");
assert_eq!(module.id, "0");
}
#[test]
fn test_deserialize_module_id_from_string() {
let raw = r#"{"id": "0", "name": "Name"}"#;
let module: super::Module = serde_json::from_str(raw).expect("Error!");
assert_eq!(module.id, "0");
}
}
#[test]
fn test_deserialize_module_id_from_number() {
let raw = r#"{"id": 0, "name": "Name"}"#;
let module: Module = serde_json::from_str(raw).expect("Error!");
assert_eq!(module.id, "0");
}
#[test]
fn test_deserialize_module_id_from_string() {
let raw = r#"{"id": "0", "name": "Name"}"#;
let module: Module = serde_json::from_str(raw).expect("Error!");
assert_eq!(module.id, "0");
}

View file

@ -12,14 +12,14 @@ homepage.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
ahash = "0.8.11"
hashbrown = "0.14.5"
foldhash.workspace = true
hashbrown = "0.15"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot", "macros"] }
# the event registry is essentially read only but must be an rwlock so we can
# setup new events on initialization, hardware-lock-elision hugely benefits this case
# as it essentially makes the lock entirely free as long as there is no writes
parking_lot = { version = "0.12", features = ["hardware-lock-elision"] }
once_cell = "1.20"
parking_lot = { workspace = true, features = ["hardware-lock-elision"] }
once_cell = "1.21"
anyhow = "1"
log = "0.4"

View file

@ -14,8 +14,8 @@ use crate::hook::ErasedHook;
use crate::runtime_local;
pub struct Registry {
events: HashMap<&'static str, TypeId, ahash::RandomState>,
handlers: HashMap<&'static str, Vec<ErasedHook>, ahash::RandomState>,
events: HashMap<&'static str, TypeId, foldhash::fast::FixedState>,
handlers: HashMap<&'static str, Vec<ErasedHook>, foldhash::fast::FixedState>,
}
impl Registry {
@ -105,8 +105,8 @@ runtime_local! {
static REGISTRY: RwLock<Registry> = RwLock::new(Registry {
// hardcoded random number is good enough here we don't care about DOS resistance
// and avoids the additional complexity of `Option<Registry>`
events: HashMap::with_hasher(ahash::RandomState::with_seeds(423, 9978, 38322, 3280080)),
handlers: HashMap::with_hasher(ahash::RandomState::with_seeds(423, 99078, 382322, 3282938)),
events: HashMap::with_hasher(foldhash::fast::FixedState::with_seed(72536814787)),
handlers: HashMap::with_hasher(foldhash::fast::FixedState::with_seed(72536814787)),
});
}

View file

@ -41,8 +41,9 @@ macro_rules! runtime_local {
#[cfg(feature = "integration_test")]
pub struct RuntimeLocal<T: 'static> {
data:
parking_lot::RwLock<hashbrown::HashMap<tokio::runtime::Id, &'static T, ahash::RandomState>>,
data: parking_lot::RwLock<
hashbrown::HashMap<tokio::runtime::Id, &'static T, foldhash::fast::FixedState>,
>,
init: fn() -> T,
}
@ -53,7 +54,7 @@ impl<T> RuntimeLocal<T> {
pub const fn __new(init: fn() -> T) -> Self {
Self {
data: parking_lot::RwLock::new(hashbrown::HashMap::with_hasher(
ahash::RandomState::with_seeds(423, 9978, 38322, 3280080),
foldhash::fast::FixedState::with_seed(12345678910),
)),
init,
}

View file

@ -20,9 +20,9 @@ helix-stdx = { path = "../helix-stdx" }
anyhow = "1"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
etcetera = "0.8"
etcetera = "0.10"
tree-sitter.workspace = true
once_cell = "1.20"
once_cell = "1.21"
log = "0.4"
# TODO: these two should be on !wasm32 only
@ -30,8 +30,7 @@ log = "0.4"
# cloning/compiling tree-sitter grammars
cc = { version = "1" }
threadpool = { version = "1.0" }
tempfile = "3.14.0"
dunce = "1.0.5"
tempfile.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
libloading = "0.8"

View file

@ -6,14 +6,6 @@ const MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR");
const MINOR: &str = env!("CARGO_PKG_VERSION_MINOR");
const PATCH: &str = env!("CARGO_PKG_VERSION_PATCH");
fn get_calver() -> String {
if PATCH == "0" {
format!("{MAJOR}.{MINOR}")
} else {
format!("{MAJOR}.{MINOR}.{PATCH}")
}
}
fn main() {
let git_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
@ -23,7 +15,17 @@ fn main() {
.and_then(|x| String::from_utf8(x.stdout).ok())
.or_else(|| option_env!("HELIX_NIX_BUILD_REV").map(|s| s.to_string()));
let calver = get_calver();
let minor = if MINOR.len() == 1 {
// Print single-digit months in '0M' format
format!("0{MINOR}")
} else {
MINOR.to_string()
};
let calver = if PATCH == "0" {
format!("{MAJOR}.{minor}")
} else {
format!("{MAJOR}.{minor}.{PATCH}")
};
let version: Cow<_> = match &git_hash {
Some(git_hash) => format!("{} ({})", calver, &git_hash[..8]).into(),
None => calver.into(),

View file

@ -273,12 +273,12 @@ fn fetch_grammar(grammar: GrammarConfiguration) -> Result<FetchStatus> {
}
// ensure the remote matches the configured remote
if get_remote_url(&grammar_dir).map_or(true, |s| s != remote) {
if get_remote_url(&grammar_dir).as_ref() != Some(&remote) {
set_remote(&grammar_dir, &remote)?;
}
// ensure the revision matches the configured revision
if get_revision(&grammar_dir).map_or(true, |s| s != revision) {
if get_revision(&grammar_dir).as_ref() != Some(&revision) {
// Fetch the exact revision from the remote.
// Supported by server-side git since v2.5.0 (July 2015),
// enabled by default on major git hosts.
@ -496,9 +496,11 @@ fn build_tree_sitter_library(
.arg("/link")
.arg(format!("/out:{}", library_path.to_str().unwrap()));
} else {
#[cfg(not(windows))]
command.arg("-fPIC");
command
.arg("-shared")
.arg("-fPIC")
.arg("-fno-exceptions")
.arg("-I")
.arg(header_path)
@ -517,8 +519,11 @@ fn build_tree_sitter_library(
cpp_command.args(compiler.args());
let object_file =
library_path.with_file_name(format!("{}_scanner.o", &grammar.grammar_id));
#[cfg(not(windows))]
cpp_command.arg("-fPIC");
cpp_command
.arg("-fPIC")
.arg("-fno-exceptions")
.arg("-I")
.arg(header_path)
@ -592,6 +597,6 @@ fn mtime(path: &Path) -> Result<SystemTime> {
/// Gives the contents of a file from a language's `runtime/queries/<lang>`
/// directory
pub fn load_runtime_file(language: &str, filename: &str) -> Result<String, std::io::Error> {
let path = crate::runtime_file(&PathBuf::new().join("queries").join(language).join(filename));
let path = crate::runtime_file(PathBuf::new().join("queries").join(language).join(filename));
std::fs::read_to_string(path)
}

View file

@ -107,8 +107,8 @@ fn find_runtime_file(rel_path: &Path) -> Option<PathBuf> {
/// The valid runtime directories are searched in priority order and the first
/// file found to exist is returned, otherwise the path to the final attempt
/// that failed.
pub fn runtime_file(rel_path: &Path) -> PathBuf {
find_runtime_file(rel_path).unwrap_or_else(|| {
pub fn runtime_file(rel_path: impl AsRef<Path>) -> PathBuf {
find_runtime_file(rel_path.as_ref()).unwrap_or_else(|| {
RUNTIME_DIRS
.last()
.map(|dir| dir.join(rel_path))

View file

@ -21,10 +21,9 @@ keywords = ["language", "server", "lsp", "vscode", "lsif"]
license = "MIT"
[dependencies]
bitflags = "2.6.0"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.134"
serde_repr = "0.1"
bitflags.workspace = true
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
url = {version = "2.5.4", features = ["serde"]}
[features]

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::{collections::HashMap, sync::Arc};
use serde::{Deserialize, Serialize};
@ -33,8 +33,13 @@ pub struct DiagnosticClientCapabilities {
pub struct DiagnosticOptions {
/// An optional identifier under which the diagnostics are
/// managed by the client.
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_option_arc_str",
deserialize_with = "deserialize_option_arc_str"
)]
pub identifier: Option<Arc<str>>,
/// Whether the language has inter file dependencies, meaning that editing code in one file can
/// result in a different diagnostic set in another file. Inter file dependencies are common
@ -48,6 +53,19 @@ pub struct DiagnosticOptions {
pub work_done_progress_options: WorkDoneProgressOptions,
}
fn serialize_option_arc_str<S: serde::Serializer>(
val: &Option<Arc<str>>,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(val.as_ref().unwrap())
}
fn deserialize_option_arc_str<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Arc<str>>, D::Error> {
Option::<String>::deserialize(deserializer).map(|opt| opt.map(|s| s.into()))
}
/// Diagnostic registration options.
///
/// @since 3.17.0
@ -81,7 +99,13 @@ pub struct DocumentDiagnosticParams {
pub text_document: TextDocumentIdentifier,
/// The additional identifier provided during registration.
pub identifier: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_option_arc_str",
deserialize_with = "deserialize_option_arc_str"
)]
pub identifier: Option<Arc<str>>,
/// The result ID of a previous response if provided.
pub previous_result_id: Option<String>,

View file

@ -16,19 +16,18 @@ homepage.workspace = true
helix-stdx = { path = "../helix-stdx" }
helix-core = { path = "../helix-core" }
helix-loader = { path = "../helix-loader" }
helix-parsec = { path = "../helix-parsec" }
helix-lsp-types = { path = "../helix-lsp-types" }
anyhow = "1.0"
futures-executor = "0.3"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
globset = "0.4.15"
globset = "0.4.16"
log = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.42", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio = { version = "1.44", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio-stream = "0.1.17"
parking_lot = "0.12.3"
parking_lot.workspace = true
arc-swap = "1"
slotmap.workspace = true
thiserror.workspace = true

View file

@ -16,11 +16,14 @@ use helix_stdx::path;
use parking_lot::Mutex;
use serde::Deserialize;
use serde_json::Value;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use std::{collections::HashMap, path::PathBuf};
use std::{
ffi::OsStr,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
};
use std::{future::Future, sync::OnceLock};
use std::{path::Path, process::Stdio};
use tokio::{
@ -85,7 +88,7 @@ impl Client {
.and_then(|root| lsp::Url::from_file_path(root).ok());
if self.root_path == root.unwrap_or(workspace)
|| root_uri.as_ref().map_or(false, |root_uri| {
|| root_uri.as_ref().is_some_and(|root_uri| {
self.workspace_folders
.lock()
.iter()
@ -170,7 +173,7 @@ impl Client {
// and that we can therefore reuse the client (but are done now)
return;
}
tokio::spawn(self.did_change_workspace(vec![workspace_for_uri(root_uri)], Vec::new()));
self.did_change_workspace(vec![workspace_for_uri(root_uri)], Vec::new())
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
@ -178,7 +181,7 @@ impl Client {
cmd: &str,
args: &[String],
config: Option<Value>,
server_environment: HashMap<String, String>,
server_environment: impl IntoIterator<Item = (impl AsRef<OsStr>, impl AsRef<OsStr>)>,
root_path: PathBuf,
root_uri: Option<lsp::Url>,
id: LanguageServerId,
@ -345,6 +348,7 @@ impl Client {
Some(OneOf::Left(true) | OneOf::Right(_))
),
LanguageServerFeature::Diagnostics => true, // there's no extra server capability
LanguageServerFeature::PullDiagnostics => capabilities.diagnostic_provider.is_some(),
LanguageServerFeature::RenameSymbol => matches!(
capabilities.rename_provider,
Some(OneOf::Left(true)) | Some(OneOf::Right(_))
@ -353,6 +357,7 @@ impl Client {
capabilities.inlay_hint_provider,
Some(OneOf::Left(true) | OneOf::Right(InlayHintServerCapabilities::Options(_)))
),
LanguageServerFeature::DocumentColors => capabilities.color_provider.is_some(),
}
}
@ -382,23 +387,11 @@ impl Client {
self.workspace_folders.lock()
}
/// Execute a RPC request on the language server.
async fn request<R: lsp::request::Request>(&self, params: R::Params) -> Result<R::Result>
where
R::Params: serde::Serialize,
R::Result: core::fmt::Debug, // TODO: temporary
{
// a future that resolves into the response
let json = self.call::<R>(params).await?;
let response = serde_json::from_value(json)?;
Ok(response)
}
/// Execute a RPC request on the language server.
fn call<R: lsp::request::Request>(
&self,
params: R::Params,
) -> impl Future<Output = Result<Value>>
) -> impl Future<Output = Result<R::Result>>
where
R::Params: serde::Serialize,
{
@ -408,7 +401,7 @@ impl Client {
fn call_with_ref<R: lsp::request::Request>(
&self,
params: &R::Params,
) -> impl Future<Output = Result<Value>>
) -> impl Future<Output = Result<R::Result>>
where
R::Params: serde::Serialize,
{
@ -419,66 +412,77 @@ impl Client {
&self,
params: &R::Params,
timeout_secs: u64,
) -> impl Future<Output = Result<Value>>
) -> impl Future<Output = Result<R::Result>>
where
R::Params: serde::Serialize,
{
let server_tx = self.server_tx.clone();
let id = self.next_request_id();
let params = serde_json::to_value(params);
// It's important that this is not part of the future so that it gets executed right away
// and the request order stays consistent.
let rx = serde_json::to_value(params)
.map_err(Error::from)
.and_then(|params| {
let request = jsonrpc::MethodCall {
jsonrpc: Some(jsonrpc::Version::V2),
id: id.clone(),
method: R::METHOD.to_string(),
params: Self::value_into_params(params),
};
let (tx, rx) = channel::<Result<Value>>(1);
server_tx
.send(Payload::Request {
chan: tx,
value: request,
})
.map_err(|e| Error::Other(e.into()))?;
Ok(rx)
});
async move {
use std::time::Duration;
use tokio::time::timeout;
let request = jsonrpc::MethodCall {
jsonrpc: Some(jsonrpc::Version::V2),
id: id.clone(),
method: R::METHOD.to_string(),
params: Self::value_into_params(params?),
};
let (tx, mut rx) = channel::<Result<Value>>(1);
server_tx
.send(Payload::Request {
chan: tx,
value: request,
})
.map_err(|e| Error::Other(e.into()))?;
// TODO: delay other calls until initialize success
timeout(Duration::from_secs(timeout_secs), rx.recv())
timeout(Duration::from_secs(timeout_secs), rx?.recv())
.await
.map_err(|_| Error::Timeout(id))? // return Timeout
.ok_or(Error::StreamClosed)?
.and_then(|value| serde_json::from_value(value).map_err(Into::into))
}
}
/// Send a RPC notification to the language server.
pub fn notify<R: lsp::notification::Notification>(
&self,
params: R::Params,
) -> impl Future<Output = Result<()>>
pub fn notify<R: lsp::notification::Notification>(&self, params: R::Params)
where
R::Params: serde::Serialize,
{
let server_tx = self.server_tx.clone();
async move {
let params = serde_json::to_value(params)?;
let params = match serde_json::to_value(params) {
Ok(params) => params,
Err(err) => {
log::error!(
"Failed to serialize params for notification '{}' for server '{}': {err}",
R::METHOD,
self.name,
);
return;
}
};
let notification = jsonrpc::Notification {
jsonrpc: Some(jsonrpc::Version::V2),
method: R::METHOD.to_string(),
params: Self::value_into_params(params),
};
let notification = jsonrpc::Notification {
jsonrpc: Some(jsonrpc::Version::V2),
method: R::METHOD.to_string(),
params: Self::value_into_params(params),
};
server_tx
.send(Payload::Notification(notification))
.map_err(|e| Error::Other(e.into()))?;
Ok(())
if let Err(err) = server_tx.send(Payload::Notification(notification)) {
log::error!(
"Failed to send notification '{}' to server '{}': {err}",
R::METHOD,
self.name
);
}
}
@ -487,31 +491,29 @@ impl Client {
&self,
id: jsonrpc::Id,
result: core::result::Result<Value, jsonrpc::Error>,
) -> impl Future<Output = Result<()>> {
) -> Result<()> {
use jsonrpc::{Failure, Output, Success, Version};
let server_tx = self.server_tx.clone();
async move {
let output = match result {
Ok(result) => Output::Success(Success {
jsonrpc: Some(Version::V2),
id,
result: serde_json::to_value(result)?,
}),
Err(error) => Output::Failure(Failure {
jsonrpc: Some(Version::V2),
id,
error,
}),
};
let output = match result {
Ok(result) => Output::Success(Success {
jsonrpc: Some(Version::V2),
id,
result,
}),
Err(error) => Output::Failure(Failure {
jsonrpc: Some(Version::V2),
id,
error,
}),
};
server_tx
.send(Payload::Response(output))
.map_err(|e| Error::Other(e.into()))?;
server_tx
.send(Payload::Response(output))
.map_err(|e| Error::Other(e.into()))?;
Ok(())
}
Ok(())
}
// -------------------------------------------------------------------------------------------
@ -570,6 +572,9 @@ impl Client {
did_rename: Some(true),
..Default::default()
}),
diagnostic: Some(lsp::DiagnosticWorkspaceClientCapabilities {
refresh_support: Some(true),
}),
..Default::default()
}),
text_document: Some(lsp::TextDocumentClientCapabilities {
@ -647,6 +652,10 @@ impl Client {
}),
..Default::default()
}),
diagnostic: Some(lsp::DiagnosticClientCapabilities {
dynamic_registration: Some(false),
related_document_support: Some(true),
}),
publish_diagnostics: Some(lsp::PublishDiagnosticsClientCapabilities {
version_support: Some(true),
tag_support: Some(lsp::TagSupport {
@ -686,14 +695,14 @@ impl Client {
work_done_progress_params: lsp::WorkDoneProgressParams::default(),
};
self.request::<lsp::request::Initialize>(params).await
self.call::<lsp::request::Initialize>(params).await
}
pub async fn shutdown(&self) -> Result<()> {
self.request::<lsp::request::Shutdown>(()).await
self.call::<lsp::request::Shutdown>(()).await
}
pub fn exit(&self) -> impl Future<Output = Result<()>> {
pub fn exit(&self) {
self.notify::<lsp::notification::Exit>(())
}
@ -701,7 +710,8 @@ impl Client {
/// early if server responds with an error.
pub async fn shutdown_and_exit(&self) -> Result<()> {
self.shutdown().await?;
self.exit().await
self.exit();
Ok(())
}
/// Forcefully shuts down the language server ignoring any errors.
@ -709,24 +719,21 @@ impl Client {
if let Err(e) = self.shutdown().await {
log::warn!("language server failed to terminate gracefully - {}", e);
}
self.exit().await
self.exit();
Ok(())
}
// -------------------------------------------------------------------------------------------
// Workspace
// -------------------------------------------------------------------------------------------
pub fn did_change_configuration(&self, settings: Value) -> impl Future<Output = Result<()>> {
pub fn did_change_configuration(&self, settings: Value) {
self.notify::<lsp::notification::DidChangeConfiguration>(
lsp::DidChangeConfigurationParams { settings },
)
}
pub fn did_change_workspace(
&self,
added: Vec<WorkspaceFolder>,
removed: Vec<WorkspaceFolder>,
) -> impl Future<Output = Result<()>> {
pub fn did_change_workspace(&self, added: Vec<WorkspaceFolder>, removed: Vec<WorkspaceFolder>) {
self.notify::<DidChangeWorkspaceFolders>(DidChangeWorkspaceFoldersParams {
event: WorkspaceFoldersChangeEvent { added, removed },
})
@ -737,7 +744,7 @@ impl Client {
old_path: &Path,
new_path: &Path,
is_dir: bool,
) -> Option<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
) -> Option<impl Future<Output = Result<Option<lsp::WorkspaceEdit>>>> {
let capabilities = self.file_operations_intests();
if !capabilities.will_rename.has_interest(old_path, is_dir) {
return None;
@ -754,24 +761,13 @@ impl Client {
old_uri: url_from_path(old_path)?,
new_uri: url_from_path(new_path)?,
}];
let request = self.call_with_timeout::<lsp::request::WillRenameFiles>(
Some(self.call_with_timeout::<lsp::request::WillRenameFiles>(
&lsp::RenameFilesParams { files },
5,
);
Some(async move {
let json = request.await?;
let response: Option<lsp::WorkspaceEdit> = serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
})
))
}
pub fn did_rename(
&self,
old_path: &Path,
new_path: &Path,
is_dir: bool,
) -> Option<impl Future<Output = std::result::Result<(), Error>>> {
pub fn did_rename(&self, old_path: &Path, new_path: &Path, is_dir: bool) -> Option<()> {
let capabilities = self.file_operations_intests();
if !capabilities.did_rename.has_interest(new_path, is_dir) {
return None;
@ -789,7 +785,8 @@ impl Client {
old_uri: url_from_path(old_path)?,
new_uri: url_from_path(new_path)?,
}];
Some(self.notify::<lsp::notification::DidRenameFiles>(lsp::RenameFilesParams { files }))
self.notify::<lsp::notification::DidRenameFiles>(lsp::RenameFilesParams { files });
Some(())
}
// -------------------------------------------------------------------------------------------
@ -802,7 +799,7 @@ impl Client {
version: i32,
doc: &Rope,
language_id: String,
) -> impl Future<Output = Result<()>> {
) {
self.notify::<lsp::notification::DidOpenTextDocument>(lsp::DidOpenTextDocumentParams {
text_document: lsp::TextDocumentItem {
uri,
@ -929,7 +926,7 @@ impl Client {
old_text: &Rope,
new_text: &Rope,
changes: &ChangeSet,
) -> Option<impl Future<Output = Result<()>>> {
) -> Option<()> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support document sync.
@ -961,18 +958,14 @@ impl Client {
kind => unimplemented!("{:?}", kind),
};
Some(self.notify::<lsp::notification::DidChangeTextDocument>(
lsp::DidChangeTextDocumentParams {
text_document,
content_changes: changes,
},
))
self.notify::<lsp::notification::DidChangeTextDocument>(lsp::DidChangeTextDocumentParams {
text_document,
content_changes: changes,
});
Some(())
}
pub fn text_document_did_close(
&self,
text_document: lsp::TextDocumentIdentifier,
) -> impl Future<Output = Result<()>> {
pub fn text_document_did_close(&self, text_document: lsp::TextDocumentIdentifier) {
self.notify::<lsp::notification::DidCloseTextDocument>(lsp::DidCloseTextDocumentParams {
text_document,
})
@ -984,7 +977,7 @@ impl Client {
&self,
text_document: lsp::TextDocumentIdentifier,
text: &Rope,
) -> Option<impl Future<Output = Result<()>>> {
) -> Option<()> {
let capabilities = self.capabilities.get().unwrap();
let include_text = match &capabilities.text_document_sync.as_ref()? {
@ -1002,12 +995,11 @@ impl Client {
lsp::TextDocumentSyncCapability::Kind(..) => false,
};
Some(self.notify::<lsp::notification::DidSaveTextDocument>(
lsp::DidSaveTextDocumentParams {
text_document,
text: include_text.then_some(text.into()),
},
))
self.notify::<lsp::notification::DidSaveTextDocument>(lsp::DidSaveTextDocumentParams {
text_document,
text: include_text.then_some(text.into()),
});
Some(())
}
pub fn completion(
@ -1016,7 +1008,7 @@ impl Client {
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
context: lsp::CompletionContext,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<lsp::CompletionResponse>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support completion.
@ -1042,14 +1034,13 @@ impl Client {
&self,
completion_item: &lsp::CompletionItem,
) -> impl Future<Output = Result<lsp::CompletionItem>> {
let res = self.call_with_ref::<lsp::request::ResolveCompletionItem>(completion_item);
async move { Ok(serde_json::from_value(res.await?)?) }
self.call_with_ref::<lsp::request::ResolveCompletionItem>(completion_item)
}
pub fn resolve_code_action(
&self,
code_action: lsp::CodeAction,
) -> Option<impl Future<Output = Result<Value>>> {
code_action: &lsp::CodeAction,
) -> Option<impl Future<Output = Result<lsp::CodeAction>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support resolving code actions.
@ -1061,7 +1052,7 @@ impl Client {
_ => return None,
}
Some(self.call::<lsp::request::CodeActionResolveRequest>(code_action))
Some(self.call_with_ref::<lsp::request::CodeActionResolveRequest>(code_action))
}
pub fn text_document_signature_help(
@ -1085,8 +1076,7 @@ impl Client {
// lsp::SignatureHelpContext
};
let res = self.call::<lsp::request::SignatureHelpRequest>(params);
Some(async move { Ok(serde_json::from_value(res.await?)?) })
Some(self.call::<lsp::request::SignatureHelpRequest>(params))
}
pub fn text_document_range_inlay_hints(
@ -1094,7 +1084,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
range: lsp::Range,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<Vec<lsp::InlayHint>>>>> {
let capabilities = self.capabilities.get().unwrap();
match capabilities.inlay_hint_provider {
@ -1114,12 +1104,31 @@ impl Client {
Some(self.call::<lsp::request::InlayHintRequest>(params))
}
pub fn text_document_document_color(
&self,
text_document: lsp::TextDocumentIdentifier,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Vec<lsp::ColorInformation>>>> {
self.capabilities.get().unwrap().color_provider.as_ref()?;
let params = lsp::DocumentColorParams {
text_document,
work_done_progress_params: lsp::WorkDoneProgressParams {
work_done_token: work_done_token.clone(),
},
partial_result_params: helix_lsp_types::PartialResultParams {
partial_result_token: work_done_token,
},
};
Some(self.call::<lsp::request::DocumentColor>(params))
}
pub fn text_document_hover(
&self,
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<lsp::Hover>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support hover.
@ -1150,7 +1159,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
options: lsp::FormattingOptions,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> {
) -> Option<impl Future<Output = Result<Option<Vec<lsp::TextEdit>>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support formatting.
@ -1183,13 +1192,7 @@ impl Client {
work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
};
let request = self.call::<lsp::request::Formatting>(params);
Some(async move {
let json = request.await?;
let response: Option<Vec<lsp::TextEdit>> = serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
})
Some(self.call::<lsp::request::Formatting>(params))
}
pub fn text_document_range_formatting(
@ -1198,7 +1201,7 @@ impl Client {
range: lsp::Range,
options: lsp::FormattingOptions,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Vec<lsp::TextEdit>>>> {
) -> Option<impl Future<Output = Result<Option<Vec<lsp::TextEdit>>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support range formatting.
@ -1214,13 +1217,33 @@ impl Client {
work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
};
let request = self.call::<lsp::request::RangeFormatting>(params);
Some(self.call::<lsp::request::RangeFormatting>(params))
}
Some(async move {
let json = request.await?;
let response: Option<Vec<lsp::TextEdit>> = serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
})
pub fn text_document_diagnostic(
&self,
text_document: lsp::TextDocumentIdentifier,
previous_result_id: Option<String>,
) -> Option<impl Future<Output = Result<lsp::DocumentDiagnosticReportResult>>> {
let capabilities = self.capabilities();
// Return early if the server does not support pull diagnostic.
let identifier = match capabilities.diagnostic_provider.as_ref()? {
lsp::DiagnosticServerCapabilities::Options(cap) => cap.identifier.clone(),
lsp::DiagnosticServerCapabilities::RegistrationOptions(cap) => {
cap.diagnostic_options.identifier.clone()
}
};
let params = lsp::DocumentDiagnosticParams {
text_document,
identifier,
previous_result_id,
work_done_progress_params: lsp::WorkDoneProgressParams::default(),
partial_result_params: lsp::PartialResultParams::default(),
};
Some(self.call::<lsp::request::DocumentDiagnosticRequest>(params))
}
pub fn text_document_document_highlight(
@ -1228,7 +1251,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<Vec<lsp::DocumentHighlight>>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support document highlight.
@ -1261,7 +1284,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> impl Future<Output = Result<Value>> {
) -> impl Future<Output = Result<T::Result>> {
let params = lsp::GotoDefinitionParams {
text_document_position_params: lsp::TextDocumentPositionParams {
text_document,
@ -1281,7 +1304,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<lsp::GotoDefinitionResponse>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-definition.
@ -1302,7 +1325,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<lsp::GotoDefinitionResponse>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-declaration.
@ -1327,7 +1350,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<lsp::GotoDefinitionResponse>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-type-definition.
@ -1351,7 +1374,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<lsp::GotoDefinitionResponse>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-definition.
@ -1376,7 +1399,7 @@ impl Client {
position: lsp::Position,
include_declaration: bool,
work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<Vec<lsp::Location>>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support goto-reference.
@ -1405,7 +1428,7 @@ impl Client {
pub fn document_symbols(
&self,
text_document: lsp::TextDocumentIdentifier,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<lsp::DocumentSymbolResponse>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support document symbols.
@ -1427,7 +1450,7 @@ impl Client {
&self,
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<lsp::PrepareRenameResponse>>>> {
let capabilities = self.capabilities.get().unwrap();
match capabilities.rename_provider {
@ -1447,7 +1470,10 @@ impl Client {
}
// empty string to get all symbols
pub fn workspace_symbols(&self, query: String) -> Option<impl Future<Output = Result<Value>>> {
pub fn workspace_symbols(
&self,
query: String,
) -> Option<impl Future<Output = Result<Option<lsp::WorkspaceSymbolResponse>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support workspace symbols.
@ -1470,7 +1496,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
range: lsp::Range,
context: lsp::CodeActionContext,
) -> Option<impl Future<Output = Result<Value>>> {
) -> Option<impl Future<Output = Result<Option<Vec<lsp::CodeActionOrCommand>>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support code actions.
@ -1498,7 +1524,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier,
position: lsp::Position,
new_name: String,
) -> Option<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
) -> Option<impl Future<Output = Result<Option<lsp::WorkspaceEdit>>>> {
if !self.supports_feature(LanguageServerFeature::RenameSymbol) {
return None;
}
@ -1514,16 +1540,13 @@ impl Client {
},
};
let request = self.call::<lsp::request::Rename>(params);
Some(async move {
let json = request.await?;
let response: Option<lsp::WorkspaceEdit> = serde_json::from_value(json)?;
Ok(response.unwrap_or_default())
})
Some(self.call::<lsp::request::Rename>(params))
}
pub fn command(&self, command: lsp::Command) -> Option<impl Future<Output = Result<Value>>> {
pub fn command(
&self,
command: lsp::Command,
) -> Option<impl Future<Output = Result<Option<Value>>>> {
let capabilities = self.capabilities.get().unwrap();
// Return early if the language server does not support executing commands.
@ -1540,10 +1563,7 @@ impl Client {
Some(self.call::<lsp::request::ExecuteCommand>(params))
}
pub fn did_change_watched_files(
&self,
changes: Vec<lsp::FileEvent>,
) -> impl Future<Output = std::result::Result<(), Error>> {
pub fn did_change_watched_files(&self, changes: Vec<lsp::FileEvent>) {
self.notify::<lsp::notification::DidChangeWatchedFiles>(lsp::DidChangeWatchedFilesParams {
changes,
})

View file

@ -113,17 +113,13 @@ impl Handler {
"Sending didChangeWatchedFiles notification to client '{}'",
client.name()
);
if let Err(err) = crate::block_on(client
.did_change_watched_files(vec![lsp::FileEvent {
uri,
// We currently always send the CHANGED state
// since we don't actually have more context at
// the moment.
typ: lsp::FileChangeType::CHANGED,
}]))
{
log::warn!("Failed to send didChangeWatchedFiles notification to client: {err}");
}
client.did_change_watched_files(vec![lsp::FileEvent {
uri,
// We currently always send the CHANGED state
// since we don't actually have more context at
// the moment.
typ: lsp::FileChangeType::CHANGED,
}]);
true
});
}

View file

@ -463,6 +463,7 @@ pub enum MethodCall {
RegisterCapability(lsp::RegistrationParams),
UnregisterCapability(lsp::UnregistrationParams),
ShowDocument(lsp::ShowDocumentParams),
WorkspaceDiagnosticRefresh,
}
impl MethodCall {
@ -494,6 +495,7 @@ impl MethodCall {
let params: lsp::ShowDocumentParams = params.parse()?;
Self::ShowDocument(params)
}
lsp::request::WorkspaceDiagnosticRefresh::METHOD => Self::WorkspaceDiagnosticRefresh,
_ => {
return Err(Error::Unhandled);
}
@ -618,51 +620,45 @@ impl Registry {
Ok(self.inner[id].clone())
}
/// If this method is called, all documents that have a reference to language servers used by the language config have to refresh their language servers,
/// as it could be that language servers of these documents were stopped by this method.
/// If this method is called, all documents that have a reference to the language server have to refresh their language servers,
/// See helix_view::editor::Editor::refresh_language_servers
pub fn restart(
pub fn restart_server(
&mut self,
name: &str,
language_config: &LanguageConfiguration,
doc_path: Option<&std::path::PathBuf>,
root_dirs: &[PathBuf],
enable_snippets: bool,
) -> Result<Vec<Arc<Client>>> {
language_config
.language_servers
.iter()
.filter_map(|LanguageServerFeatures { name, .. }| {
if let Some(old_clients) = self.inner_by_name.remove(name) {
if old_clients.is_empty() {
log::info!("restarting client for '{name}' which was manually stopped");
} else {
log::info!("stopping existing clients for '{name}'");
}
for old_client in old_clients {
self.file_event_handler.remove_client(old_client.id());
self.inner.remove(old_client.id());
tokio::spawn(async move {
let _ = old_client.force_shutdown().await;
});
}
}
let client = match self.start_client(
name.clone(),
language_config,
doc_path,
root_dirs,
enable_snippets,
) {
Ok(client) => client,
Err(StartupError::NoRequiredRootFound) => return None,
Err(StartupError::Error(err)) => return Some(Err(err)),
};
self.inner_by_name
.insert(name.to_owned(), vec![client.clone()]);
) -> Option<Result<Arc<Client>>> {
if let Some(old_clients) = self.inner_by_name.remove(name) {
if old_clients.is_empty() {
log::info!("restarting client for '{name}' which was manually stopped");
} else {
log::info!("stopping existing clients for '{name}'");
}
for old_client in old_clients {
self.file_event_handler.remove_client(old_client.id());
self.inner.remove(old_client.id());
tokio::spawn(async move {
let _ = old_client.force_shutdown().await;
});
}
}
let client = match self.start_client(
name.to_string(),
language_config,
doc_path,
root_dirs,
enable_snippets,
) {
Ok(client) => client,
Err(StartupError::NoRequiredRootFound) => return None,
Err(StartupError::Error(err)) => return Some(Err(err)),
};
self.inner_by_name
.insert(name.to_owned(), vec![client.clone()]);
Some(Ok(client))
})
.collect()
Some(Ok(client))
}
pub fn stop(&mut self, name: &str) {
@ -739,14 +735,17 @@ impl Registry {
#[derive(Debug)]
pub enum ProgressStatus {
Created,
Started(lsp::WorkDoneProgress),
Started {
title: String,
progress: lsp::WorkDoneProgress,
},
}
impl ProgressStatus {
pub fn progress(&self) -> Option<&lsp::WorkDoneProgress> {
match &self {
ProgressStatus::Created => None,
ProgressStatus::Started(progress) => Some(progress),
ProgressStatus::Started { title: _, progress } => Some(progress),
}
}
}
@ -783,6 +782,13 @@ impl LspProgressMap {
self.0.get(&id).and_then(|values| values.get(token))
}
pub fn title(&self, id: LanguageServerId, token: &lsp::ProgressToken) -> Option<&String> {
self.progress(id, token).and_then(|p| match p {
ProgressStatus::Created => None,
ProgressStatus::Started { title, .. } => Some(title),
})
}
/// Checks if progress `token` for server with `id` is created.
pub fn is_created(&mut self, id: LanguageServerId, token: &lsp::ProgressToken) -> bool {
self.0
@ -807,17 +813,39 @@ impl LspProgressMap {
self.0.get_mut(&id).and_then(|vals| vals.remove(token))
}
/// Updates the progress of `token` for server with `id` to `status`, returns the value replaced or `None`.
/// Updates the progress of `token` for server with `id` to begin state `status`
pub fn begin(
&mut self,
id: LanguageServerId,
token: lsp::ProgressToken,
status: lsp::WorkDoneProgressBegin,
) {
self.0.entry(id).or_default().insert(
token,
ProgressStatus::Started {
title: status.title.clone(),
progress: lsp::WorkDoneProgress::Begin(status),
},
);
}
/// Updates the progress of `token` for server with `id` to report state `status`.
pub fn update(
&mut self,
id: LanguageServerId,
token: lsp::ProgressToken,
status: lsp::WorkDoneProgress,
) -> Option<ProgressStatus> {
status: lsp::WorkDoneProgressReport,
) {
self.0
.entry(id)
.or_default()
.insert(token, ProgressStatus::Started(status))
.entry(token)
.and_modify(|e| match e {
ProgressStatus::Created => (),
ProgressStatus::Started { progress, .. } => {
*progress = lsp::WorkDoneProgress::Report(status)
}
});
}
}
@ -877,7 +905,7 @@ fn start_client(
&ls_config.command,
&ls_config.args,
ls_config.config.clone(),
ls_config.environment.clone(),
&ls_config.environment,
root_path,
root_uri,
id,
@ -906,17 +934,7 @@ fn start_client(
}
// next up, notify<initialized>
let notification_result = _client
.notify::<lsp::notification::Initialized>(lsp::InitializedParams {})
.await;
if let Err(e) = notification_result {
log::error!(
"failed to notify language server of its initialization: {}",
e
);
return;
}
_client.notify::<lsp::notification::Initialized>(lsp::InitializedParams {});
initialize_notify.notify_one();
});
@ -1048,7 +1066,8 @@ mod tests {
let mut source = Rope::from_str("[\n\"🇺🇸\",\n\"🎄\",\n]");
let transaction = generate_transaction_from_edits(&source, edits, OffsetEncoding::Utf8);
let transaction = generate_transaction_from_edits(&source, edits, OffsetEncoding::Utf16);
assert!(transaction.apply(&mut source));
assert_eq!(source, "[\n \"🇺🇸\",\n \"🎄\",\n]");
}
}

View file

@ -223,10 +223,7 @@ impl Transport {
language_server_name: &str,
) -> Result<()> {
let (id, result) = match output {
jsonrpc::Output::Success(jsonrpc::Success { id, result, .. }) => {
info!("{language_server_name} <- {}", result);
(id, Ok(result))
}
jsonrpc::Output::Success(jsonrpc::Success { id, result, .. }) => (id, Ok(result)),
jsonrpc::Output::Failure(jsonrpc::Failure { id, error, .. }) => {
error!("{language_server_name} <- {error}");
(id, Err(error.into()))

View file

@ -13,19 +13,20 @@ homepage.workspace = true
[dependencies]
dunce = "1.0"
etcetera = "0.8"
ropey = { version = "1.6.1", default-features = false }
etcetera = "0.10"
ropey.workspace = true
which = "7.0"
regex-cursor = "0.1.4"
bitflags = "2.6"
once_cell = "1.19"
regex-cursor = "0.1.5"
bitflags.workspace = true
once_cell = "1.21"
regex-automata = "0.4.9"
unicode-segmentation.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "0.38", features = ["fs"] }
rustix = { version = "1.0", features = ["fs"] }
[dev-dependencies]
tempfile = "3.14"
tempfile.workspace = true

View file

@ -103,6 +103,12 @@ fn expand_impl(src: &OsStr, mut resolve: impl FnMut(&OsStr) -> Option<OsString>)
let mat = captures.get_match().unwrap();
let pattern_id = mat.pattern().as_usize();
let mut range = mat.range();
// A pattern may match multiple times on a single variable, for example `${HOME:-$HOME}`:
// `${HOME:-` matches and also the default value (`$HOME`). Skip past any variables which
// have already been expanded.
if range.start < pos {
continue;
}
let var = &bytes[captures.get_group(1).unwrap().range()];
let default = if pattern_id != 5 {
let Some(bracket_pos) = find_brace_end(&bytes[range.end..]) else {
@ -203,6 +209,7 @@ mod tests {
assert_env_expand!(env, "bar/$FOO/baz", "bar/foo/baz");
assert_env_expand!(env, "bar/${FOO}/baz", "bar/foo/baz");
assert_env_expand!(env, "baz/${BAR:-bar}/foo", "baz/bar/foo");
assert_env_expand!(env, "baz/${FOO:-$FOO}/foo", "baz/foo/foo");
assert_env_expand!(env, "baz/${BAR:=bar}/foo", "baz/bar/foo");
assert_env_expand!(env, "baz/${BAR-bar}/foo", "baz/bar/foo");
assert_env_expand!(env, "baz/${BAR=bar}/foo", "baz/bar/foo");

View file

@ -51,8 +51,8 @@ mod imp {
}
fn chown(p: &Path, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
let uid = uid.map(|n| unsafe { rustix::fs::Uid::from_raw(n) });
let gid = gid.map(|n| unsafe { rustix::fs::Gid::from_raw(n) });
let uid = uid.map(rustix::fs::Uid::from_raw);
let gid = gid.map(rustix::fs::Gid::from_raw);
rustix::fs::chown(p, uid, gid)?;
Ok(())
}

View file

@ -3,5 +3,7 @@ pub mod faccess;
pub mod path;
pub mod range;
pub mod rope;
pub mod str;
pub mod time;
pub use range::Range;

View file

@ -33,7 +33,9 @@ where
}
/// Expands tilde `~` into users home directory if available, otherwise returns the path
/// unchanged. The tilde will only be expanded when present as the first component of the path
/// unchanged.
///
/// The tilde will only be expanded when present as the first component of the path
/// and only slash follows it.
pub fn expand_tilde<'a, P>(path: P) -> Cow<'a, Path>
where
@ -54,11 +56,11 @@ where
}
/// Normalize a path without resolving symlinks.
// Strategy: start from the first component and move up. Cannonicalize previous path,
// Strategy: start from the first component and move up. Canonicalize previous path,
// join component, canonicalize new path, strip prefix and join to the final result.
pub fn normalize(path: impl AsRef<Path>) -> PathBuf {
let mut components = path.as_ref().components().peekable();
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
components.next();
PathBuf::from(c.as_os_str())
} else {
@ -209,7 +211,7 @@ fn path_component_regex(windows: bool) -> String {
// TODO: support backslash path escape on windows (when using git bash for example)
let space_escape = if windows { r"[\^`]\s" } else { r"[\\]\s" };
// partially baesd on what's allowed in an url but with some care to avoid
// false positivies (like any kind of brackets or quotes)
// false positives (like any kind of brackets or quotes)
r"[\w@.\-+#$%?!,;~&]|".to_owned() + space_escape
}

View file

@ -1,10 +1,12 @@
use std::fmt;
use std::ops::{Bound, RangeBounds};
pub use regex_cursor::engines::meta::{Builder as RegexBuilder, Regex};
pub use regex_cursor::regex_automata::util::syntax::Config;
use regex_cursor::{Input as RegexInput, RopeyCursor};
use ropey::str_utils::byte_to_char_idx;
use ropey::iter::Chunks;
use ropey::RopeSlice;
use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete};
pub trait RopeSliceExt<'a>: Sized {
fn ends_with(self, text: &str) -> bool;
@ -17,23 +19,138 @@ pub trait RopeSliceExt<'a>: Sized {
fn regex_input_at<R: RangeBounds<usize>>(self, char_range: R) -> RegexInput<RopeyCursor<'a>>;
fn first_non_whitespace_char(self) -> Option<usize>;
fn last_non_whitespace_char(self) -> Option<usize>;
/// returns the char idx of `byte_idx`, if `byte_idx` is a char boundary
/// this function behaves the same as `byte_to_char` but if `byte_idx` is
/// not a valid char boundary (so within a char) this will return the next
/// char index.
/// Finds the closest byte index not exceeding `byte_idx` which lies on a character boundary.
///
/// If `byte_idx` already lies on a character boundary then it is returned as-is. When
/// `byte_idx` lies between two character boundaries, this function returns the byte index of
/// the lesser / earlier / left-hand-side boundary.
///
/// # Example
///
/// ```
/// # use ropey::RopeSlice;
/// # use helix_stdx::rope::RopeSliceExt;
/// let text = RopeSlice::from("😆");
/// for i in 1..text.len_bytes() {
/// assert_eq!(text.byte_to_char(i), 0);
/// assert_eq!(text.byte_to_next_char(i), 1);
/// }
/// let text = RopeSlice::from("⌚"); // three bytes: e2 8c 9a
/// assert_eq!(text.floor_char_boundary(0), 0);
/// assert_eq!(text.floor_char_boundary(1), 0);
/// assert_eq!(text.floor_char_boundary(2), 0);
/// assert_eq!(text.floor_char_boundary(3), 3);
/// ```
fn byte_to_next_char(self, byte_idx: usize) -> usize;
fn floor_char_boundary(self, byte_idx: usize) -> usize;
/// Finds the closest byte index not below `byte_idx` which lies on a character boundary.
///
/// If `byte_idx` already lies on a character boundary then it is returned as-is. When
/// `byte_idx` lies between two character boundaries, this function returns the byte index of
/// the greater / later / right-hand-side boundary.
///
/// # Example
///
/// ```
/// # use ropey::RopeSlice;
/// # use helix_stdx::rope::RopeSliceExt;
/// let text = RopeSlice::from("⌚"); // three bytes: e2 8c 9a
/// assert_eq!(text.ceil_char_boundary(0), 0);
/// assert_eq!(text.ceil_char_boundary(1), 3);
/// assert_eq!(text.ceil_char_boundary(2), 3);
/// assert_eq!(text.ceil_char_boundary(3), 3);
/// ```
fn ceil_char_boundary(self, byte_idx: usize) -> usize;
/// Checks whether the given `byte_idx` lies on a character boundary.
///
/// # Example
///
/// ```
/// # use ropey::RopeSlice;
/// # use helix_stdx::rope::RopeSliceExt;
/// let text = RopeSlice::from("⌚"); // three bytes: e2 8c 9a
/// assert!(text.is_char_boundary(0));
/// assert!(!text.is_char_boundary(1));
/// assert!(!text.is_char_boundary(2));
/// assert!(text.is_char_boundary(3));
/// ```
#[allow(clippy::wrong_self_convention)]
fn is_char_boundary(self, byte_idx: usize) -> bool;
/// Finds the closest byte index not exceeding `byte_idx` which lies on a grapheme cluster
/// boundary.
///
/// If `byte_idx` already lies on a grapheme cluster boundary then it is returned as-is. When
/// `byte_idx` lies between two grapheme cluster boundaries, this function returns the byte
/// index of the lesser / earlier / left-hand-side boundary.
///
/// `byte_idx` does not need to be aligned to a character boundary.
///
/// # Example
///
/// ```
/// # use ropey::RopeSlice;
/// # use helix_stdx::rope::RopeSliceExt;
/// let text = RopeSlice::from("\r\n"); // U+000D U+000A, hex: 0d 0a
/// assert_eq!(text.floor_grapheme_boundary(0), 0);
/// assert_eq!(text.floor_grapheme_boundary(1), 0);
/// assert_eq!(text.floor_grapheme_boundary(2), 2);
/// ```
fn floor_grapheme_boundary(self, byte_idx: usize) -> usize;
/// Finds the closest byte index not exceeding `byte_idx` which lies on a grapheme cluster
/// boundary.
///
/// If `byte_idx` already lies on a grapheme cluster boundary then it is returned as-is. When
/// `byte_idx` lies between two grapheme cluster boundaries, this function returns the byte
/// index of the greater / later / right-hand-side boundary.
///
/// `byte_idx` does not need to be aligned to a character boundary.
///
/// # Example
///
/// ```
/// # use ropey::RopeSlice;
/// # use helix_stdx::rope::RopeSliceExt;
/// let text = RopeSlice::from("\r\n"); // U+000D U+000A, hex: 0d 0a
/// assert_eq!(text.ceil_grapheme_boundary(0), 0);
/// assert_eq!(text.ceil_grapheme_boundary(1), 2);
/// assert_eq!(text.ceil_grapheme_boundary(2), 2);
/// ```
fn ceil_grapheme_boundary(self, byte_idx: usize) -> usize;
/// Checks whether the `byte_idx` lies on a grapheme cluster boundary.
///
/// # Example
///
/// ```
/// # use ropey::RopeSlice;
/// # use helix_stdx::rope::RopeSliceExt;
/// let text = RopeSlice::from("\r\n"); // U+000D U+000A, hex: 0d 0a
/// assert!(text.is_grapheme_boundary(0));
/// assert!(!text.is_grapheme_boundary(1));
/// assert!(text.is_grapheme_boundary(2));
/// ```
#[allow(clippy::wrong_self_convention)]
fn is_grapheme_boundary(self, byte_idx: usize) -> bool;
/// Returns an iterator over the grapheme clusters in the slice.
///
/// # Example
///
/// ```
/// # use ropey::RopeSlice;
/// # use helix_stdx::rope::RopeSliceExt;
/// let text = RopeSlice::from("😶‍🌫️🏴‍☠️🖼️");
/// let graphemes: Vec<_> = text.graphemes().collect();
/// assert_eq!(graphemes.as_slice(), &["😶‍🌫️", "🏴‍☠️", "🖼️"]);
/// ```
fn graphemes(self) -> RopeGraphemes<'a>;
/// Returns an iterator over the grapheme clusters in the slice, reversed.
///
/// The returned iterator starts at the end of the slice and ends at the beginning of the
/// slice.
///
/// # Example
///
/// ```
/// # use ropey::RopeSlice;
/// # use helix_stdx::rope::RopeSliceExt;
/// let text = RopeSlice::from("😶‍🌫️🏴‍☠️🖼️");
/// let graphemes: Vec<_> = text.graphemes_rev().collect();
/// assert_eq!(graphemes.as_slice(), &["🖼️", "🏴‍☠️", "😶‍🌫️"]);
/// ```
fn graphemes_rev(self) -> RevRopeGraphemes<'a>;
}
impl<'a> RopeSliceExt<'a> for RopeSlice<'a> {
@ -43,7 +160,7 @@ impl<'a> RopeSliceExt<'a> for RopeSlice<'a> {
return false;
}
self.get_byte_slice(len - text.len()..)
.map_or(false, |end| end == text)
.is_some_and(|end| end == text)
}
fn starts_with(self, text: &str) -> bool {
@ -52,7 +169,7 @@ impl<'a> RopeSliceExt<'a> for RopeSlice<'a> {
return false;
}
self.get_byte_slice(..text.len())
.map_or(false, |start| start == text)
.is_some_and(|start| start == text)
}
fn regex_input(self) -> RegexInput<RopeyCursor<'a>> {
@ -94,14 +211,154 @@ impl<'a> RopeSliceExt<'a> for RopeSlice<'a> {
.map(|pos| self.len_chars() - pos - 1)
}
/// returns the char idx of `byte_idx`, if `byte_idx` is
/// a char boundary this function behaves the same as `byte_to_char`
fn byte_to_next_char(self, mut byte_idx: usize) -> usize {
let (chunk, chunk_byte_off, chunk_char_off, _) = self.chunk_at_byte(byte_idx);
byte_idx -= chunk_byte_off;
let is_char_boundary =
is_utf8_char_boundary(chunk.as_bytes().get(byte_idx).copied().unwrap_or(0));
chunk_char_off + byte_to_char_idx(chunk, byte_idx) + !is_char_boundary as usize
// These three are adapted from std:
fn floor_char_boundary(self, byte_idx: usize) -> usize {
if byte_idx >= self.len_bytes() {
self.len_bytes()
} else {
let offset = self
.bytes_at(byte_idx + 1)
.reversed()
.take(4)
.position(is_utf8_char_boundary)
// A char can only be four bytes long so we are guaranteed to find a boundary.
.unwrap();
byte_idx - offset
}
}
fn ceil_char_boundary(self, byte_idx: usize) -> usize {
if byte_idx > self.len_bytes() {
self.len_bytes()
} else {
let upper_bound = self.len_bytes().min(byte_idx + 4);
self.bytes_at(byte_idx)
.position(is_utf8_char_boundary)
.map_or(upper_bound, |pos| pos + byte_idx)
}
}
fn is_char_boundary(self, byte_idx: usize) -> bool {
if byte_idx == 0 {
return true;
}
if byte_idx >= self.len_bytes() {
byte_idx == self.len_bytes()
} else {
is_utf8_char_boundary(self.bytes_at(byte_idx).next().unwrap())
}
}
fn floor_grapheme_boundary(self, mut byte_idx: usize) -> usize {
if byte_idx >= self.len_bytes() {
return self.len_bytes();
}
byte_idx = self.ceil_char_boundary(byte_idx + 1);
let (mut chunk, mut chunk_byte_idx, _, _) = self.chunk_at_byte(byte_idx);
let mut cursor = GraphemeCursor::new(byte_idx, self.len_bytes(), true);
loop {
match cursor.prev_boundary(chunk, chunk_byte_idx) {
Ok(None) => return 0,
Ok(Some(boundary)) => return boundary,
Err(GraphemeIncomplete::PrevChunk) => {
let (ch, ch_byte_idx, _, _) = self.chunk_at_byte(chunk_byte_idx - 1);
chunk = ch;
chunk_byte_idx = ch_byte_idx;
}
Err(GraphemeIncomplete::PreContext(n)) => {
let ctx_chunk = self.chunk_at_byte(n - 1).0;
cursor.provide_context(ctx_chunk, n - ctx_chunk.len());
}
_ => unreachable!(),
}
}
}
fn ceil_grapheme_boundary(self, mut byte_idx: usize) -> usize {
if byte_idx >= self.len_bytes() {
return self.len_bytes();
}
if byte_idx == 0 {
return 0;
}
byte_idx = self.floor_char_boundary(byte_idx - 1);
let (mut chunk, mut chunk_byte_idx, _, _) = self.chunk_at_byte(byte_idx);
let mut cursor = GraphemeCursor::new(byte_idx, self.len_bytes(), true);
loop {
match cursor.next_boundary(chunk, chunk_byte_idx) {
Ok(None) => return self.len_bytes(),
Ok(Some(boundary)) => return boundary,
Err(GraphemeIncomplete::NextChunk) => {
chunk_byte_idx += chunk.len();
chunk = self.chunk_at_byte(chunk_byte_idx).0;
}
Err(GraphemeIncomplete::PreContext(n)) => {
let ctx_chunk = self.chunk_at_byte(n - 1).0;
cursor.provide_context(ctx_chunk, n - ctx_chunk.len());
}
_ => unreachable!(),
}
}
}
fn is_grapheme_boundary(self, byte_idx: usize) -> bool {
// The byte must lie on a character boundary to lie on a grapheme cluster boundary.
if !self.is_char_boundary(byte_idx) {
return false;
}
let (chunk, chunk_byte_idx, _, _) = self.chunk_at_byte(byte_idx);
let mut cursor = GraphemeCursor::new(byte_idx, self.len_bytes(), true);
loop {
match cursor.is_boundary(chunk, chunk_byte_idx) {
Ok(n) => return n,
Err(GraphemeIncomplete::PreContext(n)) => {
let (ctx_chunk, ctx_byte_start, _, _) = self.chunk_at_byte(n - 1);
cursor.provide_context(ctx_chunk, ctx_byte_start);
}
Err(_) => unreachable!(),
}
}
}
fn graphemes(self) -> RopeGraphemes<'a> {
let mut chunks = self.chunks();
let first_chunk = chunks.next().unwrap_or("");
RopeGraphemes {
text: self,
chunks,
cur_chunk: first_chunk,
cur_chunk_start: 0,
cursor: GraphemeCursor::new(0, self.len_bytes(), true),
}
}
fn graphemes_rev(self) -> RevRopeGraphemes<'a> {
let (mut chunks, mut cur_chunk_start, _, _) = self.chunks_at_byte(self.len_bytes());
chunks.reverse();
let first_chunk = chunks.next().unwrap_or("");
cur_chunk_start -= first_chunk.len();
RevRopeGraphemes {
text: self,
chunks,
cur_chunk: first_chunk,
cur_chunk_start,
cursor: GraphemeCursor::new(self.len_bytes(), self.len_bytes(), true),
}
}
}
@ -112,32 +369,136 @@ const fn is_utf8_char_boundary(b: u8) -> bool {
(b as i8) >= -0x40
}
/// An iterator over the graphemes of a `RopeSlice`.
#[derive(Clone)]
pub struct RopeGraphemes<'a> {
text: RopeSlice<'a>,
chunks: Chunks<'a>,
cur_chunk: &'a str,
cur_chunk_start: usize,
cursor: GraphemeCursor,
}
impl fmt::Debug for RopeGraphemes<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RopeGraphemes")
.field("text", &self.text)
.field("chunks", &self.chunks)
.field("cur_chunk", &self.cur_chunk)
.field("cur_chunk_start", &self.cur_chunk_start)
// .field("cursor", &self.cursor)
.finish()
}
}
impl<'a> Iterator for RopeGraphemes<'a> {
type Item = RopeSlice<'a>;
fn next(&mut self) -> Option<Self::Item> {
let a = self.cursor.cur_cursor();
let b;
loop {
match self
.cursor
.next_boundary(self.cur_chunk, self.cur_chunk_start)
{
Ok(None) => {
return None;
}
Ok(Some(n)) => {
b = n;
break;
}
Err(GraphemeIncomplete::NextChunk) => {
self.cur_chunk_start += self.cur_chunk.len();
self.cur_chunk = self.chunks.next().unwrap_or("");
}
Err(GraphemeIncomplete::PreContext(idx)) => {
let (chunk, byte_idx, _, _) = self.text.chunk_at_byte(idx.saturating_sub(1));
self.cursor.provide_context(chunk, byte_idx);
}
_ => unreachable!(),
}
}
if a < self.cur_chunk_start {
Some(self.text.byte_slice(a..b))
} else {
let a2 = a - self.cur_chunk_start;
let b2 = b - self.cur_chunk_start;
Some((&self.cur_chunk[a2..b2]).into())
}
}
}
/// An iterator over the graphemes of a `RopeSlice` in reverse.
#[derive(Clone)]
pub struct RevRopeGraphemes<'a> {
text: RopeSlice<'a>,
chunks: Chunks<'a>,
cur_chunk: &'a str,
cur_chunk_start: usize,
cursor: GraphemeCursor,
}
impl fmt::Debug for RevRopeGraphemes<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RevRopeGraphemes")
.field("text", &self.text)
.field("chunks", &self.chunks)
.field("cur_chunk", &self.cur_chunk)
.field("cur_chunk_start", &self.cur_chunk_start)
// .field("cursor", &self.cursor)
.finish()
}
}
impl<'a> Iterator for RevRopeGraphemes<'a> {
type Item = RopeSlice<'a>;
fn next(&mut self) -> Option<Self::Item> {
let a = self.cursor.cur_cursor();
let b;
loop {
match self
.cursor
.prev_boundary(self.cur_chunk, self.cur_chunk_start)
{
Ok(None) => {
return None;
}
Ok(Some(n)) => {
b = n;
break;
}
Err(GraphemeIncomplete::PrevChunk) => {
self.cur_chunk = self.chunks.next().unwrap_or("");
self.cur_chunk_start -= self.cur_chunk.len();
}
Err(GraphemeIncomplete::PreContext(idx)) => {
let (chunk, byte_idx, _, _) = self.text.chunk_at_byte(idx.saturating_sub(1));
self.cursor.provide_context(chunk, byte_idx);
}
_ => unreachable!(),
}
}
if a >= self.cur_chunk_start + self.cur_chunk.len() {
Some(self.text.byte_slice(b..a))
} else {
let a2 = a - self.cur_chunk_start;
let b2 = b - self.cur_chunk_start;
Some((&self.cur_chunk[b2..a2]).into())
}
}
}
#[cfg(test)]
mod tests {
use ropey::RopeSlice;
use crate::rope::RopeSliceExt;
#[test]
fn next_char_at_byte() {
for i in 0..=6 {
assert_eq!(RopeSlice::from("foobar").byte_to_next_char(i), i);
}
for char_idx in 0..10 {
let len = "😆".len();
assert_eq!(
RopeSlice::from("😆😆😆😆😆😆😆😆😆😆").byte_to_next_char(char_idx * len),
char_idx
);
for i in 1..=len {
assert_eq!(
RopeSlice::from("😆😆😆😆😆😆😆😆😆😆").byte_to_next_char(char_idx * len + i),
char_idx + 1
);
}
}
}
#[test]
fn starts_with() {
assert!(RopeSlice::from("asdf").starts_with("a"));
@ -147,4 +508,79 @@ mod tests {
fn ends_with() {
assert!(RopeSlice::from("asdf").ends_with("f"));
}
#[test]
fn char_boundaries() {
let ascii = RopeSlice::from("ascii");
// When the given index lies on a character boundary, the index should not change.
for byte_idx in 0..=ascii.len_bytes() {
assert_eq!(ascii.floor_char_boundary(byte_idx), byte_idx);
assert_eq!(ascii.ceil_char_boundary(byte_idx), byte_idx);
assert!(ascii.is_char_boundary(byte_idx));
}
// This is a polyfill of a method of this trait which was replaced by ceil_char_boundary.
// It returns the _character index_ of the given byte index, rounding up if it does not
// already lie on a character boundary.
fn byte_to_next_char(slice: RopeSlice, byte_idx: usize) -> usize {
slice.byte_to_char(slice.ceil_char_boundary(byte_idx))
}
for i in 0..=6 {
assert_eq!(byte_to_next_char(RopeSlice::from("foobar"), i), i);
}
for char_idx in 0..10 {
let len = "😆".len();
assert_eq!(
byte_to_next_char(RopeSlice::from("😆😆😆😆😆😆😆😆😆😆"), char_idx * len),
char_idx
);
for i in 1..=len {
assert_eq!(
byte_to_next_char(RopeSlice::from("😆😆😆😆😆😆😆😆😆😆"), char_idx * len + i),
char_idx + 1
);
}
}
}
#[test]
fn grapheme_boundaries() {
let ascii = RopeSlice::from("ascii");
// When the given index lies on a grapheme boundary, the index should not change.
for byte_idx in 0..=ascii.len_bytes() {
assert_eq!(ascii.floor_char_boundary(byte_idx), byte_idx);
assert_eq!(ascii.ceil_char_boundary(byte_idx), byte_idx);
assert!(ascii.is_grapheme_boundary(byte_idx));
}
// 🏴‍☠️: U+1F3F4 U+200D U+2620 U+FE0F
// 13 bytes, hex: f0 9f 8f b4 + e2 80 8d + e2 98 a0 + ef b8 8f
let g = RopeSlice::from("🏴‍☠️\r\n");
let emoji_len = "🏴‍☠️".len();
let end = g.len_bytes();
for byte_idx in 0..emoji_len {
assert_eq!(g.floor_grapheme_boundary(byte_idx), 0);
}
for byte_idx in emoji_len..end {
assert_eq!(g.floor_grapheme_boundary(byte_idx), emoji_len);
}
assert_eq!(g.floor_grapheme_boundary(end), end);
assert_eq!(g.ceil_grapheme_boundary(0), 0);
for byte_idx in 1..=emoji_len {
assert_eq!(g.ceil_grapheme_boundary(byte_idx), emoji_len);
}
for byte_idx in emoji_len + 1..=end {
assert_eq!(g.ceil_grapheme_boundary(byte_idx), end);
}
assert!(g.is_grapheme_boundary(0));
assert!(g.is_grapheme_boundary(emoji_len));
assert!(g.is_grapheme_boundary(end));
for byte_idx in (1..emoji_len).chain(emoji_len + 1..end) {
assert!(!g.is_grapheme_boundary(byte_idx));
}
}
}

18
helix-stdx/src/str.rs Normal file
View file

@ -0,0 +1,18 @@
/// Concatenates strings together.
///
/// `concat!(a, " ", b, " ", c)` is:
/// - more performant than `format!("{a} {b} {c}")`
/// - more ergonomic than using `String::with_capacity` followed by a series of `String::push_str`
#[macro_export]
macro_rules! concat {
($($value:expr),*) => {{
// Rust does not allow using `+` as separator between value
// so we must add that at the end of everything. The `0` is necessary
// at the end so it does not end with "+ " (which would be invalid syntax)
let mut buf = String::with_capacity($($value.len() + )* 0);
$(
buf.push_str(&$value);
)*
buf
}}
}

75
helix-stdx/src/time.rs Normal file
View file

@ -0,0 +1,75 @@
use std::time::{Instant, SystemTime};
use once_cell::sync::Lazy;
const SECOND: i64 = 1;
const MINUTE: i64 = 60 * SECOND;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
const MONTH: i64 = 30 * DAY;
const YEAR: i64 = 365 * DAY;
/// Like `std::time::SystemTime::now()` but does not cause a syscall on every invocation.
///
/// There is just one syscall at the start of the program, subsequent invocations are
/// much cheaper and use the monotonic clock instead of trigerring a syscall.
#[inline]
fn now() -> SystemTime {
static START_INSTANT: Lazy<Instant> = Lazy::new(Instant::now);
static START_SYSTEM_TIME: Lazy<SystemTime> = Lazy::new(SystemTime::now);
*START_SYSTEM_TIME + START_INSTANT.elapsed()
}
/// Formats a timestamp into a human-readable relative time string.
///
/// # Arguments
///
/// * `timestamp` - A point in history. Seconds since UNIX epoch (UTC)
/// * `timezone_offset` - Timezone offset in seconds
///
/// # Returns
///
/// A String representing the relative time (e.g., "4 years ago", "11 months from now")
#[inline]
pub fn format_relative_time(timestamp: i64, timezone_offset: i32) -> String {
let timestamp = timestamp + timezone_offset as i64;
let now = now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
+ timezone_offset as i64;
let time_passed = now - timestamp;
let time_difference = time_passed.abs();
let (value, unit) = if time_difference >= YEAR {
let years = time_difference / YEAR;
(years, if years == 1 { "year" } else { "years" })
} else if time_difference >= MONTH {
let months = time_difference / MONTH;
(months, if months == 1 { "month" } else { "months" })
} else if time_difference >= DAY {
let days = time_difference / DAY;
(days, if days == 1 { "day" } else { "days" })
} else if time_difference >= HOUR {
let hours = time_difference / HOUR;
(hours, if hours == 1 { "hour" } else { "hours" })
} else if time_difference >= MINUTE {
let minutes = time_difference / MINUTE;
(minutes, if minutes == 1 { "minute" } else { "minutes" })
} else {
let seconds = time_difference / SECOND;
(seconds, if seconds == 1 { "second" } else { "seconds" })
};
let value = value.to_string();
let label = if time_passed.is_positive() {
"ago"
} else {
"from now"
};
crate::concat!(value, " ", unit, " ", label)
}

View file

@ -100,7 +100,7 @@ fn test_normalize_path() -> Result<(), Box<dyn Error>> {
assert_eq!(
path::normalize(&path),
expected,
"input {:?} and \"..\" should not erase the simlink that goes ahead",
"input {:?} and \"..\" should not erase the symlink that goes ahead",
&path
);

View file

@ -12,6 +12,24 @@ categories.workspace = true
repository.workspace = true
homepage.workspace = true
[package.metadata.deb]
# generate a .deb in target/debian/ with the command: cargo deb --no-build
name = "helix"
assets = [
{ source = "target/release/hx", dest = "/usr/lib/helix/", mode = "755" },
{ source = "../contrib/hx_launcher.sh", dest = "/usr/bin/hx", mode = "755" },
{ source = "../runtime/*", dest = "/usr/lib/helix/runtime/", mode = "644" },
{ source = "../runtime/grammars/*", dest = "/usr/lib/helix/runtime/grammars/", mode = "644" }, # to avoid sources/
{ source = "../runtime/queries/**/*", dest = "/usr/lib/helix/runtime/queries/", mode = "644" },
{ source = "../runtime/themes/**/*", dest = "/usr/lib/helix/runtime/themes/", mode = "644" },
{ source = "../README.md", dest = "/usr/share/doc/helix/", mode = "644" },
{ source = "../contrib/completion/hx.bash", dest = "/usr/share/bash-completion/completions/hx", mode = "644" },
{ source = "../contrib/completion/hx.fish", dest = "/usr/share/fish/vendor_completions.d/hx.fish", mode = "644" },
{ source = "../contrib/completion/hx.zsh", dest = "/usr/share/zsh/vendor-completions/_hx", mode = "644" },
{ source = "../contrib/Helix.desktop", dest = "/usr/share/applications/Helix.desktop", mode = "644" },
{ source = "../contrib/helix.png", dest = "/usr/share/icons/hicolor/256x256/apps/helix.png", mode = "644" },
]
[features]
default = ["git"]
unicode-lines = ["helix-core/unicode-lines", "helix-view/unicode-lines"]
@ -33,7 +51,7 @@ helix-vcs = { path = "../helix-vcs" }
helix-loader = { path = "../helix-loader" }
anyhow = "1"
once_cell = "1.20"
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"] }
@ -43,6 +61,7 @@ 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.8"
# Logging
fern = "0.7"
@ -53,13 +72,13 @@ log = "0.4"
nucleo.workspace = true
ignore = "0.4"
# markdown doc rendering
pulldown-cmark = { version = "0.12", default-features = false }
pulldown-cmark = { version = "0.13", default-features = false }
# file type detection
content_inspector = "0.2.4"
thiserror.workspace = true
# opening URLs
open = "5.3.1"
open = "5.3.2"
url = "2.5.4"
# config
@ -74,7 +93,7 @@ grep-searcher = "0.1.14"
[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.169"
libc = "0.2.171"
[target.'cfg(target_os = "macos")'.dependencies]
crossterm = { version = "0.28", features = ["event-stream", "use-dev-tty", "libc"] }
@ -83,7 +102,7 @@ crossterm = { version = "0.28", features = ["event-stream", "use-dev-tty", "libc
helix-loader = { path = "../helix-loader" }
[dev-dependencies]
smallvec = "1.13"
indoc = "2.0.5"
tempfile = "3.14.0"
smallvec = "1.14"
indoc = "2.0.6"
tempfile.workspace = true
same-file = "1.0.1"

View file

@ -1,6 +1,6 @@
use arc_swap::{access::Map, ArcSwap};
use futures_util::Stream;
use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Selection};
use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Range, Selection};
use helix_lsp::{
lsp::{self, notification::Notification},
util::lsp_range_to_range,
@ -11,7 +11,7 @@ use helix_view::{
align_view,
document::{DocumentOpenError, DocumentSavedEventResult},
editor::{ConfigEvent, EditorEvent},
events::DiagnosticsDidChange,
events::EditorConfigDidChange,
graphics::Rect,
theme,
tree::Layout,
@ -33,7 +33,7 @@ use crate::{
use log::{debug, error, info, warn};
#[cfg(not(feature = "integration"))]
use std::io::stdout;
use std::{collections::btree_map::Entry, io::stdin, path::Path, sync::Arc};
use std::{io::stdin, path::Path, sync::Arc};
#[cfg(not(windows))]
use anyhow::Context;
@ -66,11 +66,6 @@ pub struct Application {
config: Arc<ArcSwap<Config>>,
#[allow(dead_code)]
theme_loader: Arc<theme::Loader>,
#[allow(dead_code)]
syn_loader: Arc<ArcSwap<syntax::Loader>>,
signals: Signals,
jobs: Jobs,
lsp_progress: LspProgressMap,
@ -107,25 +102,7 @@ impl Application {
let mut theme_parent_dirs = vec![helix_loader::config_dir()];
theme_parent_dirs.extend(helix_loader::runtime_dirs().iter().cloned());
let theme_loader = std::sync::Arc::new(theme::Loader::new(&theme_parent_dirs));
let true_color = config.editor.true_color || crate::true_color();
let theme = config
.theme
.as_ref()
.and_then(|theme| {
theme_loader
.load(theme)
.map_err(|e| {
log::warn!("failed to load theme `{}` - {}", theme, e);
e
})
.ok()
.filter(|theme| (true_color || theme.is_16_color()))
})
.unwrap_or_else(|| theme_loader.default_theme(true_color));
let syn_loader = Arc::new(ArcSwap::from_pointee(lang_loader));
let theme_loader = theme::Loader::new(&theme_parent_dirs);
#[cfg(not(feature = "integration"))]
let backend = CrosstermBackend::new(stdout(), &config.editor);
@ -140,13 +117,14 @@ impl Application {
let handlers = handlers::setup(config.clone());
let mut editor = Editor::new(
area,
theme_loader.clone(),
syn_loader.clone(),
Arc::new(theme_loader),
Arc::new(ArcSwap::from_pointee(lang_loader)),
Arc::new(Map::new(Arc::clone(&config), |config: &Config| {
&config.editor
})),
handlers,
);
Self::load_configured_theme(&mut editor, &config.load());
let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| {
&config.keys
@ -164,7 +142,7 @@ impl Application {
// If the first file is a directory, skip it and open a picker
if let Some((first, _)) = files_it.next_if(|(p, _)| p.is_dir()) {
let picker = ui::file_picker(first, &config.load().editor);
let picker = ui::file_picker(&editor, first);
compositor.push(Box::new(overlaid(picker)));
}
@ -210,8 +188,13 @@ impl Application {
// opened last is focused on.
let view_id = editor.tree.focus;
let doc = doc_mut!(editor, &doc_id);
let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true));
doc.set_selection(view_id, pos);
let selection = pos
.into_iter()
.map(|coords| {
Range::point(pos_at_coords(doc.text().slice(..), coords, true))
})
.collect();
doc.set_selection(view_id, selection);
}
}
@ -233,15 +216,13 @@ impl Application {
editor.new_file(Action::VerticalSplit);
}
} else if stdin().is_tty() || cfg!(feature = "integration") {
editor.new_file(Action::VerticalSplit);
editor.new_file_welcome();
} else {
editor
.new_file_from_stdin(Action::VerticalSplit)
.unwrap_or_else(|_| editor.new_file(Action::VerticalSplit));
.unwrap_or_else(|_| editor.new_file_welcome());
}
editor.set_theme(theme);
#[cfg(windows)]
let signals = futures_util::stream::empty();
#[cfg(not(windows))]
@ -258,12 +239,7 @@ impl Application {
compositor,
terminal,
editor,
config,
theme_loader,
syn_loader,
signals,
jobs: Jobs::new(),
lsp_progress: LspProgressMap::new(),
@ -389,6 +365,10 @@ impl Application {
// the Application can apply it.
ConfigEvent::Update(editor_config) => {
let mut app_config = (*self.config.load().clone()).clone();
helix_event::dispatch(EditorConfigDidChange {
old_config: &app_config.editor,
editor: &mut self.editor,
});
app_config.editor = *editor_config;
if let Err(err) = self.terminal.reconfigure(app_config.editor.clone().into()) {
self.editor.set_error(err.to_string());
@ -413,10 +393,9 @@ impl Application {
fn refresh_language_config(&mut self) -> Result<(), Error> {
let lang_loader = helix_core::config::user_lang_loader()?;
self.syn_loader.store(Arc::new(lang_loader));
self.editor.syn_loader = self.syn_loader.clone();
self.editor.syn_loader.store(Arc::new(lang_loader));
for document in self.editor.documents.values_mut() {
document.detect_language(self.syn_loader.clone());
document.detect_language(self.editor.syn_loader.clone());
let diagnostics = Editor::doc_diagnostics(
&self.editor.language_servers,
&self.editor.diagnostics,
@ -428,34 +407,13 @@ impl Application {
Ok(())
}
/// Refresh theme after config change
fn refresh_theme(&mut self, config: &Config) -> Result<(), Error> {
let true_color = config.editor.true_color || crate::true_color();
let theme = config
.theme
.as_ref()
.and_then(|theme| {
self.theme_loader
.load(theme)
.map_err(|e| {
log::warn!("failed to load theme `{}` - {}", theme, e);
e
})
.ok()
.filter(|theme| (true_color || theme.is_16_color()))
})
.unwrap_or_else(|| self.theme_loader.default_theme(true_color));
self.editor.set_theme(theme);
Ok(())
}
fn refresh_config(&mut self) {
let mut refresh_config = || -> Result<(), Error> {
let default_config = Config::load_default()
.map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?;
self.refresh_language_config()?;
self.refresh_theme(&default_config)?;
// Refresh theme after config change
Self::load_configured_theme(&mut self.editor, &default_config);
self.terminal
.reconfigure(default_config.editor.clone().into())?;
// Store new config
@ -473,6 +431,37 @@ 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();
let theme = config
.theme
.as_ref()
.and_then(|theme| {
editor
.theme_loader
.load(theme)
.map_err(|e| {
log::warn!("failed to load theme `{}` - {}", theme, e);
e
})
.ok()
.filter(|theme| {
let colors_ok = true_color || theme.is_16_color();
if !colors_ok {
log::warn!(
"loaded theme `{}` but cannot use it because true color \
support is not enabled",
theme.name()
);
}
colors_ok
})
})
.unwrap_or_else(|| editor.theme_loader.default_theme(true_color));
editor.set_theme(theme);
}
#[cfg(windows)]
// no signal handling available on windows
pub async fn handle_signals(&mut self, _signal: ()) -> bool {
@ -717,33 +706,15 @@ impl Application {
// This might not be required by the spec but Neovim does this as well, so it's
// probably a good idea for compatibility.
if let Some(config) = language_server.config() {
tokio::spawn(language_server.did_change_configuration(config.clone()));
language_server.did_change_configuration(config.clone());
}
let docs = self
.editor
.documents()
.filter(|doc| doc.supports_language_server(server_id));
// trigger textDocument/didOpen for docs that are already open
for doc in docs {
let url = match doc.url() {
Some(url) => url,
None => continue, // skip documents with no path
};
let language_id =
doc.language_id().map(ToOwned::to_owned).unwrap_or_default();
tokio::spawn(language_server.text_document_did_open(
url,
doc.version(),
doc.text(),
language_id,
));
}
helix_event::dispatch(helix_view::events::LanguageServerInitialized {
editor: &mut self.editor,
server_id,
});
}
Notification::PublishDiagnostics(mut params) => {
Notification::PublishDiagnostics(params) => {
let uri = match helix_core::Uri::try_from(params.uri) {
Ok(uri) => uri,
Err(err) => {
@ -756,100 +727,16 @@ impl Application {
log::error!("Discarding publishDiagnostic notification sent by an uninitialized server: {}", language_server.name());
return;
}
// have to inline the function because of borrow checking...
let doc = self.editor.documents.values_mut()
.find(|doc| doc.uri().is_some_and(|u| u == uri))
.filter(|doc| {
if let Some(version) = params.version {
if version != doc.version() {
log::info!("Version ({version}) is out of date for {uri:?} (expected ({}), dropping PublishDiagnostic notification", doc.version());
return false;
}
}
true
});
let mut unchanged_diag_sources = Vec::new();
if let Some(doc) = &doc {
let lang_conf = doc.language.clone();
if let Some(lang_conf) = &lang_conf {
if let Some(old_diagnostics) = self.editor.diagnostics.get(&uri) {
if !lang_conf.persistent_diagnostic_sources.is_empty() {
// Sort diagnostics first by severity and then by line numbers.
// Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order
params
.diagnostics
.sort_by_key(|d| (d.severity, d.range.start));
}
for source in &lang_conf.persistent_diagnostic_sources {
let new_diagnostics = params
.diagnostics
.iter()
.filter(|d| d.source.as_ref() == Some(source));
let old_diagnostics = old_diagnostics
.iter()
.filter(|(d, d_server)| {
*d_server == server_id
&& d.source.as_ref() == Some(source)
})
.map(|(d, _)| d);
if new_diagnostics.eq(old_diagnostics) {
unchanged_diag_sources.push(source.clone())
}
}
}
}
}
let diagnostics = params.diagnostics.into_iter().map(|d| (d, server_id));
// Insert the original lsp::Diagnostics here because we may have no open document
// for diagnosic message and so we can't calculate the exact position.
// When using them later in the diagnostics picker, we calculate them on-demand.
let diagnostics = match self.editor.diagnostics.entry(uri) {
Entry::Occupied(o) => {
let current_diagnostics = o.into_mut();
// there may entries of other language servers, which is why we can't overwrite the whole entry
current_diagnostics.retain(|(_, lsp_id)| *lsp_id != server_id);
current_diagnostics.extend(diagnostics);
current_diagnostics
// Sort diagnostics first by severity and then by line numbers.
}
Entry::Vacant(v) => v.insert(diagnostics.collect()),
let provider = helix_core::diagnostic::DiagnosticProvider::Lsp {
server_id,
identifier: None,
};
// Sort diagnostics first by severity and then by line numbers.
// Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order
diagnostics
.sort_by_key(|(d, server_id)| (d.severity, d.range.start, *server_id));
if let Some(doc) = doc {
let diagnostic_of_language_server_and_not_in_unchanged_sources =
|diagnostic: &lsp::Diagnostic, ls_id| {
ls_id == server_id
&& diagnostic.source.as_ref().map_or(true, |source| {
!unchanged_diag_sources.contains(source)
})
};
let diagnostics = Editor::doc_diagnostics_with_filter(
&self.editor.language_servers,
&self.editor.diagnostics,
doc,
diagnostic_of_language_server_and_not_in_unchanged_sources,
);
doc.replace_diagnostics(
diagnostics,
&unchanged_diag_sources,
Some(server_id),
);
let doc = doc.id();
helix_event::dispatch(DiagnosticsDidChange {
editor: &mut self.editor,
doc,
});
}
self.editor.handle_lsp_diagnostics(
&provider,
uri,
params.version,
params.diagnostics,
);
}
Notification::ShowMessage(params) => {
if self.config.load().editor.lsp.display_messages {
@ -874,10 +761,11 @@ impl Application {
.compositor
.find::<ui::EditorView>()
.expect("expected at least one EditorView");
let lsp::ProgressParams { token, value } = params;
let lsp::ProgressParamsValue::WorkDone(work) = value;
let parts = match &work {
let lsp::ProgressParams {
token,
value: lsp::ProgressParamsValue::WorkDone(work),
} = params;
let (title, message, percentage) = match &work {
lsp::WorkDoneProgress::Begin(lsp::WorkDoneProgressBegin {
title,
message,
@ -905,47 +793,43 @@ impl Application {
}
};
let token_d: &dyn std::fmt::Display = match &token {
lsp::NumberOrString::Number(n) => n,
lsp::NumberOrString::String(s) => s,
};
let status = match parts {
(Some(title), Some(message), Some(percentage)) => {
format!("[{}] {}% {} - {}", token_d, percentage, title, message)
if self.editor.config().lsp.display_progress_messages {
let title =
title.or_else(|| self.lsp_progress.title(server_id, &token));
if title.is_some() || percentage.is_some() || message.is_some() {
use std::fmt::Write as _;
let mut status = format!("{}: ", language_server!().name());
if let Some(percentage) = percentage {
write!(status, "{percentage:>2}% ").unwrap();
}
if let Some(title) = title {
status.push_str(title);
}
if title.is_some() && message.is_some() {
status.push_str("");
}
if let Some(message) = message {
status.push_str(message);
}
self.editor.set_status(status);
}
(Some(title), None, Some(percentage)) => {
format!("[{}] {}% {}", token_d, percentage, title)
}
(Some(title), Some(message), None) => {
format!("[{}] {} - {}", token_d, title, message)
}
(None, Some(message), Some(percentage)) => {
format!("[{}] {}% {}", token_d, percentage, message)
}
(Some(title), None, None) => {
format!("[{}] {}", token_d, title)
}
(None, Some(message), None) => {
format!("[{}] {}", token_d, message)
}
(None, None, Some(percentage)) => {
format!("[{}] {}%", token_d, percentage)
}
(None, None, None) => format!("[{}]", token_d),
};
if let lsp::WorkDoneProgress::End(_) = work {
self.lsp_progress.end_progress(server_id, &token);
if !self.lsp_progress.is_progressing(server_id) {
editor_view.spinners_mut().get_or_create(server_id).stop();
}
} else {
self.lsp_progress.update(server_id, token, work);
}
if self.config.load().editor.lsp.display_progress_messages {
self.editor.set_status(status);
match work {
lsp::WorkDoneProgress::Begin(begin_status) => {
self.lsp_progress
.begin(server_id, token.clone(), begin_status);
}
lsp::WorkDoneProgress::Report(report_status) => {
self.lsp_progress
.update(server_id, token.clone(), report_status);
}
lsp::WorkDoneProgress::End(_) => {
self.lsp_progress.end_progress(server_id, &token);
if !self.lsp_progress.is_progressing(server_id) {
editor_view.spinners_mut().get_or_create(server_id).stop();
};
}
}
}
Notification::ProgressMessage(_params) => {
@ -958,16 +842,23 @@ impl Application {
// we need to clear those and remove the entries from the list if this leads to
// an empty diagnostic list for said files
for diags in self.editor.diagnostics.values_mut() {
diags.retain(|(_, lsp_id)| *lsp_id != server_id);
diags.retain(|(_, provider)| {
provider.language_server_id() != Some(server_id)
});
}
self.editor.diagnostics.retain(|_, diags| !diags.is_empty());
// Clear any diagnostics for documents with this server open.
for doc in self.editor.documents_mut() {
doc.clear_diagnostics(Some(server_id));
doc.clear_diagnostics_for_language_server(server_id);
}
helix_event::dispatch(helix_view::events::LanguageServerExited {
editor: &mut self.editor,
server_id,
});
// Remove the language server from the registry.
self.editor.language_servers.remove_by_id(server_id);
}
@ -1124,9 +1015,25 @@ impl Application {
let result = self.handle_show_document(params, offset_encoding);
Ok(json!(result))
}
Ok(MethodCall::WorkspaceDiagnosticRefresh) => {
for document in self.editor.documents() {
let language_server = language_server!();
handlers::diagnostics::pull_diagnostics_for_document(
document,
language_server,
);
}
Ok(serde_json::Value::Null)
}
};
tokio::spawn(language_server!().reply(id, reply));
let language_server = language_server!();
if let Err(err) = language_server.reply(id.clone(), reply) {
log::error!(
"Failed to send reply to server '{}' request {id}: {err}",
language_server.name()
);
}
}
Call::Invalid { id } => log::error!("LSP invalid method call id={:?}", id),
}

View file

@ -1,6 +1,7 @@
use anyhow::Result;
use helix_core::Position;
use helix_view::tree::Layout;
use indexmap::IndexMap;
use std::path::{Path, PathBuf};
#[derive(Default)]
@ -16,7 +17,7 @@ pub struct Args {
pub verbosity: u64,
pub log_file: Option<PathBuf>,
pub config_file: Option<PathBuf>,
pub files: Vec<(PathBuf, Position)>,
pub files: IndexMap<PathBuf, Vec<Position>>,
pub working_directory: Option<PathBuf>,
}
@ -26,6 +27,18 @@ impl Args {
let mut argv = std::env::args().peekable();
let mut line_number = 0;
let mut insert_file_with_position = |file_with_position: &str| {
let (filename, position) = parse_file(file_with_position);
// Before setting the working directory, resolve all the paths in args.files
let filename = helix_stdx::path::canonicalize(filename);
args.files
.entry(filename)
.and_modify(|positions| positions.push(position))
.or_insert_with(|| vec![position]);
};
argv.next(); // skip the program, we don't care about that
while let Some(arg) = argv.next() {
@ -92,21 +105,25 @@ impl Args {
arg if arg.starts_with('+') => {
match arg[1..].parse::<usize>() {
Ok(n) => line_number = n.saturating_sub(1),
_ => args.files.push(parse_file(arg)),
_ => insert_file_with_position(arg),
};
}
arg => args.files.push(parse_file(arg)),
arg => insert_file_with_position(arg),
}
}
// push the remaining args, if any to the files
for arg in argv {
args.files.push(parse_file(&arg));
insert_file_with_position(&arg);
}
if let Some(file) = args.files.first_mut() {
if line_number != 0 {
file.1.row = line_number;
if line_number != 0 {
if let Some(first_position) = args
.files
.first_mut()
.and_then(|(_, positions)| positions.first_mut())
{
first_position.row = line_number;
}
}

File diff suppressed because it is too large Load diff

View file

@ -14,13 +14,15 @@ use tui::{text::Span, widgets::Row};
use super::{align_view, push_jump, Align, Context, Editor};
use helix_core::{
syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection, Uri,
diagnostic::DiagnosticProvider, syntax::LanguageServerFeature,
text_annotations::InlineAnnotation, Selection, Uri,
};
use helix_stdx::path;
use helix_view::{
document::{DocumentInlayHints, DocumentInlayHintsId},
editor::Action,
handlers::lsp::SignatureHelpInvoked,
icons::ICONS,
theme::Style,
Document, View,
};
@ -31,13 +33,7 @@ use crate::{
ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent},
};
use std::{
cmp::Ordering,
collections::{BTreeMap, HashSet},
fmt::Display,
future::Future,
path::Path,
};
use std::{cmp::Ordering, collections::HashSet, fmt::Display, future::Future, path::Path};
/// Gets the first language server that is attached to a document which supports a specific feature.
/// If there is no configured language server that supports the feature, this displays a status message.
@ -61,14 +57,19 @@ macro_rules! language_server_with_feature {
}};
}
/// A wrapper around `lsp::Location` that swaps out the LSP URI for `helix_core::Uri`.
/// A wrapper around `lsp::Location` that swaps out the LSP URI for `helix_core::Uri` and adds
/// the server's offset encoding.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Location {
uri: Uri,
range: lsp::Range,
offset_encoding: OffsetEncoding,
}
fn lsp_location_to_location(location: lsp::Location) -> Option<Location> {
fn lsp_location_to_location(
location: lsp::Location,
offset_encoding: OffsetEncoding,
) -> Option<Location> {
let uri = match location.uri.try_into() {
Ok(uri) => uri,
Err(err) => {
@ -79,13 +80,13 @@ fn lsp_location_to_location(location: lsp::Location) -> Option<Location> {
Some(Location {
uri,
range: location.range,
offset_encoding,
})
}
struct SymbolInformationItem {
location: Location,
symbol: lsp::SymbolInformation,
offset_encoding: OffsetEncoding,
}
struct DiagnosticStyles {
@ -98,7 +99,6 @@ struct DiagnosticStyles {
struct PickerDiagnostic {
location: Location,
diag: lsp::Diagnostic,
offset_encoding: OffsetEncoding,
}
fn location_to_file_location(location: &Location) -> Option<FileLocation> {
@ -110,12 +110,7 @@ fn location_to_file_location(location: &Location) -> Option<FileLocation> {
Some((path.into(), line))
}
fn jump_to_location(
editor: &mut Editor,
location: &Location,
offset_encoding: OffsetEncoding,
action: Action,
) {
fn jump_to_location(editor: &mut Editor, location: &Location, action: Action) {
let (view, doc) = current!(editor);
push_jump(view, doc);
@ -124,7 +119,13 @@ fn jump_to_location(
editor.set_error(err);
return;
};
jump_to_position(editor, path, location.range, offset_encoding, action);
jump_to_position(
editor,
path,
location.range,
location.offset_encoding,
action,
);
}
fn jump_to_position(
@ -204,7 +205,7 @@ type DiagnosticsPicker = Picker<PickerDiagnostic, DiagnosticStyles>;
fn diag_picker(
cx: &Context,
diagnostics: BTreeMap<Uri, Vec<(lsp::Diagnostic, LanguageServerId)>>,
diagnostics: impl IntoIterator<Item = (Uri, Vec<(lsp::Diagnostic, DiagnosticProvider)>)>,
format: DiagnosticsFormat,
) -> DiagnosticsPicker {
// TODO: drop current_path comparison and instead use workspace: bool flag?
@ -214,15 +215,18 @@ fn diag_picker(
for (uri, diags) in diagnostics {
flat_diag.reserve(diags.len());
for (diag, ls) in diags {
if let Some(ls) = cx.editor.language_server_by_id(ls) {
for (diag, provider) in diags {
if let Some(ls) = provider
.language_server_id()
.and_then(|id| cx.editor.language_server_by_id(id))
{
flat_diag.push(PickerDiagnostic {
location: Location {
uri: uri.clone(),
range: diag.range,
offset_encoding: ls.offset_encoding(),
},
diag,
offset_encoding: ls.offset_encoding(),
});
}
}
@ -239,11 +243,22 @@ fn diag_picker(
ui::PickerColumn::new(
"severity",
|item: &PickerDiagnostic, styles: &DiagnosticStyles| {
let icons = ICONS.load();
match item.diag.severity {
Some(DiagnosticSeverity::HINT) => Span::styled("HINT", styles.hint),
Some(DiagnosticSeverity::INFORMATION) => Span::styled("INFO", styles.info),
Some(DiagnosticSeverity::WARNING) => Span::styled("WARN", styles.warning),
Some(DiagnosticSeverity::ERROR) => Span::styled("ERROR", styles.error),
Some(DiagnosticSeverity::HINT) => {
Span::styled(format!("{} HINT", icons.diagnostic().hint()), styles.hint)
}
Some(DiagnosticSeverity::INFORMATION) => {
Span::styled(format!("{} INFO", icons.diagnostic().info()), styles.info)
}
Some(DiagnosticSeverity::WARNING) => Span::styled(
format!("{} WARN", icons.diagnostic().warning()),
styles.warning,
),
Some(DiagnosticSeverity::ERROR) => Span::styled(
format!("{} ERROR", icons.diagnostic().error()),
styles.error,
),
_ => Span::raw(""),
}
.into()
@ -286,7 +301,7 @@ fn diag_picker(
flat_diag,
styles,
move |cx, diag, action| {
jump_to_location(cx.editor, &diag.location, diag.offset_encoding, action);
jump_to_location(cx.editor, &diag.location, action);
let (view, doc) = current!(cx.editor);
view.diagnostics_handler
.immediately_show_diagnostic(doc, view.id);
@ -314,10 +329,10 @@ pub fn symbol_picker(cx: &mut Context) {
location: lsp::Location::new(file.uri.clone(), symbol.selection_range),
container_name: None,
},
offset_encoding,
location: Location {
uri: uri.clone(),
range: symbol.selection_range,
offset_encoding,
},
});
for child in symbol.children.into_iter().flatten() {
@ -340,9 +355,7 @@ pub fn symbol_picker(cx: &mut Context) {
.expect("docs with active language servers must be backed by paths");
async move {
let json = request.await?;
let response: Option<lsp::DocumentSymbolResponse> = serde_json::from_value(json)?;
let symbols = match response {
let symbols = match request.await? {
Some(symbols) => symbols,
None => return anyhow::Ok(vec![]),
};
@ -355,9 +368,9 @@ pub fn symbol_picker(cx: &mut Context) {
location: Location {
uri: doc_uri.clone(),
range: symbol.location.range,
offset_encoding,
},
symbol,
offset_encoding,
})
.collect(),
lsp::DocumentSymbolResponse::Nested(symbols) => {
@ -387,14 +400,21 @@ pub fn symbol_picker(cx: &mut Context) {
cx.jobs.callback(async move {
let mut symbols = Vec::new();
// TODO if one symbol request errors, all other requests are discarded (even if they're valid)
while let Some(mut lsp_items) = futures.try_next().await? {
symbols.append(&mut lsp_items);
while let Some(response) = futures.next().await {
match response {
Ok(mut items) => symbols.append(&mut items),
Err(err) => log::error!("Error requesting document symbols: {err}"),
}
}
let call = move |_editor: &mut Editor, compositor: &mut Compositor| {
let columns = [
ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| {
display_symbol_kind(item.symbol.kind).into()
let icons = ICONS.load();
let name = display_symbol_kind(item.symbol.kind);
icons
.lsp()
.get(name)
.map_or_else(|| name.into(), |symbol| format!("{symbol} {name}").into())
}),
// Some symbols in the document symbol picker may have a URI that isn't
// the current file. It should be rare though, so we concatenate that
@ -402,6 +422,13 @@ pub fn symbol_picker(cx: &mut Context) {
ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| {
item.symbol.name.as_str().into()
}),
ui::PickerColumn::new("container", |item: &SymbolInformationItem, _| {
item.symbol
.container_name
.as_deref()
.unwrap_or_default()
.into()
}),
];
let picker = Picker::new(
@ -410,7 +437,7 @@ pub fn symbol_picker(cx: &mut Context) {
symbols,
(),
move |cx, item, action| {
jump_to_location(cx.editor, &item.location, item.offset_encoding, action);
jump_to_location(cx.editor, &item.location, action);
},
)
.with_preview(move |_editor, item| location_to_file_location(&item.location))
@ -449,30 +476,34 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
.unwrap();
let offset_encoding = language_server.offset_encoding();
async move {
let json = request.await?;
let symbols = request
.await?
.and_then(|resp| match resp {
lsp::WorkspaceSymbolResponse::Flat(symbols) => Some(symbols),
lsp::WorkspaceSymbolResponse::Nested(_) => None,
})
.unwrap_or_default();
let response: Vec<_> =
serde_json::from_value::<Option<Vec<lsp::SymbolInformation>>>(json)?
.unwrap_or_default()
.into_iter()
.filter_map(|symbol| {
let uri = match Uri::try_from(&symbol.location.uri) {
Ok(uri) => uri,
Err(err) => {
log::warn!("discarding symbol with invalid URI: {err}");
return None;
}
};
Some(SymbolInformationItem {
location: Location {
uri,
range: symbol.location.range,
},
symbol,
let response: Vec<_> = symbols
.into_iter()
.filter_map(|symbol| {
let uri = match Uri::try_from(&symbol.location.uri) {
Ok(uri) => uri,
Err(err) => {
log::warn!("discarding symbol with invalid URI: {err}");
return None;
}
};
Some(SymbolInformationItem {
location: Location {
uri,
range: symbol.location.range,
offset_encoding,
})
},
symbol,
})
.collect();
})
.collect();
anyhow::Ok(response)
}
@ -485,10 +516,14 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
let injector = injector.clone();
async move {
// TODO if one symbol request errors, all other requests are discarded (even if they're valid)
while let Some(lsp_items) = futures.try_next().await? {
for item in lsp_items {
injector.push(item)?;
while let Some(response) = futures.next().await {
match response {
Ok(items) => {
for item in items {
injector.push(item)?;
}
}
Err(err) => log::error!("Error requesting workspace symbols: {err}"),
}
}
Ok(())
@ -497,12 +532,24 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
};
let columns = [
ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| {
display_symbol_kind(item.symbol.kind).into()
let icons = ICONS.load();
let name = display_symbol_kind(item.symbol.kind);
icons
.lsp()
.get(name)
.map_or_else(|| name.into(), |symbol| format!("{symbol} {name}").into())
}),
ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| {
item.symbol.name.as_str().into()
})
.without_filtering(),
ui::PickerColumn::new("container", |item: &SymbolInformationItem, _| {
item.symbol
.container_name
.as_deref()
.unwrap_or_default()
.into()
}),
ui::PickerColumn::new("path", |item: &SymbolInformationItem, _| {
if let Some(path) = item.location.uri.as_path() {
path::get_relative_path(path)
@ -521,7 +568,7 @@ pub fn workspace_symbol_picker(cx: &mut Context) {
[],
(),
move |cx, item, action| {
jump_to_location(cx.editor, &item.location, item.offset_encoding, action);
jump_to_location(cx.editor, &item.location, action);
},
)
.with_preview(|_editor, item| location_to_file_location(&item.location))
@ -535,11 +582,7 @@ pub fn diagnostics_picker(cx: &mut Context) {
let doc = doc!(cx.editor);
if let Some(uri) = doc.uri() {
let diagnostics = cx.editor.diagnostics.get(&uri).cloned().unwrap_or_default();
let picker = diag_picker(
cx,
[(uri, diagnostics)].into(),
DiagnosticsFormat::HideSourcePath,
);
let picker = diag_picker(cx, [(uri, diagnostics)], DiagnosticsFormat::HideSourcePath);
cx.push_layer(Box::new(overlaid(picker)));
}
}
@ -657,11 +700,8 @@ pub fn code_action(cx: &mut Context) {
Some((code_action_request, language_server_id))
})
.map(|(request, ls_id)| async move {
let json = request.await?;
let response: Option<lsp::CodeActionResponse> = serde_json::from_value(json)?;
let mut actions = match response {
Some(a) => a,
None => return anyhow::Ok(Vec::new()),
let Some(mut actions) = request.await? else {
return anyhow::Ok(Vec::new());
};
// remove disabled code actions
@ -726,9 +766,12 @@ pub fn code_action(cx: &mut Context) {
cx.jobs.callback(async move {
let mut actions = Vec::new();
// TODO if one code action request errors, all other requests are ignored (even if they're valid)
while let Some(mut lsp_items) = futures.try_next().await? {
actions.append(&mut lsp_items);
while let Some(output) = futures.next().await {
match output {
Ok(mut lsp_items) => actions.append(&mut lsp_items),
Err(err) => log::error!("while gathering code actions: {err}"),
}
}
let call = move |editor: &mut Editor, compositor: &mut Compositor| {
@ -753,22 +796,16 @@ pub fn code_action(cx: &mut Context) {
match &action.lsp_item {
lsp::CodeActionOrCommand::Command(command) => {
log::debug!("code action command: {:?}", command);
execute_lsp_command(editor, action.language_server_id, command.clone());
editor.execute_lsp_command(command.clone(), action.language_server_id);
}
lsp::CodeActionOrCommand::CodeAction(code_action) => {
log::debug!("code action: {:?}", code_action);
// we support lsp "codeAction/resolve" for `edit` and `command` fields
let mut resolved_code_action = None;
if code_action.edit.is_none() || code_action.command.is_none() {
if let Some(future) =
language_server.resolve_code_action(code_action.clone())
{
if let Ok(response) = helix_lsp::block_on(future) {
if let Ok(code_action) =
serde_json::from_value::<CodeAction>(response)
{
resolved_code_action = Some(code_action);
}
if let Some(future) = language_server.resolve_code_action(code_action) {
if let Ok(code_action) = helix_lsp::block_on(future) {
resolved_code_action = Some(code_action);
}
}
}
@ -782,7 +819,7 @@ pub fn code_action(cx: &mut Context) {
// if code action provides both edit and command first the edit
// should be applied and then the command
if let Some(command) = &code_action.command {
execute_lsp_command(editor, action.language_server_id, command.clone());
editor.execute_lsp_command(command.clone(), action.language_server_id);
}
}
}
@ -798,33 +835,6 @@ pub fn code_action(cx: &mut Context) {
});
}
pub fn execute_lsp_command(
editor: &mut Editor,
language_server_id: LanguageServerId,
cmd: lsp::Command,
) {
// the command is executed on the server and communicated back
// to the client asynchronously using workspace edits
let future = match editor
.language_server_by_id(language_server_id)
.and_then(|language_server| language_server.command(cmd))
{
Some(future) => future,
None => {
editor.set_error("Language server does not support executing commands");
return;
}
};
tokio::spawn(async move {
let res = future.await;
if let Err(e) = res {
log::error!("execute LSP command: {}", e);
}
});
}
#[derive(Debug)]
pub struct ApplyEditError {
pub kind: ApplyEditErrorKind,
@ -853,17 +863,12 @@ impl Display for ApplyEditErrorKind {
}
/// Precondition: `locations` should be non-empty.
fn goto_impl(
editor: &mut Editor,
compositor: &mut Compositor,
locations: Vec<Location>,
offset_encoding: OffsetEncoding,
) {
fn goto_impl(editor: &mut Editor, compositor: &mut Compositor, locations: Vec<Location>) {
let cwdir = helix_stdx::env::current_working_dir();
match locations.as_slice() {
[location] => {
jump_to_location(editor, location, offset_encoding, Action::Replace);
jump_to_location(editor, location, Action::Replace);
}
[] => unreachable!("`locations` should be non-empty for `goto_impl`"),
_locations => {
@ -880,58 +885,73 @@ fn goto_impl(
},
)];
let picker = Picker::new(columns, 0, locations, cwdir, move |cx, location, action| {
jump_to_location(cx.editor, location, offset_encoding, action)
let picker = Picker::new(columns, 0, locations, cwdir, |cx, location, action| {
jump_to_location(cx.editor, location, action)
})
.with_preview(move |_editor, location| location_to_file_location(location));
.with_preview(|_editor, location| location_to_file_location(location));
compositor.push(Box::new(overlaid(picker)));
}
}
}
fn to_locations(definitions: Option<lsp::GotoDefinitionResponse>) -> Vec<Location> {
match definitions {
Some(lsp::GotoDefinitionResponse::Scalar(location)) => {
lsp_location_to_location(location).into_iter().collect()
}
Some(lsp::GotoDefinitionResponse::Array(locations)) => locations
.into_iter()
.flat_map(lsp_location_to_location)
.collect(),
Some(lsp::GotoDefinitionResponse::Link(locations)) => locations
.into_iter()
.map(|location_link| {
lsp::Location::new(location_link.target_uri, location_link.target_range)
})
.flat_map(lsp_location_to_location)
.collect(),
None => Vec::new(),
}
}
fn goto_single_impl<P, F>(cx: &mut Context, feature: LanguageServerFeature, request_provider: P)
where
P: Fn(&Client, lsp::Position, lsp::TextDocumentIdentifier) -> Option<F>,
F: Future<Output = helix_lsp::Result<serde_json::Value>> + 'static + Send,
F: Future<Output = helix_lsp::Result<Option<lsp::GotoDefinitionResponse>>> + 'static + Send,
{
let (view, doc) = current!(cx.editor);
let (view, doc) = current_ref!(cx.editor);
let mut futures: FuturesOrdered<_> = doc
.language_servers_with_feature(feature)
.map(|language_server| {
let offset_encoding = language_server.offset_encoding();
let pos = doc.position(view.id, offset_encoding);
let future = request_provider(language_server, pos, doc.identifier()).unwrap();
async move { anyhow::Ok((future.await?, offset_encoding)) }
})
.collect();
let language_server = language_server_with_feature!(cx.editor, doc, feature);
let offset_encoding = language_server.offset_encoding();
let pos = doc.position(view.id, offset_encoding);
let future = request_provider(language_server, pos, doc.identifier()).unwrap();
cx.callback(
future,
move |editor, compositor, response: Option<lsp::GotoDefinitionResponse>| {
let items = to_locations(response);
if items.is_empty() {
cx.jobs.callback(async move {
let mut locations = Vec::new();
while let Some(response) = futures.next().await {
match response {
Ok((response, offset_encoding)) => match response {
Some(lsp::GotoDefinitionResponse::Scalar(lsp_location)) => {
locations.extend(lsp_location_to_location(lsp_location, offset_encoding));
}
Some(lsp::GotoDefinitionResponse::Array(lsp_locations)) => {
locations.extend(lsp_locations.into_iter().flat_map(|location| {
lsp_location_to_location(location, offset_encoding)
}));
}
Some(lsp::GotoDefinitionResponse::Link(lsp_locations)) => {
locations.extend(
lsp_locations
.into_iter()
.map(|location_link| {
lsp::Location::new(
location_link.target_uri,
location_link.target_range,
)
})
.flat_map(|location| {
lsp_location_to_location(location, offset_encoding)
}),
);
}
None => (),
},
Err(err) => log::error!("Error requesting locations: {err}"),
}
}
let call = move |editor: &mut Editor, compositor: &mut Compositor| {
if locations.is_empty() {
editor.set_error("No definition found.");
} else {
goto_impl(editor, compositor, items, offset_encoding);
goto_impl(editor, compositor, locations);
}
},
);
};
Ok(Callback::EditorCompositor(Box::new(call)))
});
}
pub fn goto_declaration(cx: &mut Context) {
@ -968,38 +988,47 @@ pub fn goto_implementation(cx: &mut Context) {
pub fn goto_reference(cx: &mut Context) {
let config = cx.editor.config();
let (view, doc) = current!(cx.editor);
let (view, doc) = current_ref!(cx.editor);
// TODO could probably support multiple language servers,
// not sure if there's a real practical use case for this though
let language_server =
language_server_with_feature!(cx.editor, doc, LanguageServerFeature::GotoReference);
let offset_encoding = language_server.offset_encoding();
let pos = doc.position(view.id, offset_encoding);
let future = language_server
.goto_reference(
doc.identifier(),
pos,
config.lsp.goto_reference_include_declaration,
None,
)
.unwrap();
let mut futures: FuturesOrdered<_> = doc
.language_servers_with_feature(LanguageServerFeature::GotoReference)
.map(|language_server| {
let offset_encoding = language_server.offset_encoding();
let pos = doc.position(view.id, offset_encoding);
let future = language_server
.goto_reference(
doc.identifier(),
pos,
config.lsp.goto_reference_include_declaration,
None,
)
.unwrap();
async move { anyhow::Ok((future.await?, offset_encoding)) }
})
.collect();
cx.callback(
future,
move |editor, compositor, response: Option<Vec<lsp::Location>>| {
let items: Vec<Location> = response
.into_iter()
.flatten()
.flat_map(lsp_location_to_location)
.collect();
if items.is_empty() {
cx.jobs.callback(async move {
let mut locations = Vec::new();
while let Some(response) = futures.next().await {
match response {
Ok((lsp_locations, offset_encoding)) => locations.extend(
lsp_locations
.into_iter()
.flatten()
.flat_map(|location| lsp_location_to_location(location, offset_encoding)),
),
Err(err) => log::error!("Error requesting references: {err}"),
}
}
let call = move |editor: &mut Editor, compositor: &mut Compositor| {
if locations.is_empty() {
editor.set_error("No references found.");
} else {
goto_impl(editor, compositor, items, offset_encoding);
goto_impl(editor, compositor, locations);
}
},
);
};
Ok(Callback::EditorCompositor(Box::new(call)))
});
}
pub fn signature_help(cx: &mut Context) {
@ -1009,54 +1038,59 @@ pub fn signature_help(cx: &mut Context) {
}
pub fn hover(cx: &mut Context) {
use ui::lsp::hover::Hover;
let (view, doc) = current!(cx.editor);
if doc
.language_servers_with_feature(LanguageServerFeature::Hover)
.count()
== 0
{
cx.editor
.set_error("No configured language server supports hover");
return;
}
// TODO support multiple language servers (merge UI somehow)
let language_server =
language_server_with_feature!(cx.editor, doc, LanguageServerFeature::Hover);
// TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier
let pos = doc.position(view.id, language_server.offset_encoding());
let future = language_server
.text_document_hover(doc.identifier(), pos, None)
.unwrap();
let mut seen_language_servers = HashSet::new();
let mut futures: FuturesOrdered<_> = doc
.language_servers_with_feature(LanguageServerFeature::Hover)
.filter(|ls| seen_language_servers.insert(ls.id()))
.map(|language_server| {
let server_name = language_server.name().to_string();
// TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier
let pos = doc.position(view.id, language_server.offset_encoding());
let request = language_server
.text_document_hover(doc.identifier(), pos, None)
.unwrap();
cx.callback(
future,
move |editor, compositor, response: Option<lsp::Hover>| {
if let Some(hover) = response {
// hover.contents / .range <- used for visualizing
async move { anyhow::Ok((server_name, request.await?)) }
})
.collect();
fn marked_string_to_markdown(contents: lsp::MarkedString) -> String {
match contents {
lsp::MarkedString::String(contents) => contents,
lsp::MarkedString::LanguageString(string) => {
if string.language == "markdown" {
string.value
} else {
format!("```{}\n{}\n```", string.language, string.value)
}
}
}
}
cx.jobs.callback(async move {
let mut hovers: Vec<(String, lsp::Hover)> = Vec::new();
let contents = match hover.contents {
lsp::HoverContents::Scalar(contents) => marked_string_to_markdown(contents),
lsp::HoverContents::Array(contents) => contents
.into_iter()
.map(marked_string_to_markdown)
.collect::<Vec<_>>()
.join("\n\n"),
lsp::HoverContents::Markup(contents) => contents.value,
};
// skip if contents empty
let contents = ui::Markdown::new(contents, editor.syn_loader.clone());
let popup = Popup::new("hover", contents).auto_close(true);
compositor.replace_or_push("hover", popup);
while let Some(response) = futures.next().await {
match response {
Ok((server_name, Some(hover))) => hovers.push((server_name, hover)),
Ok(_) => (),
Err(err) => log::error!("Error requesting hover: {err}"),
}
},
);
}
let call = move |editor: &mut Editor, compositor: &mut Compositor| {
if hovers.is_empty() {
editor.set_status("No hover results available.");
return;
}
// create new popup
let contents = Hover::new(hovers, editor.syn_loader.clone());
let popup = Popup::new(Hover::ID, contents).auto_close(true);
compositor.replace_or_push(Hover::ID, popup);
};
Ok(Callback::EditorCompositor(Box::new(call)))
});
}
pub fn rename_symbol(cx: &mut Context) {
@ -1131,7 +1165,9 @@ pub fn rename_symbol(cx: &mut Context) {
match block_on(future) {
Ok(edits) => {
let _ = cx.editor.apply_workspace_edit(offset_encoding, &edits);
let _ = cx
.editor
.apply_workspace_edit(offset_encoding, &edits.unwrap_or_default());
}
Err(err) => cx.editor.set_error(err.to_string()),
}
@ -1288,7 +1324,7 @@ fn compute_inlay_hints_for_view(
if !doc.inlay_hints_oudated
&& doc
.inlay_hints(view_id)
.map_or(false, |dih| dih.id == new_doc_inlay_hints_id)
.is_some_and(|dih| dih.id == new_doc_inlay_hints_id)
{
return None;
}

File diff suppressed because it is too large Load diff

View file

@ -137,9 +137,12 @@ impl Compositor {
}
pub fn handle_event(&mut self, event: &Event, cx: &mut Context) -> bool {
// If it is a key event and a macro is being recorded, push the key event to the recording.
// 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.
if let (Event::Key(key), Some((_, keys))) = (event, &mut cx.editor.macro_recording) {
keys.push(*key);
if cx.editor.macro_replaying.is_empty() {
keys.push(*key);
}
}
let mut callbacks = Vec::new();

View file

@ -2,11 +2,13 @@ use crate::keymap;
use crate::keymap::{merge_keys, KeyTrie};
use helix_loader::merge_toml_values;
use helix_view::document::Mode;
use helix_view::icons::{Icons, ICONS};
use serde::Deserialize;
use std::collections::HashMap;
use std::fmt::Display;
use std::fs;
use std::io::Error as IOError;
use std::sync::Arc;
use toml::de::Error as TomlError;
#[derive(Debug, Clone, PartialEq)]
@ -22,6 +24,7 @@ pub struct ConfigRaw {
pub theme: Option<String>,
pub keys: Option<HashMap<Mode, KeyTrie>>,
pub editor: Option<toml::Value>,
pub icons: Option<toml::Value>,
}
impl Default for Config {
@ -64,6 +67,7 @@ impl Config {
global.and_then(|file| toml::from_str(&file).map_err(ConfigLoadError::BadConfig));
let local_config: Result<ConfigRaw, ConfigLoadError> =
local.and_then(|file| toml::from_str(&file).map_err(ConfigLoadError::BadConfig));
let res = match (global_config, local_config) {
(Ok(global), Ok(local)) => {
let mut keys = keymap::default();
@ -84,6 +88,18 @@ impl Config {
.map_err(ConfigLoadError::BadConfig)?,
};
let icons: Icons = match (global.icons, local.icons) {
(None, None) => Icons::default(),
(None, Some(val)) | (Some(val), None) => {
val.try_into().map_err(ConfigLoadError::BadConfig)?
}
(Some(global), Some(local)) => merge_toml_values(global, local, 3)
.try_into()
.map_err(ConfigLoadError::BadConfig)?,
};
ICONS.store(Arc::new(icons));
Config {
theme: local.theme.or(global.theme),
keys,
@ -100,6 +116,14 @@ impl Config {
if let Some(keymap) = config.keys {
merge_keys(&mut keys, keymap);
}
let icons = config.icons.map_or_else(
|| Ok(Icons::default()),
|val| val.try_into().map_err(ConfigLoadError::BadConfig),
)?;
ICONS.store(Arc::new(icons));
Config {
theme: config.theme,
keys,

View file

@ -1,7 +1,8 @@
use helix_event::{events, register_event};
use helix_view::document::Mode;
use helix_view::events::{
DiagnosticsDidChange, DocumentDidChange, DocumentFocusLost, SelectionDidChange,
DiagnosticsDidChange, DocumentDidChange, DocumentDidClose, DocumentDidOpen, DocumentFocusLost,
EditorConfigDidChange, LanguageServerExited, LanguageServerInitialized, SelectionDidChange,
};
use crate::commands;
@ -17,8 +18,13 @@ pub fn register() {
register_event::<OnModeSwitch>();
register_event::<PostInsertChar>();
register_event::<PostCommand>();
register_event::<DocumentDidOpen>();
register_event::<DocumentDidChange>();
register_event::<EditorConfigDidChange>();
register_event::<DocumentDidClose>();
register_event::<DocumentFocusLost>();
register_event::<SelectionDidChange>();
register_event::<DiagnosticsDidChange>();
register_event::<LanguageServerInitialized>();
register_event::<LanguageServerExited>();
}

View file

@ -6,35 +6,48 @@ use helix_event::AsyncHook;
use crate::config::Config;
use crate::events;
use crate::handlers::auto_save::AutoSaveHandler;
use crate::handlers::completion::CompletionHandler;
use crate::handlers::diagnostics::PullDiagnosticsHandler;
use crate::handlers::signature_help::SignatureHelpHandler;
pub use completion::trigger_auto_completion;
pub use helix_view::handlers::Handlers;
use self::blame::BlameHandler;
use self::document_colors::DocumentColorsHandler;
mod auto_save;
pub mod blame;
pub mod completion;
mod diagnostics;
pub mod diagnostics;
mod document_colors;
mod signature_help;
mod snippet;
pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
events::register();
let completions = CompletionHandler::new(config).spawn();
let event_tx = completion::CompletionHandler::new(config).spawn();
let signature_hints = SignatureHelpHandler::new().spawn();
let auto_save = AutoSaveHandler::new().spawn();
let document_colors = DocumentColorsHandler::default().spawn();
let blame = BlameHandler::default().spawn();
let pull_diagnostics = PullDiagnosticsHandler::new().spawn();
let handlers = Handlers {
completions,
completions: helix_view::handlers::completion::CompletionHandler::new(event_tx),
signature_hints,
auto_save,
document_colors,
blame,
pull_diagnostics,
};
helix_view::handlers::register_hooks(&handlers);
completion::register_hooks(&handlers);
signature_help::register_hooks(&handlers);
auto_save::register_hooks(&handlers);
diagnostics::register_hooks(&handlers);
snippet::register_hooks(&handlers);
document_colors::register_hooks(&handlers);
blame::register_hooks(&handlers);
handlers
}

View file

@ -87,7 +87,13 @@ fn request_auto_save(editor: &mut Editor) {
jobs: &mut Jobs::new(),
};
if let Err(e) = commands::typed::write_all_impl(context, false, false) {
let options = commands::WriteAllOptions {
force: false,
write_scratch: false,
auto_format: false,
};
if let Err(e) = commands::typed::write_all_impl(context, options) {
context.editor.set_error(format!("{}", e));
}
}

View file

@ -0,0 +1,100 @@
use std::{mem, time::Duration};
use helix_event::register_hook;
use helix_vcs::FileBlame;
use helix_view::{
editor::InlineBlameCompute,
events::{DocumentDidOpen, EditorConfigDidChange},
handlers::{BlameEvent, Handlers},
DocumentId,
};
use tokio::time::Instant;
use crate::job;
#[derive(Default)]
pub struct BlameHandler {
file_blame: Option<anyhow::Result<FileBlame>>,
doc_id: DocumentId,
show_blame_for_line_in_statusline: Option<u32>,
}
impl helix_event::AsyncHook for BlameHandler {
type Event = BlameEvent;
fn handle_event(
&mut self,
event: Self::Event,
_timeout: Option<tokio::time::Instant>,
) -> Option<tokio::time::Instant> {
self.doc_id = event.doc_id;
self.show_blame_for_line_in_statusline = event.line;
self.file_blame = Some(FileBlame::try_new(event.path));
Some(Instant::now() + Duration::from_millis(50))
}
fn finish_debounce(&mut self) {
let doc_id = self.doc_id;
let line_blame = self.show_blame_for_line_in_statusline;
let result = mem::take(&mut self.file_blame);
if let Some(result) = result {
tokio::spawn(async move {
job::dispatch(move |editor, _| {
let Some(doc) = editor.document_mut(doc_id) else {
return;
};
doc.file_blame = Some(result);
if editor.config().inline_blame.compute == InlineBlameCompute::OnDemand {
if let Some(line) = line_blame {
crate::commands::blame_line_impl(editor, doc_id, line);
} else {
editor.set_status("Blame for this file is now available")
}
}
})
.await;
});
}
}
}
pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.blame.clone();
register_hook!(move |event: &mut DocumentDidOpen<'_>| {
if event.editor.config().inline_blame.compute != InlineBlameCompute::OnDemand {
helix_event::send_blocking(
&tx,
BlameEvent {
path: event.path.to_path_buf(),
doc_id: event.doc,
line: None,
},
);
}
Ok(())
});
let tx = handlers.blame.clone();
register_hook!(move |event: &mut EditorConfigDidChange<'_>| {
let has_enabled_inline_blame = event.old_config.inline_blame.compute
== InlineBlameCompute::OnDemand
&& event.editor.config().inline_blame.compute == InlineBlameCompute::Background;
if has_enabled_inline_blame {
// request blame for all documents, since any of them could have
// outdated blame
for doc in event.editor.documents() {
if let Some(path) = doc.path() {
helix_event::send_blocking(
&tx,
BlameEvent {
path: path.to_path_buf(),
doc_id: doc.id(),
line: None,
},
);
}
}
}
Ok(())
});
}

View file

@ -1,309 +1,90 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use std::collections::HashMap;
use arc_swap::ArcSwap;
use futures_util::stream::FuturesUnordered;
use futures_util::FutureExt;
use helix_core::chars::char_is_word;
use helix_core::completion::CompletionProvider;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{cancelable_future, register_hook, send_blocking, TaskController, TaskHandle};
use helix_event::{register_hook, TaskHandle};
use helix_lsp::lsp;
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::{Mode, SavePoint};
use helix_view::handlers::lsp::CompletionEvent;
use helix_view::{DocumentId, Editor, ViewId};
use path::path_completion;
use tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use tokio_stream::StreamExt as _;
use helix_view::document::Mode;
use helix_view::handlers::completion::{CompletionEvent, ResponseContext};
use helix_view::Editor;
use tokio::task::JoinSet;
use crate::commands;
use crate::compositor::Compositor;
use crate::config::Config;
use crate::events::{OnModeSwitch, PostCommand, PostInsertChar};
use crate::job::{dispatch, dispatch_blocking};
use crate::handlers::completion::request::{request_incomplete_completion_list, Trigger};
use crate::job::dispatch;
use crate::keymap::MappableCommand;
use crate::ui::editor::InsertEvent;
use crate::ui::lsp::SignatureHelp;
use crate::ui::lsp::signature_help::SignatureHelp;
use crate::ui::{self, Popup};
use super::Handlers;
pub use item::{CompletionItem, LspCompletionItem};
pub use item::{CompletionItem, CompletionItems, CompletionResponse, LspCompletionItem};
pub use request::CompletionHandler;
pub use resolve::ResolveHandler;
mod item;
mod path;
mod request;
mod resolve;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum TriggerKind {
Auto,
TriggerChar,
Manual,
}
#[derive(Debug, Clone, Copy)]
struct Trigger {
pos: usize,
view: ViewId,
doc: DocumentId,
kind: TriggerKind,
}
#[derive(Debug)]
pub(super) struct CompletionHandler {
/// currently active trigger which will cause a
/// completion request after the timeout
trigger: Option<Trigger>,
in_flight: Option<Trigger>,
task_controller: TaskController,
config: Arc<ArcSwap<Config>>,
}
impl CompletionHandler {
pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
Self {
config,
task_controller: TaskController::new(),
trigger: None,
in_flight: None,
async fn handle_response(
requests: &mut JoinSet<CompletionResponse>,
is_incomplete: bool,
) -> Option<CompletionResponse> {
loop {
let response = requests.join_next().await?.unwrap();
if !is_incomplete && !response.context.is_incomplete && response.items.is_empty() {
continue;
}
return Some(response);
}
}
impl helix_event::AsyncHook for CompletionHandler {
type Event = CompletionEvent;
fn handle_event(
&mut self,
event: Self::Event,
_old_timeout: Option<Instant>,
) -> Option<Instant> {
if self.in_flight.is_some() && !self.task_controller.is_running() {
self.in_flight = None;
}
match event {
CompletionEvent::AutoTrigger {
cursor: trigger_pos,
doc,
view,
} => {
// techically it shouldn't be possible to switch views/documents in insert mode
// but people may create weird keymaps/use the mouse so lets be extra careful
if self
.trigger
.or(self.in_flight)
.map_or(true, |trigger| trigger.doc != doc || trigger.view != view)
{
self.trigger = Some(Trigger {
pos: trigger_pos,
view,
doc,
kind: TriggerKind::Auto,
});
}
}
CompletionEvent::TriggerChar { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.task_controller.cancel();
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::TriggerChar,
});
}
CompletionEvent::ManualTrigger { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::Manual,
});
// stop debouncing immediately and request the completion
self.finish_debounce();
return None;
}
CompletionEvent::Cancel => {
self.trigger = None;
self.task_controller.cancel();
}
CompletionEvent::DeleteText { cursor } => {
// if we deleted the original trigger, abort the completion
if matches!(self.trigger.or(self.in_flight), Some(Trigger{ pos, .. }) if cursor < pos)
{
self.trigger = None;
self.task_controller.cancel();
}
}
}
self.trigger.map(|trigger| {
// if the current request was closed forget about it
// otherwise immediately restart the completion request
let timeout = if trigger.kind == TriggerKind::Auto {
self.config.load().editor.completion_timeout
} else {
// we want almost instant completions for trigger chars
// and restarting completion requests. The small timeout here mainly
// serves to better handle cases where the completion handler
// may fall behind (so multiple events in the channel) and macros
Duration::from_millis(5)
};
Instant::now() + timeout
})
}
fn finish_debounce(&mut self) {
let trigger = self.trigger.take().expect("debounce always has a trigger");
self.in_flight = Some(trigger);
let handle = self.task_controller.restart();
dispatch_blocking(move |editor, compositor| {
request_completion(trigger, handle, editor, compositor)
});
}
}
fn request_completion(
mut trigger: Trigger,
async fn replace_completions(
handle: TaskHandle,
editor: &mut Editor,
compositor: &mut Compositor,
mut requests: JoinSet<CompletionResponse>,
is_incomplete: bool,
) {
let (view, doc) = current!(editor);
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.is_some()
|| editor.mode != Mode::Insert
{
return;
}
let text = doc.text();
let cursor = doc.selection(view.id).primary().cursor(text.slice(..));
if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
return;
}
// this looks odd... Why are we not using the trigger position from
// the `trigger` here? Won't that mean that the trigger char doesn't get
// send to the LS if we type fast enougn? Yes that is true but it's
// not actually a problem. The LSP will resolve the completion to the identifier
// anyway (in fact sending the later position is necessary to get the right results
// from LSPs that provide incomplete completion list). We rely on trigger offset
// and primary cursor matching for multi-cursor completions so this is definitely
// necessary from our side too.
trigger.pos = cursor;
let trigger_text = text.slice(..cursor);
let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.map(|ls| {
let language_server_id = ls.id();
let offset_encoding = ls.offset_encoding();
let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
let doc_id = doc.identifier();
let context = if trigger.kind == TriggerKind::Manual {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
} else {
let trigger_char =
ls.capabilities()
.completion_provider
.as_ref()
.and_then(|provider| {
provider
.trigger_characters
.as_deref()?
.iter()
.find(|&trigger| trigger_text.ends_with(trigger))
});
if trigger_char.is_some() {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
} else {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
}
};
let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
async move {
let json = completion_response.await?;
let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
let items = match response {
Some(lsp::CompletionResponse::Array(items)) => items,
// TODO: do something with is_incomplete
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete: _is_incomplete,
items,
})) => items,
None => Vec::new(),
}
.into_iter()
.map(|item| {
CompletionItem::Lsp(LspCompletionItem {
item,
provider: language_server_id,
resolved: false,
})
})
.collect();
anyhow::Ok(items)
}
.boxed()
})
.chain(path_completion(cursor, text.clone(), doc, handle.clone()))
.collect();
let future = async move {
let mut items = Vec::new();
while let Some(lsp_items) = futures.next().await {
match lsp_items {
Ok(mut lsp_items) => items.append(&mut lsp_items),
Err(err) => {
log::debug!("completion request failed: {err:?}");
}
};
}
items
};
let savepoint = doc.savepoint(view);
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
tokio::spawn(async move {
let items = cancelable_future(future, &handle).await;
let Some(items) = items.filter(|items| !items.is_empty()) else {
return;
};
while let Some(mut response) = handle_response(&mut requests, is_incomplete).await {
let handle = handle.clone();
dispatch(move |editor, compositor| {
show_completion(editor, compositor, items, trigger, savepoint);
drop(handle)
let editor_view = compositor.find::<ui::EditorView>().unwrap();
let Some(completion) = &mut editor_view.completion else {
return;
};
if handle.is_canceled() {
log::info!("dropping outdated completion response");
return;
}
completion.replace_provider_completions(&mut response, is_incomplete);
if completion.is_empty() {
editor_view.clear_completion(editor);
// clearing completions might mean we want to immediately re-request them (usually
// this occurs if typing a trigger char)
trigger_auto_completion(editor, false);
} else {
editor
.handlers
.completions
.active_completions
.insert(response.provider, response.context);
}
})
.await
});
.await;
}
}
fn show_completion(
editor: &mut Editor,
compositor: &mut Compositor,
items: Vec<CompletionItem>,
context: HashMap<CompletionProvider, ResponseContext>,
trigger: Trigger,
savepoint: Arc<SavePoint>,
) {
let (view, doc) = current_ref!(editor);
// check if the completion request is stale.
@ -320,8 +101,9 @@ fn show_completion(
if ui.completion.is_some() {
return;
}
editor.handlers.completions.active_completions = context;
let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size);
let completion_area = ui.set_completion(editor, items, trigger.pos, size);
let signature_help_area = compositor
.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
.map(|signature_help| signature_help.area(size, editor));
@ -331,11 +113,7 @@ fn show_completion(
}
}
pub fn trigger_auto_completion(
tx: &Sender<CompletionEvent>,
editor: &Editor,
trigger_char_only: bool,
) {
pub fn trigger_auto_completion(editor: &Editor, trigger_char_only: bool) {
let config = editor.config.load();
if !config.auto_completion {
return;
@ -363,15 +141,13 @@ pub fn trigger_auto_completion(
#[cfg(not(windows))]
let is_path_completion_trigger = matches!(cursor_char, Some(b'/'));
let handler = &editor.handlers.completions;
if is_trigger_char || (is_path_completion_trigger && doc.path_completion_enabled()) {
send_blocking(
tx,
CompletionEvent::TriggerChar {
cursor,
doc: doc.id(),
view: view.id,
},
);
handler.event(CompletionEvent::TriggerChar {
cursor,
doc: doc.id(),
view: view.id,
});
return;
}
@ -384,29 +160,29 @@ pub fn trigger_auto_completion(
.all(char_is_word);
if is_auto_trigger {
send_blocking(
tx,
CompletionEvent::AutoTrigger {
cursor,
doc: doc.id(),
view: view.id,
},
);
handler.event(CompletionEvent::AutoTrigger {
cursor,
doc: doc.id(),
view: view.id,
});
}
}
fn update_completions(cx: &mut commands::Context, c: Option<char>) {
fn update_completion_filter(cx: &mut commands::Context, c: Option<char>) {
cx.callback.push(Box::new(move |compositor, cx| {
let editor_view = compositor.find::<ui::EditorView>().unwrap();
if let Some(completion) = &mut editor_view.completion {
completion.update_filter(c);
if completion.is_empty() {
if completion.is_empty() || c.is_some_and(|c| !char_is_word(c)) {
editor_view.clear_completion(cx.editor);
// clearing completions might mean we want to immediately rerequest them (usually
// this occurs if typing a trigger char)
if c.is_some() {
trigger_auto_completion(&cx.editor.handlers.completions, cx.editor, false);
trigger_auto_completion(cx.editor, false);
}
} else {
let handle = cx.editor.handlers.completions.request_controller.restart();
request_incomplete_completion_list(cx.editor, handle)
}
}
}))
@ -420,7 +196,6 @@ fn clear_completions(cx: &mut commands::Context) {
}
fn completion_post_command_hook(
tx: &Sender<CompletionEvent>,
PostCommand { command, cx }: &mut PostCommand<'_, '_>,
) -> anyhow::Result<()> {
if cx.editor.mode == Mode::Insert {
@ -433,7 +208,7 @@ fn completion_post_command_hook(
MappableCommand::Static {
name: "delete_char_backward",
..
} => update_completions(cx, None),
} => update_completion_filter(cx, None),
_ => clear_completions(cx),
}
} else {
@ -459,33 +234,35 @@ fn completion_post_command_hook(
} => return Ok(()),
_ => CompletionEvent::Cancel,
};
send_blocking(tx, event);
cx.editor.handlers.completions.event(event);
}
}
Ok(())
}
pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.completions.clone();
register_hook!(move |event: &mut PostCommand<'_, '_>| completion_post_command_hook(&tx, event));
pub(super) fn register_hooks(_handlers: &Handlers) {
register_hook!(move |event: &mut PostCommand<'_, '_>| completion_post_command_hook(event));
let tx = handlers.completions.clone();
register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
if event.old_mode == Mode::Insert {
send_blocking(&tx, CompletionEvent::Cancel);
event
.cx
.editor
.handlers
.completions
.event(CompletionEvent::Cancel);
clear_completions(event.cx);
} else if event.new_mode == Mode::Insert {
trigger_auto_completion(&tx, event.cx.editor, false)
trigger_auto_completion(event.cx.editor, false)
}
Ok(())
});
let tx = handlers.completions.clone();
register_hook!(move |event: &mut PostInsertChar<'_, '_>| {
if event.cx.editor.last_completion.is_some() {
update_completions(event.cx, Some(event.c))
update_completion_filter(event.cx, Some(event.c))
} else {
trigger_auto_completion(&tx, event.cx.editor, false);
trigger_auto_completion(event.cx.editor, false);
}
Ok(())
});

View file

@ -1,10 +1,70 @@
use std::mem;
use helix_core::completion::CompletionProvider;
use helix_lsp::{lsp, LanguageServerId};
use helix_view::handlers::completion::ResponseContext;
pub struct CompletionResponse {
pub items: CompletionItems,
pub provider: CompletionProvider,
pub context: ResponseContext,
}
pub enum CompletionItems {
Lsp(Vec<lsp::CompletionItem>),
Other(Vec<CompletionItem>),
}
impl CompletionItems {
pub fn is_empty(&self) -> bool {
match self {
CompletionItems::Lsp(items) => items.is_empty(),
CompletionItems::Other(items) => items.is_empty(),
}
}
}
impl CompletionResponse {
pub fn take_items(&mut self, dst: &mut Vec<CompletionItem>) {
match &mut self.items {
CompletionItems::Lsp(items) => dst.extend(items.drain(..).map(|item| {
CompletionItem::Lsp(LspCompletionItem {
item,
provider: match self.provider {
CompletionProvider::Lsp(provider) => provider,
_ => unreachable!(),
},
resolved: false,
provider_priority: self.context.priority,
})
})),
CompletionItems::Other(items) if dst.is_empty() => mem::swap(dst, items),
CompletionItems::Other(items) => dst.append(items),
}
}
}
#[derive(Debug, PartialEq, Clone)]
pub struct LspCompletionItem {
pub item: lsp::CompletionItem,
pub provider: LanguageServerId,
pub resolved: bool,
// TODO: we should not be filtering and sorting incomplete completion list
// according to the spec but vscode does that anyway and most servers (
// including rust-analyzer) rely on that.. so we can't do that without
// breaking completions.
pub provider_priority: i8,
}
impl LspCompletionItem {
#[inline]
pub fn filter_text(&self) -> &str {
self.item
.filter_text
.as_ref()
.unwrap_or(&self.item.label)
.as_str()
}
}
#[derive(Debug, PartialEq, Clone)]
@ -13,6 +73,16 @@ pub enum CompletionItem {
Other(helix_core::CompletionItem),
}
impl CompletionItem {
#[inline]
pub fn filter_text(&self) -> &str {
match self {
CompletionItem::Lsp(item) => item.filter_text(),
CompletionItem::Other(item) => &item.label,
}
}
}
impl PartialEq<CompletionItem> for LspCompletionItem {
fn eq(&self, other: &CompletionItem) -> bool {
match other {
@ -32,6 +102,21 @@ impl PartialEq<CompletionItem> for helix_core::CompletionItem {
}
impl CompletionItem {
pub fn provider_priority(&self) -> i8 {
match self {
CompletionItem::Lsp(item) => item.provider_priority,
// sorting path completions after LSP for now
CompletionItem::Other(_) => 1,
}
}
pub fn provider(&self) -> CompletionProvider {
match self {
CompletionItem::Lsp(item) => CompletionProvider::Lsp(item.provider),
CompletionItem::Other(item) => item.provider,
}
}
pub fn preselect(&self) -> bool {
match self {
CompletionItem::Lsp(LspCompletionItem { item, .. }) => item.preselect.unwrap_or(false),

View file

@ -3,28 +3,29 @@ use std::{
fs,
path::{Path, PathBuf},
str::FromStr as _,
sync::Arc,
};
use futures_util::{future::BoxFuture, FutureExt as _};
use helix_core as core;
use helix_core::Transaction;
use helix_core::{self as core, completion::CompletionProvider, Selection, Transaction};
use helix_event::TaskHandle;
use helix_stdx::path::{self, canonicalize, fold_home_dir, get_path_suffix};
use helix_view::Document;
use helix_view::{document::SavePoint, handlers::completion::ResponseContext, Document};
use url::Url;
use super::item::CompletionItem;
use crate::handlers::completion::{item::CompletionResponse, CompletionItem, CompletionItems};
pub(crate) fn path_completion(
cursor: usize,
text: core::Rope,
selection: Selection,
doc: &Document,
handle: TaskHandle,
) -> Option<BoxFuture<'static, anyhow::Result<Vec<CompletionItem>>>> {
savepoint: Arc<SavePoint>,
) -> Option<impl FnOnce() -> CompletionResponse> {
if !doc.path_completion_enabled() {
return None;
}
let text = doc.text().clone();
let cursor = selection.primary().cursor(text.slice(..));
let cur_line = text.char_to_line(cursor);
let start = text.line_to_char(cur_line).max(cursor.saturating_sub(1000));
let line_until_cursor = text.slice(start..cursor);
@ -67,12 +68,27 @@ pub(crate) fn path_completion(
return None;
}
let future = tokio::task::spawn_blocking(move || {
// TODO: handle properly in the future
const PRIORITY: i8 = 1;
let future = move || {
let Ok(read_dir) = std::fs::read_dir(&dir_path) else {
return Vec::new();
return CompletionResponse {
items: CompletionItems::Other(Vec::new()),
provider: CompletionProvider::Path,
context: ResponseContext {
is_incomplete: false,
priority: PRIORITY,
savepoint,
},
};
};
read_dir
let edit_diff = typed_file_name
.as_ref()
.map(|s| s.chars().count())
.unwrap_or_default();
let res: Vec<_> = read_dir
.filter_map(Result::ok)
.filter_map(|dir_entry| {
dir_entry
@ -88,27 +104,32 @@ pub(crate) fn path_completion(
let kind = path_kind(&md);
let documentation = path_documentation(&md, &dir_path.join(&file_name), kind);
let edit_diff = typed_file_name
.as_ref()
.map(|f| f.len())
.unwrap_or_default();
let transaction = Transaction::change(
&text,
std::iter::once((cursor - edit_diff, cursor, Some((&file_name).into()))),
);
let transaction = Transaction::change_by_selection(&text, &selection, |range| {
let cursor = range.cursor(text.slice(..));
(cursor - edit_diff, cursor, Some((&file_name).into()))
});
Some(CompletionItem::Other(core::CompletionItem {
kind: Cow::Borrowed(kind),
label: file_name.into(),
transaction,
documentation,
documentation: Some(documentation),
provider: CompletionProvider::Path,
}))
})
.collect::<Vec<_>>()
});
.collect();
CompletionResponse {
items: CompletionItems::Other(res),
provider: CompletionProvider::Path,
context: ResponseContext {
is_incomplete: false,
priority: PRIORITY,
savepoint,
},
}
};
Some(async move { Ok(future.await?) }.boxed())
Some(future)
}
#[cfg(unix)]

View file

@ -0,0 +1,367 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use futures_util::Future;
use helix_core::completion::CompletionProvider;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{cancelable_future, TaskController, TaskHandle};
use helix_lsp::lsp;
use helix_lsp::lsp::{CompletionContext, CompletionTriggerKind};
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::{Mode, SavePoint};
use helix_view::handlers::completion::{CompletionEvent, ResponseContext};
use helix_view::{Document, DocumentId, Editor, ViewId};
use tokio::task::JoinSet;
use tokio::time::{timeout_at, Instant};
use crate::compositor::Compositor;
use crate::config::Config;
use crate::handlers::completion::item::CompletionResponse;
use crate::handlers::completion::path::path_completion;
use crate::handlers::completion::{
handle_response, replace_completions, show_completion, CompletionItems,
};
use crate::job::{dispatch, dispatch_blocking};
use crate::ui;
use crate::ui::editor::InsertEvent;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(super) enum TriggerKind {
Auto,
TriggerChar,
Manual,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Trigger {
pub(super) pos: usize,
pub(super) view: ViewId,
pub(super) doc: DocumentId,
pub(super) kind: TriggerKind,
}
#[derive(Debug)]
pub struct CompletionHandler {
/// The currently active trigger which will cause a completion request after the timeout.
trigger: Option<Trigger>,
in_flight: Option<Trigger>,
task_controller: TaskController,
config: Arc<ArcSwap<Config>>,
}
impl CompletionHandler {
pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
Self {
config,
task_controller: TaskController::new(),
trigger: None,
in_flight: None,
}
}
}
impl helix_event::AsyncHook for CompletionHandler {
type Event = CompletionEvent;
fn handle_event(
&mut self,
event: Self::Event,
_old_timeout: Option<Instant>,
) -> Option<Instant> {
if self.in_flight.is_some() && !self.task_controller.is_running() {
self.in_flight = None;
}
match event {
CompletionEvent::AutoTrigger {
cursor: trigger_pos,
doc,
view,
} => {
// Technically it shouldn't be possible to switch views/documents in insert mode
// but people may create weird keymaps/use the mouse so let's be extra careful.
if self
.trigger
.or(self.in_flight)
.map_or(true, |trigger| trigger.doc != doc || trigger.view != view)
{
self.trigger = Some(Trigger {
pos: trigger_pos,
view,
doc,
kind: TriggerKind::Auto,
});
}
}
CompletionEvent::TriggerChar { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.task_controller.cancel();
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::TriggerChar,
});
}
CompletionEvent::ManualTrigger { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::Manual,
});
// stop debouncing immediately and request the completion
self.finish_debounce();
return None;
}
CompletionEvent::Cancel => {
self.trigger = None;
self.task_controller.cancel();
}
CompletionEvent::DeleteText { cursor } => {
// if we deleted the original trigger, abort the completion
if matches!(self.trigger.or(self.in_flight), Some(Trigger{ pos, .. }) if cursor < pos)
{
self.trigger = None;
self.task_controller.cancel();
}
}
}
self.trigger.map(|trigger| {
// if the current request was closed forget about it
// otherwise immediately restart the completion request
let timeout = if trigger.kind == TriggerKind::Auto {
self.config.load().editor.completion_timeout
} else {
// we want almost instant completions for trigger chars
// and restarting completion requests. The small timeout here mainly
// serves to better handle cases where the completion handler
// may fall behind (so multiple events in the channel) and macros
Duration::from_millis(5)
};
Instant::now() + timeout
})
}
fn finish_debounce(&mut self) {
let trigger = self.trigger.take().expect("debounce always has a trigger");
self.in_flight = Some(trigger);
let handle = self.task_controller.restart();
dispatch_blocking(move |editor, compositor| {
request_completions(trigger, handle, editor, compositor)
});
}
}
fn request_completions(
mut trigger: Trigger,
handle: TaskHandle,
editor: &mut Editor,
compositor: &mut Compositor,
) {
let (view, doc) = current_ref!(editor);
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.is_some()
|| editor.mode != Mode::Insert
{
return;
}
let text = doc.text();
let cursor = doc.selection(view.id).primary().cursor(text.slice(..));
if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
return;
}
// This looks odd... Why are we not using the trigger position from the `trigger` here? Won't
// that mean that the trigger char doesn't get send to the language server if we type fast
// enough? Yes that is true but it's not actually a problem. The language server will resolve
// the completion to the identifier anyway (in fact sending the later position is necessary to
// get the right results from language servers that provide incomplete completion list). We
// rely on the trigger offset and primary cursor matching for multi-cursor completions so this
// is definitely necessary from our side too.
trigger.pos = cursor;
let doc = doc_mut!(editor, &doc.id());
let savepoint = doc.savepoint(view);
let text = doc.text();
let trigger_text = text.slice(..cursor);
let mut seen_language_servers = HashSet::new();
let language_servers: Vec<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.collect();
let mut requests = JoinSet::new();
for (priority, ls) in language_servers.iter().enumerate() {
let context = if trigger.kind == TriggerKind::Manual {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
} else {
let trigger_char =
ls.capabilities()
.completion_provider
.as_ref()
.and_then(|provider| {
provider
.trigger_characters
.as_deref()?
.iter()
.find(|&trigger| trigger_text.ends_with(trigger))
});
if trigger_char.is_some() {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
} else {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
}
};
requests.spawn(request_completions_from_language_server(
ls,
doc,
view.id,
context,
-(priority as i8),
savepoint.clone(),
));
}
if let Some(path_completion_request) = path_completion(
doc.selection(view.id).clone(),
doc,
handle.clone(),
savepoint,
) {
requests.spawn_blocking(path_completion_request);
}
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
let handle_ = handle.clone();
let request_completions = async move {
let mut context = HashMap::new();
let Some(mut response) = handle_response(&mut requests, false).await else {
return;
};
let mut items: Vec<_> = Vec::new();
response.take_items(&mut items);
context.insert(response.provider, response.context);
let deadline = Instant::now() + Duration::from_millis(100);
loop {
let Some(mut response) = timeout_at(deadline, handle_response(&mut requests, false))
.await
.ok()
.flatten()
else {
break;
};
response.take_items(&mut items);
context.insert(response.provider, response.context);
}
dispatch(move |editor, compositor| {
show_completion(editor, compositor, items, context, trigger)
})
.await;
if !requests.is_empty() {
replace_completions(handle_, requests, false).await;
}
};
tokio::spawn(cancelable_future(request_completions, handle));
}
fn request_completions_from_language_server(
ls: &helix_lsp::Client,
doc: &Document,
view: ViewId,
context: lsp::CompletionContext,
priority: i8,
savepoint: Arc<SavePoint>,
) -> impl Future<Output = CompletionResponse> {
let provider = ls.id();
let offset_encoding = ls.offset_encoding();
let text = doc.text();
let cursor = doc.selection(view).primary().cursor(text.slice(..));
let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
let doc_id = doc.identifier();
// it's important that this is before the async block (and that this is not an async function)
// to ensure the request is dispatched right away before any new edit notifications
let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
async move {
let response: Option<lsp::CompletionResponse> = completion_response
.await
.inspect_err(|err| log::error!("completion request failed: {err}"))
.ok()
.flatten();
let (mut items, is_incomplete) = match response {
Some(lsp::CompletionResponse::Array(items)) => (items, false),
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete,
items,
})) => (items, is_incomplete),
None => (Vec::new(), false),
};
items.sort_by(|item1, item2| {
let sort_text1 = item1.sort_text.as_deref().unwrap_or(&item1.label);
let sort_text2 = item2.sort_text.as_deref().unwrap_or(&item2.label);
sort_text1.cmp(sort_text2)
});
CompletionResponse {
items: CompletionItems::Lsp(items),
context: ResponseContext {
is_incomplete,
priority,
savepoint,
},
provider: CompletionProvider::Lsp(provider),
}
}
}
pub fn request_incomplete_completion_list(editor: &mut Editor, handle: TaskHandle) {
let handler = &mut editor.handlers.completions;
let mut requests = JoinSet::new();
let mut savepoint = None;
for (&provider, context) in &handler.active_completions {
if !context.is_incomplete {
continue;
}
let CompletionProvider::Lsp(ls_id) = provider else {
log::error!("non-lsp incomplete completion lists");
continue;
};
let Some(ls) = editor.language_servers.get_by_id(ls_id) else {
continue;
};
let (view, doc) = current!(editor);
let savepoint = savepoint.get_or_insert_with(|| doc.savepoint(view)).clone();
let request = request_completions_from_language_server(
ls,
doc,
view.id,
CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_FOR_INCOMPLETE_COMPLETIONS,
trigger_character: None,
},
context.priority,
savepoint,
);
requests.spawn(request);
}
if !requests.is_empty() {
tokio::spawn(replace_completions(handle, requests, true));
}
}

View file

@ -1,12 +1,24 @@
use std::time::Duration;
use helix_core::diagnostic::DiagnosticProvider;
use helix_core::syntax::LanguageServerFeature;
use helix_core::Uri;
use helix_event::{register_hook, send_blocking};
use helix_lsp::lsp;
use helix_view::document::Mode;
use helix_view::events::DiagnosticsDidChange;
use helix_view::events::{
DiagnosticsDidChange, DocumentDidChange, DocumentDidOpen, LanguageServerInitialized,
};
use helix_view::handlers::diagnostics::DiagnosticEvent;
use helix_view::handlers::lsp::PullDiagnosticsEvent;
use helix_view::handlers::Handlers;
use helix_view::{DocumentId, Editor};
use tokio::time::Instant;
use crate::events::OnModeSwitch;
use crate::job;
pub(super) fn register_hooks(_handlers: &Handlers) {
pub(super) fn register_hooks(handlers: &Handlers) {
register_hook!(move |event: &mut DiagnosticsDidChange<'_>| {
if event.editor.mode != Mode::Insert {
for (view, _) in event.editor.tree.views_mut() {
@ -21,4 +33,267 @@ pub(super) fn register_hooks(_handlers: &Handlers) {
}
Ok(())
});
let tx = handlers.pull_diagnostics.clone();
register_hook!(move |event: &mut DocumentDidChange<'_>| {
if event
.doc
.has_language_server_with_feature(LanguageServerFeature::PullDiagnostics)
{
let document_id = event.doc.id();
send_blocking(&tx, PullDiagnosticsEvent { document_id });
}
Ok(())
});
register_hook!(move |event: &mut DocumentDidOpen<'_>| {
let doc = doc!(event.editor, &event.doc);
for language_server in
doc.language_servers_with_feature(LanguageServerFeature::PullDiagnostics)
{
pull_diagnostics_for_document(doc, language_server);
}
Ok(())
});
register_hook!(move |event: &mut LanguageServerInitialized<'_>| {
let language_server = event.editor.language_server_by_id(event.server_id).unwrap();
if language_server.supports_feature(LanguageServerFeature::PullDiagnostics) {
for doc in event
.editor
.documents()
.filter(|doc| doc.supports_language_server(event.server_id))
{
pull_diagnostics_for_document(doc, language_server);
}
}
Ok(())
});
}
#[derive(Debug)]
pub(super) struct PullDiagnosticsHandler {
no_inter_file_dependency_timeout: Option<tokio::time::Instant>,
}
impl PullDiagnosticsHandler {
pub fn new() -> PullDiagnosticsHandler {
PullDiagnosticsHandler {
no_inter_file_dependency_timeout: None,
}
}
}
const TIMEOUT: Duration = Duration::from_millis(500);
const TIMEOUT_NO_INTER_FILE_DEPENDENCY: Duration = Duration::from_millis(125);
impl helix_event::AsyncHook for PullDiagnosticsHandler {
type Event = PullDiagnosticsEvent;
fn handle_event(
&mut self,
event: Self::Event,
timeout: Option<tokio::time::Instant>,
) -> Option<tokio::time::Instant> {
if timeout.is_none() {
dispatch_pull_diagnostic_for_document(event.document_id, false);
self.no_inter_file_dependency_timeout = Some(Instant::now());
}
if self
.no_inter_file_dependency_timeout
.is_some_and(|nifd_timeout| {
nifd_timeout.duration_since(Instant::now()) > TIMEOUT_NO_INTER_FILE_DEPENDENCY
})
{
dispatch_pull_diagnostic_for_document(event.document_id, true);
self.no_inter_file_dependency_timeout = Some(Instant::now());
};
Some(Instant::now() + TIMEOUT)
}
fn finish_debounce(&mut self) {
dispatch_pull_diagnostic_for_open_documents();
}
}
fn dispatch_pull_diagnostic_for_document(
document_id: DocumentId,
exclude_language_servers_without_inter_file_dependency: bool,
) {
job::dispatch_blocking(move |editor, _| {
let Some(doc) = editor.document(document_id) else {
return;
};
let language_servers = doc
.language_servers_with_feature(LanguageServerFeature::PullDiagnostics)
.filter(|ls| ls.is_initialized())
.filter(|ls| {
if !exclude_language_servers_without_inter_file_dependency {
return true;
};
ls.capabilities()
.diagnostic_provider
.as_ref()
.is_some_and(|dp| match dp {
lsp::DiagnosticServerCapabilities::Options(options) => {
options.inter_file_dependencies
}
lsp::DiagnosticServerCapabilities::RegistrationOptions(options) => {
options.diagnostic_options.inter_file_dependencies
}
})
});
for language_server in language_servers {
pull_diagnostics_for_document(doc, language_server);
}
})
}
fn dispatch_pull_diagnostic_for_open_documents() {
job::dispatch_blocking(move |editor, _| {
let documents = editor.documents.values();
for document in documents {
let language_servers = document
.language_servers_with_feature(LanguageServerFeature::PullDiagnostics)
.filter(|ls| ls.is_initialized());
for language_server in language_servers {
pull_diagnostics_for_document(document, language_server);
}
}
})
}
pub fn pull_diagnostics_for_document(
doc: &helix_view::Document,
language_server: &helix_lsp::Client,
) {
let Some(future) = language_server
.text_document_diagnostic(doc.identifier(), doc.previous_diagnostic_id.clone())
else {
return;
};
let Some(uri) = doc.uri() else {
return;
};
let identifier = language_server
.capabilities()
.diagnostic_provider
.as_ref()
.and_then(|diagnostic_provider| match diagnostic_provider {
lsp::DiagnosticServerCapabilities::Options(options) => options.identifier.clone(),
lsp::DiagnosticServerCapabilities::RegistrationOptions(options) => {
options.diagnostic_options.identifier.clone()
}
});
let language_server_id = language_server.id();
let provider = DiagnosticProvider::Lsp {
server_id: language_server_id,
identifier,
};
let document_id = doc.id();
tokio::spawn(async move {
match future.await {
Ok(result) => {
job::dispatch(move |editor, _| {
handle_pull_diagnostics_response(editor, result, provider, uri, document_id)
})
.await
}
Err(err) => {
let parsed_cancellation_data = if let helix_lsp::Error::Rpc(error) = err {
error.data.and_then(|data| {
serde_json::from_value::<lsp::DiagnosticServerCancellationData>(data).ok()
})
} else {
log::error!("Pull diagnostic request failed: {err}");
return;
};
if let Some(parsed_cancellation_data) = parsed_cancellation_data {
if parsed_cancellation_data.retrigger_request {
tokio::time::sleep(Duration::from_millis(500)).await;
job::dispatch(move |editor, _| {
if let (Some(doc), Some(language_server)) = (
editor.document(document_id),
editor.language_server_by_id(language_server_id),
) {
pull_diagnostics_for_document(doc, language_server);
}
})
.await;
}
}
}
}
});
}
fn handle_pull_diagnostics_response(
editor: &mut Editor,
result: lsp::DocumentDiagnosticReportResult,
provider: DiagnosticProvider,
uri: Uri,
document_id: DocumentId,
) {
let related_documents = match result {
lsp::DocumentDiagnosticReportResult::Report(report) => {
let (result_id, related_documents) = match report {
lsp::DocumentDiagnosticReport::Full(report) => {
editor.handle_lsp_diagnostics(
&provider,
uri,
None,
report.full_document_diagnostic_report.items,
);
(
report.full_document_diagnostic_report.result_id,
report.related_documents,
)
}
lsp::DocumentDiagnosticReport::Unchanged(report) => (
Some(report.unchanged_document_diagnostic_report.result_id),
report.related_documents,
),
};
if let Some(doc) = editor.document_mut(document_id) {
doc.previous_diagnostic_id = result_id;
};
related_documents
}
lsp::DocumentDiagnosticReportResult::Partial(report) => report.related_documents,
};
for (url, report) in related_documents.into_iter().flatten() {
let result_id = match report {
lsp::DocumentDiagnosticReportKind::Full(report) => {
let Ok(uri) = Uri::try_from(url) else {
continue;
};
editor.handle_lsp_diagnostics(&provider, uri, None, report.items);
report.result_id
}
lsp::DocumentDiagnosticReportKind::Unchanged(report) => Some(report.result_id),
};
if let Some(doc) = editor.document_mut(document_id) {
doc.previous_diagnostic_id = result_id;
}
}
}

View file

@ -0,0 +1,211 @@
use std::{collections::HashSet, time::Duration};
use futures_util::{stream::FuturesOrdered, StreamExt};
use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation};
use helix_event::{cancelable_future, register_hook};
use helix_lsp::lsp;
use helix_view::{
document::DocumentColorSwatches,
events::{DocumentDidChange, DocumentDidOpen, LanguageServerExited, LanguageServerInitialized},
handlers::{lsp::DocumentColorsEvent, Handlers},
icons::ICONS,
DocumentId, Editor, Theme,
};
use tokio::time::Instant;
use crate::job;
#[derive(Default)]
pub(super) struct DocumentColorsHandler {
docs: HashSet<DocumentId>,
}
const DOCUMENT_CHANGE_DEBOUNCE: Duration = Duration::from_millis(250);
impl helix_event::AsyncHook for DocumentColorsHandler {
type Event = DocumentColorsEvent;
fn handle_event(&mut self, event: Self::Event, _timeout: Option<Instant>) -> Option<Instant> {
let DocumentColorsEvent(doc_id) = event;
self.docs.insert(doc_id);
Some(Instant::now() + DOCUMENT_CHANGE_DEBOUNCE)
}
fn finish_debounce(&mut self) {
let docs = std::mem::take(&mut self.docs);
job::dispatch_blocking(move |editor, _compositor| {
for doc in docs {
request_document_colors(editor, doc);
}
});
}
}
fn request_document_colors(editor: &mut Editor, doc_id: DocumentId) {
if !editor.config().lsp.display_color_swatches {
return;
}
let Some(doc) = editor.document_mut(doc_id) else {
return;
};
let cancel = doc.color_swatch_controller.restart();
let mut seen_language_servers = HashSet::new();
let mut futures: FuturesOrdered<_> = doc
.language_servers_with_feature(LanguageServerFeature::DocumentColors)
.filter(|ls| seen_language_servers.insert(ls.id()))
.map(|language_server| {
let text = doc.text().clone();
let offset_encoding = language_server.offset_encoding();
let future = language_server
.text_document_document_color(doc.identifier(), None)
.unwrap();
async move {
let colors: Vec<_> = future
.await?
.into_iter()
.filter_map(|color_info| {
let pos = helix_lsp::util::lsp_pos_to_pos(
&text,
color_info.range.start,
offset_encoding,
)?;
Some((pos, color_info.color))
})
.collect();
anyhow::Ok(colors)
}
})
.collect();
tokio::spawn(async move {
let mut all_colors = Vec::new();
loop {
match cancelable_future(futures.next(), &cancel).await {
Some(Some(Ok(items))) => all_colors.extend(items),
Some(Some(Err(err))) => log::error!("document color request failed: {err}"),
Some(None) => break,
// The request was cancelled.
None => return,
}
}
job::dispatch(move |editor, _| attach_document_colors(editor, doc_id, all_colors)).await;
});
}
fn attach_document_colors(
editor: &mut Editor,
doc_id: DocumentId,
mut doc_colors: Vec<(usize, lsp::Color)>,
) {
if !editor.config().lsp.display_color_swatches {
return;
}
let Some(doc) = editor.documents.get_mut(&doc_id) else {
return;
};
if doc_colors.is_empty() {
doc.color_swatches.take();
return;
}
doc_colors.sort_by_key(|(pos, _)| *pos);
let mut color_swatches = Vec::with_capacity(doc_colors.len());
let mut color_swatches_padding = Vec::with_capacity(doc_colors.len());
let mut colors = Vec::with_capacity(doc_colors.len());
let icons = ICONS.load();
for (pos, color) in doc_colors {
color_swatches_padding.push(InlineAnnotation::new(pos, " "));
color_swatches.push(InlineAnnotation::new(pos, icons.lsp().color()));
colors.push(Theme::rgb_highlight(
(color.red * 255.) as u8,
(color.green * 255.) as u8,
(color.blue * 255.) as u8,
));
}
doc.color_swatches = Some(DocumentColorSwatches {
color_swatches,
colors,
color_swatches_padding,
});
}
pub(super) fn register_hooks(handlers: &Handlers) {
register_hook!(move |event: &mut DocumentDidOpen<'_>| {
// when a document is initially opened, request colors for it
request_document_colors(event.editor, event.doc);
Ok(())
});
let tx = handlers.document_colors.clone();
register_hook!(move |event: &mut DocumentDidChange<'_>| {
// Update the color swatch' positions, helping ensure they are displayed in the
// proper place.
let apply_color_swatch_changes = |annotations: &mut Vec<InlineAnnotation>| {
event.changes.update_positions(
annotations
.iter_mut()
.map(|annotation| (&mut annotation.char_idx, helix_core::Assoc::After)),
);
};
if let Some(DocumentColorSwatches {
color_swatches,
colors: _colors,
color_swatches_padding,
}) = &mut event.doc.color_swatches
{
apply_color_swatch_changes(color_swatches);
apply_color_swatch_changes(color_swatches_padding);
}
// Avoid re-requesting document colors if the change is a ghost transaction (completion)
// because the language server will not know about the updates to the document and will
// give out-of-date locations.
if !event.ghost_transaction {
// Cancel the ongoing request, if present.
event.doc.color_swatch_controller.cancel();
helix_event::send_blocking(&tx, DocumentColorsEvent(event.doc.id()));
}
Ok(())
});
register_hook!(move |event: &mut LanguageServerInitialized<'_>| {
let doc_ids: Vec<_> = event.editor.documents().map(|doc| doc.id()).collect();
for doc_id in doc_ids {
request_document_colors(event.editor, doc_id);
}
Ok(())
});
register_hook!(move |event: &mut LanguageServerExited<'_>| {
// Clear and re-request all color swatches when a server exits.
for doc in event.editor.documents_mut() {
if doc.supports_language_server(event.server_id) {
doc.color_swatches.take();
}
}
let doc_ids: Vec<_> = event.editor.documents().map(|doc| doc.id()).collect();
for doc_id in doc_ids {
request_document_colors(event.editor, doc_id);
}
Ok(())
});
}

View file

@ -16,7 +16,7 @@ use crate::commands::Open;
use crate::compositor::Compositor;
use crate::events::{OnModeSwitch, PostInsertChar};
use crate::handlers::Handlers;
use crate::ui::lsp::{Signature, SignatureHelp};
use crate::ui::lsp::signature_help::{Signature, SignatureHelp};
use crate::ui::Popup;
use crate::{job, ui};

View file

@ -224,8 +224,7 @@ pub fn languages_all() -> std::io::Result<()> {
for cmd in cmds {
write!(stdout, "{}", fit(""))?;
check_binary(Some(cmd));
writeln!(stdout)?;
writeln!(stdout, "{}", check_binary(Some(cmd)))?;
}
}

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