diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 529543781..286441b66 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,14 @@ updates: directory: "/" schedule: interval: "weekly" + groups: + tree-sitter: + patterns: + - "tree-sitter*" + rust-dependencies: + update-types: + - "minor" + - "patch" - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d47c2088..7ba46ce56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,7 @@ jobs: check: name: Check (msrv) runs-on: ubuntu-latest + if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule' steps: - name: Checkout sources uses: actions/checkout@v4 @@ -31,6 +32,7 @@ jobs: test: name: Test Suite runs-on: ${{ matrix.os }} + if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule' env: RUST_BACKTRACE: 1 HELIX_LOG_LEVEL: info @@ -65,6 +67,7 @@ jobs: lints: name: Lints runs-on: ubuntu-latest + if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule' steps: - name: Checkout sources uses: actions/checkout@v4 @@ -92,6 +95,7 @@ jobs: docs: name: Docs runs-on: ubuntu-latest + if: github.repository == 'helix-editor/helix' || github.event_name != 'schedule' steps: - name: Checkout sources uses: actions/checkout@v4 diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index 57f0a0db4..9638137b8 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@v4 - name: Install nix - uses: cachix/install-nix-action@v25 + uses: cachix/install-nix-action@v26 - name: Authenticate with Cachix uses: cachix/cachix-action@v14 diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index ae2e5835e..39dc08982 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup mdBook - uses: peaceiris/actions-mdbook@v1 + uses: peaceiris/actions-mdbook@v2 with: mdbook-version: 'latest' # mdbook-version: '0.4.8' @@ -27,14 +27,14 @@ jobs: echo "OUTDIR=$OUTDIR" >> $GITHUB_ENV - name: Deploy stable - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 if: startswith(github.ref, 'refs/tags/') with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./book/book - name: Deploy - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./book/book diff --git a/CHANGELOG.md b/CHANGELOG.md index b5edbf72b..de15f143c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,256 @@ +# 24.03 (2024-03-30) + +As always, a big thank you to all of the contributors! This release saw changes from 125 contributors. + +Breaking changes: + +- `suffix` file-types in the `file-types` key in language configuration have been removed ([#8006](https://github.com/helix-editor/helix/pull/8006)) +- The `file-types` key in language configuration no longer matches full filenames without a glob pattern ([#8006](https://github.com/helix-editor/helix/pull/8006)) + +Features: + +- Open URLs with the `goto_file` command ([#5820](https://github.com/helix-editor/helix/pull/5820)) +- Support drawing a border around popups and menus ([#4313](https://github.com/helix-editor/helix/pull/4313), [#9508](https://github.com/helix-editor/helix/pull/9508)) +- Track long lived diagnostic sources like Clippy or `rustc` ([#6447](https://github.com/helix-editor/helix/pull/6447), [#9280](https://github.com/helix-editor/helix/pull/9280)) + - This improves the handling of diagnostics from sources that only update the diagnostic positions on save. +- Add support for LSP `window/showDocument` requests ([#8865](https://github.com/helix-editor/helix/pull/8865)) +- Refactor ad-hoc hooks to use a new generic event system ([#8021](https://github.com/helix-editor/helix/pull/8021), [#9668](https://github.com/helix-editor/helix/pull/9668), [#9660](https://github.com/helix-editor/helix/pull/9660)) + - This improves the behavior of autocompletions. For example navigating in insert mode no longer automatically triggers completions. +- Allow using globs in the language configuration `file-types` key ([#8006](https://github.com/helix-editor/helix/pull/8006)) +- Allow specifying required roots for situational LSP activation ([#8696](https://github.com/helix-editor/helix/pull/8696)) +- Extend selections using mouse clicks in select mode ([#5436](https://github.com/helix-editor/helix/pull/5436)) +- Toggle block comments ([#4718](https://github.com/helix-editor/helix/pull/4718), [#9894](https://github.com/helix-editor/helix/pull/9894)) +- Support LSP diagnostic tags ([#9780](https://github.com/helix-editor/helix/pull/9780)) +- Add a `file-absolute-path` statusline element ([#4535](https://github.com/helix-editor/helix/pull/4535)) +- Cross injection layers in tree-sitter motions (`A-p`/`A-o`/`A-i`/`A-n`) ([#5176](https://github.com/helix-editor/helix/pull/5176)) +- Add a Amp-editor-like jumping command ([#8875](https://github.com/helix-editor/helix/pull/8875)) + +Commands: + +- `:move` - move buffers with LSP support ([#8584](https://github.com/helix-editor/helix/pull/8584)) + - Also see [#8949](https://github.com/helix-editor/helix/pull/8949) which made path changes conform to the LSP spec and fixed the behavior of this command. +- `page_cursor_up`, `page_cursor_down`, `page_cursor_half_up`, `page_cursor_half_down` - commands for scrolling the cursor and page together ([#8015](https://github.com/helix-editor/helix/pull/8015)) +- `:yank-diagnostic` - yank the diagnostic(s) under the primary cursor ([#9640](https://github.com/helix-editor/helix/pull/9640)) +- `select_line_above` / `select_line_below` - extend or shrink a selection based on the direction and anchor ([#9080](https://github.com/helix-editor/helix/pull/9080)) + +Usability improvements: + +- Make `roots` key of `[[language]]` entries in `languages.toml` configuration optional ([#8803](https://github.com/helix-editor/helix/pull/8803)) +- Exit select mode in commands that modify the buffer ([#8689](https://github.com/helix-editor/helix/pull/8689)) +- Use crossterm cursor when out of focus ([#6858](https://github.com/helix-editor/helix/pull/6858), [#8934](https://github.com/helix-editor/helix/pull/8934)) +- Join empty lines with only one space in `join_selections` ([#8989](https://github.com/helix-editor/helix/pull/8989)) +- Introduce a hybrid tree-sitter and contextual indentation heuristic ([#8307](https://github.com/helix-editor/helix/pull/8307)) +- Allow configuring the indentation heuristic ([#8307](https://github.com/helix-editor/helix/pull/8307)) +- Check for LSP rename support before showing rename prompt ([#9277](https://github.com/helix-editor/helix/pull/9277)) +- Normalize `S-` keymaps to uppercase ascii ([#9213](https://github.com/helix-editor/helix/pull/9213)) +- Add formatter status to `--health` output ([#7986](https://github.com/helix-editor/helix/pull/7986)) +- Change path normalization strategy to not resolve symlinks ([#9330](https://github.com/helix-editor/helix/pull/9330)) +- Select subtree within injections in `:tree-sitter-subtree` ([#9309](https://github.com/helix-editor/helix/pull/9309)) +- Use tilde expansion and normalization for `$HELIX_RUNTIME` paths ([1bc7aac](https://github.com/helix-editor/helix/commit/1bc7aac)) +- Improve failure message for LSP goto references ([#9382](https://github.com/helix-editor/helix/pull/9382)) +- Use injection syntax trees for bracket matching ([5e0b3cc](https://github.com/helix-editor/helix/commit/5e0b3cc)) +- Respect injections in `:tree-sitter-highlight-name` ([8b6565c](https://github.com/helix-editor/helix/commit/8b6565c)) +- Respect injections in `move_parent_node_end` ([035b8ea](https://github.com/helix-editor/helix/commit/035b8ea)) +- Use `gix` pipeline filter instead of manual CRLF implementation ([#9503](https://github.com/helix-editor/helix/pull/9503)) +- Follow Neovim's truecolor detection ([#9577](https://github.com/helix-editor/helix/pull/9577)) +- Reload language configuration with `:reload`, SIGHUP ([#9415](https://github.com/helix-editor/helix/pull/9415)) +- Allow numbers as bindings ([#8471](https://github.com/helix-editor/helix/pull/8471), [#9887](https://github.com/helix-editor/helix/pull/9887)) +- Respect undercurl config when terminfo is not available ([#9897](https://github.com/helix-editor/helix/pull/9897)) +- Ignore `.pijul`, `.hg`, `.jj` in addition to `.git` in file pickers configured to show hidden files ([#9935](https://github.com/helix-editor/helix/pull/9935)) +- Add completion for registers to `:clear-register` and `:yank-diagnostic` ([#9936](https://github.com/helix-editor/helix/pull/9936)) +- Repeat last motion for goto next/prev diagnostic ([#9966](https://github.com/helix-editor/helix/pull/9966)) +- Allow configuring a character to use when rendering narrow no-breaking space ([#9604](https://github.com/helix-editor/helix/pull/9604)) +- Switch to a streaming regex engine (regex-cursor crate) to significantly speed up regex-based commands and features ([#9422](https://github.com/helix-editor/helix/pull/9422), [#9756](https://github.com/helix-editor/helix/pull/9756), [#9891](https://github.com/helix-editor/helix/pull/9891)) + +Fixes: + +- Swap `*` and `+` registers ([#8703](https://github.com/helix-editor/helix/pull/8703), [#8708](https://github.com/helix-editor/helix/pull/8708)) +- Use terminfo to reset terminal cursor style ([#8591](https://github.com/helix-editor/helix/pull/8591)) +- Fix precedence of `@align` captures in indentat computation ([#8659](https://github.com/helix-editor/helix/pull/8659)) +- Only render the preview if a Picker has a preview function ([#8667](https://github.com/helix-editor/helix/pull/8667)) +- Fix the precedence of `ui.virtual.whitespace` ([#8750](https://github.com/helix-editor/helix/pull/8750), [#8879](https://github.com/helix-editor/helix/pull/8879)) +- Fix crash in `:indent-style` ([#9087](https://github.com/helix-editor/helix/pull/9087)) +- Fix `didSave` text inclusion when sync capability is a kind variant ([#9101](https://github.com/helix-editor/helix/pull/9101)) +- Update the history of newly focused views ([#9271](https://github.com/helix-editor/helix/pull/9271)) +- Initialize diagnostics when opening a document ([#8873](https://github.com/helix-editor/helix/pull/8873)) +- Sync views when applying edits to unfocused views ([#9173](https://github.com/helix-editor/helix/pull/9173)) + - This fixes crashes that could occur from LSP workspace edits or `:write-all`. +- Treat non-numeric `+arg`s passed in the CLI args as filenames ([#9333](https://github.com/helix-editor/helix/pull/9333)) +- Fix crash when using `mm` on an empty plaintext file ([2fb7e50](https://github.com/helix-editor/helix/commit/2fb7e50)) +- Ignore empty tree-sitter nodes in match bracket ([445f7a2](https://github.com/helix-editor/helix/commit/445f7a2)) +- Exit a language server if it sends a message with invalid JSON ([#9332](https://github.com/helix-editor/helix/pull/9332)) +- Handle failures to enable bracketed paste ([#9353](https://github.com/helix-editor/helix/pull/9353)) +- Gate all captures in a pattern behind `#is-not? local` predicates ([#9390](https://github.com/helix-editor/helix/pull/9390)) +- Make path changes LSP spec conformant ([#8949](https://github.com/helix-editor/helix/pull/8949)) +- Use range positions to determine `insert_newline` motion ([#9448](https://github.com/helix-editor/helix/pull/9448)) +- Fix division by zero when prompt completion area is too small ([#9524](https://github.com/helix-editor/helix/pull/9524)) +- Add changes to history in clipboard replacement typable commands ([#9625](https://github.com/helix-editor/helix/pull/9625)) +- Fix a crash in DAP with an unspecified `line` in breakpoints ([#9632](https://github.com/helix-editor/helix/pull/9632)) +- Fix space handling for filenames in bash completion ([#9702](https://github.com/helix-editor/helix/pull/9702), [#9708](https://github.com/helix-editor/helix/pull/9708)) +- Key diagnostics off of paths instead of LSP URIs ([#7367](https://github.com/helix-editor/helix/pull/7367)) +- Fix panic when using `join_selections_space` ([#9783](https://github.com/helix-editor/helix/pull/9783)) +- Fix panic when using `surround_replace`, `surround_delete` ([#9796](https://github.com/helix-editor/helix/pull/9796)) +- Fix panic in `surround_replace`, `surround_delete` with nested surrounds and multiple cursors ([#9815](https://github.com/helix-editor/helix/pull/9815)) +- Fix panic in `select_textobject_around` ([#9832](https://github.com/helix-editor/helix/pull/9832)) +- Don't stop reloading documents when reloading fails in `:reload-all` ([#9870](https://github.com/helix-editor/helix/pull/9870)) +- Prevent `shell_keep_pipe` from stopping on nonzero exit status codes ([#9817](https://github.com/helix-editor/helix/pull/9817)) + +Themes: + +- Add `gruber-dark` ([#8598](https://github.com/helix-editor/helix/pull/8598)) +- Update `everblush` ([#8705](https://github.com/helix-editor/helix/pull/8705)) +- Update `papercolor` ([#8718](https://github.com/helix-editor/helix/pull/8718), [#8827](https://github.com/helix-editor/helix/pull/8827)) +- Add `polmandres` ([#8759](https://github.com/helix-editor/helix/pull/8759)) +- Add `starlight` ([#8787](https://github.com/helix-editor/helix/pull/8787)) +- Update `naysayer` ([#8838](https://github.com/helix-editor/helix/pull/8838)) +- Add modus operandi themes ([#8728](https://github.com/helix-editor/helix/pull/8728), [#9912](https://github.com/helix-editor/helix/pull/9912)) +- Update `rose_pine` ([#8946](https://github.com/helix-editor/helix/pull/8946)) +- Update `darcula` ([#8738](https://github.com/helix-editor/helix/pull/8738), [#9002](https://github.com/helix-editor/helix/pull/9002), [#9449](https://github.com/helix-editor/helix/pull/9449), [#9588](https://github.com/helix-editor/helix/pull/9588)) +- Add modus vivendi themes ([#8894](https://github.com/helix-editor/helix/pull/8894), [#9912](https://github.com/helix-editor/helix/pull/9912)) +- Add `horizon-dark` ([#9008](https://github.com/helix-editor/helix/pull/9008), [#9493](https://github.com/helix-editor/helix/pull/9493)) +- Update `noctis` ([#9123](https://github.com/helix-editor/helix/pull/9123)) +- Update `nord` ([#9135](https://github.com/helix-editor/helix/pull/9135)) +- Update monokai pro themes ([#9148](https://github.com/helix-editor/helix/pull/9148)) +- Update tokyonight themes ([#9099](https://github.com/helix-editor/helix/pull/9099), [#9724](https://github.com/helix-editor/helix/pull/9724), [#9789](https://github.com/helix-editor/helix/pull/9789)) +- Add `ttox` ([#8524](https://github.com/helix-editor/helix/pull/8524)) +- Add `voxed` ([#9164](https://github.com/helix-editor/helix/pull/9164)) +- Update `sonokai` ([#9370](https://github.com/helix-editor/helix/pull/9370), [#9376](https://github.com/helix-editor/helix/pull/9376), [#5379](https://github.com/helix-editor/helix/pull/5379)) +- Update `onedark`, `onedarker` ([#9397](https://github.com/helix-editor/helix/pull/9397)) +- Update `cyan_light` ([#9375](https://github.com/helix-editor/helix/pull/9375), [#9688](https://github.com/helix-editor/helix/pull/9688)) +- Add `gruvbox_light_soft`, `gruvbox_light_hard` ([#9266](https://github.com/helix-editor/helix/pull/9266)) +- Update GitHub themes ([#9487](https://github.com/helix-editor/helix/pull/9487)) +- Add `term16_dark`, `term16_light` ([#9477](https://github.com/helix-editor/helix/pull/9477)) +- Update Zed themes ([#9544](https://github.com/helix-editor/helix/pull/9544), [#9549](https://github.com/helix-editor/helix/pull/9549)) +- Add `curzon` ([#9553](https://github.com/helix-editor/helix/pull/9553)) +- Add `monokai_soda` ([#9651](https://github.com/helix-editor/helix/pull/9651)) +- Update catppuccin themes ([#9859](https://github.com/helix-editor/helix/pull/9859)) +- Update `rasmus` ([#9939](https://github.com/helix-editor/helix/pull/9939)) +- Update `dark_plus` ([#9949](https://github.com/helix-editor/helix/pull/9949), [628dcd5](https://github.com/helix-editor/helix/commit/628dcd5)) +- Update gruvbox themes ([#9960](https://github.com/helix-editor/helix/pull/9960)) +- Add jump label theming to `dracula` ([#9973](https://github.com/helix-editor/helix/pull/9973)) +- Add jump label theming to `horizon-dark` ([#9984](https://github.com/helix-editor/helix/pull/9984)) +- Add jump label theming to catppuccin themes ([2178adf](https://github.com/helix-editor/helix/commit/2178adf), [#9983](https://github.com/helix-editor/helix/pull/9983)) +- Add jump label theming to `onedark` themes ([da2dec1](https://github.com/helix-editor/helix/commit/da2dec1)) +- Add jump label theming to rose-pine themes ([#9981](https://github.com/helix-editor/helix/pull/9981)) +- Add jump label theming to Nord themes ([#10008](https://github.com/helix-editor/helix/pull/10008)) +- Add jump label theming to Monokai themes ([#10009](https://github.com/helix-editor/helix/pull/10009)) +- Add jump label theming to gruvbox themes ([#10012](https://github.com/helix-editor/helix/pull/10012)) +- Add jump label theming to `kanagawa` ([#10030](https://github.com/helix-editor/helix/pull/10030)) +- Update material themes ([#10043](https://github.com/helix-editor/helix/pull/10043)) +- Add `jetbrains_dark` ([#9967](https://github.com/helix-editor/helix/pull/9967)) + +New languages: + +- Typst ([#7474](https://github.com/helix-editor/helix/pull/7474)) +- LPF ([#8536](https://github.com/helix-editor/helix/pull/8536)) +- GN ([#6969](https://github.com/helix-editor/helix/pull/6969)) +- DBML ([#8860](https://github.com/helix-editor/helix/pull/8860)) +- log ([#8916](https://github.com/helix-editor/helix/pull/8916)) +- Janet ([#9081](https://github.com/helix-editor/helix/pull/9081), [#9247](https://github.com/helix-editor/helix/pull/9247)) +- Agda ([#8285](https://github.com/helix-editor/helix/pull/8285)) +- Avro ([#9113](https://github.com/helix-editor/helix/pull/9113)) +- Smali ([#9089](https://github.com/helix-editor/helix/pull/9089)) +- HOCON ([#9203](https://github.com/helix-editor/helix/pull/9203)) +- Tact ([#9512](https://github.com/helix-editor/helix/pull/9512)) +- PKL ([#9515](https://github.com/helix-editor/helix/pull/9515)) +- CEL ([#9296](https://github.com/helix-editor/helix/pull/9296)) +- SpiceDB ([#9296](https://github.com/helix-editor/helix/pull/9296)) +- Hoon ([#9190](https://github.com/helix-editor/helix/pull/9190)) +- DockerCompose ([#9661](https://github.com/helix-editor/helix/pull/9661), [#9916](https://github.com/helix-editor/helix/pull/9916)) +- Groovy ([#9350](https://github.com/helix-editor/helix/pull/9350), [#9681](https://github.com/helix-editor/helix/pull/9681), [#9677](https://github.com/helix-editor/helix/pull/9677)) +- FIDL ([#9713](https://github.com/helix-editor/helix/pull/9713)) +- Powershell ([#9827](https://github.com/helix-editor/helix/pull/9827)) +- ld ([#9835](https://github.com/helix-editor/helix/pull/9835)) +- Hyperland config ([#9899](https://github.com/helix-editor/helix/pull/9899)) +- JSONC ([#9906](https://github.com/helix-editor/helix/pull/9906)) +- PHP Blade ([#9513](https://github.com/helix-editor/helix/pull/9513)) +- SuperCollider ([#9329](https://github.com/helix-editor/helix/pull/9329)) +- Koka ([#8727](https://github.com/helix-editor/helix/pull/8727)) +- PKGBUILD ([#9909](https://github.com/helix-editor/helix/pull/9909), [#9943](https://github.com/helix-editor/helix/pull/9943)) +- Ada ([#9908](https://github.com/helix-editor/helix/pull/9908)) +- Helm charts ([#9900](https://github.com/helix-editor/helix/pull/9900)) +- Ember.js templates ([#9902](https://github.com/helix-editor/helix/pull/9902)) +- Ohm ([#9991](https://github.com/helix-editor/helix/pull/9991)) + +Updated languages and queries: + +- Add HTML injection queries for Rust ([#8603](https://github.com/helix-editor/helix/pull/8603)) +- Switch to tree-sitter-ron for RON ([#8624](https://github.com/helix-editor/helix/pull/8624)) +- Update and improve comment highlighting ([#8564](https://github.com/helix-editor/helix/pull/8564), [#9253](https://github.com/helix-editor/helix/pull/9253), [#9800](https://github.com/helix-editor/helix/pull/9800), [#10014](https://github.com/helix-editor/helix/pull/10014)) +- Highlight type parameters in Rust ([#8660](https://github.com/helix-editor/helix/pull/8660)) +- Change KDL tree-sitter parsers ([#8652](https://github.com/helix-editor/helix/pull/8652)) +- Update tree-sitter-markdown ([#8721](https://github.com/helix-editor/helix/pull/8721), [#10039](https://github.com/helix-editor/helix/pull/10039)) +- Update tree-sitter-purescript ([#8712](https://github.com/helix-editor/helix/pull/8712)) +- Add type parameter highlighting to TypeScript, Go, Haskell, OCaml and Kotlin ([#8718](https://github.com/helix-editor/helix/pull/8718)) +- Add indentation queries for Scheme and lisps using tree-sitter-scheme ([#8720](https://github.com/helix-editor/helix/pull/8720)) +- Recognize `meson_options.txt` as Meson ([#8794](https://github.com/helix-editor/helix/pull/8794)) +- Add language server configuration for Nushell ([#8878](https://github.com/helix-editor/helix/pull/8878)) +- Recognize `musicxml` as XML ([#8935](https://github.com/helix-editor/helix/pull/8935)) +- Update tree-sitter-rescript ([#8962](https://github.com/helix-editor/helix/pull/8962)) +- Update tree-sitter-python ([#8976](https://github.com/helix-editor/helix/pull/8976)) +- Recognize `.envrc.local` and `.envrc.private` as env ([#8988](https://github.com/helix-editor/helix/pull/8988)) +- Update tree-sitter-gleam ([#9003](https://github.com/helix-editor/helix/pull/9003), [9ceeea5](https://github.com/helix-editor/helix/commit/9ceeea5)) +- Update tree-sitter-d ([#9021](https://github.com/helix-editor/helix/pull/9021)) +- Fix R-markdown language name for LSP detection ([#9012](https://github.com/helix-editor/helix/pull/9012)) +- Add haskell-language-server LSP configuration ([#9111](https://github.com/helix-editor/helix/pull/9111)) +- Recognize `glif` as XML ([#9130](https://github.com/helix-editor/helix/pull/9130)) +- Recognize `.prettierrc` as JSON ([#9214](https://github.com/helix-editor/helix/pull/9214)) +- Add auto-pairs configuration for scheme ([#9232](https://github.com/helix-editor/helix/pull/9232)) +- Add textobject queries for Scala ([#9191](https://github.com/helix-editor/helix/pull/9191)) +- Add textobject queries for Protobuf ([#9184](https://github.com/helix-editor/helix/pull/9184)) +- Update tree-sitter-wren ([#8544](https://github.com/helix-editor/helix/pull/8544)) +- Add `spago.yaml` as an LSP root for PureScript ([#9362](https://github.com/helix-editor/helix/pull/9362)) +- Improve highlight and indent queries for Bash, Make and CSS ([#9393](https://github.com/helix-editor/helix/pull/9393)) +- Update tree-sitter-scala ([#9348](https://github.com/helix-editor/helix/pull/9348), [#9340](https://github.com/helix-editor/helix/pull/9340), [#9475](https://github.com/helix-editor/helix/pull/9475)) +- Recognize `.bash_history` as Bash ([#9401](https://github.com/helix-editor/helix/pull/9401)) +- Recognize Helix ignore files as ignore ([#9447](https://github.com/helix-editor/helix/pull/9447)) +- Inject SQL into Scala SQL strings ([#9428](https://github.com/helix-editor/helix/pull/9428)) +- Update gdscript textobjects ([#9288](https://github.com/helix-editor/helix/pull/9288)) +- Update Go queries ([#9510](https://github.com/helix-editor/helix/pull/9510), [#9525](https://github.com/helix-editor/helix/pull/9525)) +- Update tree-sitter-nushell ([#9502](https://github.com/helix-editor/helix/pull/9502)) +- Update tree-sitter-unison, add indent queries ([#9505](https://github.com/helix-editor/helix/pull/9505)) +- Update tree-sitter-slint ([#9551](https://github.com/helix-editor/helix/pull/9551), [#9698](https://github.com/helix-editor/helix/pull/9698)) +- Update tree-sitter-swift ([#9586](https://github.com/helix-editor/helix/pull/9586)) +- Add `fish_indent` as formatter for fish ([78ed3ad](https://github.com/helix-editor/helix/commit/78ed3ad)) +- Recognize `zon` as Zig ([#9582](https://github.com/helix-editor/helix/pull/9582)) +- Add a formatter for Odin ([#9537](https://github.com/helix-editor/helix/pull/9537)) +- Update tree-sitter-erlang ([#9627](https://github.com/helix-editor/helix/pull/9627), [fdcd461](https://github.com/helix-editor/helix/commit/fdcd461)) +- Capture Rust fields as argument textobjects ([#9637](https://github.com/helix-editor/helix/pull/9637)) +- Improve Dart textobjects ([#9644](https://github.com/helix-editor/helix/pull/9644)) +- Recognize `tmux.conf` as a bash file-type ([#9653](https://github.com/helix-editor/helix/pull/9653)) +- Add textobjects queries for Nix ([#9659](https://github.com/helix-editor/helix/pull/9659)) +- Add textobjects queries for HCL ([#9658](https://github.com/helix-editor/helix/pull/9658)) +- Recognize osm and osc extensions as XML ([#9697](https://github.com/helix-editor/helix/pull/9697)) +- Update tree-sitter-sql ([#9634](https://github.com/helix-editor/helix/pull/9634)) +- Recognize pde Processing files as Java ([#9741](https://github.com/helix-editor/helix/pull/9741)) +- Update tree-sitter-lua ([#9727](https://github.com/helix-editor/helix/pull/9727)) +- Switch tree-sitter-nim parsers ([#9722](https://github.com/helix-editor/helix/pull/9722)) +- Recognize GTK builder ui files as XML ([#9754](https://github.com/helix-editor/helix/pull/9754)) +- Add configuration for markdown-oxide language server ([#9758](https://github.com/helix-editor/helix/pull/9758)) +- Add a shebang for elvish ([#9779](https://github.com/helix-editor/helix/pull/9779)) +- Fix precedence of Svelte TypeScript injection ([#9777](https://github.com/helix-editor/helix/pull/9777)) +- 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)) +- 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)) +- Recognize 'mmd' as Mermaid ([459eb9a](https://github.com/helix-editor/helix/commit/459eb9a)) +- Highlight Rust extern crate aliases ([c099dde](https://github.com/helix-editor/helix/commit/c099dde)) +- Improve parameter highlighting in C++ ([f5d95de](https://github.com/helix-editor/helix/commit/f5d95de)) +- Recognize 'rclone.conf' as INI ([#9959](https://github.com/helix-editor/helix/pull/9959)) +- Add injections for GraphQL and ERB in Ruby heredocs ([#10036](https://github.com/helix-editor/helix/pull/10036)) +- Add `main.odin` to Odin LSP roots ([#9968](https://github.com/helix-editor/helix/pull/9968)) + +Packaging: + +- Allow user overlays in Nix grammars build ([#8749](https://github.com/helix-editor/helix/pull/8749)) +- Set Cargo feature resolver to v2 ([#8917](https://github.com/helix-editor/helix/pull/8917)) +- Use workspace inheritance for common Cargo metadata ([#8925](https://github.com/helix-editor/helix/pull/8925)) +- Remove sourcehut-based tree-sitter grammars from default build ([#9316](https://github.com/helix-editor/helix/pull/9316), [#9326](https://github.com/helix-editor/helix/pull/9326)) +- Add icon to Windows executable ([#9104](https://github.com/helix-editor/helix/pull/9104)) + # 23.10 (2023-10-24) A big shout out to all the contributors! We had 118 contributors in this release. diff --git a/Cargo.lock b/Cargo.lock index 7903b158f..5c7c4fdd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,9 +89,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" [[package]] name = "android-tzdata" @@ -110,9 +110,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.81" +version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" +checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519" [[package]] name = "arc-swap" @@ -149,9 +149,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "backtrace" @@ -209,20 +209,11 @@ dependencies = [ "serde", ] -[[package]] -name = "btoi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" -dependencies = [ - "num-traits", -] - [[package]] name = "bumpalo" -version = "3.15.4" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "byteorder" @@ -244,9 +235,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cc" -version = "1.0.90" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" +checksum = "065a29261d53ba54260972629f9ca6bffa69bac13cd1fed61420f7fa68b9f8bd" [[package]] name = "cfg-if" @@ -267,30 +258,30 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] name = "clipboard-win" -version = "5.3.0" +version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d517d4b86184dbb111d3556a10f1c8a04da7428d2987bf1081602bf11c3aa9ee" +checksum = "79f4473f5144e20d9aceaf2972478f06ddf687831eafeeb434fbaf0acc4144ad" dependencies = [ "error-code", ] [[package]] name = "clru" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8191fa7302e03607ff0e237d4246cc043ff5b3cb9409d995172ba3bea16b807" +checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59" [[package]] name = "codegen" @@ -430,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ "cfg-if", - "hashbrown 0.14.3", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", @@ -474,15 +465,15 @@ checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" [[package]] name = "either" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" +checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" [[package]] name = "encoding_rs" -version = "0.8.33" +version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" dependencies = [ "cfg-if", ] @@ -537,9 +528,9 @@ checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183" [[package]] name = "fastrand" -version = "2.0.2" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "fern" @@ -561,16 +552,34 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "windows-sys 0.52.0", +] + [[package]] name = "flate2" -version = "1.0.28" +version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" dependencies = [ "crc32fast", "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -605,7 +614,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", ] [[package]] @@ -648,9 +657,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.12" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" dependencies = [ "cfg-if", "libc", @@ -665,38 +674,48 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "gix" -version = "0.58.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31887c304d9a935f3e5494fb5d6a0106c34e965168ec0db9b457424eedd0c741" +checksum = "5631c64fb4cd48eee767bf98a3cbc5c9318ef3bb71074d4c099a2371510282b6" dependencies = [ "gix-actor", + "gix-attributes", + "gix-command", "gix-commitgraph", "gix-config", "gix-date", "gix-diff", + "gix-dir", "gix-discover", "gix-features", + "gix-filter", "gix-fs", "gix-glob", "gix-hash", "gix-hashtable", + "gix-ignore", + "gix-index", "gix-lock", "gix-macros", "gix-object", "gix-odb", "gix-pack", "gix-path", + "gix-pathspec", "gix-ref", "gix-refspec", "gix-revision", "gix-revwalk", "gix-sec", + "gix-status", + "gix-submodule", "gix-tempfile", "gix-trace", "gix-traverse", "gix-url", "gix-utils", "gix-validate", + "gix-worktree", "once_cell", "parking_lot", "smallvec", @@ -705,18 +724,44 @@ dependencies = [ [[package]] name = "gix-actor" -version = "0.30.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7bb9fad6125c81372987c06469601d37e1a2d421511adb69971b9083517a8a" +checksum = "45c3a3bde455ad2ee8ba8a195745241ce0b770a8a26faae59fcf409d01b28c46" dependencies = [ "bstr", - "btoi", "gix-date", + "gix-utils", "itoa", "thiserror", "winnow", ] +[[package]] +name = "gix-attributes" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eefb48f42eac136a4a0023f49a54ec31be1c7a9589ed762c45dcb9b953f7ecc8" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a371db66cbd4e13f0ed9dc4c0fea712d7276805fccc877f77e96374d317e87ae" +dependencies = [ + "thiserror", +] + [[package]] name = "gix-chunk" version = "0.4.8" @@ -726,6 +771,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-command" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90009020dc4b3de47beed28e1334706e0a330ddd17f5cfeb097df3b15a54b77" +dependencies = [ + "bstr", + "gix-path", + "gix-trace", + "shell-words", +] + [[package]] name = "gix-commitgraph" version = "0.24.2" @@ -742,9 +799,9 @@ dependencies = [ [[package]] name = "gix-config" -version = "0.34.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e62bf2073b6ce3921ffa6d8326f645f30eec5fc4a8e8a4bc0fcb721a2f3f69dc" +checksum = "7580e05996e893347ad04e1eaceb92e1c0e6a3ffe517171af99bf6b6df0ca6e5" dependencies = [ "bstr", "gix-config-value", @@ -788,21 +845,49 @@ dependencies = [ [[package]] name = "gix-diff" -version = "0.40.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbdcb5e49c4b9729dd1c361040ae5c3cd7c497b2260b18c954f62db3a63e98cf" +checksum = "a5fbc24115b957346cd23fb0f47d830eb799c46c89cdcf2f5acc9bf2938c2d01" dependencies = [ "bstr", + "gix-command", + "gix-filter", + "gix-fs", "gix-hash", "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-worktree", + "imara-diff", + "thiserror", +] + +[[package]] +name = "gix-dir" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fcd56ffa1133f35525af890226ad0d3b2e607b4490360c94b1869e278eba3" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", "thiserror", ] [[package]] name = "gix-discover" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4669218f3ec0cbbf8f16857b32200890f8ca585f36f5817242e4115fe4551af" +checksum = "64bab49087ed3710caf77e473dc0efc54ca33d8ccc6441359725f121211482b1" dependencies = [ "bstr", "dunce", @@ -833,11 +918,32 @@ dependencies = [ "walkdir", ] +[[package]] +name = "gix-filter" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0d1f01af62bfd2fb3dd291acc2b29d4ab3e96ad52a679174626508ce98ef12" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline-blocking", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror", +] + [[package]] name = "gix-fs" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634b8a743b0aae03c1a74ee0ea24e8c5136895efac64ce52b3ea106e1c6f0613" +checksum = "e2184c40e7910529677831c8b481acf788ffd92427ed21fad65b6aa637e631b8" dependencies = [ "gix-features", "gix-utils", @@ -872,10 +978,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddf80e16f3c19ac06ce415a38b8591993d3f73aede049cb561becb5b3a8e242" dependencies = [ "gix-hash", - "hashbrown 0.14.3", + "hashbrown 0.14.5", "parking_lot", ] +[[package]] +name = "gix-ignore" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640dbeb4f5829f9fc14d31f654a34a0350e43a24e32d551ad130d99bf01f63f1" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-index" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881ab3b1fa57f497601a5add8289e72a7ae09471fc0b9bbe483b628ae8e418a1" +dependencies = [ + "bitflags 2.5.0", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "hashbrown 0.14.5", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror", +] + [[package]] name = "gix-lock" version = "13.1.1" @@ -895,21 +1041,21 @@ checksum = "1dff438f14e67e7713ab9332f5fd18c8f20eb7eb249494f6c2bf170522224032" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", ] [[package]] name = "gix-object" -version = "0.41.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693ce9d30741506cb082ef2d8b797415b48e032cce0ab23eff894c19a7e4777b" +checksum = "3d4f8efae72030df1c4a81d02dbe2348e748d9b9a11e108ed6efbd846326e051" dependencies = [ "bstr", - "btoi", "gix-actor", "gix-date", "gix-features", "gix-hash", + "gix-utils", "gix-validate", "itoa", "smallvec", @@ -919,9 +1065,9 @@ dependencies = [ [[package]] name = "gix-odb" -version = "0.57.0" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ba2fa9e81f2461b78b4d81a807867667326c84cdab48e0aed7b73a593aa1be4" +checksum = "e8bbb43d2fefdc4701ffdf9224844d05b136ae1b9a73c2f90710c8dd27a93503" dependencies = [ "arc-swap", "gix-date", @@ -939,9 +1085,9 @@ dependencies = [ [[package]] name = "gix-pack" -version = "0.47.0" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8da5f3e78c96b76c4e6fe5e8e06b76221e4a0ee9a255aa935ed1fdf68988dfd8" +checksum = "b58bad27c7677fa6b587aab3a1aca0b6c97373bd371a0a4290677c838c9bcaf1" dependencies = [ "clru", "gix-chunk", @@ -957,6 +1103,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-packetline-blocking" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31d42378a3d284732e4d589979930d0d253360eccf7ec7a80332e5ccb77e14a" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror", +] + [[package]] name = "gix-path" version = "0.10.7" @@ -970,6 +1128,21 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-pathspec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea9f934a111e0efdf93ae06e3648427e60e783099fbebd6a53a7a2ffb10a1e65" +dependencies = [ + "bitflags 2.5.0", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror", +] + [[package]] name = "gix-quote" version = "0.4.12" @@ -983,9 +1156,9 @@ dependencies = [ [[package]] name = "gix-ref" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5818958994ad7879fa566f5441ebcc48f0926aa027b28948e6fbf6578894dc31" +checksum = "fd4aba68b925101cb45d6df328979af0681364579db889098a0de75b36c77b65" dependencies = [ "gix-actor", "gix-date", @@ -1005,9 +1178,9 @@ dependencies = [ [[package]] name = "gix-refspec" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613aa4d93034c5791d13bdc635e530f4ddab1412ddfb4a8215f76213177b61c7" +checksum = "dde848865834a54fe4d9b4573f15d0e9a68eaf3d061b42d3ed52b4b8acf880b2" dependencies = [ "bstr", "gix-hash", @@ -1019,9 +1192,9 @@ dependencies = [ [[package]] name = "gix-revision" -version = "0.26.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "288f6549d7666db74dc3f169a9a333694fc28ecd2f5aa7b2c979c89eb556751a" +checksum = "9e34196e1969bd5d36e2fbc4467d893999132219d503e23474a8ad2b221cb1e8" dependencies = [ "bstr", "gix-date", @@ -1035,9 +1208,9 @@ dependencies = [ [[package]] name = "gix-revwalk" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9b4d91dfc5c14fee61a28c65113ded720403b65a0f46169c0460f731a5d03c" +checksum = "e0a7d393ae814eeaae41a333c0ff684b243121cc61ccdc5bbe9897094588047d" dependencies = [ "gix-commitgraph", "gix-date", @@ -1060,12 +1233,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "gix-status" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c413bfd2952e4ee92e48438dac3c696f3555e586a34d184a427f6bedd1e4f9" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-submodule" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb7ea05666362472fecd44c1fc35fe48a5b9b841b431cc4f85b95e6f20c23ec" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror", +] + [[package]] name = "gix-tempfile" version = "13.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a761d76594f4443b675e85928e4902dec333273836bd386906f01e7e346a0d11" dependencies = [ + "dashmap", "gix-fs", "libc", "once_cell", @@ -1075,16 +1286,17 @@ dependencies = [ [[package]] name = "gix-trace" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b838b2db8f62c9447d483a4c28d251b67fee32741a82cb4d35e9eb4e9fdc5ab" +checksum = "f924267408915fddcd558e3f37295cc7d6a3e50f8bd8b606cee0808c3915157e" [[package]] name = "gix-traverse" -version = "0.37.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfc30c5b5e4e838683b59e1b0574ce6bc1c35916df9709aaab32bb7751daf08b" +checksum = "f4029ec209b0cc480d209da3837a42c63801dd8548f09c1f4502c60accb62aeb" dependencies = [ + "bitflags 2.5.0", "gix-commitgraph", "gix-date", "gix-hash", @@ -1097,9 +1309,9 @@ dependencies = [ [[package]] name = "gix-url" -version = "0.27.2" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f0b24f3ecc79a5a53539de9c2e99425d0ef23feacdcf3faac983aa9a2f26849" +checksum = "0db829ebdca6180fbe32be7aed393591df6db4a72dbbc0b8369162390954d1cf" dependencies = [ "bstr", "gix-features", @@ -1111,10 +1323,11 @@ dependencies = [ [[package]] name = "gix-utils" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0066432d4c277f9877f091279a597ea5331f68ca410efc874f0bdfb1cd348f92" +checksum = "35192df7fd0fa112263bad8021e2df7167df4cc2a6e6d15892e1e55621d3d4dc" dependencies = [ + "bstr", "fastrand", "unicode-normalization", ] @@ -1129,6 +1342,24 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-worktree" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f06ca5dd164678914fc9280ba9d1ffeb66499ccc16ab1278c513828beee88401" +dependencies = [ + "bstr", + "gix-attributes", + "gix-features", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", +] + [[package]] name = "globset" version = "0.4.14" @@ -1197,9 +1428,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", "allocator-api2", @@ -1207,7 +1438,7 @@ dependencies = [ [[package]] name = "helix-core" -version = "23.10.0" +version = "24.3.0" dependencies = [ "ahash", "arc-swap", @@ -1216,7 +1447,8 @@ dependencies = [ "dunce", "encoding_rs", "etcetera", - "hashbrown 0.14.3", + "globset", + "hashbrown 0.14.5", "helix-loader", "helix-stdx", "imara-diff", @@ -1244,7 +1476,7 @@ dependencies = [ [[package]] name = "helix-dap" -version = "23.10.0" +version = "24.3.0" dependencies = [ "anyhow", "fern", @@ -1259,12 +1491,12 @@ dependencies = [ [[package]] name = "helix-event" -version = "23.10.0" +version = "24.3.0" dependencies = [ "ahash", "anyhow", "futures-executor", - "hashbrown 0.14.3", + "hashbrown 0.14.5", "log", "once_cell", "parking_lot", @@ -1273,7 +1505,7 @@ dependencies = [ [[package]] name = "helix-loader" -version = "23.10.0" +version = "24.3.0" dependencies = [ "anyhow", "cc", @@ -1292,9 +1524,10 @@ dependencies = [ [[package]] name = "helix-lsp" -version = "23.10.0" +version = "24.3.0" dependencies = [ "anyhow", + "arc-swap", "futures-executor", "futures-util", "globset", @@ -1307,6 +1540,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", + "slotmap", "thiserror", "tokio", "tokio-stream", @@ -1314,22 +1548,26 @@ dependencies = [ [[package]] name = "helix-parsec" -version = "23.10.0" +version = "24.3.0" [[package]] name = "helix-stdx" -version = "23.10.0" +version = "24.3.0" dependencies = [ + "bitflags 2.5.0", "dunce", "etcetera", + "regex-cursor", "ropey", + "rustix", "tempfile", "which 6.0.1", + "windows-sys 0.52.0", ] [[package]] name = "helix-term" -version = "23.10.0" +version = "24.3.0" dependencies = [ "anyhow", "arc-swap", @@ -1364,6 +1602,7 @@ dependencies = [ "smallvec", "steel-core", "tempfile", + "termini", "tokio", "tokio-stream", "toml", @@ -1372,7 +1611,7 @@ dependencies = [ [[package]] name = "helix-tui" -version = "23.10.0" +version = "24.3.0" dependencies = [ "bitflags 2.5.0", "cassowary", @@ -1389,7 +1628,7 @@ dependencies = [ [[package]] name = "helix-vcs" -version = "23.10.0" +version = "24.3.0" dependencies = [ "anyhow", "arc-swap", @@ -1405,7 +1644,7 @@ dependencies = [ [[package]] name = "helix-view" -version = "23.10.0" +version = "24.3.0" dependencies = [ "anyhow", "arc-swap", @@ -1431,6 +1670,7 @@ dependencies = [ "serde_json", "slotmap", "steel-core", + "tempfile", "tokio", "tokio-stream", "toml", @@ -1503,9 +1743,9 @@ dependencies = [ [[package]] name = "im-lists" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e7233fb8b1ffc0b1d6033fd311a74a0443164628c62abbc871185ee95098b63" +checksum = "c38da2a11f128e1af1585abdd7041a96fc1adcd6ca2cb33e7fc5728326edbe8a" dependencies = [ "smallvec", ] @@ -1551,7 +1791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown 0.14.3", + "hashbrown 0.14.5", ] [[package]] @@ -1594,11 +1834,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kstring" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3066350882a1cd6d950d055997f379ac37fd39f81cd4d8ed186032eb3c5747" +dependencies = [ + "static_assertions", +] + [[package]] name = "lasso" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4644821e1c3d7a560fe13d842d13f587c07348a1a05d3a797152d41c90c56df2" +source = "git+https://github.com/lcnr/lasso/?branch=patch-1#87767f540cd040764325a53fc790c27e17c69d33" dependencies = [ "dashmap", "hashbrown 0.13.2", @@ -1607,9 +1855,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.153" +version = "0.2.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" [[package]] name = "libloading" @@ -1628,18 +1876,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" dependencies = [ "cfg-if", - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] name = "libredox" -version = "0.0.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.5.0", "libc", - "redox_syscall", ] [[package]] @@ -1650,9 +1897,9 @@ checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" [[package]] name = "lock_api" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", @@ -1737,9 +1984,9 @@ dependencies = [ [[package]] name = "num" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af" +checksum = "3135b08af27d103b0a51f2ae0f8632117b7b185ccf931445affa8df530576a41" dependencies = [ "num-bigint", "num-complex", @@ -1788,9 +2035,9 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.44" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", @@ -1812,9 +2059,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -1872,9 +2119,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" dependencies = [ "lock_api", "parking_lot_core", @@ -1882,15 +2129,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.1", "smallvec", - "windows-targets 0.48.5", + "windows-targets 0.52.5", ] [[package]] @@ -1913,9 +2160,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" @@ -1948,9 +2195,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.79" +version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" dependencies = [ "unicode-ident", ] @@ -1963,9 +2210,9 @@ checksum = "744a264d26b88a6a7e37cbad97953fa233b94d585236310bcbc88474b4092d79" [[package]] name = "pulldown-cmark" -version = "0.9.6" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +checksum = "dce76ce678ffc8e5675b22aa1405de0b7037e2fdf8913fea40d1926c6fe1e6e7" dependencies = [ "bitflags 2.5.0", "memchr", @@ -1993,9 +2240,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.35" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] @@ -2074,11 +2321,20 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" +dependencies = [ + "bitflags 2.5.0", +] + [[package]] name = "redox_users" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" dependencies = [ "getrandom", "libredox", @@ -2108,6 +2364,19 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-cursor" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae4327b5fde3ae6fda0152128d3d59b95a5aad7be91c405869300091720f7169" +dependencies = [ + "log", + "memchr", + "regex-automata", + "regex-syntax", + "ropey", +] + [[package]] name = "regex-syntax" version = "0.8.3" @@ -2150,9 +2419,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.32" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.5.0", "errno", @@ -2190,29 +2459,29 @@ checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" [[package]] name = "serde" -version = "1.0.197" +version = "1.0.200" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.200" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", ] [[package]] name = "serde_json" -version = "1.0.115" +version = "1.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" +checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" dependencies = [ "itoa", "ryu", @@ -2221,13 +2490,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb" +checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", ] [[package]] @@ -2245,6 +2514,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "signal-hook" version = "0.3.17" @@ -2268,9 +2543,9 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] @@ -2340,9 +2615,9 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" -version = "0.5.6" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", "windows-sys 0.52.0", @@ -2357,7 +2632,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "steel-core" version = "0.6.0" -source = "git+https://github.com/mattwparas/steel.git#c5451eaf7fc8e4ba39eb0536b0ccc9b469c2e50b" +source = "git+https://github.com/mattwparas/steel.git#57d9cf6f6cead3a324bebe1710617a6bfbd1432d" dependencies = [ "abi_stable", "anyhow", @@ -2395,17 +2670,17 @@ dependencies = [ [[package]] name = "steel-derive" version = "0.5.0" -source = "git+https://github.com/mattwparas/steel.git#c5451eaf7fc8e4ba39eb0536b0ccc9b469c2e50b" +source = "git+https://github.com/mattwparas/steel.git#57d9cf6f6cead3a324bebe1710617a6bfbd1432d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", ] [[package]] name = "steel-gen" version = "0.2.0" -source = "git+https://github.com/mattwparas/steel.git#c5451eaf7fc8e4ba39eb0536b0ccc9b469c2e50b" +source = "git+https://github.com/mattwparas/steel.git#57d9cf6f6cead3a324bebe1710617a6bfbd1432d" dependencies = [ "codegen", "serde", @@ -2415,7 +2690,7 @@ dependencies = [ [[package]] name = "steel-parser" version = "0.6.0" -source = "git+https://github.com/mattwparas/steel.git#c5451eaf7fc8e4ba39eb0536b0ccc9b469c2e50b" +source = "git+https://github.com/mattwparas/steel.git#57d9cf6f6cead3a324bebe1710617a6bfbd1432d" dependencies = [ "fxhash", "lasso", @@ -2435,9 +2710,9 @@ checksum = "e9557cb6521e8d009c51a8666f09356f4b817ba9ba0981a305bd86aee47bd35c" [[package]] name = "strsim" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" @@ -2452,9 +2727,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.55" +version = "2.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "002a1b3dbf967edfafc32655d0f377ab0bb7b994aa1d32c8cc7e9b8bf3ebb8f0" +checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" dependencies = [ "proc-macro2", "quote", @@ -2504,22 +2779,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.58" +version = "1.0.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" +checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.58" +version = "1.0.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" +checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", ] [[package]] @@ -2533,9 +2808,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.34" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", @@ -2556,9 +2831,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", @@ -2606,7 +2881,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", ] [[package]] @@ -2622,9 +2897,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.7.8" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" dependencies = [ "serde", "serde_spanned", @@ -2643,9 +2918,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.19.15" +version = "0.22.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +checksum = "d3328d4f68a705b2a4498da1d580585d39a6510f98318a2cec3018a7ec61ddef" dependencies = [ "indexmap 2.2.6", "serde", @@ -2656,8 +2931,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.20.10" -source = "git+https://github.com/tree-sitter/tree-sitter?rev=660481dbf71413eba5a928b0b0ab8da50c1109e0#660481dbf71413eba5a928b0b0ab8da50c1109e0" +version = "0.22.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688200d842c76dd88f9a7719ecb0483f79f5a766fb1c100756d5d8a059abc71b" dependencies = [ "cc", "regex", @@ -2746,9 +3022,9 @@ checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] name = "unicode-width" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" [[package]] name = "url" @@ -2805,7 +3081,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", "wasm-bindgen-shared", ] @@ -2827,7 +3103,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2886,11 +3162,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" dependencies = [ - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -2905,7 +3181,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] @@ -2923,7 +3199,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] @@ -2943,17 +3219,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" dependencies = [ - "windows_aarch64_gnullvm 0.52.4", - "windows_aarch64_msvc 0.52.4", - "windows_i686_gnu 0.52.4", - "windows_i686_msvc 0.52.4", - "windows_x86_64_gnu 0.52.4", - "windows_x86_64_gnullvm 0.52.4", - "windows_x86_64_msvc 0.52.4", + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", ] [[package]] @@ -2964,9 +3241,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" [[package]] name = "windows_aarch64_msvc" @@ -2976,9 +3253,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" [[package]] name = "windows_i686_gnu" @@ -2988,9 +3265,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.4" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" [[package]] name = "windows_i686_msvc" @@ -3000,9 +3283,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" [[package]] name = "windows_x86_64_gnu" @@ -3012,9 +3295,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" [[package]] name = "windows_x86_64_gnullvm" @@ -3024,9 +3307,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" [[package]] name = "windows_x86_64_msvc" @@ -3036,15 +3319,15 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "winnow" -version = "0.5.40" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +checksum = "14b9415ee827af173ebb3f15f9083df5a122eb93572ec28741fb153356ea2578" dependencies = [ "memchr", ] @@ -3057,7 +3340,7 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "xtask" -version = "23.10.0" +version = "24.3.0" dependencies = [ "helix-core", "helix-loader", @@ -3083,5 +3366,5 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.60", ] diff --git a/Cargo.toml b/Cargo.toml index 49d0cc321..ce210c69b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,8 +23,9 @@ default-members = [ # If working locally, use the local path dependency # steel-core = { path = "../../steel/crates/steel-core", version = "0.6.0", features = ["anyhow", "dylibs"] } steel-core = { git = "https://github.com/mattwparas/steel.git", version = "0.6.0", features = ["anyhow", "dylibs"] } -tree-sitter = { version = "0.20", git = "https://github.com/tree-sitter/tree-sitter", rev = "660481dbf71413eba5a928b0b0ab8da50c1109e0" } +tree-sitter = { version = "0.22" } nucleo = "0.2.0" +slotmap = "1.0.7" [profile.release] lto = "thin" @@ -44,7 +45,7 @@ package.helix-tui.opt-level = 2 package.helix-term.opt-level = 2 [workspace.package] -version = "23.10.0" +version = "24.3.0" edition = "2021" authors = ["Blaž Hrastnik "] categories = ["editor"] diff --git a/base16_theme.toml b/base16_theme.toml index 268a38df6..4a071a60b 100644 --- a/base16_theme.toml +++ b/base16_theme.toml @@ -28,6 +28,8 @@ "label" = "magenta" "namespace" = "magenta" "ui.help" = { fg = "white", bg = "black" } +"ui.virtual.jump-label" = { fg = "blue", modifiers = ["bold", "underlined"] } +"ui.virtual.ruler" = { bg = "black" } "markup.heading" = "blue" "markup.list" = "red" diff --git a/book/src/configuration.md b/book/src/configuration.md index a43ede76a..5e22cebf8 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -68,6 +68,7 @@ Its settings will be merged with the configuration directory `config.toml` and t | `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` | | `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` | | `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid` +| `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"` ### `[editor.statusline]` Section @@ -75,7 +76,7 @@ Allows configuring the statusline at the bottom of the editor. The configuration distinguishes between three areas of the status line: -`[ ... ... LEFT ... ... | ... ... ... ... CENTER ... ... ... ... | ... ... RIGHT ... ... ]` +`[ ... ... LEFT ... ... | ... ... ... CENTER ... ... ... | ... ... RIGHT ... ... ]` Statusline elements can be defined as follows: @@ -108,6 +109,7 @@ The following statusline elements can be configured: | `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) | | `spinner` | A progress spinner indicating LSP activity | | `file-name` | The path/name of the opened file | +| `file-absolute-path` | The absolute path/name of the opened file | | `file-base-name` | The basename of the opened file | | `file-modification-indicator` | The indicator to show whether the file is modified (a `[+]` appears when there are unsaved changes) | | `file-encoding` | The encoding of the opened file if it differs from UTF-8 | @@ -177,7 +179,7 @@ All git related options are only enabled in a git repository. |`git-ignore` | Enables reading `.gitignore` files | `true` |`git-global` | Enables reading global `.gitignore`, whose path is specified in git's config: `core.excludesfile` option | `true` |`git-exclude` | Enables reading `.git/info/exclude` files | `true` -|`max-depth` | Set with an integer value for maximum depth to recurse | Defaults to `None`. +|`max-depth` | Set with an integer value for maximum depth to recurse | Unset by default Ignore files can be placed locally as `.ignore` or put in your home directory as `~/.ignore`. They support the usual ignore and negative ignore (unignore) rules used in `.gitignore` files. @@ -223,7 +225,7 @@ Additionally, this setting can be used in a language config. Unless the editor setting is `false`, this will override the editor config in documents with this language. -Example `languages.toml` that adds <> and removes '' +Example `languages.toml` that adds `<>` and removes `''` ```toml [[language]] @@ -253,8 +255,8 @@ Options for rendering whitespace with visible characters. Use `:set whitespace.r | Key | Description | Default | |-----|-------------|---------| -| `render` | Whether to render whitespace. May either be `"all"` or `"none"`, or a table with sub-keys `space`, `nbsp`, `tab`, and `newline` | `"none"` | -| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `newline` or `tabpad` | See example below | +| `render` | Whether to render whitespace. May either be `all` or `none`, or a table with sub-keys `space`, `nbsp`, `nnbsp`, `tab`, and `newline` | `none` | +| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `nnbsp`, `newline` or `tabpad` | See example below | Example @@ -265,11 +267,14 @@ render = "all" [editor.whitespace.render] space = "all" tab = "all" +nbsp = "none" +nnbsp = "none" newline = "none" [editor.whitespace.characters] space = "·" nbsp = "⍽" +nnbsp = "␣" tab = "→" newline = "⏎" tabpad = "·" # Tabs will look like "→···" (depending on tab width) @@ -340,7 +345,12 @@ Currently unused #### `[editor.gutters.diff]` Section -Currently unused +The `diff` gutter option displays colored bars indicating whether a `git` diff represents that a line was added, removed or changed. +These colors are controlled by the theme attributes `diff.plus`, `diff.minus` and `diff.delta`. + +Other diff providers will eventually be supported by a future plugin system. + +There are currently no options for this section. #### `[editor.gutters.spacer]` Section @@ -370,8 +380,25 @@ wrap-indicator = "" # set wrap-indicator to "" to hide it ### `[editor.smart-tab]` Section +Options for navigating and editing using tab key. | Key | Description | Default | |------------|-------------|---------| | `enable` | If set to true, then when the cursor is in a position with non-whitespace to its left, instead of inserting a tab, it will run `move_parent_node_end`. If there is only whitespace to the left, then it inserts a tab as normal. With the default bindings, to explicitly insert a tab character, press Shift-tab. | `true` | | `supersede-menu` | Normally, when a menu is on screen, such as when auto complete is triggered, the tab key is bound to cycling through the items. This means when menus are on screen, one cannot use the tab key to trigger the `smart-tab` command. If this option is set to true, the `smart-tab` command always takes precedence, which means one cannot use the tab key to cycle through menu items. One of the other bindings must be used instead, such as arrow keys or `C-n`/`C-p`. | `false` | + + +Due to lack of support for S-tab in some terminals, the default keybindings don't fully embrace smart-tab editing experience. If you enjoy smart-tab navigation and a terminal that supports the [Enhanced Keyboard protocol](https://github.com/helix-editor/helix/wiki/Terminal-Support#enhanced-keyboard-protocol), consider setting extra keybindings: + +``` +[keys.normal] +tab = "move_parent_node_end" +S-tab = "move_parent_node_start" + +[keys.insert] +S-tab = "move_parent_node_start" + +[keys.select] +tab = "extend_parent_node_end" +S-tab = "extend_parent_node_start" +``` diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index a78dd7935..8418138c8 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -1,5 +1,7 @@ | Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default LSP | | --- | --- | --- | --- | --- | +| ada | ✓ | ✓ | | `ada_language_server`, `ada_language_server` | +| adl | ✓ | ✓ | ✓ | | | agda | ✓ | | | | | astro | ✓ | | | | | awk | ✓ | ✓ | | `awk-language-server` | @@ -8,12 +10,15 @@ | beancount | ✓ | | | | | bibtex | ✓ | | | `texlab` | | bicep | ✓ | | | `bicep-langserver` | +| bitbake | ✓ | | | `bitbake-language-server` | +| blade | ✓ | | | | | blueprint | ✓ | | | `blueprint-compiler` | | c | ✓ | ✓ | ✓ | `clangd` | | c-sharp | ✓ | ✓ | | `OmniSharp` | | cabal | | | | `haskell-language-server-wrapper` | | cairo | ✓ | ✓ | ✓ | `cairo-language-server` | | capnp | ✓ | | ✓ | | +| cel | ✓ | | | | | clojure | ✓ | | | `clojure-lsp` | | cmake | ✓ | ✓ | ✓ | `cmake-language-server` | | comment | ✓ | | | | @@ -29,9 +34,11 @@ | devicetree | ✓ | | | | | dhall | ✓ | ✓ | | `dhall-lsp-server` | | diff | ✓ | | | | +| docker-compose | ✓ | | ✓ | `docker-compose-langserver`, `yaml-language-server` | | dockerfile | ✓ | | | `docker-langserver` | | dot | ✓ | | | `dot-language-server` | | dtd | ✓ | | | | +| earthfile | ✓ | ✓ | ✓ | `earthlyls` | | edoc | ✓ | | | | | eex | ✓ | | | | | ejs | ✓ | | | | @@ -42,6 +49,7 @@ | erb | ✓ | | | | | erlang | ✓ | ✓ | | `erlang_ls` | | esdl | ✓ | | | | +| fidl | ✓ | | | | | fish | ✓ | ✓ | ✓ | | | forth | ✓ | | | `forth-lsp` | | fortran | ✓ | | ✓ | `fortls` | @@ -55,6 +63,7 @@ | git-ignore | ✓ | | | | | git-rebase | ✓ | | | | | gleam | ✓ | ✓ | | `gleam` | +| glimmer | ✓ | | | `ember-language-server` | | glsl | ✓ | ✓ | ✓ | | | gn | ✓ | | | | | go | ✓ | ✓ | ✓ | `gopls`, `golangci-lint-langserver` | @@ -62,16 +71,20 @@ | gomod | ✓ | | | `gopls` | | gotmpl | ✓ | | | `gopls` | | gowork | ✓ | | | `gopls` | -| graphql | ✓ | | | `graphql-lsp` | +| graphql | ✓ | ✓ | | `graphql-lsp` | +| groovy | ✓ | | | | | hare | ✓ | | | | | haskell | ✓ | ✓ | | `haskell-language-server-wrapper` | | haskell-persistent | ✓ | | | | -| hcl | ✓ | | ✓ | `terraform-ls` | +| hcl | ✓ | ✓ | ✓ | `terraform-ls` | | heex | ✓ | ✓ | | `elixir-ls` | +| helm | ✓ | | | `helm_ls` | | hocon | ✓ | | ✓ | | +| hoon | ✓ | | | | | hosts | ✓ | | | | | html | ✓ | | | `vscode-html-language-server` | -| hurl | ✓ | | ✓ | | +| hurl | ✓ | ✓ | ✓ | | +| hyprlang | ✓ | | ✓ | | | idris | | | | `idris2-lsp` | | iex | ✓ | | | | | ini | ✓ | | | | @@ -80,15 +93,19 @@ | javascript | ✓ | ✓ | ✓ | `typescript-language-server` | | jinja | ✓ | | | | | jsdoc | ✓ | | | | -| json | ✓ | | ✓ | `vscode-json-language-server` | +| json | ✓ | ✓ | ✓ | `vscode-json-language-server` | | json5 | ✓ | | | | +| jsonc | ✓ | | ✓ | `vscode-json-language-server` | | jsonnet | ✓ | | | `jsonnet-language-server` | | jsx | ✓ | ✓ | ✓ | `typescript-language-server` | | julia | ✓ | ✓ | ✓ | `julia` | | just | ✓ | ✓ | ✓ | | | kdl | ✓ | ✓ | ✓ | | +| koka | ✓ | | ✓ | `koka` | | kotlin | ✓ | | | `kotlin-language-server` | | latex | ✓ | ✓ | | `texlab` | +| ld | ✓ | | ✓ | | +| ldif | ✓ | | | | | lean | ✓ | | | `lean` | | ledger | ✓ | | | | | llvm | ✓ | ✓ | ✓ | | @@ -99,22 +116,24 @@ | lua | ✓ | ✓ | ✓ | `lua-language-server` | | make | ✓ | | ✓ | | | markdoc | ✓ | | | `markdoc-ls` | -| markdown | ✓ | | | `marksman` | +| markdown | ✓ | | | `marksman`, `markdown-oxide` | | markdown.inline | ✓ | | | | | matlab | ✓ | ✓ | ✓ | | | mermaid | ✓ | | | | | meson | ✓ | | ✓ | | | mint | | | | `mint` | +| move | ✓ | | | | | msbuild | ✓ | | ✓ | | | nasm | ✓ | ✓ | | | | nickel | ✓ | | ✓ | `nls` | | nim | ✓ | ✓ | ✓ | `nimlangserver` | -| nix | ✓ | | | `nil` | +| nix | ✓ | ✓ | | `nil` | | nu | ✓ | | | `nu` | | nunjucks | ✓ | | | | | ocaml | ✓ | | ✓ | `ocamllsp` | | ocaml-interface | ✓ | | | `ocamllsp` | | odin | ✓ | | ✓ | `ols` | +| ohm | ✓ | ✓ | ✓ | | | opencl | ✓ | ✓ | ✓ | `clangd` | | openscad | ✓ | | | `openscad-lsp` | | org | ✓ | | | | @@ -122,10 +141,15 @@ | passwd | ✓ | | | | | pem | ✓ | | | | | perl | ✓ | ✓ | ✓ | `perlnavigator` | +| pest | ✓ | ✓ | ✓ | `pest-language-server` | | php | ✓ | ✓ | ✓ | `intelephense` | +| php-only | ✓ | | | | +| pkgbuild | ✓ | ✓ | ✓ | `pkgbuild-language-server`, `bash-language-server` | +| pkl | ✓ | | ✓ | | | po | ✓ | ✓ | | | | pod | ✓ | | | | | ponylang | ✓ | ✓ | ✓ | | +| powershell | ✓ | | | | | prisma | ✓ | | | `prisma-language-server` | | prolog | | | | `swipl` | | protobuf | ✓ | ✓ | ✓ | `bufls`, `pb` | @@ -148,35 +172,39 @@ | scala | ✓ | ✓ | ✓ | `metals` | | scheme | ✓ | | ✓ | | | scss | ✓ | | | `vscode-css-language-server` | -| slint | ✓ | | ✓ | `slint-lsp` | +| slint | ✓ | ✓ | ✓ | `slint-lsp` | | smali | ✓ | | ✓ | | | smithy | ✓ | | | `cs` | | sml | ✓ | | | | -| solidity | ✓ | | | `solc` | +| solidity | ✓ | ✓ | | `solc` | +| spicedb | ✓ | | | | | sql | ✓ | | | | | sshclientconfig | ✓ | | | | | starlark | ✓ | ✓ | | | | strace | ✓ | | | | +| supercollider | ✓ | | | | | svelte | ✓ | | ✓ | `svelteserver` | | sway | ✓ | ✓ | ✓ | `forc` | | swift | ✓ | | | `sourcekit-lsp` | | t32 | ✓ | | | | | tablegen | ✓ | ✓ | ✓ | | +| tact | ✓ | ✓ | ✓ | | | task | ✓ | | | | +| tcl | ✓ | | ✓ | | | templ | ✓ | | | `templ` | | tfvars | ✓ | | ✓ | `terraform-ls` | | todotxt | ✓ | | | | -| toml | ✓ | | | `taplo` | +| toml | ✓ | ✓ | | `taplo` | | tsq | ✓ | | | | | tsx | ✓ | ✓ | ✓ | `typescript-language-server` | | twig | ✓ | | | | | typescript | ✓ | ✓ | ✓ | `typescript-language-server` | -| typst | ✓ | | | `typst-lsp` | +| typst | ✓ | | | `tinymist`, `typst-lsp` | | ungrammar | ✓ | | | | -| unison | ✓ | | | | +| unison | ✓ | | ✓ | | | uxntal | ✓ | | | | | v | ✓ | ✓ | ✓ | `v-analyzer` | -| vala | ✓ | | | `vala-language-server` | +| vala | ✓ | ✓ | | `vala-language-server` | | verilog | ✓ | ✓ | | `svlangserver` | | vhdl | ✓ | | | `vhdl_ls` | | vhs | ✓ | | | | @@ -189,6 +217,7 @@ | wren | ✓ | ✓ | ✓ | | | xit | ✓ | | | | | xml | ✓ | | ✓ | | +| xtc | ✓ | | | | | yaml | ✓ | | ✓ | `yaml-language-server`, `ansible-language-server` | | yuck | ✓ | | | | | zig | ✓ | ✓ | ✓ | `zls` | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index f4fcb6f62..cf1b550dc 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -86,3 +86,5 @@ | `:clear-register` | Clear given register. If no argument is provided, clear all registers. | | `:redraw` | Clear and re-render the whole UI | | `:move` | Move the current buffer and its corresponding file to a different path | +| `:yank-diagnostic` | Yank diagnostic(s) under primary cursor to register, or clipboard by default | +| `:read`, `:r` | Load a file into buffer | diff --git a/book/src/guides/textobject.md b/book/src/guides/textobject.md index 405f11c1b..a31baef93 100644 --- a/book/src/guides/textobject.md +++ b/book/src/guides/textobject.md @@ -25,6 +25,8 @@ The following [captures][tree-sitter-captures] are recognized: | `parameter.inside` | | `comment.inside` | | `comment.around` | +| `entry.inside` | +| `entry.around` | [Example query files][textobject-examples] can be found in the helix GitHub repository. @@ -44,4 +46,4 @@ doesn't make sense in a navigation context. [tree-sitter-queries]: https://tree-sitter.github.io/tree-sitter/using-parsers#query-syntax [tree-sitter-captures]: https://tree-sitter.github.io/tree-sitter/using-parsers#capturing-nodes -[textobject-examples]: https://github.com/search?q=repo%3Ahelix-editor%2Fhelix+filename%3Atextobjects.scm&type=Code&ref=advsearch&l=&l= +[textobject-examples]: https://github.com/search?q=repo%3Ahelix-editor%2Fhelix+path%3A%2A%2A/textobjects.scm&type=Code&ref=advsearch&l=&l= diff --git a/book/src/install.md b/book/src/install.md index 1f200e2ed..07865e698 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -76,6 +76,15 @@ Releases are available in the `extra` repository: ```sh sudo pacman -S helix ``` + +> 💡 When installed from the `extra` repository, run Helix with `helix` instead of `hx`. +> +> For example: +> ```sh +> helix --health +> ``` +> to check health + Additionally, a [helix-git](https://aur.archlinux.org/packages/helix-git/) package is available in the AUR, which builds the master branch. diff --git a/book/src/keymap.md b/book/src/keymap.md index a3e41666f..55b467a0e 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -13,6 +13,8 @@ - [Window mode](#window-mode) - [Space mode](#space-mode) - [Popup](#popup) + - [Completion Menu](#completion-menu) + - [Signature-help Popup](#signature-help-popup) - [Unimpaired](#unimpaired) - [Insert mode](#insert-mode) - [Select / extend mode](#select--extend-mode) @@ -23,6 +25,8 @@ > 💡 Mappings marked (**TS**) require a tree-sitter grammar for the file type. +> ⚠️ Some terminals' default key mappings conflict with Helix's. If any of the mappings described on this page do not work as expected, check your terminal's mappings to ensure they do not conflict. See the (wiki)[https://github.com/helix-editor/helix/wiki/Terminal-Support] for known conflicts. + ## Normal mode Normal mode is the default mode when you launch helix. You can return to it from other modes by pressing the `Escape` key. @@ -48,13 +52,13 @@ Normal mode is the default mode when you launch helix. You can return to it from | `T` | Find 'till previous char | `till_prev_char` | | `F` | Find previous char | `find_prev_char` | | `G` | Go to line number `` | `goto_line` | -| `Alt-.` | Repeat last motion (`f`, `t` or `m`) | `repeat_last_motion` | +| `Alt-.` | Repeat last motion (`f`, `t`, `m`, `[` or `]`) | `repeat_last_motion` | | `Home` | Move to the start of the line | `goto_line_start` | | `End` | Move to the end of the line | `goto_line_end` | | `Ctrl-b`, `PageUp` | Move page up | `page_up` | | `Ctrl-f`, `PageDown` | Move page down | `page_down` | -| `Ctrl-u` | Move half page up | `half_page_up` | -| `Ctrl-d` | Move half page down | `half_page_down` | +| `Ctrl-u` | Move cursor and page half page up | `page_cursor_half_up` | +| `Ctrl-d` | Move cursor and page half page down | `page_cursor_half_down` | | `Ctrl-i` | Jump forward on the jumplist | `jump_forward` | | `Ctrl-o` | Jump backward on the jumplist | `jump_backward` | | `Ctrl-s` | Save the current selection to the jumplist | `save_selection` | @@ -182,18 +186,18 @@ normal mode) is persistent and can be exited using the escape key. This is useful when you're simply looking over text and not actively editing it. -| Key | Description | Command | -| ----- | ----------- | ------- | -| `z`, `c` | Vertically center the line | `align_view_center` | -| `t` | Align the line to the top of the screen | `align_view_top` | -| `b` | Align the line to the bottom of the screen | `align_view_bottom` | -| `m` | Align the line to the middle of the screen (horizontally) | `align_view_middle` | -| `j`, `down` | Scroll the view downwards | `scroll_down` | -| `k`, `up` | Scroll the view upwards | `scroll_up` | -| `Ctrl-f`, `PageDown` | Move page down | `page_down` | -| `Ctrl-b`, `PageUp` | Move page up | `page_up` | -| `Ctrl-d` | Move half page down | `half_page_down` | -| `Ctrl-u` | Move half page up | `half_page_up` | +| Key | Description | Command | +| ----- | ----------- | ------- | +| `z`, `c` | Vertically center the line | `align_view_center` | +| `t` | Align the line to the top of the screen | `align_view_top` | +| `b` | Align the line to the bottom of the screen | `align_view_bottom` | +| `m` | Align the line to the middle of the screen (horizontally) | `align_view_middle` | +| `j`, `down` | Scroll the view downwards | `scroll_down` | +| `k`, `up` | Scroll the view upwards | `scroll_up` | +| `Ctrl-f`, `PageDown` | Move page down | `page_down` | +| `Ctrl-b`, `PageUp` | Move page up | `page_up` | +| `Ctrl-u` | Move cursor and page half page up | `page_cursor_half_up` | +| `Ctrl-d` | Move cursor and page half page down | `page_cursor_half_down` | #### Goto mode @@ -223,6 +227,7 @@ Jumps to various locations. | `.` | Go to last modification in current file | `goto_last_modification` | | `j` | Move down textual (instead of visual) line | `move_line_down` | | `k` | Move up textual (instead of visual) line | `move_line_up` | +| `w` | Show labels at each word and select the word that belongs to the entered labels | `goto_word` | #### Match mode @@ -278,7 +283,8 @@ This layer is a kludge of mappings, mostly pickers. | `F` | Open file picker at current working directory | `file_picker_in_current_directory` | | `b` | Open buffer picker | `buffer_picker` | | `j` | Open jumplist picker | `jumplist_picker` | -| `g` | Debug (experimental) | N/A | +| `g` | Open changed file picker | `changed_file_picker` | +| `G` | Debug (experimental) | N/A | | `k` | Show documentation for item under cursor in a [popup](#popup) (**LSP**) | `hover` | | `s` | Open document symbol picker (**LSP**) | `symbol_picker` | | `S` | Open workspace symbol picker (**LSP**) | `workspace_symbol_picker` | @@ -289,6 +295,9 @@ This layer is a kludge of mappings, mostly pickers. | `h` | Select symbol references (**LSP**) | `select_references_to_symbol_under_cursor` | | `'` | Open last fuzzy picker | `last_picker` | | `w` | Enter [window mode](#window-mode) | N/A | +| `c` | Comment/uncomment selections | `toggle_comments` | +| `C` | Block comment/uncomment selections | `toggle_block_comments` | +| `Alt-c` | Line comment/uncomment selections | `toggle_line_comments` | | `p` | Paste system clipboard after selections | `paste_clipboard_after` | | `P` | Paste system clipboard before selections | `paste_clipboard_before` | | `y` | Yank selections to clipboard | `yank_to_clipboard` | @@ -301,13 +310,31 @@ This layer is a kludge of mappings, mostly pickers. ##### Popup -Displays documentation for item under cursor. +Displays documentation for item under cursor. Remapping currently not supported. | Key | Description | | ---- | ----------- | | `Ctrl-u` | Scroll up | | `Ctrl-d` | Scroll down | +##### Completion Menu + +Displays documentation for the selected completion item. Remapping currently not supported. + +| Key | Description | +| ---- | ----------- | +| `Shift-Tab`, `Ctrl-p`, `Up` | Previous entry | +| `Tab`, `Ctrl-n`, `Down` | Next entry | + +##### Signature-help Popup + +Displays the signature of the selected completion item. Remapping currently not supported. + +| Key | Description | +| ---- | ----------- | +| `Alt-p` | Previous signature | +| `Alt-n` | Next signature | + #### Unimpaired These mappings are in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired). diff --git a/book/src/lang-support.md b/book/src/lang-support.md index 3f96673bc..aede33fa0 100644 --- a/book/src/lang-support.md +++ b/book/src/lang-support.md @@ -1,7 +1,7 @@ # Language Support The following languages and Language Servers are supported. To use -Language Server features, you must first [install][lsp-install-wiki] the +Language Server features, you must first [configure][lsp-config-wiki] the appropriate Language Server. You can check the language support in your installed helix version with `hx --health`. @@ -11,6 +11,6 @@ Languages][adding-languages] guide for more language configuration information. {{#include ./generated/lang-support.md}} -[lsp-install-wiki]: https://github.com/helix-editor/helix/wiki/How-to-install-the-default-language-servers +[lsp-config-wiki]: https://github.com/helix-editor/helix/wiki/Language-Server-Configurations [lang-config]: ./languages.md [adding-languages]: ./guides/adding_languages.md diff --git a/book/src/languages.md b/book/src/languages.md index 944ebf097..33ecbb92f 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -42,7 +42,7 @@ name = "mylang" scope = "source.mylang" injection-regex = "mylang" file-types = ["mylang", "myl"] -comment-token = "#" +comment-tokens = "#" indent = { tab-width = 2, unit = " " } formatter = { command = "mylang-formatter" , args = ["--stdin"] } language-servers = [ "mylang-lsp" ] @@ -61,7 +61,8 @@ These configuration keys are available: | `roots` | A set of marker files to look for when trying to find the workspace root. For example `Cargo.lock`, `yarn.lock` | | `auto-format` | Whether to autoformat this language when saving | | `diagnostic-severity` | Minimal severity of diagnostic for it to be displayed. (Allowed values: `Error`, `Warning`, `Info`, `Hint`) | -| `comment-token` | The token to use as a comment-token | +| `comment-tokens` | The tokens to use as a comment token, either a single token `"//"` or an array `["//", "///", "//!"]` (the first token will be used for commenting). Also configurable as `comment-token` for backwards compatibility| +| `block-comment-tokens`| The start and end tokens for a multiline comment either an array or single table of `{ start = "/*", end = "*/"}`. The first set of tokens will be used for commenting, any pairs in the array can be uncommented | | `indent` | The indent to use. Has sub keys `unit` (the text inserted into the document when indenting; usually set to N spaces or `"\t"` for tabs) and `tab-width` (the number of spaces rendered for a tab) | | `language-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`) | @@ -78,24 +79,26 @@ from the above section. `file-types` is a list of strings or tables, for example: ```toml -file-types = ["Makefile", "toml", { suffix = ".git/config" }] +file-types = ["toml", { glob = "Makefile" }, { glob = ".git/config" }, { glob = ".github/workflows/*.yaml" } ] ``` When determining a language configuration to use, Helix searches the file-types with the following priorities: -1. Exact match: if the filename of a file is an exact match of a string in a - `file-types` list, that language wins. In the example above, `"Makefile"` - will match against `Makefile` files. -2. Extension: if there are no exact matches, any `file-types` string that - matches the file extension of a given file wins. In the example above, the - `"toml"` matches files like `Cargo.toml` or `languages.toml`. -3. Suffix: if there are still no matches, any values in `suffix` tables - are checked against the full path of the given file. In the example above, - the `{ suffix = ".git/config" }` would match against any `config` files - in `.git` directories. Note: `/` is used as the directory separator but is - replaced at runtime with the appropriate path separator for the operating - system, so this rule would match against `.git\config` files on Windows. +1. Glob: values in `glob` tables are checked against the full path of the given + file. Globs are standard Unix-style path globs (e.g. the kind you use in Shell) + and can be used to match paths for a specific prefix, suffix, directory, etc. + In the above example, the `{ glob = "Makefile" }` config would match files + with the name `Makefile`, the `{ glob = ".git/config" }` config would match + `config` files in `.git` directories, and the `{ glob = ".github/workflows/*.yaml" }` + config would match any `yaml` files in `.github/workflow` directories. Note + that globs should always use the Unix path separator `/` even on Windows systems; + the matcher will automatically take the machine-specific separators into account. + If the glob isn't an absolute path or doesn't already start with a glob prefix, + `*/` will automatically be added to ensure it matches for any subdirectory. +2. Extension: if there are no glob matches, any `file-types` string that matches + the file extension of a given file wins. In the example above, the `"toml"` + config matches files like `Cargo.toml` or `languages.toml`. ## Language Server configuration @@ -120,13 +123,14 @@ languages = { typescript = [ { formatCommand ="prettier --stdin-filepath ${INPUT These are the available options for a language server. -| Key | Description | -| ---- | ----------- | -| `command` | The name or path of the language server binary to execute. Binaries must be in `$PATH` | -| `args` | A list of arguments to pass to the language server binary | -| `config` | LSP initialization options | -| `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` | -| `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` | +| Key | Description | +| ---- | ----------- | +| `command` | The name or path of the language server binary to execute. Binaries must be in `$PATH` | +| `args` | A list of arguments to pass to the language server binary | +| `config` | LSP initialization options | +| `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` | +| `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` | +| `required-root-patterns` | A list of `glob` patterns to look for in the working directory. The language server is started if at least one of them is found. | A `format` sub-table within `config` can be used to pass extra formatting options to [Document Formatting Requests](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_formatting). @@ -146,6 +150,8 @@ They have to be defined in the `[language-server]` table as described in the pre Different languages can use the same language server instance, e.g. `typescript-language-server` is used for javascript, jsx, tsx and typescript by default. +The definition order of language servers affects the order in the results list of code action menu. + In case multiple language servers are specified in the `language-servers` attribute of a `language`, it's often useful to only enable/disable certain language-server features for these language servers. diff --git a/book/src/themes.md b/book/src/themes.md index f040dfb19..e3b95c0a7 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -36,13 +36,6 @@ For inspiration, you can find the default `theme.toml` user-submitted themes [here](https://github.com/helix-editor/helix/blob/master/runtime/themes). -### Using the linter - -Use the supplied linting tool to check for errors and missing scopes: - -```sh -cargo xtask themelint onedark # replace onedark with -``` ## The details of theme creation @@ -186,6 +179,7 @@ We use a similar set of scopes as - `parameter` - Function parameters - `other` - `member` - Fields of composite data types (e.g. structs, unions) + - `private` - Private fields that use a unique syntax (currently just ECMAScript-based languages) - `label` @@ -213,6 +207,7 @@ We use a similar set of scopes as - `function` - `builtin` - `method` + - `private` - Private methods that use a unique syntax (currently just ECMAScript-based languages) - `macro` - `special` (preprocessor in C) @@ -251,6 +246,7 @@ We use a similar set of scopes as - `gutter` - gutter indicator - `delta` - modifications - `moved` - renamed or moved files/changes + - `conflict` - merge conflicts - `gutter` - gutter indicator #### Interface @@ -314,6 +310,7 @@ These scopes are used for theming the editor interface: | `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (LSPs are not required to set a kind) | | `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (LSPs 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.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 | @@ -333,5 +330,7 @@ These scopes are used for theming the editor interface: | `diagnostic.info` | Diagnostics info (editing area) | | `diagnostic.warning` | Diagnostics warning (editing area) | | `diagnostic.error` | Diagnostics error (editing area) | +| `diagnostic.unnecessary` | Diagnostics with unnecessary tag (editing area) | +| `diagnostic.deprecated` | Diagnostics with deprecated tag (editing area) | [editor-section]: ./configuration.md#editor-section diff --git a/contrib/Helix.appdata.xml b/contrib/Helix.appdata.xml index c455b242e..a46ef796b 100644 --- a/contrib/Helix.appdata.xml +++ b/contrib/Helix.appdata.xml @@ -6,27 +6,26 @@ Helix A post-modern text editor مُحَرِّرُ نُصُوصٍ سَابِقٌ لِعَهدِه + + Blaž Hrastnik +

Helix is a terminal-based text editor inspired by Kakoune / Neovim and written in Rust.

+

+ مُحَرِّرُ نُصُوصٍ يَعمَلُ فِي الطَّرَفِيَّة، مُستَلهَمٌ مِن Kakoune وَ Neovim وَمَكتُوبٌ بِلُغَةِ رَست البَرمَجِيَّة. +

  • Vim-like modal editing
  • +
  • تَحرِيرٌ وَضعِيٌّ شَبيهٌ بِـVim
  • Multiple selections
  • +
  • تَحدِيدَاتٌ لِلنَّصِ مُتَعَدِّدَة
  • Built-in language server support
  • +
  • دَعْمٌ مُدمَجٌ لِخَوادِمِ اللُّغَات
  • Smart, incremental syntax highlighting and code editing via tree-sitter
  • -
-
- -

- مُحَرِّرُ نُصُوصٍ يَعمَلُ فِي الطَّرَفِيَّة، مُستَلهَمٌ مِن Kakoune وَ Neovim وَمَكتُوبٌ بِلُغَةِ رَست البَرمَجِيَّة. -

-
    -
  • تَحرِيرٌ وَضعِيٌّ شَبيهٌ بِـVim
  • -
  • تَحدِيدَاتٌ لِلنَّصِ مُتَعَدِّدَة
  • -
  • دَعْمٌ مُدمَجٌ لِخَوادِمِ اللُّغَات
  • -
  • تَحرِيرُ التَّعلِيمَاتِ البَّرمَجِيَّةِ مَعَ تَمييزٍ لِلتَّركِيبِ النَّحُويِّ بِواسِطَةِ tree-sitter
  • +
  • تَحرِيرُ التَّعلِيمَاتِ البَّرمَجِيَّةِ مَعَ تَمييزٍ لِلتَّركِيبِ النَّحُويِّ بِواسِطَةِ tree-sitter
@@ -48,6 +47,9 @@ + + https://helix-editor.com/news/release-24-03-highlights/ + https://helix-editor.com/news/release-23-10-highlights/ @@ -71,9 +73,9 @@ - + keyboard - + Utility diff --git a/contrib/completion/hx.bash b/contrib/completion/hx.bash index 01b42deb6..62ca029bf 100644 --- a/contrib/completion/hx.bash +++ b/contrib/completion/hx.bash @@ -5,19 +5,20 @@ _hx() { # $1 command name # $2 word being completed # $3 word preceding - COMPREPLY=() case "$3" in -g | --grammar) - COMPREPLY=($(compgen -W "fetch build" -- $2)) + COMPREPLY="$(compgen -W 'fetch build' -- $2)" ;; --health) local languages=$(hx --health |tail -n '+7' |awk '{print $1}' |sed 's/\x1b\[[0-9;]*m//g') - COMPREPLY=($(compgen -W "$languages" -- $2)) + COMPREPLY="$(compgen -W """$languages""" -- $2)" ;; *) - COMPREPLY=($(compgen -fd -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config --log" -- $2)) + COMPREPLY="$(compgen -fd -W "-h --help --tutor -V --version -v -vv -vvv --health -g --grammar --vsplit --hsplit -c --config --log" -- """$2""")" ;; esac -} && complete -o filenames -F _hx hx + local IFS=$'\n' + COMPREPLY=($COMPREPLY) +} && complete -o filenames -F _hx hx diff --git a/flake.lock b/flake.lock index 9bb5dece1..48fb4a59f 100644 --- a/flake.lock +++ b/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1701025348, - "narHash": "sha256-42GHmYH+GF7VjwGSt+fVT1CQuNpGanJbNgVHTAZppUM=", + "lastModified": 1709610799, + "narHash": "sha256-5jfLQx0U9hXbi2skYMGodDJkIgffrjIOgMRjZqms2QE=", "owner": "ipetkov", "repo": "crane", - "rev": "42afaeb1a0325194a7cdb526332d2cb92fddd07b", + "rev": "81c393c776d5379c030607866afef6406ca1be57", "type": "github" }, "original": { @@ -25,11 +25,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1694529238, - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "lastModified": 1709126324, + "narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=", "owner": "numtide", "repo": "flake-utils", - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "rev": "d465f4819400de7c8d874d50b982301f28a84605", "type": "github" }, "original": { @@ -40,11 +40,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1700794826, - "narHash": "sha256-RyJTnTNKhO0yqRpDISk03I/4A67/dp96YRxc86YOPgU=", + "lastModified": 1709479366, + "narHash": "sha256-n6F0n8UV6lnTZbYPl1A9q1BS0p4hduAv1mGAP17CVd0=", "owner": "nixos", "repo": "nixpkgs", - "rev": "5a09cb4b393d58f9ed0d9ca1555016a8543c2ac8", + "rev": "b8697e57f10292a6165a20f03d2f42920dfaf973", "type": "github" }, "original": { @@ -72,11 +72,11 @@ ] }, "locked": { - "lastModified": 1701137803, - "narHash": "sha256-0LcPAdql5IhQSUXJx3Zna0dYTgdIoYO7zUrsKgiBd04=", + "lastModified": 1709604635, + "narHash": "sha256-le4fwmWmjGRYWwkho0Gr7mnnZndOOe4XGbLw68OvF40=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "9dd940c967502f844eacea52a61e9596268d4f70", + "rev": "e86c0fb5d3a22a5f30d7f64ecad88643fe26449d", "type": "github" }, "original": { diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 0c68c8a47..e4da581f6 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -23,24 +23,23 @@ helix-loader = { path = "../helix-loader" } ropey = { version = "1.6.1", default-features = false, features = ["simd"] } smallvec = "1.13" smartstring = "1.0.1" -unicode-segmentation = "1.10" +unicode-segmentation = "1.11" unicode-width = "0.1" unicode-general-category = "0.6" -# slab = "0.4.2" -slotmap = "1.0" +slotmap.workspace = true tree-sitter.workspace = true once_cell = "1.19" arc-swap = "1" regex = "1" -bitflags = "2.4" -ahash = "0.8.6" +bitflags = "2.5" +ahash = "0.8.11" hashbrown = { version = "0.14.3", features = ["raw"] } dunce = "1.0" log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -toml = "0.7" +toml = "0.8" imara-diff = "0.1.0" @@ -49,12 +48,13 @@ encoding_rs = "0.8" chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] } etcetera = "0.8" -textwrap = "0.16.0" +textwrap = "0.16.1" steel-core = { workspace = true, optional = true } nucleo.workspace = true parking_lot = "0.12" +globset = "0.4.14" [dev-dependencies] quickcheck = { version = "1", default-features = false } -indoc = "2.0.4" +indoc = "2.0.5" diff --git a/helix-core/src/comment.rs b/helix-core/src/comment.rs index 9c7e50f33..536b710ab 100644 --- a/helix-core/src/comment.rs +++ b/helix-core/src/comment.rs @@ -1,9 +1,12 @@ //! This module contains the functionality toggle comments on lines over the selection //! using the comment character defined in the user's `languages.toml` +use smallvec::SmallVec; + use crate::{ - find_first_non_whitespace_char, Change, Rope, RopeSlice, Selection, Tendril, Transaction, + syntax::BlockCommentToken, Change, Range, Rope, RopeSlice, Selection, Tendril, Transaction, }; +use helix_stdx::rope::RopeSliceExt; use std::borrow::Cow; /// Given text, a comment token, and a set of line indices, returns the following: @@ -22,12 +25,12 @@ fn find_line_comment( ) -> (bool, Vec, usize, usize) { let mut commented = true; let mut to_change = Vec::new(); - let mut min = usize::MAX; // minimum col for find_first_non_whitespace_char + let mut min = usize::MAX; // minimum col for first_non_whitespace_char let mut margin = 1; let token_len = token.chars().count(); for line in lines { let line_slice = text.line(line); - if let Some(pos) = find_first_non_whitespace_char(line_slice) { + if let Some(pos) = line_slice.first_non_whitespace_char() { let len = line_slice.len_chars(); if pos < min { @@ -94,6 +97,222 @@ pub fn toggle_line_comments(doc: &Rope, selection: &Selection, token: Option<&st Transaction::change(doc, changes.into_iter()) } +#[derive(Debug, PartialEq, Eq)] +pub enum CommentChange { + Commented { + range: Range, + start_pos: usize, + end_pos: usize, + start_margin: bool, + end_margin: bool, + start_token: String, + end_token: String, + }, + Uncommented { + range: Range, + start_pos: usize, + end_pos: usize, + start_token: String, + end_token: String, + }, + Whitespace { + range: Range, + }, +} + +pub fn find_block_comments( + tokens: &[BlockCommentToken], + text: RopeSlice, + selection: &Selection, +) -> (bool, Vec) { + let mut commented = true; + let mut only_whitespace = true; + let mut comment_changes = Vec::with_capacity(selection.len()); + let default_tokens = tokens.first().cloned().unwrap_or_default(); + // TODO: check if this can be removed on MSRV bump + #[allow(clippy::redundant_clone)] + let mut start_token = default_tokens.start.clone(); + #[allow(clippy::redundant_clone)] + let mut end_token = default_tokens.end.clone(); + + let mut tokens = tokens.to_vec(); + // sort the tokens by length, so longer tokens will match first + tokens.sort_by(|a, b| { + if a.start.len() == b.start.len() { + b.end.len().cmp(&a.end.len()) + } else { + b.start.len().cmp(&a.start.len()) + } + }); + for range in selection { + let selection_slice = range.slice(text); + if let (Some(start_pos), Some(end_pos)) = ( + selection_slice.first_non_whitespace_char(), + selection_slice.last_non_whitespace_char(), + ) { + let mut line_commented = false; + let mut after_start = 0; + let mut before_end = 0; + let len = (end_pos + 1) - start_pos; + + for BlockCommentToken { start, end } in &tokens { + let start_len = start.chars().count(); + let end_len = end.chars().count(); + after_start = start_pos + start_len; + before_end = end_pos.saturating_sub(end_len); + + if len >= start_len + end_len { + let start_fragment = selection_slice.slice(start_pos..after_start); + let end_fragment = selection_slice.slice(before_end + 1..end_pos + 1); + + // block commented with these tokens + if start_fragment == start.as_str() && end_fragment == end.as_str() { + start_token = start.to_string(); + end_token = end.to_string(); + line_commented = true; + break; + } + } + } + + if !line_commented { + comment_changes.push(CommentChange::Uncommented { + range: *range, + start_pos, + end_pos, + start_token: default_tokens.start.clone(), + end_token: default_tokens.end.clone(), + }); + commented = false; + } else { + comment_changes.push(CommentChange::Commented { + range: *range, + start_pos, + end_pos, + start_margin: selection_slice + .get_char(after_start) + .map_or(false, |c| c == ' '), + end_margin: after_start != before_end + && selection_slice + .get_char(before_end) + .map_or(false, |c| c == ' '), + start_token: start_token.to_string(), + end_token: end_token.to_string(), + }); + } + only_whitespace = false; + } else { + comment_changes.push(CommentChange::Whitespace { range: *range }); + } + } + if only_whitespace { + commented = false; + } + (commented, comment_changes) +} + +#[must_use] +pub fn create_block_comment_transaction( + doc: &Rope, + selection: &Selection, + commented: bool, + comment_changes: Vec, +) -> (Transaction, SmallVec<[Range; 1]>) { + let mut changes: Vec = Vec::with_capacity(selection.len() * 2); + let mut ranges: SmallVec<[Range; 1]> = SmallVec::with_capacity(selection.len()); + let mut offs = 0; + for change in comment_changes { + if commented { + if let CommentChange::Commented { + range, + start_pos, + end_pos, + start_token, + end_token, + start_margin, + end_margin, + } = change + { + let from = range.from(); + changes.push(( + from + start_pos, + from + start_pos + start_token.len() + start_margin as usize, + None, + )); + changes.push(( + from + end_pos - end_token.len() - end_margin as usize + 1, + from + end_pos + 1, + None, + )); + } + } else { + // uncommented so manually map ranges through changes + match change { + CommentChange::Uncommented { + range, + start_pos, + end_pos, + start_token, + end_token, + } => { + let from = range.from(); + changes.push(( + from + start_pos, + from + start_pos, + Some(Tendril::from(format!("{} ", start_token))), + )); + changes.push(( + from + end_pos + 1, + from + end_pos + 1, + Some(Tendril::from(format!(" {}", end_token))), + )); + + let offset = start_token.chars().count() + end_token.chars().count() + 2; + ranges.push( + Range::new(from + offs, from + offs + end_pos + 1 + offset) + .with_direction(range.direction()), + ); + offs += offset; + } + CommentChange::Commented { range, .. } | CommentChange::Whitespace { range } => { + ranges.push(Range::new(range.from() + offs, range.to() + offs)); + } + } + } + } + (Transaction::change(doc, changes.into_iter()), ranges) +} + +#[must_use] +pub fn toggle_block_comments( + doc: &Rope, + selection: &Selection, + tokens: &[BlockCommentToken], +) -> Transaction { + let text = doc.slice(..); + let (commented, comment_changes) = find_block_comments(tokens, text, selection); + let (mut transaction, ranges) = + create_block_comment_transaction(doc, selection, commented, comment_changes); + if !commented { + transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + } + transaction +} + +pub fn split_lines_of_selection(text: RopeSlice, selection: &Selection) -> Selection { + let mut ranges = SmallVec::new(); + for range in selection.ranges() { + let (line_start, line_end) = range.line_range(text.slice(..)); + let mut pos = text.line_to_char(line_start); + for line in text.slice(pos..text.line_to_char(line_end + 1)).lines() { + let start = pos; + pos += line.len_chars(); + ranges.push(Range::new(start, pos)); + } + } + Selection::new(ranges, 0) +} + #[cfg(test)] mod test { use super::*; @@ -149,4 +368,49 @@ mod test { // TODO: account for uncommenting with uneven comment indentation } + + #[test] + fn test_find_block_comments() { + // three lines 5 characters. + let mut doc = Rope::from("1\n2\n3"); + // select whole document + let selection = Selection::single(0, doc.len_chars()); + + let text = doc.slice(..); + + let res = find_block_comments(&[BlockCommentToken::default()], text, &selection); + + assert_eq!( + res, + ( + false, + vec![CommentChange::Uncommented { + range: Range::new(0, 5), + start_pos: 0, + end_pos: 4, + start_token: "/*".to_string(), + end_token: "*/".to_string(), + }] + ) + ); + + // comment + let transaction = toggle_block_comments(&doc, &selection, &[BlockCommentToken::default()]); + transaction.apply(&mut doc); + + assert_eq!(doc, "/* 1\n2\n3 */"); + + // uncomment + let selection = Selection::single(0, doc.len_chars()); + let transaction = toggle_block_comments(&doc, &selection, &[BlockCommentToken::default()]); + transaction.apply(&mut doc); + assert_eq!(doc, "1\n2\n3"); + + // don't panic when there is just a space in comment + doc = Rope::from("/* */"); + let selection = Selection::single(0, doc.len_chars()); + let transaction = toggle_block_comments(&doc, &selection, &[BlockCommentToken::default()]); + transaction.apply(&mut doc); + assert_eq!(doc, ""); + } } diff --git a/helix-core/src/config.rs b/helix-core/src/config.rs index 2076fc224..27cd4e297 100644 --- a/helix-core/src/config.rs +++ b/helix-core/src/config.rs @@ -1,10 +1,45 @@ -/// Syntax configuration loader based on built-in languages.toml. -pub fn default_syntax_loader() -> crate::syntax::Configuration { +use crate::syntax::{Configuration, Loader, LoaderError}; + +/// Language configuration based on built-in languages.toml. +pub fn default_lang_config() -> Configuration { helix_loader::config::default_lang_config() .try_into() - .expect("Could not serialize built-in languages.toml") + .expect("Could not deserialize built-in languages.toml") } -/// Syntax configuration loader based on user configured languages.toml. -pub fn user_syntax_loader() -> Result { + +/// Language configuration loader based on built-in languages.toml. +pub fn default_lang_loader() -> Loader { + Loader::new(default_lang_config()).expect("Could not compile loader for default config") +} + +#[derive(Debug)] +pub enum LanguageLoaderError { + DeserializeError(toml::de::Error), + LoaderError(LoaderError), +} + +impl std::fmt::Display for LanguageLoaderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DeserializeError(err) => write!(f, "Failed to parse language config: {err}"), + Self::LoaderError(err) => write!(f, "Failed to compile language config: {err}"), + } + } +} + +impl std::error::Error for LanguageLoaderError {} + +/// Language configuration based on user configured languages.toml. +pub fn user_lang_config() -> Result { helix_loader::config::user_lang_config()?.try_into() } + +/// Language configuration loader based on user configured languages.toml. +pub fn user_lang_loader() -> Result { + let config: Configuration = helix_loader::config::user_lang_config() + .map_err(LanguageLoaderError::DeserializeError)? + .try_into() + .map_err(LanguageLoaderError::DeserializeError)?; + + Loader::new(config).map_err(LanguageLoaderError::LoaderError) +} diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index 52d77a9aa..d41119d38 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -1,4 +1,6 @@ //! LSP diagnostic utility types. +use std::fmt; + use serde::{Deserialize, Serialize}; /// Describes the severity level of a [`Diagnostic`]. @@ -47,8 +49,25 @@ pub struct Diagnostic { pub message: String, pub severity: Option, pub code: Option, - pub language_server_id: usize, + pub provider: DiagnosticProvider, pub tags: Vec, pub source: Option, pub data: Option, } + +// TODO turn this into an enum + feature flag when lsp becomes optional +pub type DiagnosticProvider = LanguageServerId; + +// 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 +// trivial newtype wrapper and we would need something similar once we define +// completions in core +slotmap::new_key_type! { + pub struct LanguageServerId; +} + +impl fmt::Display for LanguageServerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self.0) + } +} diff --git a/helix-core/src/doc_formatter.rs b/helix-core/src/doc_formatter.rs index c7dc9081f..cbe2da3b6 100644 --- a/helix-core/src/doc_formatter.rs +++ b/helix-core/src/doc_formatter.rs @@ -116,7 +116,7 @@ impl Default for TextFormat { #[derive(Debug)] pub struct DocumentFormatter<'t> { text_fmt: &'t TextFormat, - annotations: &'t TextAnnotations, + annotations: &'t TextAnnotations<'t>, /// The visual position at the end of the last yielded word boundary visual_pos: Position, diff --git a/helix-core/src/doc_formatter/test.rs b/helix-core/src/doc_formatter/test.rs index ac8918bb7..d2b6ddc74 100644 --- a/helix-core/src/doc_formatter/test.rs +++ b/helix-core/src/doc_formatter/test.rs @@ -1,5 +1,3 @@ -use std::rc::Rc; - use crate::doc_formatter::{DocumentFormatter, TextFormat}; use crate::text_annotations::{InlineAnnotation, Overlay, TextAnnotations}; @@ -105,7 +103,7 @@ fn overlay_text(text: &str, char_pos: usize, softwrap: bool, overlays: &[Overlay DocumentFormatter::new_at_prev_checkpoint( text.into(), &TextFormat::new_test(softwrap), - TextAnnotations::default().add_overlay(overlays.into(), None), + TextAnnotations::default().add_overlay(overlays, None), char_pos, ) .0 @@ -142,7 +140,7 @@ fn annotate_text(text: &str, softwrap: bool, annotations: &[InlineAnnotation]) - DocumentFormatter::new_at_prev_checkpoint( text.into(), &TextFormat::new_test(softwrap), - TextAnnotations::default().add_inline_annotations(annotations.into(), None), + TextAnnotations::default().add_inline_annotations(annotations, None), 0, ) .0 @@ -164,15 +162,24 @@ fn annotation() { "foo foo foo foo \n.foo foo foo foo \n.foo foo foo " ); } + #[test] fn annotation_and_overlay() { + let annotations = [InlineAnnotation { + char_idx: 0, + text: "fooo".into(), + }]; + let overlay = [Overlay { + char_idx: 0, + grapheme: "\t".into(), + }]; assert_eq!( DocumentFormatter::new_at_prev_checkpoint( "bbar".into(), &TextFormat::new_test(false), TextAnnotations::default() - .add_inline_annotations(Rc::new([InlineAnnotation::new(0, "fooo")]), None) - .add_overlay(Rc::new([Overlay::new(0, "\t")]), None), + .add_inline_annotations(annotations.as_slice(), None) + .add_overlay(overlay.as_slice(), None), 0, ) .0 diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index d9e5e0224..a02d6e4dd 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -278,23 +278,6 @@ pub fn ensure_grapheme_boundary_prev(slice: RopeSlice, char_idx: usize) -> usize } } -/// Returns the passed byte index if it's already a grapheme boundary, -/// or the next grapheme boundary byte index if not. -#[must_use] -#[inline] -pub fn ensure_grapheme_boundary_next_byte(slice: RopeSlice, byte_idx: usize) -> usize { - if byte_idx == 0 { - byte_idx - } else { - // TODO: optimize so we're not constructing grapheme cursor twice - if is_grapheme_boundary_byte(slice, byte_idx) { - byte_idx - } else { - next_grapheme_boundary_byte(slice, byte_idx) - } - } -} - /// Returns whether the given char position is a grapheme boundary. #[must_use] pub fn is_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> bool { @@ -425,6 +408,85 @@ impl<'a> Iterator for RopeGraphemes<'a> { } } +/// 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<'a> fmt::Debug for RevRopeGraphemes<'a> { + 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> RevRopeGraphemes<'a> { + #[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> { + 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> { diff --git a/helix-core/src/increment/date_time.rs b/helix-core/src/increment/date_time.rs index 2980bb58b..04cff6b47 100644 --- a/helix-core/src/increment/date_time.rs +++ b/helix-core/src/increment/date_time.rs @@ -27,7 +27,7 @@ pub fn increment(selected_text: &str, amount: i64) -> Option { let date_time = NaiveDateTime::parse_from_str(date_time, format.fmt).ok()?; Some( date_time - .checked_add_signed(Duration::minutes(amount))? + .checked_add_signed(Duration::try_minutes(amount)?)? .format(format.fmt) .to_string(), ) @@ -35,14 +35,15 @@ pub fn increment(selected_text: &str, amount: i64) -> Option { (true, false) => { let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?; Some( - date.checked_add_signed(Duration::days(amount))? + date.checked_add_signed(Duration::try_days(amount)?)? .format(format.fmt) .to_string(), ) } (false, true) => { let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?; - let (adjusted_time, _) = time.overflowing_add_signed(Duration::minutes(amount)); + let (adjusted_time, _) = + time.overflowing_add_signed(Duration::try_minutes(amount)?); Some(adjusted_time.format(format.fmt).to_string()) } (false, false) => None, diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index c29bb3a0b..ca72fc3a2 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -1,10 +1,10 @@ use std::{borrow::Cow, collections::HashMap}; +use helix_stdx::rope::RopeSliceExt; use tree_sitter::{Query, QueryCursor, QueryPredicateArg}; use crate::{ chars::{char_is_line_ending, char_is_whitespace}, - find_first_non_whitespace_char, graphemes::{grapheme_width, tab_width_at}, syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax}, tree_sitter::Node, @@ -247,41 +247,18 @@ fn add_indent_level( } } -/// Computes for node and all ancestors whether they are the first node on their line. -/// The first entry in the return value represents the root node, the last one the node itself -fn get_first_in_line(mut node: Node, new_line_byte_pos: Option) -> Vec { - let mut first_in_line = Vec::new(); - loop { - if let Some(prev) = node.prev_sibling() { - // If we insert a new line, the first node at/after the cursor is considered to be the first in its line - let first = prev.end_position().row != node.start_position().row - || new_line_byte_pos.map_or(false, |byte_pos| { - node.start_byte() >= byte_pos && prev.start_byte() < byte_pos - }); - first_in_line.push(Some(first)); - } else { - // Nodes that have no previous siblings are first in their line if and only if their parent is - // (which we don't know yet) - first_in_line.push(None); - } - if let Some(parent) = node.parent() { - node = parent; - } else { - break; - } - } - - let mut result = Vec::with_capacity(first_in_line.len()); - let mut parent_is_first = true; // The root node is by definition the first node in its line - for first in first_in_line.into_iter().rev() { - if let Some(first) = first { - result.push(first); - parent_is_first = first; - } else { - result.push(parent_is_first); +/// Return true if only whitespace comes before the node on its line. +/// If given, new_line_byte_pos is treated the same way as any existing newline. +fn is_first_in_line(node: Node, text: RopeSlice, new_line_byte_pos: Option) -> bool { + let mut line_start_byte_pos = text.line_to_byte(node.start_position().row); + if let Some(pos) = new_line_byte_pos { + if line_start_byte_pos < pos && pos <= node.start_byte() { + line_start_byte_pos = pos; } } - result + text.byte_slice(line_start_byte_pos..node.start_byte()) + .chars() + .all(|c| c.is_whitespace()) } /// The total indent for some line of code. @@ -852,7 +829,6 @@ pub fn treesitter_indent_for_pos<'a>( byte_pos, new_line_byte_pos, )?; - let mut first_in_line = get_first_in_line(node, new_line.then_some(byte_pos)); let mut result = Indentation::default(); // We always keep track of all the indent changes on one line, in order to only indent once @@ -861,9 +837,7 @@ pub fn treesitter_indent_for_pos<'a>( let mut indent_for_line_below = Indentation::default(); loop { - // This can safely be unwrapped because `first_in_line` contains - // one entry for each ancestor of the node (which is what we iterate over) - let is_first = *first_in_line.last().unwrap(); + let is_first = is_first_in_line(node, text, new_line_byte_pos); // Apply all indent definitions for this node. // Since we only iterate over each node once, we can remove the @@ -906,7 +880,6 @@ pub fn treesitter_indent_for_pos<'a>( } node = parent; - first_in_line.pop(); } else { // Only add the indentation for the line below if that line // is not after the line that the indentation is calculated for. @@ -970,7 +943,7 @@ pub fn indent_for_newline( let mut num_attempts = 0; for line_idx in (0..=line_before).rev() { let line = text.line(line_idx); - let first_non_whitespace_char = match find_first_non_whitespace_char(line) { + let first_non_whitespace_char = match line.first_non_whitespace_char() { Some(i) => i, None => { continue; diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index f72b515d5..634c5350a 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -39,9 +39,6 @@ pub mod unicode { pub use helix_loader::find_workspace; -pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option { - line.chars().position(|ch| !ch.is_whitespace()) -} mod rope_reader; pub use rope_reader::RopeReader; diff --git a/helix-core/src/match_brackets.rs b/helix-core/src/match_brackets.rs index b8bcc28ca..95d6a3dc4 100644 --- a/helix-core/src/match_brackets.rs +++ b/helix-core/src/match_brackets.rs @@ -9,16 +9,32 @@ use crate::Syntax; const MAX_PLAINTEXT_SCAN: usize = 10000; const MATCH_LIMIT: usize = 16; -// Limit matching pairs to only ( ) { } [ ] < > ' ' " " -const PAIRS: &[(char, char)] = &[ +pub const BRACKETS: [(char, char); 7] = [ ('(', ')'), ('{', '}'), ('[', ']'), ('<', '>'), - ('\'', '\''), - ('\"', '\"'), + ('«', '»'), + ('「', '」'), + ('(', ')'), ]; +// The difference between BRACKETS and PAIRS is that we can find matching +// BRACKETS in a plain text file, but we can't do the same for PAIRs. +// PAIRS also contains all BRACKETS. +pub const PAIRS: [(char, char); BRACKETS.len() + 3] = { + let mut pairs = [(' ', ' '); BRACKETS.len() + 3]; + let mut idx = 0; + while idx < BRACKETS.len() { + pairs[idx] = BRACKETS[idx]; + idx += 1; + } + pairs[idx] = ('"', '"'); + pairs[idx + 1] = ('\'', '\''); + pairs[idx + 2] = ('`', '`'); + pairs +}; + /// Returns the position of the matching bracket under cursor. /// /// If the cursor is on the opening bracket, the position of @@ -30,7 +46,7 @@ const PAIRS: &[(char, char)] = &[ /// If no matching bracket is found, `None` is returned. #[must_use] pub fn find_matching_bracket(syntax: &Syntax, doc: RopeSlice, pos: usize) -> Option { - if pos >= doc.len_chars() || !is_valid_bracket(doc.char(pos)) { + if pos >= doc.len_chars() || !is_valid_pair(doc.char(pos)) { return None; } find_pair(syntax, doc, pos, false) @@ -67,7 +83,7 @@ fn find_pair( let (start_byte, end_byte) = surrounding_bytes(doc, &node)?; let (start_char, end_char) = (doc.byte_to_char(start_byte), doc.byte_to_char(end_byte)); - if is_valid_pair(doc, start_char, end_char) { + if is_valid_pair_on_pos(doc, start_char, end_char) { if end_byte == pos { return Some(start_char); } @@ -140,14 +156,22 @@ fn find_pair( /// If no matching bracket is found, `None` is returned. #[must_use] pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Option { - // Don't do anything when the cursor is not on top of a bracket. let bracket = doc.get_char(cursor_pos)?; + let matching_bracket = { + let pair = get_pair(bracket); + if pair.0 == bracket { + pair.1 + } else { + pair.0 + } + }; + // Don't do anything when the cursor is not on top of a bracket. if !is_valid_bracket(bracket) { return None; } // Determine the direction of the matching. - let is_fwd = is_forward_bracket(bracket); + let is_fwd = is_open_bracket(bracket); let chars_iter = if is_fwd { doc.chars_at(cursor_pos + 1) } else { @@ -159,19 +183,7 @@ pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Opt for (i, candidate) in chars_iter.take(MAX_PLAINTEXT_SCAN).enumerate() { if candidate == bracket { open_cnt += 1; - } else if is_valid_pair( - doc, - if is_fwd { - cursor_pos - } else { - cursor_pos - i - 1 - }, - if is_fwd { - cursor_pos + i + 1 - } else { - cursor_pos - }, - ) { + } else if candidate == matching_bracket { // Return when all pending brackets have been closed. if open_cnt == 1 { return Some(if is_fwd { @@ -187,15 +199,49 @@ pub fn find_matching_bracket_plaintext(doc: RopeSlice, cursor_pos: usize) -> Opt None } -fn is_valid_bracket(c: char) -> bool { - PAIRS.iter().any(|(l, r)| *l == c || *r == c) +/// Returns the open and closing chars pair. If not found in +/// [`BRACKETS`] returns (ch, ch). +/// +/// ``` +/// use helix_core::match_brackets::get_pair; +/// +/// assert_eq!(get_pair('['), ('[', ']')); +/// assert_eq!(get_pair('}'), ('{', '}')); +/// assert_eq!(get_pair('"'), ('"', '"')); +/// ``` +pub fn get_pair(ch: char) -> (char, char) { + PAIRS + .iter() + .find(|(open, close)| *open == ch || *close == ch) + .copied() + .unwrap_or((ch, ch)) +} + +pub fn is_open_bracket(ch: char) -> bool { + BRACKETS.iter().any(|(l, _)| *l == ch) +} + +pub fn is_close_bracket(ch: char) -> bool { + BRACKETS.iter().any(|(_, r)| *r == ch) +} + +pub fn is_valid_bracket(ch: char) -> bool { + BRACKETS.iter().any(|(l, r)| *l == ch || *r == ch) +} + +pub fn is_open_pair(ch: char) -> bool { + PAIRS.iter().any(|(l, _)| *l == ch) +} + +pub fn is_close_pair(ch: char) -> bool { + PAIRS.iter().any(|(_, r)| *r == ch) } -fn is_forward_bracket(c: char) -> bool { - PAIRS.iter().any(|(l, _)| *l == c) +pub fn is_valid_pair(ch: char) -> bool { + PAIRS.iter().any(|(l, r)| *l == ch || *r == ch) } -fn is_valid_pair(doc: RopeSlice, start_char: usize, end_char: usize) -> bool { +fn is_valid_pair_on_pos(doc: RopeSlice, start_char: usize, end_char: usize) -> bool { PAIRS.contains(&(doc.char(start_char), doc.char(end_char))) } diff --git a/helix-core/src/object.rs b/helix-core/src/object.rs index d2d4fe70a..17a393caf 100644 --- a/helix-core/src/object.rs +++ b/helix-core/src/object.rs @@ -1,76 +1,137 @@ -use crate::{Range, RopeSlice, Selection, Syntax}; -use tree_sitter::Node; +use crate::{movement::Direction, syntax::TreeCursor, Range, RopeSlice, Selection, Syntax}; pub fn expand_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { - select_node_impl(syntax, text, selection, |mut node, from, to| { - while node.start_byte() == from && node.end_byte() == to { - node = node.parent()?; + let cursor = &mut syntax.walk(); + + selection.transform(|range| { + let from = text.char_to_byte(range.from()); + let to = text.char_to_byte(range.to()); + + let byte_range = from..to; + cursor.reset_to_byte_range(from, to); + + while cursor.node().byte_range() == byte_range { + if !cursor.goto_parent() { + break; + } } - Some(node) + + let node = cursor.node(); + let from = text.byte_to_char(node.start_byte()); + let to = text.byte_to_char(node.end_byte()); + + Range::new(to, from).with_direction(range.direction()) }) } pub fn shrink_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { - select_node_impl(syntax, text, selection, |descendant, _from, _to| { - descendant.child(0).or(Some(descendant)) - }) + select_node_impl( + syntax, + text, + selection, + |cursor| { + cursor.goto_first_child(); + }, + None, + ) } -pub fn select_sibling( - syntax: &Syntax, - text: RopeSlice, - selection: Selection, - sibling_fn: &F, -) -> Selection -where - F: Fn(Node) -> Option, -{ - select_node_impl(syntax, text, selection, |descendant, _from, _to| { - find_sibling_recursive(descendant, sibling_fn) +pub fn select_next_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { + select_node_impl( + syntax, + text, + selection, + |cursor| { + while !cursor.goto_next_sibling() { + if !cursor.goto_parent() { + break; + } + } + }, + Some(Direction::Forward), + ) +} + +pub fn select_all_siblings(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { + selection.transform_iter(|range| { + let mut cursor = syntax.walk(); + let (from, to) = range.into_byte_range(text); + cursor.reset_to_byte_range(from, to); + + if !cursor.goto_parent_with(|parent| parent.child_count() > 1) { + return vec![range].into_iter(); + } + + select_children(&mut cursor, text, range).into_iter() }) } -fn find_sibling_recursive(node: Node, sibling_fn: F) -> Option -where - F: Fn(Node) -> Option, -{ - sibling_fn(node).or_else(|| { - node.parent() - .and_then(|node| find_sibling_recursive(node, sibling_fn)) +pub fn select_all_children(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { + selection.transform_iter(|range| { + let mut cursor = syntax.walk(); + let (from, to) = range.into_byte_range(text); + cursor.reset_to_byte_range(from, to); + select_children(&mut cursor, text, range).into_iter() }) } +fn select_children<'n>( + cursor: &'n mut TreeCursor<'n>, + text: RopeSlice, + range: Range, +) -> Vec { + let children = cursor + .named_children() + .map(|child| Range::from_node(child, text, range.direction())) + .collect::>(); + + if !children.is_empty() { + children + } else { + vec![range] + } +} + +pub fn select_prev_sibling(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { + select_node_impl( + syntax, + text, + selection, + |cursor| { + while !cursor.goto_prev_sibling() { + if !cursor.goto_parent() { + break; + } + } + }, + Some(Direction::Backward), + ) +} + fn select_node_impl( syntax: &Syntax, text: RopeSlice, selection: Selection, - select_fn: F, + motion: F, + direction: Option, ) -> Selection where - F: Fn(Node, usize, usize) -> Option, + F: Fn(&mut TreeCursor), { - let tree = syntax.tree(); + let cursor = &mut syntax.walk(); selection.transform(|range| { let from = text.char_to_byte(range.from()); let to = text.char_to_byte(range.to()); - let node = match tree - .root_node() - .descendant_for_byte_range(from, to) - .and_then(|node| select_fn(node, from, to)) - { - Some(node) => node, - None => return range, - }; + cursor.reset_to_byte_range(from, to); + motion(cursor); + + let node = cursor.node(); let from = text.byte_to_char(node.start_byte()); let to = text.byte_to_char(node.end_byte()); - if range.head < range.anchor { - Range::new(to, from) - } else { - Range::new(from, to) - } + Range::new(from, to).with_direction(direction.unwrap_or_else(|| range.direction())) }) } diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index c44685eea..48eaf289c 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -7,11 +7,14 @@ use crate::{ ensure_grapheme_boundary_next, ensure_grapheme_boundary_prev, next_grapheme_boundary, prev_grapheme_boundary, }, + line_ending::get_line_ending, movement::Direction, Assoc, ChangeSet, RopeGraphemes, RopeSlice, }; +use helix_stdx::rope::{self, RopeSliceExt}; use smallvec::{smallvec, SmallVec}; use std::borrow::Cow; +use tree_sitter::Node; /// A single selection range. /// @@ -71,6 +74,12 @@ impl Range { Self::new(head, head) } + pub fn from_node(node: Node, text: RopeSlice, direction: Direction) -> Self { + let from = text.byte_to_char(node.start_byte()); + let to = text.byte_to_char(node.end_byte()); + Range::new(from, to).with_direction(direction) + } + /// Start of the range. #[inline] #[must_use] @@ -113,7 +122,7 @@ impl Range { } /// `Direction::Backward` when head < anchor. - /// `Direction::Backward` otherwise. + /// `Direction::Forward` otherwise. #[inline] #[must_use] pub fn direction(&self) -> Direction { @@ -374,6 +383,12 @@ impl Range { let second = graphemes.next(); first.is_some() && second.is_none() } + + /// Converts this char range into an in order byte range, discarding + /// direction. + pub fn into_byte_range(&self, text: RopeSlice) -> (usize, usize) { + (text.char_to_byte(self.from()), text.char_to_byte(self.to())) + } } impl From<(usize, usize)> for Range { @@ -703,17 +718,26 @@ impl IntoIterator for Selection { } } +impl From for Selection { + fn from(range: Range) -> Self { + Self { + ranges: smallvec![range], + primary_index: 0, + } + } +} + // TODO: checkSelection -> check if valid for doc length && sorted pub fn keep_or_remove_matches( text: RopeSlice, selection: &Selection, - regex: &crate::regex::Regex, + regex: &rope::Regex, remove: bool, ) -> Option { let result: SmallVec<_> = selection .iter() - .filter(|range| regex.is_match(&range.fragment(text)) ^ remove) + .filter(|range| regex.is_match(text.regex_input_at(range.from()..range.to())) ^ remove) .copied() .collect(); @@ -724,25 +748,20 @@ pub fn keep_or_remove_matches( None } +// TODO: support to split on capture #N instead of whole match pub fn select_on_matches( text: RopeSlice, selection: &Selection, - regex: &crate::regex::Regex, + regex: &rope::Regex, ) -> Option { let mut result = SmallVec::with_capacity(selection.len()); for sel in selection { - // TODO: can't avoid occasional allocations since Regex can't operate on chunks yet - let fragment = sel.fragment(text); - - let sel_start = sel.from(); - let start_byte = text.char_to_byte(sel_start); - - for mat in regex.find_iter(&fragment) { + for mat in regex.find_iter(text.regex_input_at(sel.from()..sel.to())) { // TODO: retain range direction - let start = text.byte_to_char(start_byte + mat.start()); - let end = text.byte_to_char(start_byte + mat.end()); + let start = text.byte_to_char(mat.start()); + let end = text.byte_to_char(mat.end()); let range = Range::new(start, end); // Make sure the match is not right outside of the selection. @@ -761,12 +780,7 @@ pub fn select_on_matches( None } -// TODO: support to split on capture #N instead of whole match -pub fn split_on_matches( - text: RopeSlice, - selection: &Selection, - regex: &crate::regex::Regex, -) -> Selection { +pub fn split_on_newline(text: RopeSlice, selection: &Selection) -> Selection { let mut result = SmallVec::with_capacity(selection.len()); for sel in selection { @@ -776,21 +790,49 @@ pub fn split_on_matches( continue; } - // TODO: can't avoid occasional allocations since Regex can't operate on chunks yet - let fragment = sel.fragment(text); - let sel_start = sel.from(); let sel_end = sel.to(); - let start_byte = text.char_to_byte(sel_start); + let mut start = sel_start; + + for line in sel.slice(text).lines() { + let Some(line_ending) = get_line_ending(&line) else { + break; + }; + let line_end = start + line.len_chars(); + // TODO: retain range direction + result.push(Range::new(start, line_end - line_ending.len_chars())); + start = line_end; + } + + if start < sel_end { + result.push(Range::new(start, sel_end)); + } + } + + // TODO: figure out a new primary index + Selection::new(result, 0) +} + +pub fn split_on_matches(text: RopeSlice, selection: &Selection, regex: &rope::Regex) -> Selection { + let mut result = SmallVec::with_capacity(selection.len()); + + for sel in selection { + // Special case: zero-width selection. + if sel.from() == sel.to() { + result.push(*sel); + continue; + } + let sel_start = sel.from(); + let sel_end = sel.to(); let mut start = sel_start; - for mat in regex.find_iter(&fragment) { + for mat in regex.find_iter(text.regex_input_at(sel_start..sel_end)) { // TODO: retain range direction - let end = text.byte_to_char(start_byte + mat.start()); + let end = text.byte_to_char(mat.start()); result.push(Range::new(start, end)); - start = text.byte_to_char(start_byte + mat.end()); + start = text.byte_to_char(mat.end()); } if start < sel_end { @@ -1021,14 +1063,12 @@ mod test { #[test] fn test_select_on_matches() { - use crate::regex::{Regex, RegexBuilder}; - let r = Rope::from_str("Nobody expects the Spanish inquisition"); let s = r.slice(..); let selection = Selection::single(0, r.len_chars()); assert_eq!( - select_on_matches(s, &selection, &Regex::new(r"[A-Z][a-z]*").unwrap()), + select_on_matches(s, &selection, &rope::Regex::new(r"[A-Z][a-z]*").unwrap()), Some(Selection::new( smallvec![Range::new(0, 6), Range::new(19, 26)], 0 @@ -1038,8 +1078,14 @@ mod test { let r = Rope::from_str("This\nString\n\ncontains multiple\nlines"); let s = r.slice(..); - let start_of_line = RegexBuilder::new(r"^").multi_line(true).build().unwrap(); - let end_of_line = RegexBuilder::new(r"$").multi_line(true).build().unwrap(); + let start_of_line = rope::RegexBuilder::new() + .syntax(rope::Config::new().multi_line(true)) + .build(r"^") + .unwrap(); + let end_of_line = rope::RegexBuilder::new() + .syntax(rope::Config::new().multi_line(true)) + .build(r"$") + .unwrap(); // line without ending assert_eq!( @@ -1077,9 +1123,9 @@ mod test { select_on_matches( s, &Selection::single(0, s.len_chars()), - &RegexBuilder::new(r"^[a-z ]*$") - .multi_line(true) - .build() + &rope::RegexBuilder::new() + .syntax(rope::Config::new().multi_line(true)) + .build(r"^[a-z ]*$") .unwrap() ), Some(Selection::new( @@ -1171,13 +1217,15 @@ mod test { #[test] fn test_split_on_matches() { - use crate::regex::Regex; - let text = Rope::from(" abcd efg wrs xyz 123 456"); let selection = Selection::new(smallvec![Range::new(0, 9), Range::new(11, 20),], 0); - let result = split_on_matches(text.slice(..), &selection, &Regex::new(r"\s+").unwrap()); + let result = split_on_matches( + text.slice(..), + &selection, + &rope::Regex::new(r"\s+").unwrap(), + ); assert_eq!( result.ranges(), diff --git a/helix-core/src/surround.rs b/helix-core/src/surround.rs index b96cce5a0..e45346c92 100644 --- a/helix-core/src/surround.rs +++ b/helix-core/src/surround.rs @@ -1,18 +1,16 @@ use std::fmt::Display; -use crate::{movement::Direction, search, Range, Selection}; +use crate::{ + graphemes::next_grapheme_boundary, + match_brackets::{ + find_matching_bracket, find_matching_bracket_fuzzy, get_pair, is_close_bracket, + is_open_bracket, + }, + movement::Direction, + search, Range, Selection, Syntax, +}; use ropey::RopeSlice; -pub const PAIRS: &[(char, char)] = &[ - ('(', ')'), - ('[', ']'), - ('{', '}'), - ('<', '>'), - ('«', '»'), - ('「', '」'), - ('(', ')'), -]; - #[derive(Debug, PartialEq, Eq)] pub enum Error { PairNotFound, @@ -34,32 +32,68 @@ impl Display for Error { type Result = std::result::Result; -/// Given any char in [PAIRS], return the open and closing chars. If not found in -/// [PAIRS] return (ch, ch). +/// Finds the position of surround pairs of any [`crate::match_brackets::PAIRS`] +/// using tree-sitter when possible. /// -/// ``` -/// use helix_core::surround::get_pair; +/// # Returns /// -/// assert_eq!(get_pair('['), ('[', ']')); -/// assert_eq!(get_pair('}'), ('{', '}')); -/// assert_eq!(get_pair('"'), ('"', '"')); -/// ``` -pub fn get_pair(ch: char) -> (char, char) { - PAIRS - .iter() - .find(|(open, close)| *open == ch || *close == ch) - .copied() - .unwrap_or((ch, ch)) +/// Tuple `(anchor, head)`, meaning it is not always ordered. +pub fn find_nth_closest_pairs_pos( + syntax: Option<&Syntax>, + text: RopeSlice, + range: Range, + skip: usize, +) -> Result<(usize, usize)> { + match syntax { + Some(syntax) => find_nth_closest_pairs_ts(syntax, text, range, skip), + None => find_nth_closest_pairs_plain(text, range, skip), + } } -pub fn find_nth_closest_pairs_pos( +fn find_nth_closest_pairs_ts( + syntax: &Syntax, text: RopeSlice, range: Range, mut skip: usize, ) -> Result<(usize, usize)> { - let is_open_pair = |ch| PAIRS.iter().any(|(open, _)| *open == ch); - let is_close_pair = |ch| PAIRS.iter().any(|(_, close)| *close == ch); + let mut opening = range.from(); + // We want to expand the selection if we are already on the found pair, + // otherwise we would need to subtract "-1" from "range.to()". + let mut closing = range.to(); + + while skip > 0 { + closing = find_matching_bracket_fuzzy(syntax, text, closing).ok_or(Error::PairNotFound)?; + opening = find_matching_bracket(syntax, text, closing).ok_or(Error::PairNotFound)?; + // If we're already on a closing bracket "find_matching_bracket_fuzzy" will return + // the position of the opening bracket. + if closing < opening { + (opening, closing) = (closing, opening); + } + // In case found brackets are partially inside current selection. + if range.from() < opening || closing < range.to() - 1 { + closing = next_grapheme_boundary(text, closing); + } else { + skip -= 1; + if skip != 0 { + closing = next_grapheme_boundary(text, closing); + } + } + } + + // Keep the original direction. + if let Direction::Forward = range.direction() { + Ok((opening, closing)) + } else { + Ok((closing, opening)) + } +} + +fn find_nth_closest_pairs_plain( + text: RopeSlice, + range: Range, + mut skip: usize, +) -> Result<(usize, usize)> { let mut stack = Vec::with_capacity(2); let pos = range.from(); let mut close_pos = pos.saturating_sub(1); @@ -67,7 +101,7 @@ pub fn find_nth_closest_pairs_pos( for ch in text.chars_at(pos) { close_pos += 1; - if is_open_pair(ch) { + if is_open_bracket(ch) { // Track open pairs encountered so that we can step over // the corresponding close pairs that will come up further // down the loop. We want to find a lone close pair whose @@ -76,7 +110,7 @@ pub fn find_nth_closest_pairs_pos( continue; } - if !is_close_pair(ch) { + if !is_close_bracket(ch) { // We don't care if this character isn't a brace pair item, // so short circuit here. continue; @@ -157,7 +191,11 @@ pub fn find_nth_pairs_pos( ) }; - Option::zip(open, close).ok_or(Error::PairNotFound) + // preserve original direction + match range.direction() { + Direction::Forward => Option::zip(open, close).ok_or(Error::PairNotFound), + Direction::Backward => Option::zip(close, open).ok_or(Error::PairNotFound), + } } fn find_nth_open_pair( @@ -167,6 +205,10 @@ fn find_nth_open_pair( mut pos: usize, n: usize, ) -> Option { + if pos >= text.len_chars() { + return None; + } + let mut chars = text.chars_at(pos + 1); // Adjusts pos for the first iteration, and handles the case of the @@ -245,6 +287,7 @@ fn find_nth_close_pair( /// are automatically detected around each cursor (note that this may result /// in them selecting different surround characters for each selection). pub fn get_surround_pos( + syntax: Option<&Syntax>, text: RopeSlice, selection: &Selection, ch: Option, @@ -253,14 +296,19 @@ pub fn get_surround_pos( let mut change_pos = Vec::new(); for &range in selection { - let (open_pos, close_pos) = match ch { - Some(ch) => find_nth_pairs_pos(text, ch, range, skip)?, - None => find_nth_closest_pairs_pos(text, range, skip)?, + let (open_pos, close_pos) = { + let range_raw = match ch { + Some(ch) => find_nth_pairs_pos(text, ch, range, skip)?, + None => find_nth_closest_pairs_pos(syntax, text, range, skip)?, + }; + let range = Range::new(range_raw.0, range_raw.1); + (range.from(), range.to()) }; if change_pos.contains(&open_pos) || change_pos.contains(&close_pos) { return Err(Error::CursorOverlap); } - change_pos.extend_from_slice(&[open_pos, close_pos]); + // ensure the positions are always paired in the forward direction + change_pos.extend_from_slice(&[open_pos.min(close_pos), close_pos.max(open_pos)]); } Ok(change_pos) } @@ -283,7 +331,7 @@ mod test { ); assert_eq!( - get_surround_pos(doc.slice(..), &selection, Some('('), 1).unwrap(), + get_surround_pos(None, doc.slice(..), &selection, Some('('), 1).unwrap(), expectations ); } @@ -298,7 +346,7 @@ mod test { ); assert_eq!( - get_surround_pos(doc.slice(..), &selection, Some('('), 1), + get_surround_pos(None, doc.slice(..), &selection, Some('('), 1), Err(Error::PairNotFound) ); } @@ -313,7 +361,7 @@ mod test { ); assert_eq!( - get_surround_pos(doc.slice(..), &selection, Some('('), 1), + get_surround_pos(None, doc.slice(..), &selection, Some('('), 1), Err(Error::PairNotFound) // overlapping surround chars ); } @@ -328,7 +376,7 @@ mod test { ); assert_eq!( - get_surround_pos(doc.slice(..), &selection, Some('['), 1), + get_surround_pos(None, doc.slice(..), &selection, Some('['), 1), Err(Error::CursorOverlap) ); } @@ -382,6 +430,21 @@ mod test { ) } + #[test] + fn test_find_nth_closest_pairs_pos_index_range_panic() { + #[rustfmt::skip] + let (doc, selection, _) = + rope_with_selections_and_expectations( + "(a)c)", + "^^^^^" + ); + + assert_eq!( + find_nth_closest_pairs_pos(None, doc.slice(..), selection.primary(), 1), + Err(Error::PairNotFound) + ) + } + // Create a Rope and a matching Selection using a specification language. // ^ is a single-point selection. // _ is an expected index. These are returned as a Vec for use in assertions. diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 24de1a338..3cf818f60 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -1,3 +1,5 @@ +mod tree_cursor; + use crate::{ auto_pairs::AutoPairs, chars::char_is_line_ending, @@ -10,7 +12,9 @@ use crate::{ use ahash::RandomState; use arc_swap::{ArcSwap, Guard}; use bitflags::bitflags; +use globset::GlobSet; use hashbrown::raw::RawTable; +use helix_stdx::rope::{self, RopeSliceExt}; use slotmap::{DefaultKey as LayerId, HopSlotMap}; use std::{ @@ -19,7 +23,7 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, fmt::{self, Display}, hash::{Hash, Hasher}, - mem::{replace, transmute}, + mem::replace, path::{Path, PathBuf}, str::FromStr, sync::Arc, @@ -30,6 +34,8 @@ use serde::{ser::SerializeSeq, Deserialize, Serialize}; use helix_loader::grammar::{get_language, load_runtime_file}; +pub use tree_cursor::TreeCursor; + fn deserialize_regex<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -82,12 +88,6 @@ pub struct Configuration { pub language_server: HashMap, } -impl Default for Configuration { - fn default() -> Self { - crate::config::default_syntax_loader() - } -} - // largely based on tree-sitter/cli/src/loader.rs #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] @@ -103,7 +103,19 @@ pub struct LanguageConfiguration { pub shebangs: Vec, // interpreter(s) associated with language #[serde(default)] pub roots: Vec, // these indicate project roots <.git, Cargo.toml> - pub comment_token: Option, + #[serde( + default, + skip_serializing, + deserialize_with = "from_comment_tokens", + alias = "comment-token" + )] + pub comment_tokens: Option>, + #[serde( + default, + skip_serializing, + deserialize_with = "from_block_comment_tokens" + )] + pub block_comment_tokens: Option>, pub text_width: Option, pub soft_wrap: Option, @@ -164,9 +176,11 @@ pub enum FileType { /// The extension of the file, either the `Path::extension` or the full /// filename if the file does not have an extension. Extension(String), - /// The suffix of a file. This is compared to a given file's absolute - /// path, so it can be used to detect files based on their directories. - Suffix(String), + /// A Unix-style path glob. This is compared to the file's absolute path, so + /// it can be used to detect files based on their directories. If the glob + /// is not an absolute path and does not already start with a glob pattern, + /// a glob pattern will be prepended to it. + Glob(globset::Glob), } impl Serialize for FileType { @@ -178,9 +192,9 @@ impl Serialize for FileType { match self { FileType::Extension(extension) => serializer.serialize_str(extension), - FileType::Suffix(suffix) => { + FileType::Glob(glob) => { let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("suffix", &suffix.replace(std::path::MAIN_SEPARATOR, "/"))?; + map.serialize_entry("glob", glob.glob())?; map.end() } } @@ -213,9 +227,20 @@ impl<'de> Deserialize<'de> for FileType { M: serde::de::MapAccess<'de>, { match map.next_entry::()? { - Some((key, suffix)) if key == "suffix" => Ok(FileType::Suffix({ - suffix.replace('/', std::path::MAIN_SEPARATOR_STR) - })), + Some((key, mut glob)) if key == "glob" => { + // If the glob isn't an absolute path or already starts + // with a glob pattern, add a leading glob so we + // properly match relative paths. + if !glob.starts_with('/') && !glob.starts_with("*/") { + glob.insert_str(0, "*/"); + } + + globset::Glob::new(glob.as_str()) + .map(FileType::Glob) + .map_err(|err| { + serde::de::Error::custom(format!("invalid `glob` pattern: {}", err)) + }) + } Some((key, _value)) => Err(serde::de::Error::custom(format!( "unknown key in `file-types` list: {}", key @@ -231,6 +256,59 @@ impl<'de> Deserialize<'de> for FileType { } } +fn from_comment_tokens<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum CommentTokens { + Multiple(Vec), + Single(String), + } + Ok( + Option::::deserialize(deserializer)?.map(|tokens| match tokens { + CommentTokens::Single(val) => vec![val], + CommentTokens::Multiple(vals) => vals, + }), + ) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BlockCommentToken { + pub start: String, + pub end: String, +} + +impl Default for BlockCommentToken { + fn default() -> Self { + BlockCommentToken { + start: "/*".to_string(), + end: "*/".to_string(), + } + } +} + +fn from_block_comment_tokens<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum BlockCommentTokens { + Multiple(Vec), + Single(BlockCommentToken), + } + Ok( + Option::::deserialize(deserializer)?.map(|tokens| match tokens { + BlockCommentTokens::Single(val) => vec![val], + BlockCommentTokens::Multiple(vals) => vals, + }), + ) +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "kebab-case")] pub enum LanguageServerFeature { @@ -358,6 +436,22 @@ where serializer.end() } +fn deserialize_required_root_patterns<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let patterns = Vec::::deserialize(deserializer)?; + if patterns.is_empty() { + return Ok(None); + } + let mut builder = globset::GlobSetBuilder::new(); + for pattern in patterns { + let glob = globset::Glob::new(&pattern).map_err(serde::de::Error::custom)?; + builder.add(glob); + } + builder.build().map(Some).map_err(serde::de::Error::custom) +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct LanguageServerConfiguration { @@ -371,6 +465,12 @@ pub struct LanguageServerConfiguration { pub config: Option, #[serde(default = "default_timeout")] pub timeout: u64, + #[serde( + default, + skip_serializing, + deserialize_with = "deserialize_required_root_patterns" + )] + pub required_root_patterns: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -709,7 +809,7 @@ impl LanguageConfiguration { if query_text.is_empty() { return None; } - let lang = self.highlight_config.get()?.as_ref()?.language; + let lang = &self.highlight_config.get()?.as_ref()?.language; Query::new(lang, &query_text) .map_err(|e| { log::error!( @@ -752,6 +852,47 @@ pub struct SoftWrap { pub wrap_at_text_width: Option, } +#[derive(Debug)] +struct FileTypeGlob { + glob: globset::Glob, + language_id: usize, +} + +impl FileTypeGlob { + fn new(glob: globset::Glob, language_id: usize) -> Self { + Self { glob, language_id } + } +} + +#[derive(Debug)] +struct FileTypeGlobMatcher { + matcher: globset::GlobSet, + file_types: Vec, +} + +impl FileTypeGlobMatcher { + fn new(file_types: Vec) -> Result { + let mut builder = globset::GlobSetBuilder::new(); + for file_type in &file_types { + builder.add(file_type.glob.clone()); + } + + Ok(Self { + matcher: builder.build()?, + file_types, + }) + } + + fn language_id_for_path(&self, path: &Path) -> Option<&usize> { + self.matcher + .matches(path) + .iter() + .filter_map(|idx| self.file_types.get(*idx)) + .max_by_key(|file_type| file_type.glob.glob().len()) + .map(|file_type| &file_type.language_id) + } +} + // Expose loader as Lazy<> global since it's always static? #[derive(Debug)] @@ -759,7 +900,7 @@ pub struct Loader { // highlight_names ? language_configs: Vec>, language_config_ids_by_extension: HashMap, // Vec - language_config_ids_by_suffix: HashMap, + language_config_ids_glob_matcher: FileTypeGlobMatcher, language_config_ids_by_shebang: HashMap, language_server_configs: HashMap, @@ -767,66 +908,57 @@ pub struct Loader { scopes: ArcSwap>, } +pub type LoaderError = globset::Error; + impl Loader { - pub fn new(config: Configuration) -> Self { - let mut loader = Self { - language_configs: Vec::new(), - language_server_configs: config.language_server, - language_config_ids_by_extension: HashMap::new(), - language_config_ids_by_suffix: HashMap::new(), - language_config_ids_by_shebang: HashMap::new(), - scopes: ArcSwap::from_pointee(Vec::new()), - }; + pub fn new(config: Configuration) -> Result { + let mut language_configs = Vec::new(); + let mut language_config_ids_by_extension = HashMap::new(); + let mut language_config_ids_by_shebang = HashMap::new(); + let mut file_type_globs = Vec::new(); for config in config.language { // get the next id - let language_id = loader.language_configs.len(); + let language_id = language_configs.len(); for file_type in &config.file_types { // entry().or_insert(Vec::new).push(language_id); match file_type { - FileType::Extension(extension) => loader - .language_config_ids_by_extension - .insert(extension.clone(), language_id), - FileType::Suffix(suffix) => loader - .language_config_ids_by_suffix - .insert(suffix.clone(), language_id), + FileType::Extension(extension) => { + language_config_ids_by_extension.insert(extension.clone(), language_id); + } + FileType::Glob(glob) => { + file_type_globs.push(FileTypeGlob::new(glob.to_owned(), language_id)); + } }; } for shebang in &config.shebangs { - loader - .language_config_ids_by_shebang - .insert(shebang.clone(), language_id); + language_config_ids_by_shebang.insert(shebang.clone(), language_id); } - loader.language_configs.push(Arc::new(config)); + language_configs.push(Arc::new(config)); } - loader + Ok(Self { + language_configs, + language_config_ids_by_extension, + language_config_ids_glob_matcher: FileTypeGlobMatcher::new(file_type_globs)?, + language_config_ids_by_shebang, + language_server_configs: config.language_server, + scopes: ArcSwap::from_pointee(Vec::new()), + }) } pub fn language_config_for_file_name(&self, path: &Path) -> Option> { // Find all the language configurations that match this file name // or a suffix of the file name. - let configuration_id = path - .file_name() - .and_then(|n| n.to_str()) - .and_then(|file_name| self.language_config_ids_by_extension.get(file_name)) + let configuration_id = self + .language_config_ids_glob_matcher + .language_id_for_path(path) .or_else(|| { path.extension() .and_then(|extension| extension.to_str()) .and_then(|extension| self.language_config_ids_by_extension.get(extension)) - }) - .or_else(|| { - self.language_config_ids_by_suffix - .iter() - .find_map(|(file_type, id)| { - if path.to_str()?.ends_with(file_type) { - Some(id) - } else { - None - } - }) }); configuration_id.and_then(|&id| self.language_configs.get(id).cloned()) @@ -938,7 +1070,7 @@ thread_local! { pub struct Syntax { layers: HopSlotMap, root: LayerId, - loader: Arc, + loader: Arc>, } fn byte_range_to_str(range: std::ops::Range, source: RopeSlice) -> Cow { @@ -949,7 +1081,7 @@ impl Syntax { pub fn new( source: RopeSlice, config: Arc, - loader: Arc, + loader: Arc>, ) -> Option { let root_layer = LanguageLayer { tree: None, @@ -962,6 +1094,7 @@ impl Syntax { start_point: Point::new(0, 0), end_point: Point::new(usize::MAX, usize::MAX), }], + parent: None, }; // track scope_descriptor: a Vec of scopes for item in tree @@ -993,9 +1126,10 @@ impl Syntax { let mut queue = VecDeque::new(); queue.push_back(self.root); - let scopes = self.loader.scopes.load(); + let loader = self.loader.load(); + let scopes = loader.scopes.load(); let injection_callback = |language: &InjectionLanguageMarker| { - self.loader + loader .language_configuration_for_injection_string(language) .and_then(|language_config| language_config.highlight_config(&scopes)) }; @@ -1231,6 +1365,7 @@ impl Syntax { depth, ranges, flags: LayerUpdateFlags::empty(), + parent: Some(layer_id), }; // Find an identical existing layer @@ -1364,6 +1499,12 @@ impl Syntax { .descendant_for_byte_range(start, end) } + pub fn walk(&self) -> TreeCursor<'_> { + // data structure to find the smallest range that contains a point + // when some of the ranges in the structure can overlap. + TreeCursor::new(&self.layers, self.root) + } + // Commenting // comment_strings_for_pos // is_commented @@ -1396,6 +1537,7 @@ pub struct LanguageLayer { pub ranges: Vec, pub depth: u32, flags: LayerUpdateFlags, + parent: Option, } /// This PartialEq implementation only checks if that @@ -1415,13 +1557,7 @@ impl PartialEq for LanguageLayer { impl Hash for LanguageLayer { fn hash(&self, state: &mut H) { self.depth.hash(state); - // The transmute is necessary here because tree_sitter::Language does not derive Hash at the moment. - // However it does use #[repr] transparent so the transmute here is safe - // as `Language` (which `Grammar` is an alias for) is just a newtype wrapper around a (thin) pointer. - // This is also compatible with the PartialEq implementation of language - // as that is just a pointer comparison. - let language: *const () = unsafe { transmute(self.config.language) }; - language.hash(state); + self.config.language.hash(state); self.ranges.hash(state); } } @@ -1438,7 +1574,7 @@ impl LanguageLayer { .map_err(|_| Error::InvalidRanges)?; parser - .set_language(self.config.language) + .set_language(&self.config.language) .map_err(|_| Error::InvalidLanguage)?; // unsafe { syntax.parser.set_cancellation_flag(cancellation_flag) }; @@ -1597,7 +1733,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::{iter, mem, ops, str, usize}; use tree_sitter::{ Language as Grammar, Node, Parser, Point, Query, QueryCaptures, QueryCursor, QueryError, - QueryMatch, Range, TextProvider, Tree, TreeCursor, + QueryMatch, Range, TextProvider, Tree, }; const CANCELLATION_CHECK_INTERVAL: usize = 100; @@ -1738,7 +1874,7 @@ impl HighlightConfiguration { // Construct a single query by concatenating the three query strings, but record the // range of pattern indices that belong to each individual string. - let query = Query::new(language, &query_source)?; + let query = Query::new(&language, &query_source)?; let mut highlights_pattern_index = 0; for i in 0..(query.pattern_count()) { let pattern_offset = query.start_byte_for_pattern(i); @@ -1747,7 +1883,7 @@ impl HighlightConfiguration { } } - let injections_query = Query::new(language, injection_query)?; + let injections_query = Query::new(&language, injection_query)?; let combined_injections_patterns = (0..injections_query.pattern_count()) .filter(|&i| { injections_query @@ -1898,11 +2034,16 @@ impl HighlightConfiguration { node_slice }; - static SHEBANG_REGEX: Lazy = Lazy::new(|| Regex::new(SHEBANG).unwrap()); + static SHEBANG_REGEX: Lazy = + Lazy::new(|| rope::Regex::new(SHEBANG).unwrap()); injection_capture = SHEBANG_REGEX - .captures(&Cow::from(lines)) - .map(|cap| InjectionLanguageMarker::Shebang(cap[1].to_owned())) + .captures_iter(lines.regex_input()) + .map(|cap| { + let cap = lines.byte_slice(cap.get_group(1).unwrap().range()); + InjectionLanguageMarker::Shebang(cap.into()) + }) + .next() } else if index == self.injection_content_capture_index { content_node = Some(capture.node); } @@ -2526,7 +2667,7 @@ pub fn pretty_print_tree(fmt: &mut W, node: Node) -> fmt::Result fn pretty_print_tree_impl( fmt: &mut W, - cursor: &mut TreeCursor, + cursor: &mut tree_sitter::TreeCursor, depth: usize, ) -> fmt::Result { let node = cursor.node(); @@ -2592,15 +2733,21 @@ mod test { let loader = Loader::new(Configuration { language: vec![], language_server: HashMap::new(), - }); + }) + .unwrap(); let language = get_language("rust").unwrap(); - let query = Query::new(language, query_str).unwrap(); + let query = Query::new(&language, query_str).unwrap(); let textobject = TextObjectQuery { query }; let mut cursor = QueryCursor::new(); let config = HighlightConfiguration::new(language, "", "", "").unwrap(); - let syntax = Syntax::new(source.slice(..), Arc::new(config), Arc::new(loader)).unwrap(); + let syntax = Syntax::new( + source.slice(..), + Arc::new(config), + Arc::new(ArcSwap::from_pointee(loader)), + ) + .unwrap(); let root = syntax.tree().root_node(); let mut test = |capture, range| { @@ -2618,10 +2765,10 @@ mod test { ) }; - test("quantified_nodes", 1..36); + test("quantified_nodes", 1..37); // NOTE: Enable after implementing proper node group capturing - // test("quantified_nodes_grouped", 1..36); - // test("multiple_nodes_grouped", 1..36); + // test("quantified_nodes_grouped", 1..37); + // test("multiple_nodes_grouped", 1..37); } #[test] @@ -2654,7 +2801,8 @@ mod test { let loader = Loader::new(Configuration { language: vec![], language_server: HashMap::new(), - }); + }) + .unwrap(); let language = get_language("rust").unwrap(); let config = HighlightConfiguration::new( @@ -2674,7 +2822,12 @@ mod test { fn main() {} ", ); - let syntax = Syntax::new(source.slice(..), Arc::new(config), Arc::new(loader)).unwrap(); + let syntax = Syntax::new( + source.slice(..), + Arc::new(config), + Arc::new(ArcSwap::from_pointee(loader)), + ) + .unwrap(); let tree = syntax.tree(); let root = tree.root_node(); assert_eq!(root.kind(), "source_file"); @@ -2760,11 +2913,17 @@ mod test { let loader = Loader::new(Configuration { language: vec![], language_server: HashMap::new(), - }); + }) + .unwrap(); let language = get_language(language_name).unwrap(); let config = HighlightConfiguration::new(language, "", "", "").unwrap(); - let syntax = Syntax::new(source.slice(..), Arc::new(config), Arc::new(loader)).unwrap(); + let syntax = Syntax::new( + source.slice(..), + Arc::new(config), + Arc::new(ArcSwap::from_pointee(loader)), + ) + .unwrap(); let root = syntax .tree() @@ -2780,7 +2939,7 @@ mod test { #[test] fn test_pretty_print() { - let source = r#"/// Hello"#; + let source = r#"// Hello"#; assert_pretty_print("rust", source, "(line_comment)", 0, source.len()); // A large tree should be indented with fields: @@ -2799,7 +2958,8 @@ mod test { " (macro_invocation\n", " macro: (identifier)\n", " (token_tree\n", - " (string_literal))))))", + " (string_literal\n", + " (string_content)))))))", ), 0, source.len(), @@ -2818,7 +2978,7 @@ mod test { // rule but `name` and `body` belong to an unnamed helper `_method_rest`. // This can cause a bug with a pretty-printing implementation that // uses `Node::field_name_for_child` to determine field names but is - // fixed when using `TreeCursor::field_name`. + // fixed when using `tree_sitter::TreeCursor::field_name`. let source = "def self.method_name true end"; diff --git a/helix-core/src/syntax/tree_cursor.rs b/helix-core/src/syntax/tree_cursor.rs new file mode 100644 index 000000000..692d5890a --- /dev/null +++ b/helix-core/src/syntax/tree_cursor.rs @@ -0,0 +1,264 @@ +use std::{cmp::Reverse, ops::Range}; + +use super::{LanguageLayer, LayerId}; + +use slotmap::HopSlotMap; +use tree_sitter::Node; + +/// The byte range of an injection layer. +/// +/// Injection ranges may overlap, but all overlapping parts are subsets of their parent ranges. +/// This allows us to sort the ranges ahead of time in order to efficiently find a range that +/// contains a point with maximum depth. +#[derive(Debug)] +struct InjectionRange { + start: usize, + end: usize, + layer_id: LayerId, + depth: u32, +} + +pub struct TreeCursor<'a> { + layers: &'a HopSlotMap, + root: LayerId, + current: LayerId, + injection_ranges: Vec, + // TODO: Ideally this would be a `tree_sitter::TreeCursor<'a>` but + // that returns very surprising results in testing. + cursor: Node<'a>, +} + +impl<'a> TreeCursor<'a> { + pub(super) fn new(layers: &'a HopSlotMap, root: LayerId) -> Self { + let mut injection_ranges = Vec::new(); + + for (layer_id, layer) in layers.iter() { + // Skip the root layer + if layer.parent.is_none() { + continue; + } + for byte_range in layer.ranges.iter() { + let range = InjectionRange { + start: byte_range.start_byte, + end: byte_range.end_byte, + layer_id, + depth: layer.depth, + }; + injection_ranges.push(range); + } + } + + injection_ranges.sort_unstable_by_key(|range| (range.end, Reverse(range.depth))); + + let cursor = layers[root].tree().root_node(); + + Self { + layers, + root, + current: root, + injection_ranges, + cursor, + } + } + + pub fn node(&self) -> Node<'a> { + self.cursor + } + + pub fn goto_parent(&mut self) -> bool { + if let Some(parent) = self.node().parent() { + self.cursor = parent; + return true; + } + + // If we are already on the root layer, we cannot ascend. + if self.current == self.root { + return false; + } + + // Ascend to the parent layer. + let range = self.node().byte_range(); + let parent_id = self.layers[self.current] + .parent + .expect("non-root layers have a parent"); + self.current = parent_id; + let root = self.layers[self.current].tree().root_node(); + self.cursor = root + .descendant_for_byte_range(range.start, range.end) + .unwrap_or(root); + + true + } + + pub fn goto_parent_with

(&mut self, predicate: P) -> bool + where + P: Fn(&Node) -> bool, + { + while self.goto_parent() { + if predicate(&self.node()) { + return true; + } + } + + false + } + + /// Finds the injection layer that has exactly the same range as the given `range`. + fn layer_id_of_byte_range(&self, search_range: Range) -> Option { + let start_idx = self + .injection_ranges + .partition_point(|range| range.end < search_range.end); + + self.injection_ranges[start_idx..] + .iter() + .take_while(|range| range.end == search_range.end) + .find_map(|range| (range.start == search_range.start).then_some(range.layer_id)) + } + + fn goto_first_child_impl(&mut self, named: bool) -> bool { + // Check if the current node's range is an exact injection layer range. + if let Some(layer_id) = self + .layer_id_of_byte_range(self.node().byte_range()) + .filter(|&layer_id| layer_id != self.current) + { + // Switch to the child layer. + self.current = layer_id; + self.cursor = self.layers[self.current].tree().root_node(); + return true; + } + + let child = if named { + self.cursor.named_child(0) + } else { + self.cursor.child(0) + }; + + if let Some(child) = child { + // Otherwise descend in the current tree. + self.cursor = child; + true + } else { + false + } + } + + pub fn goto_first_child(&mut self) -> bool { + self.goto_first_child_impl(false) + } + + pub fn goto_first_named_child(&mut self) -> bool { + self.goto_first_child_impl(true) + } + + fn goto_next_sibling_impl(&mut self, named: bool) -> bool { + let sibling = if named { + self.cursor.next_named_sibling() + } else { + self.cursor.next_sibling() + }; + + if let Some(sibling) = sibling { + self.cursor = sibling; + true + } else { + false + } + } + + pub fn goto_next_sibling(&mut self) -> bool { + self.goto_next_sibling_impl(false) + } + + pub fn goto_next_named_sibling(&mut self) -> bool { + self.goto_next_sibling_impl(true) + } + + fn goto_prev_sibling_impl(&mut self, named: bool) -> bool { + let sibling = if named { + self.cursor.prev_named_sibling() + } else { + self.cursor.prev_sibling() + }; + + if let Some(sibling) = sibling { + self.cursor = sibling; + true + } else { + false + } + } + + pub fn goto_prev_sibling(&mut self) -> bool { + self.goto_prev_sibling_impl(false) + } + + pub fn goto_prev_named_sibling(&mut self) -> bool { + self.goto_prev_sibling_impl(true) + } + + /// Finds the injection layer that contains the given start-end range. + fn layer_id_containing_byte_range(&self, start: usize, end: usize) -> LayerId { + let start_idx = self + .injection_ranges + .partition_point(|range| range.end < end); + + self.injection_ranges[start_idx..] + .iter() + .take_while(|range| range.start < end) + .find_map(|range| (range.start <= start).then_some(range.layer_id)) + .unwrap_or(self.root) + } + + pub fn reset_to_byte_range(&mut self, start: usize, end: usize) { + self.current = self.layer_id_containing_byte_range(start, end); + let root = self.layers[self.current].tree().root_node(); + self.cursor = root.descendant_for_byte_range(start, end).unwrap_or(root); + } + + /// Returns an iterator over the children of the node the TreeCursor is on + /// at the time this is called. + pub fn children(&'a mut self) -> ChildIter { + let parent = self.node(); + + ChildIter { + cursor: self, + parent, + named: false, + } + } + + /// Returns an iterator over the named children of the node the TreeCursor is on + /// at the time this is called. + pub fn named_children(&'a mut self) -> ChildIter { + let parent = self.node(); + + ChildIter { + cursor: self, + parent, + named: true, + } + } +} + +pub struct ChildIter<'n> { + cursor: &'n mut TreeCursor<'n>, + parent: Node<'n>, + named: bool, +} + +impl<'n> Iterator for ChildIter<'n> { + type Item = Node<'n>; + + fn next(&mut self) -> Option { + // first iteration, just visit the first child + if self.cursor.node() == self.parent { + self.cursor + .goto_first_child_impl(self.named) + .then(|| self.cursor.node()) + } else { + self.cursor + .goto_next_sibling_impl(self.named) + .then(|| self.cursor.node()) + } + } +} diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs index 11d19d485..1576914e3 100644 --- a/helix-core/src/text_annotations.rs +++ b/helix-core/src/text_annotations.rs @@ -1,6 +1,5 @@ use std::cell::Cell; use std::ops::Range; -use std::rc::Rc; use crate::syntax::Highlight; use crate::Tendril; @@ -92,23 +91,23 @@ pub struct LineAnnotation { } #[derive(Debug)] -struct Layer { - annotations: Rc<[A]>, +struct Layer<'a, A, M> { + annotations: &'a [A], current_index: Cell, metadata: M, } -impl Clone for Layer { +impl Clone for Layer<'_, A, M> { fn clone(&self) -> Self { Layer { - annotations: self.annotations.clone(), + annotations: self.annotations, current_index: self.current_index.clone(), metadata: self.metadata.clone(), } } } -impl Layer { +impl Layer<'_, A, M> { pub fn reset_pos(&self, char_idx: usize, get_char_idx: impl Fn(&A) -> usize) { let new_index = self .annotations @@ -128,8 +127,8 @@ impl Layer { } } -impl From<(Rc<[A]>, M)> for Layer { - fn from((annotations, metadata): (Rc<[A]>, M)) -> Layer { +impl<'a, A, M> From<(&'a [A], M)> for Layer<'a, A, M> { + fn from((annotations, metadata): (&'a [A], M)) -> Layer { Layer { annotations, current_index: Cell::new(0), @@ -147,13 +146,13 @@ fn reset_pos(layers: &[Layer], pos: usize, get_pos: impl Fn(&A) -> u /// Annotations that change that is displayed when the document is render. /// Also commonly called virtual text. #[derive(Default, Debug, Clone)] -pub struct TextAnnotations { - inline_annotations: Vec>>, - overlays: Vec>>, - line_annotations: Vec>, +pub struct TextAnnotations<'a> { + inline_annotations: Vec>>, + overlays: Vec>>, + line_annotations: Vec>, } -impl TextAnnotations { +impl<'a> TextAnnotations<'a> { /// Prepare the TextAnnotations for iteration starting at char_idx pub fn reset_pos(&self, char_idx: usize) { reset_pos(&self.inline_annotations, char_idx, |annot| annot.char_idx); @@ -194,7 +193,7 @@ impl TextAnnotations { /// the annotations that belong to the layers added first will be shown first. pub fn add_inline_annotations( &mut self, - layer: Rc<[InlineAnnotation]>, + layer: &'a [InlineAnnotation], highlight: Option, ) -> &mut Self { self.inline_annotations.push((layer, highlight).into()); @@ -211,7 +210,7 @@ impl TextAnnotations { /// /// If multiple layers contain overlay at the same position /// the overlay from the layer added last will be show. - pub fn add_overlay(&mut self, layer: Rc<[Overlay]>, highlight: Option) -> &mut Self { + pub fn add_overlay(&mut self, layer: &'a [Overlay], highlight: Option) -> &mut Self { self.overlays.push((layer, highlight).into()); self } @@ -220,7 +219,7 @@ impl TextAnnotations { /// /// The line annotations **must be sorted** by their `char_idx`. /// Multiple line annotations with the same `char_idx` **are not allowed**. - pub fn add_line_annotation(&mut self, layer: Rc<[LineAnnotation]>) -> &mut Self { + pub fn add_line_annotation(&mut self, layer: &'a [LineAnnotation]) -> &mut Self { self.line_annotations.push((layer, ()).into()); self } diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs index bf00a4580..7576b3a78 100644 --- a/helix-core/src/textobject.rs +++ b/helix-core/src/textobject.rs @@ -7,9 +7,9 @@ use crate::chars::{categorize_char, char_is_whitespace, CharCategory}; use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary}; use crate::line_ending::rope_is_line_ending; use crate::movement::Direction; -use crate::surround; use crate::syntax::LanguageConfiguration; use crate::Range; +use crate::{surround, Syntax}; fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize { use CharCategory::{Eol, Whitespace}; @@ -199,25 +199,28 @@ pub fn textobject_paragraph( } pub fn textobject_pair_surround( + syntax: Option<&Syntax>, slice: RopeSlice, range: Range, textobject: TextObject, ch: char, count: usize, ) -> Range { - textobject_pair_surround_impl(slice, range, textobject, Some(ch), count) + textobject_pair_surround_impl(syntax, slice, range, textobject, Some(ch), count) } pub fn textobject_pair_surround_closest( + syntax: Option<&Syntax>, slice: RopeSlice, range: Range, textobject: TextObject, count: usize, ) -> Range { - textobject_pair_surround_impl(slice, range, textobject, None, count) + textobject_pair_surround_impl(syntax, slice, range, textobject, None, count) } fn textobject_pair_surround_impl( + syntax: Option<&Syntax>, slice: RopeSlice, range: Range, textobject: TextObject, @@ -226,8 +229,7 @@ fn textobject_pair_surround_impl( ) -> Range { let pair_pos = match ch { Some(ch) => surround::find_nth_pairs_pos(slice, ch, range, count), - // Automatically find the closest surround pairs - None => surround::find_nth_closest_pairs_pos(slice, range, count), + None => surround::find_nth_closest_pairs_pos(syntax, slice, range, count), }; pair_pos .map(|(anchor, head)| match textobject { @@ -574,7 +576,8 @@ mod test { let slice = doc.slice(..); for &case in scenario { let (pos, objtype, expected_range, ch, count) = case; - let result = textobject_pair_surround(slice, Range::point(pos), objtype, ch, count); + let result = + textobject_pair_surround(None, slice, Range::point(pos), objtype, ch, count); assert_eq!( result, expected_range.into(), diff --git a/helix-core/tests/indent.rs b/helix-core/tests/indent.rs index faf845c07..31946c56e 100644 --- a/helix-core/tests/indent.rs +++ b/helix-core/tests/indent.rs @@ -1,10 +1,12 @@ +use arc_swap::ArcSwap; use helix_core::{ indent::{indent_level_for_line, treesitter_indent_for_pos, IndentStyle}, syntax::{Configuration, Loader}, Syntax, }; +use helix_stdx::rope::RopeSliceExt; use ropey::Rope; -use std::{ops::Range, path::PathBuf, process::Command}; +use std::{ops::Range, path::PathBuf, process::Command, sync::Arc}; #[test] fn test_treesitter_indent_rust() { @@ -186,7 +188,7 @@ fn test_treesitter_indent( lang_scope: &str, ignored_lines: Vec>, ) { - let loader = Loader::new(indent_tests_config()); + let loader = Loader::new(indent_tests_config()).unwrap(); // set runtime path so we can find the queries let mut runtime = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -197,7 +199,12 @@ fn test_treesitter_indent( let indent_style = IndentStyle::from_str(&language_config.indent.as_ref().unwrap().unit); let highlight_config = language_config.highlight_config(&[]).unwrap(); let text = doc.slice(..); - let syntax = Syntax::new(text, highlight_config, std::sync::Arc::new(loader)).unwrap(); + let syntax = Syntax::new( + text, + highlight_config, + Arc::new(ArcSwap::from_pointee(loader)), + ) + .unwrap(); let indent_query = language_config.indent_query().unwrap(); for i in 0..doc.len_lines() { @@ -205,7 +212,7 @@ fn test_treesitter_indent( if ignored_lines.iter().any(|range| range.contains(&(i + 1))) { continue; } - if let Some(pos) = helix_core::find_first_non_whitespace_char(line) { + if let Some(pos) = line.first_non_whitespace_char() { let tab_width: usize = 4; let suggested_indent = treesitter_indent_for_pos( indent_query, diff --git a/helix-dap/src/client.rs b/helix-dap/src/client.rs index 18af13ae7..2f5b3d0fc 100644 --- a/helix-dap/src/client.rs +++ b/helix-dap/src/client.rs @@ -2,7 +2,7 @@ use crate::{ requests::DisconnectArguments, transport::{Payload, Request, Response, Transport}, types::*, - Error, Result, ThreadId, + Error, Result, }; use helix_core::syntax::DebuggerQuirks; diff --git a/helix-event/Cargo.toml b/helix-event/Cargo.toml index f3c721581..bc3ee7f95 100644 --- a/helix-event/Cargo.toml +++ b/helix-event/Cargo.toml @@ -12,7 +12,7 @@ homepage.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -ahash = "0.8.3" +ahash = "0.8.11" hashbrown = "0.14.0" tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "parking_lot", "macros"] } # the event registry is essentially read only but must be an rwlock so we can diff --git a/helix-loader/Cargo.toml b/helix-loader/Cargo.toml index 469bedc10..d15d87f95 100644 --- a/helix-loader/Cargo.toml +++ b/helix-loader/Cargo.toml @@ -19,7 +19,7 @@ helix-stdx = { path = "../helix-stdx" } anyhow = "1" serde = { version = "1.0", features = ["derive"] } -toml = "0.7" +toml = "0.8" etcetera = "0.8" tree-sitter.workspace = true once_cell = "1.19" @@ -30,7 +30,7 @@ log = "0.4" # cloning/compiling tree-sitter grammars cc = { version = "1" } threadpool = { version = "1.0" } -tempfile = "3.9.0" +tempfile = "3.10.1" dunce = "1.0.4" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index f8fac6703..badb9bd64 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -53,7 +53,7 @@ fn prioritize_runtime_dirs() -> Vec { rt_dirs.push(conf_rt_dir); if let Ok(dir) = std::env::var("HELIX_RUNTIME") { - let dir = path::expand_tilde(dir); + let dir = path::expand_tilde(Path::new(&dir)); rt_dirs.push(path::normalize(dir)); } @@ -126,7 +126,7 @@ pub fn config_dir() -> PathBuf { pub fn cache_dir() -> PathBuf { // TODO: allow env var override - let strategy = choose_base_strategy().expect("Unable to find the config directory!"); + let strategy = choose_base_strategy().expect("Unable to find the cache directory!"); let mut path = strategy.cache_dir(); path.push("helix"); path diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index 8e9e3407c..f87f15f21 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -27,6 +27,8 @@ lsp-types = { version = "0.95" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" -tokio = { version = "1.35", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } -tokio-stream = "0.1.14" +tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } +tokio-stream = "0.1.15" parking_lot = "0.12.1" +arc-swap = "1" +slotmap.workspace = true diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 94bad6faf..254625a3a 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -2,11 +2,11 @@ use crate::{ file_operations::FileOperationsInterest, find_lsp_workspace, jsonrpc, transport::{Payload, Transport}, - Call, Error, OffsetEncoding, Result, + Call, Error, LanguageServerId, OffsetEncoding, Result, }; use helix_core::{find_workspace, syntax::LanguageServerFeature, ChangeSet, Rope}; -use helix_loader::{self, VERSION_AND_GIT_HASH}; +use helix_loader::VERSION_AND_GIT_HASH; use helix_stdx::path; use lsp::{ notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport, @@ -46,7 +46,7 @@ fn workspace_for_uri(uri: lsp::Url) -> WorkspaceFolder { #[derive(Debug)] pub struct Client { - id: usize, + id: LanguageServerId, name: String, _process: Child, server_tx: UnboundedSender, @@ -177,13 +177,16 @@ impl Client { args: &[String], config: Option, server_environment: HashMap, - root_markers: &[String], - manual_roots: &[PathBuf], - id: usize, + root_path: PathBuf, + root_uri: Option, + id: LanguageServerId, name: String, req_timeout: u64, - doc_path: Option<&std::path::PathBuf>, - ) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc)> { + ) -> Result<( + Self, + UnboundedReceiver<(LanguageServerId, Call)>, + Arc, + )> { // Resolve path to the binary let cmd = helix_stdx::env::which(cmd)?; @@ -206,22 +209,6 @@ impl Client { let (server_rx, server_tx, initialize_notify) = Transport::start(reader, writer, stderr, id, name.clone()); - let (workspace, workspace_is_cwd) = find_workspace(); - let workspace = path::normalize(workspace); - let root = find_lsp_workspace( - doc_path - .and_then(|x| x.parent().and_then(|x| x.to_str())) - .unwrap_or("."), - root_markers, - manual_roots, - &workspace, - workspace_is_cwd, - ); - - // `root_uri` and `workspace_folder` can be empty in case there is no workspace - // `root_url` can not, use `workspace` as a fallback - let root_path = root.clone().unwrap_or_else(|| workspace.clone()); - let root_uri = root.and_then(|root| lsp::Url::from_file_path(root).ok()); let workspace_folders = root_uri .clone() @@ -251,7 +238,7 @@ impl Client { &self.name } - pub fn id(&self) -> usize { + pub fn id(&self) -> LanguageServerId { self.id } @@ -410,6 +397,16 @@ impl Client { &self, params: R::Params, ) -> impl Future> + where + R::Params: serde::Serialize, + { + self.call_with_ref::(¶ms) + } + + fn call_with_ref( + &self, + params: &R::Params, + ) -> impl Future> where R::Params: serde::Serialize, { @@ -418,7 +415,7 @@ impl Client { fn call_with_timeout( &self, - params: R::Params, + params: &R::Params, timeout_secs: u64, ) -> impl Future> where @@ -427,17 +424,16 @@ impl Client { let server_tx = self.server_tx.clone(); let id = self.next_request_id(); + let params = serde_json::to_value(params); async move { use std::time::Duration; use tokio::time::timeout; - let params = serde_json::to_value(params)?; - let request = jsonrpc::MethodCall { jsonrpc: Some(jsonrpc::Version::V2), id: id.clone(), method: R::METHOD.to_string(), - params: Self::value_into_params(params), + params: Self::value_into_params(params?), }; let (tx, mut rx) = channel::>(1); @@ -648,6 +644,12 @@ impl Client { }), publish_diagnostics: Some(lsp::PublishDiagnosticsClientCapabilities { version_support: Some(true), + tag_support: Some(lsp::TagSupport { + value_set: vec![ + lsp::DiagnosticTag::UNNECESSARY, + lsp::DiagnosticTag::DEPRECATED, + ], + }), ..Default::default() }), inlay_hint: Some(lsp::InlayHintClientCapabilities { @@ -748,7 +750,7 @@ impl Client { new_uri: url_from_path(new_path)?, }]; let request = self.call_with_timeout::( - lsp::RenameFilesParams { files }, + &lsp::RenameFilesParams { files }, 5, ); @@ -1033,20 +1035,10 @@ impl Client { pub fn resolve_completion_item( &self, - completion_item: lsp::CompletionItem, - ) -> Option>> { - let capabilities = self.capabilities.get().unwrap(); - - // Return early if the server does not support resolving completion items. - match capabilities.completion_provider { - Some(lsp::CompletionOptions { - resolve_provider: Some(true), - .. - }) => (), - _ => return None, - } - - Some(self.call::(completion_item)) + completion_item: &lsp::CompletionItem, + ) -> impl Future> { + let res = self.call_with_ref::(completion_item); + async move { Ok(serde_json::from_value(res.await?)?) } } pub fn resolve_code_action( diff --git a/helix-lsp/src/file_event.rs b/helix-lsp/src/file_event.rs index 93457fa55..8f4c16756 100644 --- a/helix-lsp/src/file_event.rs +++ b/helix-lsp/src/file_event.rs @@ -3,24 +3,24 @@ use std::{collections::HashMap, path::PathBuf, sync::Weak}; use globset::{GlobBuilder, GlobSetBuilder}; use tokio::sync::mpsc; -use crate::{lsp, Client}; +use crate::{lsp, Client, LanguageServerId}; enum Event { FileChanged { path: PathBuf, }, Register { - client_id: usize, + client_id: LanguageServerId, client: Weak, registration_id: String, options: lsp::DidChangeWatchedFilesRegistrationOptions, }, Unregister { - client_id: usize, + client_id: LanguageServerId, registration_id: String, }, RemoveClient { - client_id: usize, + client_id: LanguageServerId, }, } @@ -59,7 +59,7 @@ impl Handler { pub fn register( &self, - client_id: usize, + client_id: LanguageServerId, client: Weak, registration_id: String, options: lsp::DidChangeWatchedFilesRegistrationOptions, @@ -72,7 +72,7 @@ impl Handler { }); } - pub fn unregister(&self, client_id: usize, registration_id: String) { + pub fn unregister(&self, client_id: LanguageServerId, registration_id: String) { let _ = self.tx.send(Event::Unregister { client_id, registration_id, @@ -83,12 +83,12 @@ impl Handler { let _ = self.tx.send(Event::FileChanged { path }); } - pub fn remove_client(&self, client_id: usize) { + pub fn remove_client(&self, client_id: LanguageServerId) { let _ = self.tx.send(Event::RemoveClient { client_id }); } async fn run(mut rx: mpsc::UnboundedReceiver) { - let mut state: HashMap = HashMap::new(); + let mut state: HashMap = HashMap::new(); while let Some(event) = rx.recv().await { match event { Event::FileChanged { path } => { diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 4ce445aee..0a3c6a33d 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -5,6 +5,7 @@ pub mod jsonrpc; pub mod snippet; mod transport; +use arc_swap::ArcSwap; pub use client::Client; pub use futures_executor::block_on; pub use jsonrpc::Call; @@ -16,6 +17,7 @@ use helix_core::syntax::{ LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures, }; use helix_stdx::path; +use slotmap::SlotMap; use tokio::sync::mpsc::UnboundedReceiver; use std::{ @@ -27,8 +29,9 @@ use std::{ use thiserror::Error; use tokio_stream::wrappers::UnboundedReceiverStream; -pub type Result = core::result::Result; +pub type Result = core::result::Result; pub type LanguageServerName = String; +pub use helix_core::diagnostic::LanguageServerId; #[derive(Error, Debug)] pub enum Error { @@ -283,7 +286,8 @@ pub mod util { .chars_at(cursor) .skip(1) .take_while(|ch| chars::char_is_word(*ch)) - .count(); + .count() + + 1; } (start, end) } @@ -538,6 +542,16 @@ pub mod util { } else { return (0, 0, None); }; + + if start > end { + log::error!( + "Invalid LSP text edit start {:?} > end {:?}, discarding", + start, + end + ); + return (0, 0, None); + } + (start, end, replacement) }), ) @@ -639,38 +653,42 @@ impl Notification { #[derive(Debug)] pub struct Registry { - inner: HashMap>>, - syn_loader: Arc, - counter: usize, - pub incoming: SelectAll>, + inner: SlotMap>, + inner_by_name: HashMap>>, + syn_loader: Arc>, + pub incoming: SelectAll>, pub file_event_handler: file_event::Handler, } impl Registry { - pub fn new(syn_loader: Arc) -> Self { + pub fn new(syn_loader: Arc>) -> Self { Self { - inner: HashMap::new(), + inner: SlotMap::with_key(), + inner_by_name: HashMap::new(), syn_loader, - counter: 0, incoming: SelectAll::new(), file_event_handler: file_event::Handler::new(), } } - pub fn get_by_id(&self, id: usize) -> Option<&Client> { - self.inner - .values() - .flatten() - .find(|client| client.id() == id) - .map(|client| &**client) + pub fn get_by_id(&self, id: LanguageServerId) -> Option<&Arc> { + self.inner.get(id) } - pub fn remove_by_id(&mut self, id: usize) { + pub fn remove_by_id(&mut self, id: LanguageServerId) { + let Some(client) = self.inner.remove(id) else { + log::error!("client was already removed"); + return + }; self.file_event_handler.remove_client(id); - self.inner.retain(|_, language_servers| { - language_servers.retain(|ls| id != ls.id()); - !language_servers.is_empty() - }); + let instances = self + .inner_by_name + .get_mut(client.name()) + .expect("inner and inner_by_name must be synced"); + instances.retain(|ls| id != ls.id()); + if instances.is_empty() { + self.inner_by_name.remove(client.name()); + } } fn start_client( @@ -680,25 +698,28 @@ impl Registry { doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, - ) -> Result> { - let config = self - .syn_loader + ) -> Result, StartupError> { + let syn_loader = self.syn_loader.load(); + let config = syn_loader .language_server_configs() .get(&name) .ok_or_else(|| anyhow::anyhow!("Language server '{name}' not defined"))?; - let id = self.counter; - self.counter += 1; - let NewClient(client, incoming) = start_client( - id, - name, - ls_config, - config, - doc_path, - root_dirs, - enable_snippets, - )?; - self.incoming.push(UnboundedReceiverStream::new(incoming)); - Ok(client) + let id = self.inner.try_insert_with_key(|id| { + start_client( + id, + name, + ls_config, + config, + doc_path, + root_dirs, + enable_snippets, + ) + .map(|client| { + self.incoming.push(UnboundedReceiverStream::new(client.1)); + client.0 + }) + })?; + 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, @@ -715,41 +736,39 @@ impl Registry { .language_servers .iter() .filter_map(|LanguageServerFeatures { name, .. }| { - if self.inner.contains_key(name) { - let client = match self.start_client( - name.clone(), - language_config, - doc_path, - root_dirs, - enable_snippets, - ) { - Ok(client) => client, - error => return Some(error), - }; - let old_clients = self - .inner - .insert(name.clone(), vec![client.clone()]) - .unwrap(); - + if let Some(old_clients) = self.inner_by_name.remove(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; }); } - - Some(Ok(client)) - } else { - None } + 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()]); + + Some(Ok(client)) }) .collect() } pub fn stop(&mut self, name: &str) { - if let Some(clients) = self.inner.remove(name) { + if let Some(clients) = self.inner_by_name.remove(name) { for client in clients { self.file_event_handler.remove_client(client.id()); + self.inner.remove(client.id()); tokio::spawn(async move { let _ = client.force_shutdown().await; }); @@ -764,13 +783,13 @@ impl Registry { root_dirs: &'a [PathBuf], enable_snippets: bool, ) -> impl Iterator>)> + 'a { - language_config.language_servers.iter().map( + language_config.language_servers.iter().filter_map( move |LanguageServerFeatures { name, .. }| { - if let Some(clients) = self.inner.get(name) { + if let Some(clients) = self.inner_by_name.get(name) { if let Some((_, client)) = clients.iter().enumerate().find(|(i, client)| { client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0) }) { - return (name.to_owned(), Ok(client.clone())); + return Some((name.to_owned(), Ok(client.clone()))); } } match self.start_client( @@ -781,20 +800,21 @@ impl Registry { enable_snippets, ) { Ok(client) => { - self.inner + self.inner_by_name .entry(name.to_owned()) .or_default() .push(client.clone()); - (name.clone(), Ok(client)) + Some((name.clone(), Ok(client))) } - Err(err) => (name.to_owned(), Err(err)), + Err(StartupError::NoRequiredRootFound) => None, + Err(StartupError::Error(err)) => Some((name.to_owned(), Err(err))), } }, ) } pub fn iter_clients(&self) -> impl Iterator> { - self.inner.values().flatten() + self.inner.values() } } @@ -817,7 +837,7 @@ impl ProgressStatus { /// Acts as a container for progress reported by language servers. Each server /// has a unique id assigned at creation through [`Registry`]. This id is then used /// to store the progress in this map. -pub struct LspProgressMap(HashMap>); +pub struct LspProgressMap(HashMap>); impl LspProgressMap { pub fn new() -> Self { @@ -825,28 +845,35 @@ impl LspProgressMap { } /// Returns a map of all tokens corresponding to the language server with `id`. - pub fn progress_map(&self, id: usize) -> Option<&HashMap> { + pub fn progress_map( + &self, + id: LanguageServerId, + ) -> Option<&HashMap> { self.0.get(&id) } - pub fn is_progressing(&self, id: usize) -> bool { + pub fn is_progressing(&self, id: LanguageServerId) -> bool { self.0.get(&id).map(|it| !it.is_empty()).unwrap_or_default() } /// Returns last progress status for a given server with `id` and `token`. - pub fn progress(&self, id: usize, token: &lsp::ProgressToken) -> Option<&ProgressStatus> { + pub fn progress( + &self, + id: LanguageServerId, + token: &lsp::ProgressToken, + ) -> Option<&ProgressStatus> { self.0.get(&id).and_then(|values| values.get(token)) } /// Checks if progress `token` for server with `id` is created. - pub fn is_created(&mut self, id: usize, token: &lsp::ProgressToken) -> bool { + pub fn is_created(&mut self, id: LanguageServerId, token: &lsp::ProgressToken) -> bool { self.0 .get(&id) .map(|values| values.get(token).is_some()) .unwrap_or_default() } - pub fn create(&mut self, id: usize, token: lsp::ProgressToken) { + pub fn create(&mut self, id: LanguageServerId, token: lsp::ProgressToken) { self.0 .entry(id) .or_default() @@ -856,7 +883,7 @@ impl LspProgressMap { /// Ends the progress by removing the `token` from server with `id`, if removed returns the value. pub fn end_progress( &mut self, - id: usize, + id: LanguageServerId, token: &lsp::ProgressToken, ) -> Option { self.0.get_mut(&id).and_then(|vals| vals.remove(token)) @@ -865,7 +892,7 @@ impl LspProgressMap { /// Updates the progress of `token` for server with `id` to `status`, returns the value replaced or `None`. pub fn update( &mut self, - id: usize, + id: LanguageServerId, token: lsp::ProgressToken, status: lsp::WorkDoneProgress, ) -> Option { @@ -876,30 +903,68 @@ impl LspProgressMap { } } -struct NewClient(Arc, UnboundedReceiver<(usize, Call)>); +struct NewClient(Arc, UnboundedReceiver<(LanguageServerId, Call)>); + +enum StartupError { + NoRequiredRootFound, + Error(Error), +} + +impl> From for StartupError { + fn from(value: T) -> Self { + StartupError::Error(value.into()) + } +} /// start_client takes both a LanguageConfiguration and a LanguageServerConfiguration to ensure that /// it is only called when it makes sense. fn start_client( - id: usize, + id: LanguageServerId, name: String, config: &LanguageConfiguration, ls_config: &LanguageServerConfiguration, doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, -) -> Result { +) -> Result { + let (workspace, workspace_is_cwd) = helix_loader::find_workspace(); + let workspace = path::normalize(workspace); + let root = find_lsp_workspace( + doc_path + .and_then(|x| x.parent().and_then(|x| x.to_str())) + .unwrap_or("."), + &config.roots, + config.workspace_lsp_roots.as_deref().unwrap_or(root_dirs), + &workspace, + workspace_is_cwd, + ); + + // `root_uri` and `workspace_folder` can be empty in case there is no workspace + // `root_url` can not, use `workspace` as a fallback + let root_path = root.clone().unwrap_or_else(|| workspace.clone()); + let root_uri = root.and_then(|root| lsp::Url::from_file_path(root).ok()); + + if let Some(globset) = &ls_config.required_root_patterns { + if !root_path + .read_dir()? + .flatten() + .map(|entry| entry.file_name()) + .any(|entry| globset.is_match(entry)) + { + return Err(StartupError::NoRequiredRootFound); + } + } + let (client, incoming, initialize_notify) = Client::start( &ls_config.command, &ls_config.args, ls_config.config.clone(), ls_config.environment.clone(), - &config.roots, - config.workspace_lsp_roots.as_deref().unwrap_or(root_dirs), + root_path, + root_uri, id, name, ls_config.timeout, - doc_path, )?; let client = Arc::new(client); diff --git a/helix-lsp/src/transport.rs b/helix-lsp/src/transport.rs index f2f35d6ab..bd671abe1 100644 --- a/helix-lsp/src/transport.rs +++ b/helix-lsp/src/transport.rs @@ -1,4 +1,4 @@ -use crate::{jsonrpc, Error, Result}; +use crate::{jsonrpc, Error, LanguageServerId, Result}; use anyhow::Context; use log::{error, info}; use serde::{Deserialize, Serialize}; @@ -37,7 +37,7 @@ enum ServerMessage { #[derive(Debug)] pub struct Transport { - id: usize, + id: LanguageServerId, name: String, pending_requests: Mutex>>>, } @@ -47,10 +47,10 @@ impl Transport { server_stdout: BufReader, server_stdin: BufWriter, server_stderr: BufReader, - id: usize, + id: LanguageServerId, name: String, ) -> ( - UnboundedReceiver<(usize, jsonrpc::Call)>, + UnboundedReceiver<(LanguageServerId, jsonrpc::Call)>, UnboundedSender, Arc, ) { @@ -194,7 +194,7 @@ impl Transport { async fn process_server_message( &self, - client_tx: &UnboundedSender<(usize, jsonrpc::Call)>, + client_tx: &UnboundedSender<(LanguageServerId, jsonrpc::Call)>, msg: ServerMessage, language_server_name: &str, ) -> Result<()> { @@ -251,7 +251,7 @@ impl Transport { async fn recv( transport: Arc, mut server_stdout: BufReader, - client_tx: UnboundedSender<(usize, jsonrpc::Call)>, + client_tx: UnboundedSender<(LanguageServerId, jsonrpc::Call)>, ) { let mut recv_buffer = String::new(); loop { @@ -329,7 +329,7 @@ impl Transport { async fn send( transport: Arc, mut server_stdin: BufWriter, - client_tx: UnboundedSender<(usize, jsonrpc::Call)>, + client_tx: UnboundedSender<(LanguageServerId, jsonrpc::Call)>, mut client_rx: UnboundedReceiver, initialize_notify: Arc, ) { diff --git a/helix-stdx/Cargo.toml b/helix-stdx/Cargo.toml index e77f8b91f..f17e08854 100644 --- a/helix-stdx/Cargo.toml +++ b/helix-stdx/Cargo.toml @@ -16,6 +16,14 @@ dunce = "1.0" etcetera = "0.8" ropey = { version = "1.6.1", default-features = false } which = "6.0" +regex-cursor = "0.1.4" +bitflags = "2.4" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.52", features = ["Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } + +[target.'cfg(unix)'.dependencies] +rustix = { version = "0.38", features = ["fs"] } [dev-dependencies] -tempfile = "3.9" +tempfile = "3.10" diff --git a/helix-stdx/src/env.rs b/helix-stdx/src/env.rs index 90a0aee87..59aba0adc 100644 --- a/helix-stdx/src/env.rs +++ b/helix-stdx/src/env.rs @@ -42,8 +42,9 @@ pub fn binary_exists>(binary_name: T) -> bool { pub fn which>( binary_name: T, ) -> Result { - which::which(binary_name.as_ref()).map_err(|err| ExecutableNotFoundError { - command: binary_name.as_ref().to_string_lossy().into_owned(), + let binary_name = binary_name.as_ref(); + which::which(binary_name).map_err(|err| ExecutableNotFoundError { + command: binary_name.to_string_lossy().into_owned(), inner: err, }) } diff --git a/helix-stdx/src/faccess.rs b/helix-stdx/src/faccess.rs new file mode 100644 index 000000000..0270c1f8a --- /dev/null +++ b/helix-stdx/src/faccess.rs @@ -0,0 +1,459 @@ +//! From + +use std::io; +use std::path::Path; + +use bitflags::bitflags; + +// Licensed under MIT from faccess +bitflags! { + /// Access mode flags for `access` function to test for. + pub struct AccessMode: u8 { + /// Path exists + const EXISTS = 0b0001; + /// Path can likely be read + const READ = 0b0010; + /// Path can likely be written to + const WRITE = 0b0100; + /// Path can likely be executed + const EXECUTE = 0b1000; + } +} + +#[cfg(unix)] +mod imp { + use super::*; + + use rustix::fs::Access; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + pub fn access(p: &Path, mode: AccessMode) -> io::Result<()> { + let mut imode = Access::empty(); + + if mode.contains(AccessMode::EXISTS) { + imode |= Access::EXISTS; + } + + if mode.contains(AccessMode::READ) { + imode |= Access::READ_OK; + } + + if mode.contains(AccessMode::WRITE) { + imode |= Access::WRITE_OK; + } + + if mode.contains(AccessMode::EXECUTE) { + imode |= Access::EXEC_OK; + } + + rustix::fs::access(p, imode)?; + Ok(()) + } + + fn chown(p: &Path, uid: Option, gid: Option) -> 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) }); + rustix::fs::chown(p, uid, gid)?; + Ok(()) + } + + pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { + let from_meta = std::fs::metadata(from)?; + let to_meta = std::fs::metadata(to)?; + let from_gid = from_meta.gid(); + let to_gid = to_meta.gid(); + + let mut perms = from_meta.permissions(); + perms.set_mode(perms.mode() & 0o0777); + if from_gid != to_gid && chown(to, None, Some(from_gid)).is_err() { + let new_perms = (perms.mode() & 0o0707) | ((perms.mode() & 0o07) << 3); + perms.set_mode(new_perms); + } + + std::fs::set_permissions(to, perms)?; + + Ok(()) + } +} + +// Licensed under MIT from faccess except for `chown`, `copy_metadata` and `is_acl_inherited` +#[cfg(windows)] +mod imp { + + use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, ERROR_SUCCESS, HANDLE, PSID}; + use windows_sys::Win32::Security::Authorization::{ + GetNamedSecurityInfoW, SetNamedSecurityInfoW, SE_FILE_OBJECT, + }; + use windows_sys::Win32::Security::{ + AccessCheck, AclSizeInformation, GetAce, GetAclInformation, GetSidIdentifierAuthority, + ImpersonateSelf, IsValidAcl, IsValidSid, MapGenericMask, RevertToSelf, + SecurityImpersonation, ACCESS_ALLOWED_CALLBACK_ACE, ACL, ACL_SIZE_INFORMATION, + DACL_SECURITY_INFORMATION, GENERIC_MAPPING, GROUP_SECURITY_INFORMATION, INHERITED_ACE, + LABEL_SECURITY_INFORMATION, OBJECT_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, + PRIVILEGE_SET, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SID_IDENTIFIER_AUTHORITY, TOKEN_DUPLICATE, TOKEN_QUERY, + }; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ACCESS_RIGHTS, FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, + FILE_GENERIC_WRITE, + }; + use windows_sys::Win32::System::Threading::{GetCurrentThread, OpenThreadToken}; + + use super::*; + + use std::ffi::c_void; + + use std::os::windows::{ffi::OsStrExt, fs::OpenOptionsExt}; + + struct SecurityDescriptor { + sd: PSECURITY_DESCRIPTOR, + owner: PSID, + group: PSID, + dacl: *mut ACL, + } + + impl Drop for SecurityDescriptor { + fn drop(&mut self) { + if !self.sd.is_null() { + unsafe { + LocalFree(self.sd); + } + } + } + } + + impl SecurityDescriptor { + fn for_path(p: &Path) -> io::Result { + let path = std::fs::canonicalize(p)?; + let pathos = path.into_os_string(); + let mut pathw: Vec = Vec::with_capacity(pathos.len() + 1); + pathw.extend(pathos.encode_wide()); + pathw.push(0); + + let mut sd = std::ptr::null_mut(); + let mut owner = std::ptr::null_mut(); + let mut group = std::ptr::null_mut(); + let mut dacl = std::ptr::null_mut(); + + let err = unsafe { + GetNamedSecurityInfoW( + pathw.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION + | GROUP_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | LABEL_SECURITY_INFORMATION, + &mut owner, + &mut group, + &mut dacl, + std::ptr::null_mut(), + &mut sd, + ) + }; + + if err == ERROR_SUCCESS { + Ok(SecurityDescriptor { + sd, + owner, + group, + dacl, + }) + } else { + Err(io::Error::last_os_error()) + } + } + + fn is_acl_inherited(&self) -> bool { + let mut acl_info: ACL_SIZE_INFORMATION = unsafe { ::core::mem::zeroed() }; + let acl_info_ptr: *mut c_void = &mut acl_info as *mut _ as *mut c_void; + let mut ace: ACCESS_ALLOWED_CALLBACK_ACE = unsafe { ::core::mem::zeroed() }; + + unsafe { + GetAclInformation( + self.dacl, + acl_info_ptr, + std::mem::size_of_val(&acl_info) as u32, + AclSizeInformation, + ) + }; + + for i in 0..acl_info.AceCount { + let mut ptr = &mut ace as *mut _ as *mut c_void; + unsafe { GetAce(self.dacl, i, &mut ptr) }; + if (ace.Header.AceFlags as u32 & INHERITED_ACE) != 0 { + return true; + } + } + + false + } + + fn descriptor(&self) -> &PSECURITY_DESCRIPTOR { + &self.sd + } + + fn owner(&self) -> &PSID { + &self.owner + } + } + + struct ThreadToken(HANDLE); + impl Drop for ThreadToken { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + + impl ThreadToken { + fn new() -> io::Result { + unsafe { + if ImpersonateSelf(SecurityImpersonation) == 0 { + return Err(io::Error::last_os_error()); + } + + let token: *mut HANDLE = std::ptr::null_mut(); + let err = + OpenThreadToken(GetCurrentThread(), TOKEN_DUPLICATE | TOKEN_QUERY, 0, token); + + RevertToSelf(); + + if err == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Self(*token)) + } + } + + fn as_handle(&self) -> &HANDLE { + &self.0 + } + } + + // Based roughly on Tcl's NativeAccess() + // https://github.com/tcltk/tcl/blob/2ee77587e4dc2150deb06b48f69db948b4ab0584/win/tclWinFile.c + fn eaccess(p: &Path, mut mode: FILE_ACCESS_RIGHTS) -> io::Result<()> { + let md = p.metadata()?; + + if !md.is_dir() { + // Read Only is ignored for directories + if mode & FILE_GENERIC_WRITE == FILE_GENERIC_WRITE && md.permissions().readonly() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "File is read only", + )); + } + + // If it doesn't have the correct extension it isn't executable + if mode & FILE_GENERIC_EXECUTE == FILE_GENERIC_EXECUTE { + if let Some(ext) = p.extension().and_then(|s| s.to_str()) { + match ext { + "exe" | "com" | "bat" | "cmd" => (), + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "File not executable", + )) + } + } + } + } + + return std::fs::OpenOptions::new() + .access_mode(mode) + .open(p) + .map(|_| ()); + } + + let sd = SecurityDescriptor::for_path(p)?; + + // Unmapped Samba users are assigned a top level authority of 22 + // ACL tests are likely to be misleading + const SAMBA_UNMAPPED: SID_IDENTIFIER_AUTHORITY = SID_IDENTIFIER_AUTHORITY { + Value: [0, 0, 0, 0, 0, 22], + }; + unsafe { + let owner = sd.owner(); + if IsValidSid(*owner) != 0 + && (*GetSidIdentifierAuthority(*owner)).Value == SAMBA_UNMAPPED.Value + { + return Ok(()); + } + } + + let token = ThreadToken::new()?; + + let mut privileges: PRIVILEGE_SET = unsafe { std::mem::zeroed() }; + let mut granted_access: u32 = 0; + let mut privileges_length = std::mem::size_of::() as u32; + let mut result = 0; + + let mut mapping = GENERIC_MAPPING { + GenericRead: FILE_GENERIC_READ, + GenericWrite: FILE_GENERIC_WRITE, + GenericExecute: FILE_GENERIC_EXECUTE, + GenericAll: FILE_ALL_ACCESS, + }; + + unsafe { MapGenericMask(&mut mode, &mut mapping) }; + + if unsafe { + AccessCheck( + *sd.descriptor(), + *token.as_handle(), + mode, + &mut mapping, + &mut privileges, + &mut privileges_length, + &mut granted_access, + &mut result, + ) + } != 0 + { + if result == 0 { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Permission Denied", + )) + } else { + Ok(()) + } + } else { + Err(io::Error::last_os_error()) + } + } + + pub fn access(p: &Path, mode: AccessMode) -> io::Result<()> { + let mut imode = 0; + + if mode.contains(AccessMode::READ) { + imode |= FILE_GENERIC_READ; + } + + if mode.contains(AccessMode::WRITE) { + imode |= FILE_GENERIC_WRITE; + } + + if mode.contains(AccessMode::EXECUTE) { + imode |= FILE_GENERIC_EXECUTE; + } + + if imode == 0 { + if p.exists() { + Ok(()) + } else { + Err(io::Error::new(io::ErrorKind::NotFound, "Not Found")) + } + } else { + eaccess(p, imode) + } + } + + fn chown(p: &Path, sd: SecurityDescriptor) -> io::Result<()> { + let path = std::fs::canonicalize(p)?; + let pathos = path.as_os_str(); + let mut pathw = Vec::with_capacity(pathos.len() + 1); + pathw.extend(pathos.encode_wide()); + pathw.push(0); + + let mut owner = std::ptr::null_mut(); + let mut group = std::ptr::null_mut(); + let mut dacl = std::ptr::null(); + + let mut si = OBJECT_SECURITY_INFORMATION::default(); + if unsafe { IsValidSid(sd.owner) } != 0 { + si |= OWNER_SECURITY_INFORMATION; + owner = sd.owner; + } + + if unsafe { IsValidSid(sd.group) } != 0 { + si |= GROUP_SECURITY_INFORMATION; + group = sd.group; + } + + if unsafe { IsValidAcl(sd.dacl) } != 0 { + si |= DACL_SECURITY_INFORMATION; + if !sd.is_acl_inherited() { + si |= PROTECTED_DACL_SECURITY_INFORMATION; + } + dacl = sd.dacl as *const _; + } + + let err = unsafe { + SetNamedSecurityInfoW( + pathw.as_ptr(), + SE_FILE_OBJECT, + si, + owner, + group, + dacl, + std::ptr::null(), + ) + }; + + if err == ERROR_SUCCESS { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + + pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { + let sd = SecurityDescriptor::for_path(from)?; + chown(to, sd)?; + + let meta = std::fs::metadata(from)?; + let perms = meta.permissions(); + + std::fs::set_permissions(to, perms)?; + + Ok(()) + } +} + +// Licensed under MIT from faccess except for `copy_metadata` +#[cfg(not(any(unix, windows)))] +mod imp { + use super::*; + + pub fn access(p: &Path, mode: AccessMode) -> io::Result<()> { + if mode.contains(AccessMode::WRITE) { + if std::fs::metadata(p)?.permissions().readonly() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Path is read only", + )); + } else { + return Ok(()); + } + } + + if p.exists() { + Ok(()) + } else { + Err(io::Error::new(io::ErrorKind::NotFound, "Path not found")) + } + } + + pub fn copy_metadata(from: &path, to: &Path) -> io::Result<()> { + let meta = std::fs::metadata(from)?; + let perms = meta.permissions(); + std::fs::set_permissions(to, perms)?; + + Ok(()) + } +} + +pub fn readonly(p: &Path) -> bool { + match imp::access(p, AccessMode::WRITE) { + Ok(_) => false, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => true, + } +} + +pub fn copy_metadata(from: &Path, to: &Path) -> io::Result<()> { + imp::copy_metadata(from, to) +} diff --git a/helix-stdx/src/lib.rs b/helix-stdx/src/lib.rs index 68fe3ec37..19602c205 100644 --- a/helix-stdx/src/lib.rs +++ b/helix-stdx/src/lib.rs @@ -1,3 +1,4 @@ pub mod env; +pub mod faccess; pub mod path; pub mod rope; diff --git a/helix-stdx/src/path.rs b/helix-stdx/src/path.rs index 5746657c3..968596a70 100644 --- a/helix-stdx/src/path.rs +++ b/helix-stdx/src/path.rs @@ -1,37 +1,52 @@ pub use etcetera::home_dir; -use std::path::{Component, Path, PathBuf}; +use std::{ + borrow::Cow, + ffi::OsString, + path::{Component, Path, PathBuf, MAIN_SEPARATOR_STR}, +}; use crate::env::current_working_dir; /// Replaces users home directory from `path` with tilde `~` if the directory /// is available, otherwise returns the path unchanged. -pub fn fold_home_dir(path: &Path) -> PathBuf { +pub fn fold_home_dir<'a, P>(path: P) -> Cow<'a, Path> +where + P: Into>, +{ + let path = path.into(); if let Ok(home) = home_dir() { if let Ok(stripped) = path.strip_prefix(&home) { - return PathBuf::from("~").join(stripped); + let mut path = OsString::with_capacity(2 + stripped.as_os_str().len()); + path.push("~"); + path.push(MAIN_SEPARATOR_STR); + path.push(stripped); + return Cow::Owned(PathBuf::from(path)); } } - path.to_path_buf() + path } /// Expands tilde `~` into users home directory if available, otherwise returns the path /// unchanged. The tilde will only be expanded when present as the first component of the path /// and only slash follows it. -pub fn expand_tilde(path: impl AsRef) -> PathBuf { - let path = path.as_ref(); - let mut components = path.components().peekable(); - if let Some(Component::Normal(c)) = components.peek() { - if c == &"~" { - if let Ok(home) = home_dir() { - // it's ok to unwrap, the path starts with `~` - return home.join(path.strip_prefix("~").unwrap()); +pub fn expand_tilde<'a, P>(path: P) -> Cow<'a, Path> +where + P: Into>, +{ + let path = path.into(); + let mut components = path.components(); + if let Some(Component::Normal(c)) = components.next() { + if c == "~" { + if let Ok(mut buf) = home_dir() { + buf.push(components); + return Cow::Owned(buf); } } } - path.to_path_buf() + path } /// Normalize a path without resolving symlinks. @@ -109,9 +124,9 @@ pub fn normalize(path: impl AsRef) -> PathBuf { /// This function is used instead of [`std::fs::canonicalize`] because we don't want to verify /// here if the path exists, just normalize it's components. pub fn canonicalize(path: impl AsRef) -> PathBuf { - let path = expand_tilde(path); + let path = expand_tilde(path.as_ref()); let path = if path.is_relative() { - current_working_dir().join(path) + Cow::Owned(current_working_dir().join(path)) } else { path }; @@ -119,18 +134,21 @@ pub fn canonicalize(path: impl AsRef) -> PathBuf { normalize(path) } -pub fn get_relative_path(path: impl AsRef) -> PathBuf { - let path = PathBuf::from(path.as_ref()); - let path = if path.is_absolute() { +pub fn get_relative_path<'a, P>(path: P) -> Cow<'a, Path> +where + P: Into>, +{ + let path = path.into(); + if path.is_absolute() { let cwdir = normalize(current_working_dir()); - normalize(&path) - .strip_prefix(cwdir) - .map(PathBuf::from) - .unwrap_or(path) - } else { - path - }; - fold_home_dir(&path) + if let Ok(stripped) = normalize(&path).strip_prefix(cwdir) { + return Cow::Owned(PathBuf::from(stripped)); + } + + return fold_home_dir(path); + } + + path } /// Returns a truncated filepath where the basepart of the path is reduced to the first @@ -164,22 +182,50 @@ pub fn get_relative_path(path: impl AsRef) -> PathBuf { /// pub fn get_truncated_path(path: impl AsRef) -> PathBuf { let cwd = current_working_dir(); - let path = path - .as_ref() - .strip_prefix(cwd) - .unwrap_or_else(|_| path.as_ref()); + let path = path.as_ref(); + let path = path.strip_prefix(cwd).unwrap_or(path); let file = path.file_name().unwrap_or_default(); let base = path.parent().unwrap_or_else(|| Path::new("")); - let mut ret = PathBuf::new(); + let mut ret = PathBuf::with_capacity(file.len()); + // A char can't be directly pushed to a PathBuf + let mut first_char_buffer = String::new(); for d in base { - ret.push( - d.to_string_lossy() - .chars() - .next() - .unwrap_or_default() - .to_string(), - ); + let Some(first_char) = d.to_string_lossy().chars().next() else { + break; + }; + first_char_buffer.push(first_char); + ret.push(&first_char_buffer); + first_char_buffer.clear(); } ret.push(file); ret } + +#[cfg(test)] +mod tests { + use std::{ + ffi::OsStr, + path::{Component, Path}, + }; + + use crate::path; + + #[test] + fn expand_tilde() { + for path in ["~", "~/foo"] { + let expanded = path::expand_tilde(Path::new(path)); + + let tilde = Component::Normal(OsStr::new("~")); + + let mut component_count = 0; + for component in expanded.components() { + // No tilde left. + assert_ne!(component, tilde); + component_count += 1; + } + + // The path was at least expanded to something. + assert_ne!(component_count, 0); + } + } +} diff --git a/helix-stdx/src/rope.rs b/helix-stdx/src/rope.rs index 4ee39d4a8..2695555e3 100644 --- a/helix-stdx/src/rope.rs +++ b/helix-stdx/src/rope.rs @@ -1,11 +1,42 @@ +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::RopeSlice; -pub trait RopeSliceExt: Sized { +pub trait RopeSliceExt<'a>: Sized { fn ends_with(self, text: &str) -> bool; fn starts_with(self, text: &str) -> bool; + fn regex_input(self) -> RegexInput>; + fn regex_input_at_bytes>( + self, + byte_range: R, + ) -> RegexInput>; + fn regex_input_at>(self, char_range: R) -> RegexInput>; + fn first_non_whitespace_char(self) -> Option; + fn last_non_whitespace_char(self) -> Option; + /// 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. + /// + /// # 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); + /// } + /// ``` + fn byte_to_next_char(self, byte_idx: usize) -> usize; } -impl RopeSliceExt for RopeSlice<'_> { +impl<'a> RopeSliceExt<'a> for RopeSlice<'a> { fn ends_with(self, text: &str) -> bool { let len = self.len_bytes(); if len < text.len() { @@ -23,4 +54,87 @@ impl RopeSliceExt for RopeSlice<'_> { self.get_byte_slice(..len - text.len()) .map_or(false, |start| start == text) } + + fn regex_input(self) -> RegexInput> { + RegexInput::new(self) + } + + fn regex_input_at>(self, char_range: R) -> RegexInput> { + let start_bound = match char_range.start_bound() { + Bound::Included(&val) => Bound::Included(self.char_to_byte(val)), + Bound::Excluded(&val) => Bound::Excluded(self.char_to_byte(val)), + Bound::Unbounded => Bound::Unbounded, + }; + let end_bound = match char_range.end_bound() { + Bound::Included(&val) => Bound::Included(self.char_to_byte(val)), + Bound::Excluded(&val) => Bound::Excluded(self.char_to_byte(val)), + Bound::Unbounded => Bound::Unbounded, + }; + self.regex_input_at_bytes((start_bound, end_bound)) + } + fn regex_input_at_bytes>( + self, + byte_range: R, + ) -> RegexInput> { + let input = match byte_range.start_bound() { + Bound::Included(&pos) | Bound::Excluded(&pos) => { + RegexInput::new(RopeyCursor::at(self, pos)) + } + Bound::Unbounded => RegexInput::new(self), + }; + input.range(byte_range) + } + fn first_non_whitespace_char(self) -> Option { + self.chars().position(|ch| !ch.is_whitespace()) + } + fn last_non_whitespace_char(self) -> Option { + self.chars_at(self.len_chars()) + .reversed() + .position(|ch| !ch.is_whitespace()) + .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 + } +} + +// copied from std +#[inline] +const fn is_utf8_char_boundary(b: u8) -> bool { + // This is bit magic equivalent to: b < 128 || b >= 192 + (b as i8) >= -0x40 +} + +#[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 + ); + } + } + } } diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 6623026e2..8d41124a8 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -42,7 +42,8 @@ crossterm = { version = "0.27", features = ["event-stream"] } signal-hook = "0.3" tokio-stream = "0.1" futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false } -arc-swap = { version = "1.6.0" } +arc-swap = { version = "1.7.1" } +termini = "1" # Logging fern = "0.6" @@ -53,16 +54,16 @@ log = "0.4" nucleo.workspace = true ignore = "0.4" # markdown doc rendering -pulldown-cmark = { version = "0.9", default-features = false } +pulldown-cmark = { version = "0.10", default-features = false } # file type detection content_inspector = "0.2.4" # opening URLs -open = "5.0.1" +open = "5.1.2" url = "2.5.0" # config -toml = "0.7" +toml = "0.8" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } @@ -76,7 +77,7 @@ steel-core = { workspace = true, optional = true } [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.152" +libc = "0.2.153" [target.'cfg(target_os = "macos")'.dependencies] crossterm = { version = "0.27", features = ["event-stream", "use-dev-tty"] } @@ -86,5 +87,5 @@ helix-loader = { path = "../helix-loader" } [dev-dependencies] smallvec = "1.13" -indoc = "2.0.4" -tempfile = "3.9.0" +indoc = "2.0.5" +tempfile = "3.10.1" diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index db1b72f3c..aa86f421a 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -4,7 +4,7 @@ use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Selection}; use helix_lsp::{ lsp::{self, notification::Notification}, util::lsp_range_to_range, - LspProgressMap, + LanguageServerId, LspProgressMap, }; use helix_stdx::path::get_relative_path; use helix_view::{ @@ -66,7 +66,7 @@ pub struct Application { #[allow(dead_code)] theme_loader: Arc, #[allow(dead_code)] - syn_loader: Arc, + syn_loader: Arc>, signals: Signals, jobs: Jobs, @@ -96,11 +96,7 @@ fn setup_integration_logging() { } impl Application { - pub fn new( - args: Args, - config: Config, - syn_loader_conf: syntax::Configuration, - ) -> Result { + pub fn new(args: Args, config: Config, lang_loader: syntax::Loader) -> Result { #[cfg(feature = "integration")] setup_integration_logging(); @@ -126,7 +122,7 @@ impl Application { }) .unwrap_or_else(|| theme_loader.default_theme(true_color)); - let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); + let syn_loader = Arc::new(ArcSwap::from_pointee(lang_loader)); #[cfg(not(feature = "integration"))] let backend = CrosstermBackend::new(stdout(), &config.editor); @@ -415,10 +411,9 @@ impl Application { /// refresh language config after config change fn refresh_language_config(&mut self) -> Result<(), Error> { - let syntax_config = helix_core::config::user_syntax_loader() - .map_err(|err| anyhow::anyhow!("Failed to load language config: {}", err))?; + let lang_loader = helix_core::config::user_lang_loader()?; - self.syn_loader = std::sync::Arc::new(syntax::Loader::new(syntax_config)); + self.syn_loader.store(Arc::new(lang_loader)); self.editor.syn_loader = self.syn_loader.clone(); for document in self.editor.documents.values_mut() { document.detect_language(self.syn_loader.clone()); @@ -681,7 +676,7 @@ impl Application { pub async fn handle_language_server_message( &mut self, call: helix_lsp::Call, - server_id: usize, + server_id: LanguageServerId, ) { use helix_lsp::{Call, MethodCall, Notification}; @@ -750,7 +745,7 @@ impl Application { } Notification::PublishDiagnostics(mut params) => { let path = match params.uri.to_file_path() { - Ok(path) => path, + Ok(path) => helix_stdx::path::normalize(&path), Err(_) => { log::error!("Unsupported file URI: {}", params.uri); return; @@ -779,9 +774,7 @@ impl Application { let lang_conf = doc.language.clone(); if let Some(lang_conf) = &lang_conf { - if let Some(old_diagnostics) = - self.editor.diagnostics.get(¶ms.uri) - { + if let Some(old_diagnostics) = self.editor.diagnostics.get(&path) { 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 @@ -814,7 +807,7 @@ impl Application { // 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(params.uri) { + let diagnostics = match self.editor.diagnostics.entry(path) { 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 @@ -1058,12 +1051,7 @@ impl Application { Ok(json!(result)) } Ok(MethodCall::RegisterCapability(params)) => { - if let Some(client) = self - .editor - .language_servers - .iter_clients() - .find(|client| client.id() == server_id) - { + if let Some(client) = self.editor.language_servers.get_by_id(server_id) { for reg in params.registrations { match reg.method.as_str() { lsp::notification::DidChangeWatchedFiles::METHOD => { diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 6254b8228..f0e95aba7 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -4,17 +4,28 @@ pub(crate) mod lsp; pub(crate) mod typed; pub use dap::*; -use helix_vcs::Hunk; +use helix_event::status; +use helix_stdx::{ + path::expand_tilde, + rope::{self, RopeSliceExt}, +}; +use helix_vcs::{FileChange, Hunk}; pub use lsp::*; pub use engine::ScriptingEngine; -use tui::widgets::Row; +use tui::{ + text::Span, + widgets::{Cell, Row}, +}; pub use typed::*; use helix_core::{ - char_idx_at_visual_offset, comment, + char_idx_at_visual_offset, + chars::char_is_word, + comment, doc_formatter::TextFormat, - encoding, find_first_non_whitespace_char, find_workspace, graphemes, + encoding, find_workspace, + graphemes::{self, next_grapheme_boundary, RevRopeGraphemes}, history::UndoKind, increment, indent, indent::IndentStyle, @@ -22,16 +33,15 @@ use helix_core::{ match_brackets, movement::{self, move_vertically_visual, Direction}, object, pos_at_coords, - regex::{self, Regex, RegexBuilder}, + regex::{self, Regex}, search::{self, CharMatcher}, selection, shellwords, surround, - syntax::LanguageServerFeature, - text_annotations::TextAnnotations, + syntax::{BlockCommentToken, LanguageServerFeature}, + text_annotations::{Overlay, TextAnnotations}, textobject, - tree_sitter::Node, unicode::width::UnicodeWidthChar, visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes, - RopeReader, RopeSlice, Selection, SmallVec, Tendril, Transaction, + RopeReader, RopeSlice, Selection, SmallVec, Syntax, Tendril, Transaction, }; use helix_view::{ document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, @@ -39,6 +49,7 @@ use helix_view::{ info::Info, input::KeyEvent, keyboard::KeyCode, + theme::Style, tree, view::View, Document, DocumentId, Editor, ViewId, @@ -54,11 +65,12 @@ use crate::{ filter_picker_entry, job::Callback, keymap::ReverseKeymap, - ui::{self, overlay::overlaid, Picker, Popup, Prompt, PromptEvent}, + ui::{self, menu::Item, overlay::overlaid, Picker, Popup, Prompt, PromptEvent}, }; use crate::job::{self, Jobs}; use std::{ + cmp::Ordering, collections::{HashMap, HashSet}, fmt, future::Future, @@ -283,6 +295,10 @@ impl MappableCommand { page_down, "Move page down", half_page_up, "Move half page up", half_page_down, "Move half page down", + page_cursor_up, "Move page and cursor up", + page_cursor_down, "Move page and cursor down", + page_cursor_half_up, "Move page and cursor half up", + page_cursor_half_down, "Move page and cursor half down", select_all, "Select whole document", select_regex, "Select all regex matches inside selections", split_selection, "Split selections on regex matches", @@ -301,6 +317,8 @@ impl MappableCommand { extend_line, "Select current line, if already selected, extend to another line based on the anchor", extend_line_below, "Select current line, if already selected, extend to next line", extend_line_above, "Select current line, if already selected, extend to previous line", + select_line_above, "Select current line, if already selected, extend or shrink line above based on the anchor", + select_line_below, "Select current line, if already selected, extend or shrink line below based on the anchor", extend_to_line_bounds, "Extend selection to line bounds", shrink_to_line_bounds, "Shrink selection to line bounds", delete_selection, "Delete selection", @@ -320,6 +338,7 @@ impl MappableCommand { buffer_picker, "Open buffer picker", jumplist_picker, "Open jumplist picker", symbol_picker, "Open symbol picker", + changed_file_picker, "Open changed file picker", select_references_to_symbol_under_cursor, "Select symbol references", workspace_symbol_picker, "Open workspace symbol picker", diagnostics_picker, "Open diagnostic picker", @@ -416,6 +435,8 @@ impl MappableCommand { completion, "Invoke completion popup", hover, "Show docs for item under cursor", toggle_comments, "Comment/uncomment selections", + toggle_line_comments, "Line comment/uncomment selections", + toggle_block_comments, "Block comment/uncomment selections", rotate_selections_forward, "Rotate selections forward", rotate_selections_backward, "Rotate selections backward", rotate_selection_contents_forward, "Rotate selection contents forward", @@ -423,8 +444,10 @@ impl MappableCommand { reverse_selection_contents, "Reverse selections contents", expand_selection, "Expand selection to parent syntax node", shrink_selection, "Shrink selection to previously expanded syntax node", - select_next_sibling, "Select next sibling in syntax tree", - select_prev_sibling, "Select previous sibling in syntax tree", + select_next_sibling, "Select next sibling in the syntax tree", + select_prev_sibling, "Select previous sibling the in syntax tree", + select_all_siblings, "Select all siblings of the current node", + select_all_children, "Select all children of the current node", jump_forward, "Jump forward on jumplist", jump_backward, "Jump backward on jumplist", save_selection, "Save current selection to jumplist", @@ -469,6 +492,8 @@ impl MappableCommand { goto_prev_comment, "Goto previous comment", goto_next_test, "Goto next test", goto_prev_test, "Goto previous test", + goto_next_entry, "Goto next pairing", + goto_prev_entry, "Goto previous pairing", goto_next_paragraph, "Goto next paragraph", goto_prev_paragraph, "Goto previous paragraph", dap_launch, "Launch debug target", @@ -499,6 +524,8 @@ impl MappableCommand { record_macro, "Record macro", replay_macro, "Replay macro", command_palette, "Open command palette", + goto_word, "Jump to a two-character label", + extend_to_word, "Extend to a two-character label", ); } @@ -625,6 +652,7 @@ fn move_impl(cx: &mut Context, move_fn: MoveFn, dir: Direction, behaviour: Movem &mut annotations, ) }); + drop(annotations); doc.set_selection(view.id, selection); } @@ -788,28 +816,29 @@ fn goto_line_start(cx: &mut Context) { } fn goto_next_buffer(cx: &mut Context) { - goto_buffer(cx.editor, Direction::Forward); + goto_buffer(cx.editor, Direction::Forward, cx.count()); } fn goto_previous_buffer(cx: &mut Context) { - goto_buffer(cx.editor, Direction::Backward); + goto_buffer(cx.editor, Direction::Backward, cx.count()); } -fn goto_buffer(editor: &mut Editor, direction: Direction) { +fn goto_buffer(editor: &mut Editor, direction: Direction, count: usize) { let current = view!(editor).doc; let id = match direction { Direction::Forward => { let iter = editor.documents.keys(); - let mut iter = iter.skip_while(|id| *id != ¤t); - iter.next(); // skip current item - iter.next().or_else(|| editor.documents.keys().next()) + // skip 'count' times past current buffer + iter.cycle().skip_while(|id| *id != ¤t).nth(count) } Direction::Backward => { let iter = editor.documents.keys(); - let mut iter = iter.rev().skip_while(|id| *id != ¤t); - iter.next(); // skip current item - iter.next().or_else(|| editor.documents.keys().next_back()) + // skip 'count' times past current buffer + iter.rev() + .cycle() + .skip_while(|id| *id != ¤t) + .nth(count) } } .unwrap(); @@ -834,7 +863,7 @@ fn kill_to_line_start(cx: &mut Context) { let head = if anchor == first_char && line != 0 { // select until previous line line_end_char_index(&text, line - 1) - } else if let Some(pos) = find_first_non_whitespace_char(text.line(line)) { + } else if let Some(pos) = text.line(line).first_non_whitespace_char() { if first_char + pos < anchor { // select until first non-blank in line if cursor is after it first_char + pos @@ -896,7 +925,7 @@ fn goto_first_nonwhitespace_impl(view: &mut View, doc: &mut Document, movement: let selection = doc.selection(view.id).clone().transform(|range| { let line = range.cursor_line(text); - if let Some(pos) = find_first_non_whitespace_char(text.line(line)) { + if let Some(pos) = text.line(line).first_non_whitespace_char() { let pos = pos + text.line_to_char(line); range.put_cursor(text, pos, movement == Movement::Extend) } else { @@ -1190,25 +1219,51 @@ fn goto_file_impl(cx: &mut Context, action: Action) { let primary = selections.primary(); // Checks whether there is only one selection with a width of 1 if selections.len() == 1 && primary.len() == 1 { - let count = cx.count(); - let text_slice = text.slice(..); - // In this case it selects the WORD under the cursor - let current_word = textobject::textobject_word( - text_slice, - primary, - textobject::TextObject::Inside, - count, - true, - ); - // Trims some surrounding chars so that the actual file is opened. - let surrounding_chars: &[_] = &['\'', '"', '(', ')']; paths.clear(); - paths.push( - current_word - .fragment(text_slice) - .trim_matches(surrounding_chars) - .to_string(), - ); + + let is_valid_path_char = |c: &char| { + #[cfg(target_os = "windows")] + let valid_chars = &[ + '@', '/', '\\', '.', '-', '_', '+', '#', '$', '%', '{', '}', '[', ']', ':', '!', + '~', '=', + ]; + #[cfg(not(target_os = "windows"))] + let valid_chars = &['@', '/', '.', '-', '_', '+', '#', '$', '%', '~', '=', ':']; + + valid_chars.contains(c) || c.is_alphabetic() || c.is_numeric() + }; + + let cursor_pos = primary.cursor(text.slice(..)); + let pre_cursor_pos = cursor_pos.saturating_sub(1); + let post_cursor_pos = cursor_pos + 1; + let start_pos = if is_valid_path_char(&text.char(cursor_pos)) { + cursor_pos + } else if is_valid_path_char(&text.char(pre_cursor_pos)) { + pre_cursor_pos + } else { + post_cursor_pos + }; + + let prefix_len = text + .chars_at(start_pos) + .reversed() + .take_while(is_valid_path_char) + .count(); + + let postfix_len = text + .chars_at(start_pos) + .take_while(is_valid_path_char) + .count(); + + let path: Cow = text + .slice((start_pos - prefix_len)..(start_pos + postfix_len)) + .into(); + log::debug!("Goto file path: {}", path); + + match expand_tilde(PathBuf::from(path.as_ref())).to_str() { + Some(path) => paths.push(path.to_string()), + None => cx.editor.set_error("Couldn't get string out of path."), + }; } for sel in paths { @@ -1218,7 +1273,8 @@ fn goto_file_impl(cx: &mut Context, action: Action) { } if let Ok(url) = Url::parse(p) { - return open_url(cx, url, action); + open_url(cx, url, action); + continue; } let path = &rel_path.join(p); @@ -1625,7 +1681,7 @@ fn switch_to_lowercase(cx: &mut Context) { }); } -pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { +pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor: bool) { use Direction::*; let config = cx.editor.config(); let (view, doc) = current!(cx.editor); @@ -1645,7 +1701,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { let doc_text = doc.text().slice(..); let viewport = view.inner_area(doc); let text_fmt = doc.text_format(viewport.width, None); - let annotations = view.text_annotations(doc, None); + let mut annotations = view.text_annotations(&*doc, None); (view.offset.anchor, view.offset.vertical_offset) = char_idx_at_visual_offset( doc_text, view.offset.anchor, @@ -1655,6 +1711,30 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { &annotations, ); + if sync_cursor { + let movement = match cx.editor.mode { + Mode::Select => Movement::Extend, + _ => Movement::Move, + }; + // TODO: When inline diagnostics gets merged- 1. move_vertically_visual removes + // line annotations/diagnostics so the cursor may jump further than the view. + // 2. If the cursor lands on a complete line of virtual text, the cursor will + // jump a different distance than the view. + let selection = doc.selection(view.id).clone().transform(|range| { + move_vertically_visual( + doc_text, + range, + direction, + offset.unsigned_abs(), + movement, + &text_fmt, + &mut annotations, + ) + }); + doc.set_selection(view.id, selection); + return; + } + let mut head; match direction { Forward => { @@ -1699,31 +1779,56 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { let mut sel = doc.selection(view.id).clone(); let idx = sel.primary_index(); sel = sel.replace(idx, prim_sel); + drop(annotations); doc.set_selection(view.id, sel); } fn page_up(cx: &mut Context) { let view = view!(cx.editor); let offset = view.inner_height(); - scroll(cx, offset, Direction::Backward); + scroll(cx, offset, Direction::Backward, false); } fn page_down(cx: &mut Context) { let view = view!(cx.editor); let offset = view.inner_height(); - scroll(cx, offset, Direction::Forward); + scroll(cx, offset, Direction::Forward, false); } fn half_page_up(cx: &mut Context) { let view = view!(cx.editor); let offset = view.inner_height() / 2; - scroll(cx, offset, Direction::Backward); + scroll(cx, offset, Direction::Backward, false); } fn half_page_down(cx: &mut Context) { let view = view!(cx.editor); let offset = view.inner_height() / 2; - scroll(cx, offset, Direction::Forward); + scroll(cx, offset, Direction::Forward, false); +} + +fn page_cursor_up(cx: &mut Context) { + let view = view!(cx.editor); + let offset = view.inner_height(); + scroll(cx, offset, Direction::Backward, true); +} + +fn page_cursor_down(cx: &mut Context) { + let view = view!(cx.editor); + let offset = view.inner_height(); + scroll(cx, offset, Direction::Forward, true); +} + +fn page_cursor_half_up(cx: &mut Context) { + let view = view!(cx.editor); + let offset = view.inner_height() / 2; + scroll(cx, offset, Direction::Backward, true); +} + +fn page_cursor_half_down(cx: &mut Context) { + let view = view!(cx.editor); + let offset = view.inner_height() / 2; + scroll(cx, offset, Direction::Forward, true); } #[allow(deprecated)] @@ -1872,11 +1977,7 @@ fn split_selection(cx: &mut Context) { fn split_selection_on_newline(cx: &mut Context) { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); - // only compile the regex once - #[allow(clippy::trivial_regex)] - static REGEX: Lazy = - Lazy::new(|| Regex::new(r"\r\n|[\n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}]").unwrap()); - let selection = selection::split_on_matches(text, doc.selection(view.id), ®EX); + let selection = selection::split_on_newline(text, doc.selection(view.id)); doc.set_selection(view.id, selection); } @@ -1895,8 +1996,7 @@ fn merge_consecutive_selections(cx: &mut Context) { #[allow(clippy::too_many_arguments)] fn search_impl( editor: &mut Editor, - contents: &str, - regex: &Regex, + regex: &rope::Regex, movement: Movement, direction: Direction, scrolloff: usize, @@ -1924,23 +2024,20 @@ fn search_impl( // do a reverse search and wraparound to the end, we don't need to search // the text before the current cursor position for matches, but by slicing // it out, we need to add it back to the position of the selection. - let mut offset = 0; + let doc = doc!(editor).text().slice(..); // use find_at to find the next match after the cursor, loop around the end // Careful, `Regex` uses `bytes` as offsets, not character indices! let mut mat = match direction { - Direction::Forward => regex.find_at(contents, start), - Direction::Backward => regex.find_iter(&contents[..start]).last(), + Direction::Forward => regex.find(doc.regex_input_at_bytes(start..)), + Direction::Backward => regex.find_iter(doc.regex_input_at_bytes(..start)).last(), }; if mat.is_none() { if wrap_around { mat = match direction { - Direction::Forward => regex.find(contents), - Direction::Backward => { - offset = start; - regex.find_iter(&contents[start..]).last() - } + Direction::Forward => regex.find(doc.regex_input()), + Direction::Backward => regex.find_iter(doc.regex_input_at_bytes(start..)).last(), }; } if show_warnings { @@ -1957,8 +2054,8 @@ fn search_impl( let selection = doc.selection(view.id); if let Some(mat) = mat { - let start = text.byte_to_char(mat.start() + offset); - let end = text.byte_to_char(mat.end() + offset); + let start = text.byte_to_char(mat.start()); + let end = text.byte_to_char(mat.end()); if end == 0 { // skip empty matches that don't make sense @@ -2001,14 +2098,13 @@ fn searcher(cx: &mut Context, direction: Direction) { let config = cx.editor.config(); let scrolloff = config.scrolloff; let wrap_around = config.search.wrap_around; - - let doc = doc!(cx.editor); + let movement = if cx.editor.mode() == Mode::Select { + Movement::Extend + } else { + Movement::Move + }; // TODO: could probably share with select_on_matches? - - // HAXX: sadly we can't avoid allocating a single string for the whole buffer since we can't - // feed chunks into the regex yet - let contents = doc.text().slice(..).to_string(); let completions = search_completions(cx, Some(reg)); ui::regex_prompt( @@ -2030,9 +2126,8 @@ fn searcher(cx: &mut Context, direction: Direction) { } search_impl( cx.editor, - &contents, ®ex, - Movement::Move, + movement, direction, scrolloff, wrap_around, @@ -2050,8 +2145,6 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir let config = cx.editor.config(); let scrolloff = config.scrolloff; if let Some(query) = cx.editor.registers.first(register, cx.editor) { - let doc = doc!(cx.editor); - let contents = doc.text().slice(..).to_string(); let search_config = &config.search; let case_insensitive = if search_config.smart_case { !query.chars().any(char::is_uppercase) @@ -2059,15 +2152,17 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir false }; let wrap_around = search_config.wrap_around; - if let Ok(regex) = RegexBuilder::new(&query) - .case_insensitive(case_insensitive) - .multi_line(true) - .build() + if let Ok(regex) = rope::RegexBuilder::new() + .syntax( + rope::Config::new() + .case_insensitive(case_insensitive) + .multi_line(true), + ) + .build(&query) { for _ in 0..count { search_impl( cx.editor, - &contents, ®ex, movement, direction, @@ -2204,7 +2299,7 @@ fn global_search(cx: &mut Context) { let reg = cx.register.unwrap_or('/'); let completions = search_completions(cx, Some(reg)); - ui::regex_prompt( + ui::raw_regex_prompt( cx, "global-search:".into(), Some(reg), @@ -2215,7 +2310,7 @@ fn global_search(cx: &mut Context) { .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) .collect() }, - move |cx, regex, event| { + move |cx, _, input, event| { if event != PromptEvent::Validate { return; } @@ -2230,7 +2325,7 @@ fn global_search(cx: &mut Context) { if let Ok(matcher) = RegexMatcherBuilder::new() .case_smart(smart_case) - .build(regex.as_str()) + .build(input) { let search_root = helix_stdx::env::current_working_dir(); if !search_root.exists() { @@ -2435,7 +2530,7 @@ fn search_in_directory(cx: &mut Context, search_root: PathBuf) { let reg = cx.register.unwrap_or('/'); let completions = search_completions(cx, Some(reg)); - ui::regex_prompt( + ui::raw_regex_prompt( cx, "global-search:".into(), Some(reg), @@ -2446,7 +2541,7 @@ fn search_in_directory(cx: &mut Context, search_root: PathBuf) { .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) .collect() }, - move |cx, regex, event| { + move |cx, _, input, event| { if event != PromptEvent::Validate { return; } @@ -2461,7 +2556,7 @@ fn search_in_directory(cx: &mut Context, search_root: PathBuf) { if let Ok(matcher) = RegexMatcherBuilder::new() .case_smart(smart_case) - .build(regex.as_str()) + .build(input) { let search_root = search_root.clone(); @@ -2476,7 +2571,7 @@ fn search_in_directory(cx: &mut Context, search_root: PathBuf) { let dedup_symlinks = file_picker_config.deduplicate_links; let absolute_root = search_root .canonicalize() - .unwrap_or_else(|_| search_root.clone()); + .unwrap_or_else(|_| search_root.to_owned().to_path_buf()); let injector_ = injector.clone(); std::thread::spawn(move || { @@ -2637,7 +2732,6 @@ fn extend_line_below(cx: &mut Context) { fn extend_line_above(cx: &mut Context) { extend_line_impl(cx, Extend::Above); } - fn extend_line_impl(cx: &mut Context, extend: Extend) { let count = cx.count(); let (view, doc) = current!(cx.editor); @@ -2676,6 +2770,59 @@ fn extend_line_impl(cx: &mut Context, extend: Extend) { doc.set_selection(view.id, selection); } +fn select_line_below(cx: &mut Context) { + select_line_impl(cx, Extend::Below); +} +fn select_line_above(cx: &mut Context) { + select_line_impl(cx, Extend::Above); +} +fn select_line_impl(cx: &mut Context, extend: Extend) { + let mut count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text(); + let saturating_add = |a: usize, b: usize| (a + b).min(text.len_lines()); + let selection = doc.selection(view.id).clone().transform(|range| { + let (start_line, end_line) = range.line_range(text.slice(..)); + let start = text.line_to_char(start_line); + let end = text.line_to_char(saturating_add(end_line, 1)); + let direction = range.direction(); + + // Extending to line bounds is counted as one step + if range.from() != start || range.to() != end { + count = count.saturating_sub(1) + } + let (anchor_line, head_line) = match (&extend, direction) { + (Extend::Above, Direction::Forward) => (start_line, end_line.saturating_sub(count)), + (Extend::Above, Direction::Backward) => (end_line, start_line.saturating_sub(count)), + (Extend::Below, Direction::Forward) => (start_line, saturating_add(end_line, count)), + (Extend::Below, Direction::Backward) => (end_line, saturating_add(start_line, count)), + }; + let (anchor, head) = match anchor_line.cmp(&head_line) { + Ordering::Less => ( + text.line_to_char(anchor_line), + text.line_to_char(saturating_add(head_line, 1)), + ), + Ordering::Equal => match extend { + Extend::Above => ( + text.line_to_char(saturating_add(anchor_line, 1)), + text.line_to_char(head_line), + ), + Extend::Below => ( + text.line_to_char(head_line), + text.line_to_char(saturating_add(anchor_line, 1)), + ), + }, + + Ordering::Greater => ( + text.line_to_char(saturating_add(anchor_line, 1)), + text.line_to_char(head_line), + ), + }; + Range::new(anchor, head) + }); + + doc.set_selection(view.id, selection); +} fn extend_to_line_bounds(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -2750,14 +2897,19 @@ fn selection_is_linewise(selection: &Selection, text: &Rope) -> bool { }) } -fn delete_selection_impl(cx: &mut Context, op: Operation) { +enum YankAction { + Yank, + NoYank, +} + +fn delete_selection_impl(cx: &mut Context, op: Operation, yank: YankAction) { let (view, doc) = current!(cx.editor); let selection = doc.selection(view.id); let only_whole_lines = selection_is_linewise(selection, doc.text()); - if cx.register != Some('_') { - // first yank the selection + if cx.register != Some('_') && matches!(yank, YankAction::Yank) { + // yank the selection let text = doc.text().slice(..); let values: Vec = selection.fragments(text).map(Cow::into_owned).collect(); let reg_name = cx.register.unwrap_or('"'); @@ -2765,9 +2917,9 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) { cx.editor.set_error(err.to_string()); return; } - }; + } - // then delete + // delete the selection let transaction = Transaction::delete_by_selection(doc.text(), selection, |range| (range.from(), range.to())); doc.apply(&transaction, view.id); @@ -2833,21 +2985,19 @@ fn delete_by_selection_insert_mode( } fn delete_selection(cx: &mut Context) { - delete_selection_impl(cx, Operation::Delete); + delete_selection_impl(cx, Operation::Delete, YankAction::Yank); } fn delete_selection_noyank(cx: &mut Context) { - cx.register = Some('_'); - delete_selection_impl(cx, Operation::Delete); + delete_selection_impl(cx, Operation::Delete, YankAction::NoYank); } fn change_selection(cx: &mut Context) { - delete_selection_impl(cx, Operation::Change); + delete_selection_impl(cx, Operation::Change, YankAction::Yank); } fn change_selection_noyank(cx: &mut Context) { - cx.register = Some('_'); - delete_selection_impl(cx, Operation::Change); + delete_selection_impl(cx, Operation::Change, YankAction::NoYank); } fn collapse_selection(cx: &mut Context) { @@ -2994,17 +3144,8 @@ fn buffer_picker(cx: &mut Context) { let path = self .path .as_deref() - // <<<<<<< HEAD - // .map(helix_core::path::get_relative_path); - // let path = match path - // .as_deref() - // .and_then(Path::to_str) - // .or_else(|| self.name.as_ref().map(|x| x.as_str())) - // { - // ======= .map(helix_stdx::path::get_relative_path); let path = match path.as_deref().and_then(Path::to_str) { - // >>>>>>> origin/master Some(path) => path, None => SCRATCH_BUFFER_NAME, }; @@ -3124,6 +3265,7 @@ fn jumplist_picker(cx: &mut Context) { .flat_map(|(view, _)| { view.jumps .iter() + .rev() .map(|(doc_id, selection)| new_meta(view, *doc_id, selection.clone())) }) .collect(), @@ -3146,6 +3288,94 @@ fn jumplist_picker(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } +fn changed_file_picker(cx: &mut Context) { + pub struct FileChangeData { + cwd: PathBuf, + style_untracked: Style, + style_modified: Style, + style_conflict: Style, + style_deleted: Style, + style_renamed: Style, + } + + impl Item for FileChange { + type Data = FileChangeData; + + fn format(&self, data: &Self::Data) -> Row { + let process_path = |path: &PathBuf| { + path.strip_prefix(&data.cwd) + .unwrap_or(path) + .display() + .to_string() + }; + + let (sign, style, content) = match self { + Self::Untracked { path } => ("[+]", data.style_untracked, process_path(path)), + Self::Modified { path } => ("[~]", data.style_modified, process_path(path)), + Self::Conflict { path } => ("[x]", data.style_conflict, process_path(path)), + Self::Deleted { path } => ("[-]", data.style_deleted, process_path(path)), + Self::Renamed { from_path, to_path } => ( + "[>]", + data.style_renamed, + format!("{} -> {}", process_path(from_path), process_path(to_path)), + ), + }; + + Row::new([Cell::from(Span::styled(sign, style)), Cell::from(content)]) + } + } + + let cwd = helix_stdx::env::current_working_dir(); + if !cwd.exists() { + cx.editor + .set_error("Current working directory does not exist"); + return; + } + + let added = cx.editor.theme.get("diff.plus"); + let modified = cx.editor.theme.get("diff.delta"); + let conflict = cx.editor.theme.get("diff.delta.conflict"); + let deleted = cx.editor.theme.get("diff.minus"); + let renamed = cx.editor.theme.get("diff.delta.moved"); + + let picker = Picker::new( + Vec::new(), + FileChangeData { + cwd: cwd.clone(), + style_untracked: added, + style_modified: modified, + style_conflict: conflict, + style_deleted: deleted, + style_renamed: renamed, + }, + |cx, meta: &FileChange, action| { + let path_to_open = meta.path(); + if let Err(e) = cx.editor.open(path_to_open, action) { + let err = if let Some(err) = e.source() { + format!("{}", err) + } else { + format!("unable to open \"{}\"", path_to_open.display()) + }; + cx.editor.set_error(err); + } + }, + ) + .with_preview(|_editor, meta| Some((meta.path().to_path_buf().into(), None))); + let injector = picker.injector(); + + cx.editor + .diff_providers + .clone() + .for_each_changed_file(cwd, move |change| match change { + Ok(change) => injector.push(change).is_ok(), + Err(err) => { + status::report_blocking(err); + true + } + }); + cx.push_layer(Box::new(overlaid(picker))); +} + impl ui::menu::Item for MappableCommand { type Data = ReverseKeymap; @@ -3302,11 +3532,11 @@ fn insert_with_indent(cx: &mut Context, cursor_fallback: IndentFallbackPos) { } else { // move cursor to the fallback position let pos = match cursor_fallback { - IndentFallbackPos::LineStart => { - find_first_non_whitespace_char(text.line(cursor_line)) - .map(|ws_offset| ws_offset + cursor_line_start) - .unwrap_or(cursor_line_start) - } + IndentFallbackPos::LineStart => text + .line(cursor_line) + .first_non_whitespace_char() + .map(|ws_offset| ws_offset + cursor_line_start) + .unwrap_or(cursor_line_start), IndentFallbackPos::LineEnd => line_end_char_index(&text, cursor_line), }; @@ -3597,48 +3827,55 @@ fn goto_last_diag(cx: &mut Context) { } fn goto_next_diag(cx: &mut Context) { - let (view, doc) = current!(cx.editor); + let motion = move |editor: &mut Editor| { + let (view, doc) = current!(editor); - let cursor_pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); + let cursor_pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); - let diag = doc - .diagnostics() - .iter() - .find(|diag| diag.range.start > cursor_pos) - .or_else(|| doc.diagnostics().first()); + let diag = doc + .diagnostics() + .iter() + .find(|diag| diag.range.start > cursor_pos) + .or_else(|| doc.diagnostics().first()); - let selection = match diag { - Some(diag) => Selection::single(diag.range.start, diag.range.end), - None => return, + let selection = match diag { + Some(diag) => Selection::single(diag.range.start, diag.range.end), + None => return, + }; + doc.set_selection(view.id, selection); }; - doc.set_selection(view.id, selection); + + cx.editor.apply_motion(motion); } fn goto_prev_diag(cx: &mut Context) { - let (view, doc) = current!(cx.editor); + let motion = move |editor: &mut Editor| { + let (view, doc) = current!(editor); - let cursor_pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); + let cursor_pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); - let diag = doc - .diagnostics() - .iter() - .rev() - .find(|diag| diag.range.start < cursor_pos) - .or_else(|| doc.diagnostics().last()); - - let selection = match diag { - // NOTE: the selection is reversed because we're jumping to the - // previous diagnostic. - Some(diag) => Selection::single(diag.range.end, diag.range.start), - None => return, + let diag = doc + .diagnostics() + .iter() + .rev() + .find(|diag| diag.range.start < cursor_pos) + .or_else(|| doc.diagnostics().last()); + + let selection = match diag { + // NOTE: the selection is reversed because we're jumping to the + // previous diagnostic. + Some(diag) => Selection::single(diag.range.end, diag.range.start), + None => return, + }; + doc.set_selection(view.id, selection); }; - doc.set_selection(view.id, selection); + cx.editor.apply_motion(motion) } fn goto_first_change(cx: &mut Context) { @@ -4349,6 +4586,7 @@ fn replace_with_yanked_impl(editor: &mut Editor, register: char, count: usize) { return; }; let values: Vec<_> = values.map(|value| value.to_string()).collect(); + let scrolloff = editor.config().scrolloff; let (view, doc) = current!(editor); let repeat = std::iter::repeat( @@ -4371,6 +4609,8 @@ fn replace_with_yanked_impl(editor: &mut Editor, register: char, count: usize) { }); doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); + view.ensure_cursor_in_view(doc, scrolloff); } fn replace_selections_with_clipboard(cx: &mut Context) { @@ -4594,16 +4834,27 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) { // select inserted spaces let transaction = if select_space { + let mut offset: usize = 0; let ranges: SmallVec<_> = changes .iter() - .scan(0, |offset, change| { - let range = Range::point(change.0 - *offset); - *offset += change.1 - change.0 - 1; // -1 because cursor is 0-sized - Some(range) + .filter_map(|change| { + if change.2.is_some() { + let range = Range::point(change.0 - offset); + offset += change.1 - change.0 - 1; // -1 adjusts for the replacement of the range by a space + Some(range) + } else { + offset += change.1 - change.0; + None + } }) .collect(); - let selection = Selection::new(ranges, 0); - Transaction::change(text, changes.into_iter()).with_selection(selection) + let t = Transaction::change(text, changes.into_iter()); + if ranges.is_empty() { + t + } else { + let selection = Selection::new(ranges, 0); + t.with_selection(selection) + } } else { Transaction::change(text, changes.into_iter()) }; @@ -4686,18 +4937,124 @@ pub fn completion(cx: &mut Context) { } // comments -fn toggle_comments(cx: &mut Context) { +type CommentTransactionFn = fn( + line_token: Option<&str>, + block_tokens: Option<&[BlockCommentToken]>, + doc: &Rope, + selection: &Selection, +) -> Transaction; + +fn toggle_comments_impl(cx: &mut Context, comment_transaction: CommentTransactionFn) { let (view, doc) = current!(cx.editor); - let token = doc + let line_token: Option<&str> = doc .language_config() - .and_then(|lc| lc.comment_token.as_ref()) - .map(|tc| tc.as_ref()); - let transaction = comment::toggle_line_comments(doc.text(), doc.selection(view.id), token); + .and_then(|lc| lc.comment_tokens.as_ref()) + .and_then(|tc| tc.first()) + .map(|tc| tc.as_str()); + let block_tokens: Option<&[BlockCommentToken]> = doc + .language_config() + .and_then(|lc| lc.block_comment_tokens.as_ref()) + .map(|tc| &tc[..]); + + let transaction = + comment_transaction(line_token, block_tokens, doc.text(), doc.selection(view.id)); doc.apply(&transaction, view.id); exit_select_mode(cx); } +/// commenting behavior: +/// 1. only line comment tokens -> line comment +/// 2. each line block commented -> uncomment all lines +/// 3. whole selection block commented -> uncomment selection +/// 4. all lines not commented and block tokens -> comment uncommented lines +/// 5. no comment tokens and not block commented -> line comment +fn toggle_comments(cx: &mut Context) { + toggle_comments_impl(cx, |line_token, block_tokens, doc, selection| { + let text = doc.slice(..); + + // only have line comment tokens + if line_token.is_some() && block_tokens.is_none() { + return comment::toggle_line_comments(doc, selection, line_token); + } + + let split_lines = comment::split_lines_of_selection(text, selection); + + let default_block_tokens = &[BlockCommentToken::default()]; + let block_comment_tokens = block_tokens.unwrap_or(default_block_tokens); + + let (line_commented, line_comment_changes) = + comment::find_block_comments(block_comment_tokens, text, &split_lines); + + // block commented by line would also be block commented so check this first + if line_commented { + return comment::create_block_comment_transaction( + doc, + &split_lines, + line_commented, + line_comment_changes, + ) + .0; + } + + let (block_commented, comment_changes) = + comment::find_block_comments(block_comment_tokens, text, selection); + + // check if selection has block comments + if block_commented { + return comment::create_block_comment_transaction( + doc, + selection, + block_commented, + comment_changes, + ) + .0; + } + + // not commented and only have block comment tokens + if line_token.is_none() && block_tokens.is_some() { + return comment::create_block_comment_transaction( + doc, + &split_lines, + line_commented, + line_comment_changes, + ) + .0; + } + + // not block commented at all and don't have any tokens + comment::toggle_line_comments(doc, selection, line_token) + }) +} + +fn toggle_line_comments(cx: &mut Context) { + toggle_comments_impl(cx, |line_token, block_tokens, doc, selection| { + if line_token.is_none() && block_tokens.is_some() { + let default_block_tokens = &[BlockCommentToken::default()]; + let block_comment_tokens = block_tokens.unwrap_or(default_block_tokens); + comment::toggle_block_comments( + doc, + &comment::split_lines_of_selection(doc.slice(..), selection), + block_comment_tokens, + ) + } else { + comment::toggle_line_comments(doc, selection, line_token) + } + }); +} + +fn toggle_block_comments(cx: &mut Context) { + toggle_comments_impl(cx, |line_token, block_tokens, doc, selection| { + if line_token.is_some() && block_tokens.is_none() { + comment::toggle_line_comments(doc, selection, line_token) + } else { + let default_block_tokens = &[BlockCommentToken::default()]; + let block_comment_tokens = block_tokens.unwrap_or(default_block_tokens); + comment::toggle_block_comments(doc, selection, block_comment_tokens) + } + }); +} + fn rotate_selections(cx: &mut Context, direction: Direction) { let count = cx.count(); let (view, doc) = current!(cx.editor); @@ -4818,18 +5175,17 @@ fn shrink_selection(cx: &mut Context) { cx.editor.apply_motion(motion); } -fn select_sibling_impl(cx: &mut Context, sibling_fn: &'static F) +fn select_sibling_impl(cx: &mut Context, sibling_fn: F) where - F: Fn(Node) -> Option, + F: Fn(&helix_core::Syntax, RopeSlice, Selection) -> Selection + 'static, { - let motion = |editor: &mut Editor| { + let motion = move |editor: &mut Editor| { let (view, doc) = current!(editor); if let Some(syntax) = doc.syntax() { let text = doc.text().slice(..); let current_selection = doc.selection(view.id); - let selection = - object::select_sibling(syntax, text, current_selection.clone(), sibling_fn); + let selection = sibling_fn(syntax, text, current_selection.clone()); doc.set_selection(view.id, selection); } }; @@ -4837,11 +5193,11 @@ where } fn select_next_sibling(cx: &mut Context) { - select_sibling_impl(cx, &|node| Node::next_sibling(&node)) + select_sibling_impl(cx, object::select_next_sibling) } fn select_prev_sibling(cx: &mut Context) { - select_sibling_impl(cx, &|node| Node::prev_sibling(&node)) + select_sibling_impl(cx, object::select_prev_sibling) } fn move_node_bound_impl(cx: &mut Context, dir: Direction, movement: Movement) { @@ -4883,6 +5239,36 @@ pub fn extend_parent_node_start(cx: &mut Context) { move_node_bound_impl(cx, Direction::Backward, Movement::Extend) } +fn select_all_impl(editor: &mut Editor, select_fn: F) +where + F: Fn(&Syntax, RopeSlice, Selection) -> Selection, +{ + let (view, doc) = current!(editor); + + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + let current_selection = doc.selection(view.id); + let selection = select_fn(syntax, text, current_selection.clone()); + doc.set_selection(view.id, selection); + } +} + +fn select_all_siblings(cx: &mut Context) { + let motion = |editor: &mut Editor| { + select_all_impl(editor, object::select_all_siblings); + }; + + cx.editor.apply_motion(motion); +} + +fn select_all_children(cx: &mut Context) { + let motion = |editor: &mut Editor| { + select_all_impl(editor, object::select_all_children); + }; + + cx.editor.apply_motion(motion); +} + fn match_brackets(cx: &mut Context) { let (view, doc) = current!(cx.editor); let is_select = cx.editor.mode == Mode::Select; @@ -5118,11 +5504,11 @@ fn align_view_middle(cx: &mut Context) { } fn scroll_up(cx: &mut Context) { - scroll(cx, cx.count(), Direction::Backward); + scroll(cx, cx.count(), Direction::Backward, false); } fn scroll_down(cx: &mut Context) { - scroll(cx, cx.count(), Direction::Forward); + scroll(cx, cx.count(), Direction::Forward, false); } fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direction) { @@ -5205,6 +5591,14 @@ fn goto_prev_test(cx: &mut Context) { goto_ts_object_impl(cx, "test", Direction::Backward) } +fn goto_next_entry(cx: &mut Context) { + goto_ts_object_impl(cx, "entry", Direction::Forward) +} + +fn goto_prev_entry(cx: &mut Context) { + goto_ts_object_impl(cx, "entry", Direction::Backward) +} + fn select_textobject_around(cx: &mut Context) { select_textobject(cx, textobject::TextObject::Around); } @@ -5269,15 +5663,25 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { 'a' => textobject_treesitter("parameter", range), 'c' => textobject_treesitter("comment", range), 'T' => textobject_treesitter("test", range), + 'e' => textobject_treesitter("entry", range), 'p' => textobject::textobject_paragraph(text, range, objtype, count), 'm' => textobject::textobject_pair_surround_closest( - text, range, objtype, count, + doc.syntax(), + text, + range, + objtype, + count, ), 'g' => textobject_change(range), // TODO: cancel new ranges if inconsistent surround matches across lines - ch if !ch.is_ascii_alphanumeric() => { - textobject::textobject_pair_surround(text, range, objtype, ch, count) - } + ch if !ch.is_ascii_alphanumeric() => textobject::textobject_pair_surround( + doc.syntax(), + text, + range, + objtype, + ch, + count, + ), _ => range, } }); @@ -5301,7 +5705,9 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { ("a", "Argument/parameter (tree-sitter)"), ("c", "Comment (tree-sitter)"), ("T", "Test (tree-sitter)"), - ("m", "Closest surrounding pair"), + ("e", "Data structure entry (tree-sitter)"), + ("m", "Closest surrounding pair (tree-sitter)"), + ("g", "Change"), (" ", "... or any character acting as a pair"), ]; @@ -5314,7 +5720,7 @@ fn surround_add(cx: &mut Context) { // surround_len is the number of new characters being added. let (open, close, surround_len) = match event.char() { Some(ch) => { - let (o, c) = surround::get_pair(ch); + let (o, c) = match_brackets::get_pair(ch); let mut open = Tendril::new(); open.push(o); let mut close = Tendril::new(); @@ -5365,13 +5771,14 @@ fn surround_replace(cx: &mut Context) { let text = doc.text().slice(..); let selection = doc.selection(view.id); - let change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) { - Ok(c) => c, - Err(err) => { - cx.editor.set_error(err.to_string()); - return; - } - }; + let change_pos = + match surround::get_surround_pos(doc.syntax(), text, selection, surround_ch, count) { + Ok(c) => c, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; let selection = selection.clone(); let ranges: SmallVec<[Range; 1]> = change_pos.iter().map(|&p| Range::point(p)).collect(); @@ -5386,13 +5793,22 @@ fn surround_replace(cx: &mut Context) { Some(to) => to, None => return doc.set_selection(view.id, selection), }; - let (open, close) = surround::get_pair(to); + let (open, close) = match_brackets::get_pair(to); + + // the changeset has to be sorted to allow nested surrounds + let mut sorted_pos: Vec<(usize, char)> = Vec::new(); + for p in change_pos.chunks(2) { + sorted_pos.push((p[0], open)); + sorted_pos.push((p[1], close)); + } + sorted_pos.sort_unstable(); + let transaction = Transaction::change( doc.text(), - change_pos.iter().enumerate().map(|(i, &pos)| { + sorted_pos.iter().map(|&pos| { let mut t = Tendril::new(); - t.push(if i % 2 == 0 { open } else { close }); - (pos, pos + 1, Some(t)) + t.push(pos.1); + (pos.0, pos.0 + 1, Some(t)) }), ); doc.set_selection(view.id, selection); @@ -5414,14 +5830,15 @@ fn surround_delete(cx: &mut Context) { let text = doc.text().slice(..); let selection = doc.selection(view.id); - let change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) { - Ok(c) => c, - Err(err) => { - cx.editor.set_error(err.to_string()); - return; - } - }; - + let mut change_pos = + match surround::get_surround_pos(doc.syntax(), text, selection, surround_ch, count) { + Ok(c) => c, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; + change_pos.sort_unstable(); // the changeset has to be sorted to allow nested surrounds let transaction = Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); doc.apply(&transaction, view.id); @@ -5477,16 +5894,9 @@ fn shell_keep_pipe(cx: &mut Context) { for (i, range) in selection.ranges().iter().enumerate() { let fragment = range.slice(text); - let (_output, success) = match shell_impl(shell, input, Some(fragment.into())) { - Ok(result) => result, - Err(err) => { - cx.editor.set_error(err.to_string()); - return; - } - }; - - // if the process exits successfully, keep the selection - if success { + if let Err(err) = shell_impl(shell, input, Some(fragment.into())) { + log::debug!("Shell command failed: {}", err); + } else { ranges.push(*range); if i >= old_index && index.is_none() { index = Some(ranges.len() - 1); @@ -5505,7 +5915,7 @@ fn shell_keep_pipe(cx: &mut Context) { ); } -fn shell_impl(shell: &[String], cmd: &str, input: Option) -> anyhow::Result<(Tendril, bool)> { +fn shell_impl(shell: &[String], cmd: &str, input: Option) -> anyhow::Result { tokio::task::block_in_place(|| helix_lsp::block_on(shell_impl_async(shell, cmd, input))) } @@ -5513,7 +5923,7 @@ async fn shell_impl_async( shell: &[String], cmd: &str, input: Option, -) -> anyhow::Result<(Tendril, bool)> { +) -> anyhow::Result { use std::process::Stdio; use tokio::process::Command; ensure!(!shell.is_empty(), "No shell set"); @@ -5576,7 +5986,7 @@ async fn shell_impl_async( let str = std::str::from_utf8(&output.stdout) .map_err(|_| anyhow!("Process did not output valid UTF-8"))?; let tendril = Tendril::from(str); - Ok((tendril, output.status.success())) + Ok(tendril) } fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { @@ -5597,14 +6007,14 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { let mut shell_output: Option = None; let mut offset = 0isize; for range in selection.ranges() { - let (output, success) = if let Some(output) = shell_output.as_ref() { - (output.clone(), true) + let output = if let Some(output) = shell_output.as_ref() { + output.clone() } else { let fragment = range.slice(text); match shell_impl(shell, cmd, pipe.then(|| fragment.into())) { Ok(result) => { if !pipe { - shell_output = Some(result.0.clone()); + shell_output = Some(result.clone()); } result } @@ -5615,11 +6025,6 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { } }; - if !success { - cx.editor.set_error("Command failed"); - return; - } - let output_len = output.chars().count(); let (from, to, deleted_len) = match behavior { @@ -5860,3 +6265,188 @@ fn replay_macro(cx: &mut Context) { cx.editor.macro_replaying.pop(); })); } + +fn goto_word(cx: &mut Context) { + jump_to_word(cx, Movement::Move) +} + +fn extend_to_word(cx: &mut Context) { + jump_to_word(cx, Movement::Extend) +} + +fn jump_to_label(cx: &mut Context, labels: Vec, behaviour: Movement) { + let doc = doc!(cx.editor); + let alphabet = &cx.editor.config().jump_label_alphabet; + if labels.is_empty() { + return; + } + let alphabet_char = |i| { + let mut res = Tendril::new(); + res.push(alphabet[i]); + res + }; + + // Add label for each jump candidate to the View as virtual text. + let text = doc.text().slice(..); + let mut overlays: Vec<_> = labels + .iter() + .enumerate() + .flat_map(|(i, range)| { + [ + Overlay::new(range.from(), alphabet_char(i / alphabet.len())), + Overlay::new( + graphemes::next_grapheme_boundary(text, range.from()), + alphabet_char(i % alphabet.len()), + ), + ] + }) + .collect(); + overlays.sort_unstable_by_key(|overlay| overlay.char_idx); + let (view, doc) = current!(cx.editor); + doc.set_jump_labels(view.id, overlays); + + // Accept two characters matching a visible label. Jump to the candidate + // for that label if it exists. + let primary_selection = doc.selection(view.id).primary(); + let view = view.id; + let doc = doc.id(); + cx.on_next_key(move |cx, event| { + let alphabet = &cx.editor.config().jump_label_alphabet; + let Some(i) = event + .char() + .and_then(|ch| alphabet.iter().position(|&it| it == ch)) + else { + doc_mut!(cx.editor, &doc).remove_jump_labels(view); + return; + }; + let outer = i * alphabet.len(); + // Bail if the given character cannot be a jump label. + if outer > labels.len() { + doc_mut!(cx.editor, &doc).remove_jump_labels(view); + return; + } + cx.on_next_key(move |cx, event| { + doc_mut!(cx.editor, &doc).remove_jump_labels(view); + let alphabet = &cx.editor.config().jump_label_alphabet; + let Some(inner) = event + .char() + .and_then(|ch| alphabet.iter().position(|&it| it == ch)) + else { + return; + }; + if let Some(mut range) = labels.get(outer + inner).copied() { + range = if behaviour == Movement::Extend { + let anchor = if range.anchor < range.head { + let from = primary_selection.from(); + if range.anchor < from { + range.anchor + } else { + from + } + } else { + let to = primary_selection.to(); + if range.anchor > to { + range.anchor + } else { + to + } + }; + Range::new(anchor, range.head) + } else { + range.with_direction(Direction::Forward) + }; + doc_mut!(cx.editor, &doc).set_selection(view, range.into()); + } + }); + }); +} + +fn jump_to_word(cx: &mut Context, behaviour: Movement) { + // Calculate the jump candidates: ranges for any visible words with two or + // more characters. + let alphabet = &cx.editor.config().jump_label_alphabet; + let jump_label_limit = alphabet.len() * alphabet.len(); + let mut words = Vec::with_capacity(jump_label_limit); + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + + // This is not necessarily exact if there is virtual text like soft wrap. + // It's ok though because the extra jump labels will not be rendered. + let start = text.line_to_char(text.char_to_line(view.offset.anchor)); + let end = text.line_to_char(view.estimate_last_doc_line(doc) + 1); + + let primary_selection = doc.selection(view.id).primary(); + let cursor = primary_selection.cursor(text); + let mut cursor_fwd = Range::point(cursor); + let mut cursor_rev = Range::point(cursor); + if text.get_char(cursor).is_some_and(|c| !c.is_whitespace()) { + let cursor_word_end = movement::move_next_word_end(text, cursor_fwd, 1); + // single grapheme words need a specical case + if cursor_word_end.anchor == cursor { + cursor_fwd = cursor_word_end; + } + let cursor_word_start = movement::move_prev_word_start(text, cursor_rev, 1); + if cursor_word_start.anchor == next_grapheme_boundary(text, cursor) { + cursor_rev = cursor_word_start; + } + } + 'outer: loop { + let mut changed = false; + while cursor_fwd.head < end { + cursor_fwd = movement::move_next_word_end(text, cursor_fwd, 1); + // The cursor is on a word that is atleast two graphemes long and + // madeup of word characters. The latter condition is needed because + // move_next_word_end simply treats a sequence of characters from + // the same char class as a word so `=<` would also count as a word. + let add_label = RevRopeGraphemes::new(text.slice(..cursor_fwd.head)) + .take(2) + .take_while(|g| g.chars().all(char_is_word)) + .count() + == 2; + if !add_label { + continue; + } + changed = true; + // skip any leading whitespace + cursor_fwd.anchor += text + .chars_at(cursor_fwd.anchor) + .take_while(|&c| !char_is_word(c)) + .count(); + words.push(cursor_fwd); + if words.len() == jump_label_limit { + break 'outer; + } + break; + } + while cursor_rev.head > start { + cursor_rev = movement::move_prev_word_start(text, cursor_rev, 1); + // The cursor is on a word that is atleast two graphemes long and + // madeup of word characters. The latter condition is needed because + // move_prev_word_start simply treats a sequence of characters from + // the same char class as a word so `=<` would also count as a word. + let add_label = RopeGraphemes::new(text.slice(cursor_rev.head..)) + .take(2) + .take_while(|g| g.chars().all(char_is_word)) + .count() + == 2; + if !add_label { + continue; + } + changed = true; + cursor_rev.anchor -= text + .chars_at(cursor_rev.anchor) + .reversed() + .take_while(|&c| !char_is_word(c)) + .count(); + words.push(cursor_rev); + if words.len() == jump_label_limit { + break 'outer; + } + break; + } + if !changed { + break; + } + } + jump_to_label(cx, words, behaviour) +} diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index d62b0a4e5..eda94c44f 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -8,7 +8,7 @@ use dap::{StackFrame, Thread, ThreadStates}; use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate}; use helix_dap::{self as dap, Client}; use helix_lsp::block_on; -use helix_view::{editor::Breakpoint, graphics::Margin}; +use helix_view::editor::Breakpoint; use serde_json::{to_value, Value}; use tokio_stream::wrappers::UnboundedReceiverStream; @@ -581,12 +581,7 @@ pub fn dap_variables(cx: &mut Context) { } let contents = Text::from(tui::text::Text::from(variables)); - let margin = if cx.editor.popup_border() { - Margin::all(1) - } else { - Margin::none() - }; - let popup = Popup::new("dap-variables", contents).margin(margin); + let popup = Popup::new("dap-variables", contents); cx.replace_or_push_layer("dap-variables", popup); } diff --git a/helix-term/src/commands/engine/steel.rs b/helix-term/src/commands/engine/steel.rs index 82e4f2ddb..fa69a1d22 100644 --- a/helix-term/src/commands/engine/steel.rs +++ b/helix-term/src/commands/engine/steel.rs @@ -2,7 +2,6 @@ use arc_swap::ArcSwapAny; use helix_core::{ extensions::steel_implementations::{rope_module, SteelRopeSlice}, graphemes, - regex::Regex, shellwords::Shellwords, syntax::{AutoPairConfig, SoftWrap}, Range, Selection, Tendril, @@ -24,7 +23,7 @@ use once_cell::sync::Lazy; use steel::{ gc::unsafe_erased_pointers::CustomReference, rerrs::ErrorKind, - rvals::{as_underlying_type, FromSteelVal, IntoSteelVal, SteelString}, + rvals::{as_underlying_type, IntoSteelVal, SteelString}, steel_vm::{engine::Engine, register_fn::RegisterFn}, steelerr, SteelErr, SteelVal, }; @@ -2572,14 +2571,16 @@ pub fn custom_insert_newline(cx: &mut Context, indent: String) { } fn search_in_directory(cx: &mut Context, directory: String) { - let search_path = expand_tilde(&PathBuf::from(directory)); - crate::commands::search_in_directory(cx, search_path); + let buf = PathBuf::from(directory); + let search_path = expand_tilde(&buf); + let path = search_path.to_path_buf(); + crate::commands::search_in_directory(cx, path); } // TODO: Result should create unrecoverable result, and should have a special // recoverable result - that way we can handle both, not one in particular fn regex_selection(cx: &mut Context, regex: String) { - if let Ok(regex) = Regex::new(®ex) { + if let Ok(regex) = helix_stdx::rope::Regex::new(®ex) { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); if let Some(selection) = diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index a1f7bf17d..e7ba9f0fc 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,4 +1,4 @@ -use futures_util::{stream::FuturesUnordered, FutureExt}; +use futures_util::{stream::FuturesOrdered, FutureExt}; use helix_lsp::{ block_on, lsp::{ @@ -6,7 +6,7 @@ use helix_lsp::{ NumberOrString, }, util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, - Client, OffsetEncoding, + Client, LanguageServerId, OffsetEncoding, }; use tokio_stream::StreamExt; use tui::{ @@ -21,7 +21,6 @@ use helix_stdx::path; use helix_view::{ document::{DocumentInlayHints, DocumentInlayHintsId}, editor::Action, - graphics::Margin, handlers::lsp::SignatureHelpInvoked, theme::Style, Document, View, @@ -38,7 +37,7 @@ use std::{ collections::{BTreeMap, HashSet}, fmt::Write, future::Future, - path::PathBuf, + path::{Path, PathBuf}, }; /// Gets the first language server that is attached to a document which supports a specific feature. @@ -134,7 +133,7 @@ struct DiagnosticStyles { } struct PickerDiagnostic { - url: lsp::Url, + path: PathBuf, diag: lsp::Diagnostic, offset_encoding: OffsetEncoding, } @@ -167,8 +166,7 @@ impl ui::menu::Item for PickerDiagnostic { let path = match format { DiagnosticsFormat::HideSourcePath => String::new(), DiagnosticsFormat::ShowSourcePath => { - let file_path = self.url.to_file_path().unwrap(); - let path = path::get_truncated_path(file_path); + let path = path::get_truncated_path(&self.path); format!("{}: ", path.to_string_lossy()) } }; @@ -208,24 +206,33 @@ fn jump_to_location( return; } }; + jump_to_position(editor, &path, location.range, offset_encoding, action); +} - let doc = match editor.open(&path, action) { +fn jump_to_position( + editor: &mut Editor, + path: &Path, + range: lsp::Range, + offset_encoding: OffsetEncoding, + action: Action, +) { + let doc = match editor.open(path, action) { Ok(id) => doc_mut!(editor, &id), Err(err) => { - let err = format!("failed to open path: {:?}: {:?}", location.uri, err); + let err = format!("failed to open path: {:?}: {:?}", path, err); editor.set_error(err); return; } }; let view = view_mut!(editor); // TODO: convert inside server - let new_range = - if let Some(new_range) = lsp_range_to_range(doc.text(), location.range, offset_encoding) { - new_range - } else { - log::warn!("lsp position out of bounds - {:?}", location.range); - return; - }; + let new_range = if let Some(new_range) = lsp_range_to_range(doc.text(), range, offset_encoding) + { + new_range + } else { + log::warn!("lsp position out of bounds - {:?}", range); + return; + }; // we flip the range so that the cursor sits on the start of the symbol // (for example start of the function). doc.set_selection(view.id, Selection::single(new_range.head, new_range.anchor)); @@ -258,21 +265,20 @@ enum DiagnosticsFormat { fn diag_picker( cx: &Context, - diagnostics: BTreeMap>, - _current_path: Option, + diagnostics: BTreeMap>, format: DiagnosticsFormat, ) -> Picker { // TODO: drop current_path comparison and instead use workspace: bool flag? // flatten the map to a vec of (url, diag) pairs let mut flat_diag = Vec::new(); - for (url, diags) in diagnostics { + for (path, diags) in diagnostics { flat_diag.reserve(diags.len()); for (diag, ls) in diags { if let Some(ls) = cx.editor.language_server_by_id(ls) { flat_diag.push(PickerDiagnostic { - url: url.clone(), + path: path.clone(), diag, offset_encoding: ls.offset_encoding(), }); @@ -292,22 +298,17 @@ fn diag_picker( (styles, format), move |cx, PickerDiagnostic { - url, + path, diag, offset_encoding, }, action| { - jump_to_location( - cx.editor, - &lsp::Location::new(url.clone(), diag.range), - *offset_encoding, - action, - ) + jump_to_position(cx.editor, path, diag.range, *offset_encoding, action) }, ) - .with_preview(move |_editor, PickerDiagnostic { url, diag, .. }| { - let location = lsp::Location::new(url.clone(), diag.range); - Some(location_to_file_location(&location)) + .with_preview(move |_editor, PickerDiagnostic { path, diag, .. }| { + let line = Some((diag.range.start.line as usize, diag.range.end.line as usize)); + Some((path.clone().into(), line)) }) .truncate_start(false) } @@ -339,7 +340,7 @@ pub fn symbol_picker(cx: &mut Context) { let mut seen_language_servers = HashSet::new(); - let mut futures: FuturesUnordered<_> = doc + let mut futures: FuturesOrdered<_> = doc .language_servers_with_feature(LanguageServerFeature::DocumentSymbols) .filter(|ls| seen_language_servers.insert(ls.id())) .map(|language_server| { @@ -414,7 +415,7 @@ pub fn workspace_symbol_picker(cx: &mut Context) { let get_symbols = move |pattern: String, editor: &mut Editor| { let doc = doc!(editor); let mut seen_language_servers = HashSet::new(); - let mut futures: FuturesUnordered<_> = doc + let mut futures: FuturesOrdered<_> = doc .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) .filter(|ls| seen_language_servers.insert(ls.id())) .map(|language_server| { @@ -470,17 +471,16 @@ pub fn workspace_symbol_picker(cx: &mut Context) { pub fn diagnostics_picker(cx: &mut Context) { let doc = doc!(cx.editor); - if let Some(current_url) = doc.url() { + if let Some(current_path) = doc.path() { let diagnostics = cx .editor .diagnostics - .get(¤t_url) + .get(current_path) .cloned() .unwrap_or_default(); let picker = diag_picker( cx, - [(current_url.clone(), diagnostics)].into(), - Some(current_url), + [(current_path.clone(), diagnostics)].into(), DiagnosticsFormat::HideSourcePath, ); cx.push_layer(Box::new(overlaid(picker))); @@ -488,22 +488,15 @@ pub fn diagnostics_picker(cx: &mut Context) { } pub fn workspace_diagnostics_picker(cx: &mut Context) { - let doc = doc!(cx.editor); - let current_url = doc.url(); // TODO not yet filtered by LanguageServerFeature, need to do something similar as Document::shown_diagnostics here for all open documents let diagnostics = cx.editor.diagnostics.clone(); - let picker = diag_picker( - cx, - diagnostics, - current_url, - DiagnosticsFormat::ShowSourcePath, - ); + let picker = diag_picker(cx, diagnostics, DiagnosticsFormat::ShowSourcePath); cx.push_layer(Box::new(overlaid(picker))); } struct CodeActionOrCommandItem { lsp_item: lsp::CodeActionOrCommand, - language_server_id: usize, + language_server_id: LanguageServerId, } impl ui::menu::Item for CodeActionOrCommandItem { @@ -580,7 +573,7 @@ pub fn code_action(cx: &mut Context) { let mut seen_language_servers = HashSet::new(); - let mut futures: FuturesUnordered<_> = doc + let mut futures: FuturesOrdered<_> = doc .language_servers_with_feature(LanguageServerFeature::CodeAction) .filter(|ls| seen_language_servers.insert(ls.id())) // TODO this should probably already been filtered in something like "language_servers_with_feature" @@ -739,15 +732,7 @@ pub fn code_action(cx: &mut Context) { }); picker.move_down(); // pre-select the first item - let margin = if editor.menu_border() { - Margin::vertical(1) - } else { - Margin::none() - }; - - let popup = Popup::new("code-action", picker) - .with_scrollbar(false) - .margin(margin); + let popup = Popup::new("code-action", picker).with_scrollbar(false); compositor.replace_or_push("code-action", popup); }; @@ -763,7 +748,11 @@ impl ui::menu::Item for lsp::Command { } } -pub fn execute_lsp_command(editor: &mut Editor, language_server_id: usize, cmd: lsp::Command) { +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 @@ -1040,7 +1029,7 @@ pub fn rename_symbol(cx: &mut Context) { fn create_rename_prompt( editor: &Editor, prefill: String, - language_server_id: Option, + language_server_id: Option, ) -> Box { let prompt = ui::Prompt::new( "rename-to:".into(), @@ -1321,11 +1310,11 @@ fn compute_inlay_hints_for_view( view_id, DocumentInlayHints { id: new_doc_inlay_hints_id, - type_inlay_hints: type_inlay_hints.into(), - parameter_inlay_hints: parameter_inlay_hints.into(), - other_inlay_hints: other_inlay_hints.into(), - padding_before_inlay_hints: padding_before_inlay_hints.into(), - padding_after_inlay_hints: padding_after_inlay_hints.into(), + type_inlay_hints, + parameter_inlay_hints, + other_inlay_hints, + padding_before_inlay_hints, + padding_after_inlay_hints, }, ); doc.inlay_hints_oudated = false; diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index a05087fae..ce9d22f25 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1,3 +1,4 @@ +use std::io::BufReader; use std::ops::Deref; use crate::job::Job; @@ -6,9 +7,9 @@ use super::*; use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; -use helix_core::{encoding, line_ending, shellwords::Shellwords}; -use helix_view::document::DEFAULT_LANGUAGE_NAME; -use helix_view::editor::{Action, CloseError, ConfigEvent}; +use helix_core::{line_ending, shellwords::Shellwords}; +use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; +use helix_view::editor::{CloseError, ConfigEvent}; use serde_json::Value; use ui::completers::{self, Completer}; @@ -111,14 +112,14 @@ fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> ensure!(!args.is_empty(), "wrong argument count"); for arg in args { let (path, pos) = args::parse_file(arg); - let path = helix_stdx::path::expand_tilde(&path); + let path = helix_stdx::path::expand_tilde(path); // If the path is a directory, open a file picker on that directory and update the status // message if let Ok(true) = std::fs::canonicalize(&path).map(|p| p.is_dir()) { let callback = async move { let call: job::Callback = job::Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { - let picker = ui::file_picker(path, &editor.config()); + let picker = ui::file_picker(path.into_owned(), &editor.config()); compositor.push(Box::new(overlaid(picker))); }, )); @@ -310,7 +311,7 @@ fn buffer_next( return Ok(()); } - goto_buffer(cx.editor, Direction::Forward); + goto_buffer(cx.editor, Direction::Forward, 1); Ok(()) } @@ -323,7 +324,7 @@ fn buffer_previous( return Ok(()); } - goto_buffer(cx.editor, Direction::Backward); + goto_buffer(cx.editor, Direction::Backward, 1); Ok(()) } @@ -1087,11 +1088,11 @@ fn change_current_directory( return Ok(()); } - let dir = helix_stdx::path::expand_tilde( - args.first() - .context("target directory not provided")? - .as_ref(), - ); + let dir = args + .first() + .context("target directory not provided")? + .as_ref(); + let dir = helix_stdx::path::expand_tilde(Path::new(dir)); helix_stdx::env::set_current_working_dir(dir)?; @@ -1327,7 +1328,11 @@ fn reload_all( // Ensure that the view is synced with the document's history. view.sync_changes(doc); - doc.reload(view, &cx.editor.diff_providers)?; + if let Err(error) = doc.reload(view, &cx.editor.diff_providers) { + cx.editor.set_error(format!("{}", error)); + continue; + } + if let Some(path) = doc.path() { cx.editor .language_servers @@ -2266,7 +2271,7 @@ fn run_shell_command_text( let args = args.join(" "); let callback = async move { - let (output, success) = shell_impl_async(&shell, &args, None).await?; + let output = shell_impl_async(&shell, &args, None).await?; let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { if !output.is_empty() { @@ -2276,11 +2281,7 @@ fn run_shell_command_text( )); compositor.replace_or_push("shell", popup); } - if success { - editor.set_status("Command succeeded"); - } else { - editor.set_error("Command failed"); - } + editor.set_status("Command succeeded"); }, )); Ok(call) @@ -2303,7 +2304,7 @@ fn run_shell_command( let args = args.join(" "); let callback = async move { - let (output, success) = shell_impl_async(&shell, &args, None).await?; + let output = shell_impl_async(&shell, &args, None).await?; let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { if !output.is_empty() { @@ -2316,11 +2317,7 @@ fn run_shell_command( )); compositor.replace_or_push("shell", popup); } - if success { - editor.set_status("Command succeeded"); - } else { - editor.set_error("Command failed"); - } + editor.set_status("Command succeeded"); }, )); Ok(call) @@ -2460,6 +2457,79 @@ fn move_buffer( Ok(()) } +fn yank_diagnostic( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let reg = match args.first() { + Some(s) => { + ensure!(s.chars().count() == 1, format!("Invalid register {s}")); + s.chars().next().unwrap() + } + None => '+', + }; + + let (view, doc) = current_ref!(cx.editor); + let primary = doc.selection(view.id).primary(); + + // Look only for diagnostics that intersect with the primary selection + let diag: Vec<_> = doc + .diagnostics() + .iter() + .filter(|d| primary.overlaps(&helix_core::Range::new(d.range.start, d.range.end))) + .map(|d| d.message.clone()) + .collect(); + let n = diag.len(); + if n == 0 { + bail!("No diagnostics under primary selection"); + } + + cx.editor.registers.write(reg, diag)?; + cx.editor.set_status(format!( + "Yanked {n} diagnostic{} to register {reg}", + if n == 1 { "" } else { "s" } + )); + Ok(()) +} + +fn read(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let scrolloff = cx.editor.config().scrolloff; + let (view, doc) = current!(cx.editor); + + ensure!(!args.is_empty(), "file name is expected"); + ensure!(args.len() == 1, "only the file name is expected"); + + let filename = args.get(0).unwrap(); + let path = PathBuf::from(filename.to_string()); + ensure!( + path.exists() && path.is_file(), + "path is not a file: {:?}", + path + ); + + let file = std::fs::File::open(path).map_err(|err| anyhow!("error opening file: {}", err))?; + let mut reader = BufReader::new(file); + let (contents, _, _) = read_to_string(&mut reader, Some(doc.encoding())) + .map_err(|err| anyhow!("error reading file: {}", err))?; + let contents = Tendril::from(contents); + let selection = doc.selection(view.id); + let transaction = Transaction::insert(doc.text(), selection, contents); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); + view.ensure_cursor_in_view(doc, scrolloff); + + Ok(()) +} + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -3060,7 +3130,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ aliases: &[], doc: "Clear given register. If no argument is provided, clear all registers.", fun: clear_register, - signature: CommandSignature::none(), + signature: CommandSignature::all(completers::register), }, TypableCommand { name: "redraw", @@ -3076,6 +3146,20 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: move_buffer, signature: CommandSignature::positional(&[completers::filename]), }, + TypableCommand { + name: "yank-diagnostic", + aliases: &[], + doc: "Yank diagnostic(s) under primary cursor to register, or clipboard by default", + fun: yank_diagnostic, + signature: CommandSignature::all(completers::register), + }, + TypableCommand { + name: "read", + aliases: &["r"], + doc: "Load a file into buffer", + fun: read, + signature: CommandSignature::positional(&[completers::filename]), + }, ]; pub static TYPABLE_COMMAND_MAP: Lazy> = diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index ef5369f85..d45809d36 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -9,10 +9,9 @@ use crate::handlers::completion::CompletionHandler; use crate::handlers::signature_help::SignatureHelpHandler; pub use completion::trigger_auto_completion; -pub use helix_view::handlers::lsp::SignatureHelpInvoked; pub use helix_view::handlers::Handlers; -mod completion; +pub mod completion; mod signature_help; pub fn setup(config: Arc>) -> Handlers { diff --git a/helix-term/src/handlers/completion.rs b/helix-term/src/handlers/completion.rs index d71fd24fc..68956c85f 100644 --- a/helix-term/src/handlers/completion.rs +++ b/helix-term/src/handlers/completion.rs @@ -30,6 +30,8 @@ use crate::ui::lsp::SignatureHelp; use crate::ui::{self, CompletionItem, Popup}; use super::Handlers; +pub use resolve::ResolveHandler; +mod resolve; #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum TriggerKind { @@ -221,9 +223,17 @@ fn request_completion( .iter() .find(|&trigger| trigger_text.ends_with(trigger)) }); - lsp::CompletionContext { - trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER, - trigger_character: trigger_char.cloned(), + + 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, + } } }; @@ -243,7 +253,7 @@ fn request_completion( .into_iter() .map(|item| CompletionItem { item, - language_server_id, + provider: language_server_id, resolved: false, }) .collect(); diff --git a/helix-term/src/handlers/completion/resolve.rs b/helix-term/src/handlers/completion/resolve.rs new file mode 100644 index 000000000..fb5179e13 --- /dev/null +++ b/helix-term/src/handlers/completion/resolve.rs @@ -0,0 +1,153 @@ +use std::sync::Arc; + +use helix_lsp::lsp; +use tokio::sync::mpsc::Sender; +use tokio::time::{Duration, Instant}; + +use helix_event::{send_blocking, AsyncHook, CancelRx}; +use helix_view::Editor; + +use crate::handlers::completion::CompletionItem; +use crate::job; + +/// A hook for resolving incomplete completion items. +/// +/// From the [LSP spec](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion): +/// +/// > If computing full completion items is expensive, servers can additionally provide a +/// > handler for the completion item resolve request. ... +/// > A typical use case is for example: the `textDocument/completion` request doesn't fill +/// > in the `documentation` property for returned completion items since it is expensive +/// > to compute. When the item is selected in the user interface then a +/// > 'completionItem/resolve' request is sent with the selected completion item as a parameter. +/// > The returned completion item should have the documentation property filled in. +pub struct ResolveHandler { + last_request: Option>, + resolver: Sender, +} + +impl ResolveHandler { + pub fn new() -> ResolveHandler { + ResolveHandler { + last_request: None, + resolver: ResolveTimeout { + next_request: None, + in_flight: None, + } + .spawn(), + } + } + + pub fn ensure_item_resolved(&mut self, editor: &mut Editor, item: &mut CompletionItem) { + if item.resolved { + return; + } + let needs_resolve = item.item.documentation.is_none() + || item.item.detail.is_none() + || item.item.additional_text_edits.is_none(); + if !needs_resolve { + item.resolved = true; + return; + } + if self.last_request.as_deref().is_some_and(|it| it == item) { + return; + } + let Some(ls) = editor.language_servers.get_by_id(item.provider).cloned() else { + item.resolved = true; + return; + }; + if matches!( + ls.capabilities().completion_provider, + Some(lsp::CompletionOptions { + resolve_provider: Some(true), + .. + }) + ) { + let item = Arc::new(item.clone()); + self.last_request = Some(item.clone()); + send_blocking(&self.resolver, ResolveRequest { item, ls }) + } else { + item.resolved = true; + } + } +} + +struct ResolveRequest { + item: Arc, + ls: Arc, +} + +#[derive(Default)] +struct ResolveTimeout { + next_request: Option, + in_flight: Option<(helix_event::CancelTx, Arc)>, +} + +impl AsyncHook for ResolveTimeout { + type Event = ResolveRequest; + + fn handle_event( + &mut self, + request: Self::Event, + timeout: Option, + ) -> Option { + if self + .next_request + .as_ref() + .is_some_and(|old_request| old_request.item == request.item) + { + timeout + } else if self + .in_flight + .as_ref() + .is_some_and(|(_, old_request)| old_request.item == request.item.item) + { + self.next_request = None; + None + } else { + self.next_request = Some(request); + Some(Instant::now() + Duration::from_millis(150)) + } + } + + fn finish_debounce(&mut self) { + let Some(request) = self.next_request.take() else { return }; + let (tx, rx) = helix_event::cancelation(); + self.in_flight = Some((tx, request.item.clone())); + tokio::spawn(request.execute(rx)); + } +} + +impl ResolveRequest { + async fn execute(self, cancel: CancelRx) { + let future = self.ls.resolve_completion_item(&self.item.item); + let Some(resolved_item) = helix_event::cancelable_future(future, cancel).await else { + return; + }; + job::dispatch(move |_, compositor| { + if let Some(completion) = &mut compositor + .find::() + .unwrap() + .completion + { + let resolved_item = match resolved_item { + Ok(item) => CompletionItem { + item, + resolved: true, + ..*self.item + }, + Err(err) => { + log::error!("completion resolve request failed: {err}"); + // set item to resolved so we don't request it again + // we could also remove it but that oculd be odd ui + let mut item = (*self.item).clone(); + item.resolved = true; + item + } + }; + completion.replace_item(&self.item, resolved_item); + }; + }) + .await + } +} diff --git a/helix-term/src/handlers/signature_help.rs b/helix-term/src/handlers/signature_help.rs index 3c746548a..4fc008118 100644 --- a/helix-term/src/handlers/signature_help.rs +++ b/helix-term/src/handlers/signature_help.rs @@ -5,7 +5,7 @@ use helix_core::syntax::LanguageServerFeature; use helix_event::{ cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx, }; -use helix_lsp::lsp; +use helix_lsp::lsp::{self, SignatureInformation}; use helix_stdx::rope::RopeSliceExt; use helix_view::document::Mode; use helix_view::events::{DocumentDidChange, SelectionDidChange}; @@ -18,7 +18,7 @@ use crate::commands::Open; use crate::compositor::Compositor; use crate::events::{OnModeSwitch, PostInsertChar}; use crate::handlers::Handlers; -use crate::ui::lsp::SignatureHelp; +use crate::ui::lsp::{Signature, SignatureHelp}; use crate::ui::Popup; use crate::{job, ui}; @@ -82,6 +82,7 @@ impl helix_event::AsyncHook for SignatureHelpHandler { } } self.state = if open { State::Open } else { State::Closed }; + return timeout; } } @@ -138,6 +139,31 @@ pub fn request_signature_help( }); } +fn active_param_range( + signature: &SignatureInformation, + response_active_parameter: Option, +) -> Option<(usize, usize)> { + let param_idx = signature + .active_parameter + .or(response_active_parameter) + .unwrap_or(0) as usize; + let param = signature.parameters.as_ref()?.get(param_idx)?; + match ¶m.label { + lsp::ParameterLabel::Simple(string) => { + let start = signature.label.find(string.as_str())?; + Some((start, start + string.len())) + } + lsp::ParameterLabel::LabelOffsets([start, end]) => { + // LS sends offsets based on utf-16 based string representation + // but highlighting in helix is done using byte offset. + use helix_core::str_utils::char_to_byte_idx; + let from = char_to_byte_idx(&signature.label, *start as usize); + let to = char_to_byte_idx(&signature.label, *end as usize); + Some((from, to)) + } + } +} + pub fn show_signature_help( editor: &mut Editor, compositor: &mut Compositor, @@ -184,54 +210,64 @@ pub fn show_signature_help( let doc = doc!(editor); let language = doc.language_name().unwrap_or(""); - let signature = match response - .signatures - .get(response.active_signature.unwrap_or(0) as usize) - { - Some(s) => s, - None => return, - }; - let mut contents = SignatureHelp::new( - signature.label.clone(), - language.to_string(), - Arc::clone(&editor.syn_loader), - ); + if response.signatures.is_empty() { + return; + } - let signature_doc = if config.lsp.display_signature_help_docs { - signature.documentation.as_ref().map(|doc| match doc { - lsp::Documentation::String(s) => s.clone(), - lsp::Documentation::MarkupContent(markup) => markup.value.clone(), + let signatures: Vec = response + .signatures + .into_iter() + .map(|s| { + let active_param_range = active_param_range(&s, response.active_parameter); + + let signature_doc = if config.lsp.display_signature_help_docs { + s.documentation.map(|doc| match doc { + lsp::Documentation::String(s) => s, + lsp::Documentation::MarkupContent(markup) => markup.value, + }) + } else { + None + }; + + Signature { + signature: s.label, + signature_doc, + active_param_range, + } }) - } else { - None - }; + .collect(); - contents.set_signature_doc(signature_doc); - - let active_param_range = || -> Option<(usize, usize)> { - let param_idx = signature - .active_parameter - .or(response.active_parameter) - .unwrap_or(0) as usize; - let param = signature.parameters.as_ref()?.get(param_idx)?; - match ¶m.label { - lsp::ParameterLabel::Simple(string) => { - let start = signature.label.find(string.as_str())?; - Some((start, start + string.len())) - } - lsp::ParameterLabel::LabelOffsets([start, end]) => { - // LS sends offsets based on utf-16 based string representation - // but highlighting in helix is done using byte offset. - use helix_core::str_utils::char_to_byte_idx; - let from = char_to_byte_idx(&signature.label, *start as usize); - let to = char_to_byte_idx(&signature.label, *end as usize); - Some((from, to)) + let old_popup = compositor.find_id::>(SignatureHelp::ID); + let lsp_signature = response.active_signature.map(|s| s as usize); + + // take the new suggested lsp signature if changed + // otherwise take the old signature if possible + // otherwise the last one (in case there is less signatures than before) + let active_signature = old_popup + .as_ref() + .map(|popup| { + let old_lsp_sig = popup.contents().lsp_signature(); + let old_sig = popup + .contents() + .active_signature() + .min(signatures.len() - 1); + + if old_lsp_sig != lsp_signature { + lsp_signature.unwrap_or(old_sig) + } else { + old_sig } - } - }; - contents.set_active_param_range(active_param_range()); + }) + .unwrap_or(lsp_signature.unwrap_or_default()); + + let contents = SignatureHelp::new( + language.to_string(), + Arc::clone(&editor.syn_loader), + active_signature, + lsp_signature, + signatures, + ); - let old_popup = compositor.find_id::>(SignatureHelp::ID); let mut popup = Popup::new(SignatureHelp::ID, contents) .position(old_popup.and_then(|p| p.get_position())) .position_bias(Open::Above) diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs index 5f2019265..0bbb5735c 100644 --- a/helix-term/src/health.rs +++ b/helix-term/src/health.rs @@ -2,7 +2,7 @@ use crossterm::{ style::{Color, Print, Stylize}, tty::IsTty, }; -use helix_core::config::{default_syntax_loader, user_syntax_loader}; +use helix_core::config::{default_lang_config, user_lang_config}; use helix_loader::grammar::load_runtime_file; use helix_view::clipboard::get_clipboard_provider; use std::io::Write; @@ -128,7 +128,7 @@ pub fn languages_all() -> std::io::Result<()> { let stdout = std::io::stdout(); let mut stdout = stdout.lock(); - let mut syn_loader_conf = match user_syntax_loader() { + let mut syn_loader_conf = match user_lang_config() { Ok(conf) => conf, Err(err) => { let stderr = std::io::stderr(); @@ -141,7 +141,7 @@ pub fn languages_all() -> std::io::Result<()> { err )?; writeln!(stderr, "{}", "Using default language config".yellow())?; - default_syntax_loader() + default_lang_config() } }; @@ -234,7 +234,7 @@ pub fn language(lang_str: String) -> std::io::Result<()> { let stdout = std::io::stdout(); let mut stdout = stdout.lock(); - let syn_loader_conf = match user_syntax_loader() { + let syn_loader_conf = match user_lang_config() { Ok(conf) => conf, Err(err) => { let stderr = std::io::stderr(); @@ -247,7 +247,7 @@ pub fn language(lang_str: String) -> std::io::Result<()> { err )?; writeln!(stderr, "{}", "Using default language config".yellow())?; - default_syntax_loader() + default_lang_config() } }; diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 22046be2d..158882d1f 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -303,6 +303,15 @@ impl Keymaps { self.sticky.as_ref() } + pub fn contains_key(&self, mode: Mode, key: KeyEvent) -> bool { + let keymaps = &*self.map(); + let keymap = &keymaps[&mode]; + keymap + .search(self.pending()) + .and_then(KeyTrie::node) + .is_some_and(|node| node.contains_key(&key)) + } + pub(crate) fn get_with_map( &mut self, keymaps: &HashMap, diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 763ed4ae7..5a3e8eed4 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -58,6 +58,7 @@ pub fn default() -> HashMap { "k" => move_line_up, "j" => move_line_down, "." => goto_last_modification, + "w" => goto_word, }, ":" => command_mode, @@ -86,10 +87,12 @@ pub fn default() -> HashMap { "A-;" => flip_selections, "A-o" | "A-up" => expand_selection, "A-i" | "A-down" => shrink_selection, + "A-I" | "A-S-down" => select_all_children, "A-p" | "A-left" => select_prev_sibling, "A-n" | "A-right" => select_next_sibling, "A-e" => move_parent_node_end, "A-b" => move_parent_node_start, + "A-a" => select_all_siblings, "%" => select_all, "x" => extend_line_below, @@ -113,6 +116,7 @@ pub fn default() -> HashMap { "t" => goto_prev_class, "a" => goto_prev_parameter, "c" => goto_prev_comment, + "e" => goto_prev_entry, "T" => goto_prev_test, "p" => goto_prev_paragraph, "space" => add_newline_above, @@ -126,6 +130,7 @@ pub fn default() -> HashMap { "t" => goto_next_class, "a" => goto_next_parameter, "c" => goto_next_comment, + "e" => goto_next_entry, "T" => goto_next_test, "p" => goto_next_paragraph, "space" => add_newline_below, @@ -178,8 +183,8 @@ pub fn default() -> HashMap { "esc" => normal_mode, "C-b" | "pageup" => page_up, "C-f" | "pagedown" => page_down, - "C-u" => half_page_up, - "C-d" => half_page_down, + "C-u" => page_cursor_half_up, + "C-d" => page_cursor_half_down, "C-w" => { "Window" "C-w" | "w" => rotate_view, @@ -222,9 +227,10 @@ pub fn default() -> HashMap { "S" => workspace_symbol_picker, "d" => diagnostics_picker, "D" => workspace_diagnostics_picker, + "g" => changed_file_picker, "a" => code_action, "'" => last_picker, - "g" => { "Debug (experimental)" sticky=true + "G" => { "Debug (experimental)" sticky=true "l" => dap_launch, "r" => dap_restart, "b" => dap_toggle_breakpoint, @@ -276,6 +282,9 @@ pub fn default() -> HashMap { "k" => hover, "r" => rename_symbol, "h" => select_references_to_symbol_under_cursor, + "c" => toggle_comments, + "C" => toggle_block_comments, + "A-c" => toggle_line_comments, "?" => command_palette, }, "z" => { "View" @@ -287,8 +296,8 @@ pub fn default() -> HashMap { "j" | "down" => scroll_down, "C-b" | "pageup" => page_up, "C-f" | "pagedown" => page_down, - "C-u" | "backspace" => half_page_up, - "C-d" | "space" => half_page_down, + "C-u" | "backspace" => page_cursor_half_up, + "C-d" | "space" => page_cursor_half_down, "/" => search, "?" => rsearch, @@ -304,8 +313,8 @@ pub fn default() -> HashMap { "j" | "down" => scroll_down, "C-b" | "pageup" => page_up, "C-f" | "pagedown" => page_down, - "C-u" | "backspace" => half_page_up, - "C-d" | "space" => half_page_down, + "C-u" | "backspace" => page_cursor_half_up, + "C-d" | "space" => page_cursor_half_down, "/" => search, "?" => rsearch, @@ -357,6 +366,7 @@ pub fn default() -> HashMap { "g" => { "Goto" "k" => extend_line_up, "j" => extend_line_down, + "w" => extend_to_word, }, })); let insert = keymap!({ "Insert mode" diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index b1413ed0d..cf4fbd9fa 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -20,25 +20,39 @@ mod handlers; use ignore::DirEntry; use url::Url; -pub use keymap::macros::*; - -#[cfg(not(windows))] -fn true_color() -> bool { - std::env::var("COLORTERM") - .map(|v| matches!(v.as_str(), "truecolor" | "24bit")) - .unwrap_or(false) -} #[cfg(windows)] fn true_color() -> bool { true } +#[cfg(not(windows))] +fn true_color() -> bool { + if matches!( + std::env::var("COLORTERM").map(|v| matches!(v.as_str(), "truecolor" | "24bit")), + Ok(true) + ) { + return true; + } + + match termini::TermInfo::from_env() { + Ok(t) => { + t.extended_cap("RGB").is_some() + || t.extended_cap("Tc").is_some() + || (t.extended_cap("setrgbf").is_some() && t.extended_cap("setrgbb").is_some()) + } + Err(_) => false, + } +} + /// Function used for filtering dir entries in the various file pickers. fn filter_picker_entry(entry: &DirEntry, root: &Path, dedup_symlinks: bool) -> bool { - // We always want to ignore the .git directory, otherwise if + // We always want to ignore popular VCS directories, otherwise if // `ignore` is turned off, we end up with a lot of noise // in our picker. - if entry.file_name() == ".git" { + if matches!( + entry.file_name().to_str(), + Some(".git" | ".pijul" | ".jj" | ".hg" | ".svn") + ) { return false; } diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index 87e0bb096..35aaef8e6 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -148,18 +148,18 @@ FLAGS: } }; - let syn_loader_conf = helix_core::config::user_syntax_loader().unwrap_or_else(|err| { - eprintln!("Bad language config: {}", err); + let lang_loader = helix_core::config::user_lang_loader().unwrap_or_else(|err| { + eprintln!("{}", err); eprintln!("Press to continue with default language config"); use std::io::Read; // This waits for an enter press. let _ = std::io::stdin().read(&mut []); - helix_core::config::default_syntax_loader() + helix_core::config::default_lang_loader() }); // TODO: use the thread local executor to spawn the application task separately from the work pool - let mut app = Application::new(args, config, syn_loader_conf) - .context("unable to create new application")?; + let mut app = + Application::new(args, config, lang_loader).context("unable to create new application")?; let exit_code = app.run(&mut EventStream::new()).await?; diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 53d5e912d..1b06a58c3 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,11 +1,10 @@ use crate::{ compositor::{Component, Context, Event, EventResult}, - handlers::trigger_auto_completion, + handlers::{completion::ResolveHandler, trigger_auto_completion}, }; use helix_view::{ document::SavePoint, editor::CompleteAction, - graphics::Margin, handlers::lsp::SignatureHelpInvoked, theme::{Modifier, Style}, ViewId, @@ -17,10 +16,9 @@ use std::{borrow::Cow, sync::Arc}; use helix_core::{chars, Change, Transaction}; use helix_view::{graphics::Rect, Document, Editor}; -use crate::commands; use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent}; -use helix_lsp::{lsp, util, OffsetEncoding}; +use helix_lsp::{lsp, util, LanguageServerId, OffsetEncoding}; impl menu::Item for CompletionItem { type Data = (); @@ -92,7 +90,7 @@ impl menu::Item for CompletionItem { #[derive(Debug, PartialEq, Default, Clone)] pub struct CompletionItem { pub item: lsp::CompletionItem, - pub language_server_id: usize, + pub provider: LanguageServerId, pub resolved: bool, } @@ -102,6 +100,7 @@ pub struct Completion { #[allow(dead_code)] trigger_offset: usize, filter: String, + resolve_handler: ResolveHandler, } impl Completion { @@ -221,7 +220,7 @@ impl Completion { ($item:expr) => { match editor .language_servers - .get_by_id($item.language_server_id) + .get_by_id($item.provider) { Some(ls) => ls, None => { @@ -282,14 +281,8 @@ impl Completion { // let language_server = language_server!(item); // let offset_encoding = language_server.offset_encoding(); - // let language_server = editor - // .language_servers - // .get_by_id(item.language_server_id) - // .unwrap(); - let mut language_server_option = None; - // resolve item if not yet resolved if !item.resolved { let language_server = language_server!(item); // let offset_encoding = language_server.offset_encoding(); @@ -354,16 +347,9 @@ impl Completion { } }); - let margin = if editor.menu_border() { - Margin::vertical(1) - } else { - Margin::none() - }; - let popup = Popup::new(Self::ID, menu) .with_scrollbar(false) - .ignore_escape_key(true) - .margin(margin); + .ignore_escape_key(true); let (view, doc) = current_ref!(editor); let text = doc.text().slice(..); @@ -382,6 +368,7 @@ impl Completion { // TODO: expand nucleo api to allow moving straight to a Utf32String here // and avoid allocation during matching filter: String::from(fragment), + resolve_handler: ResolveHandler::new(), }; // need to recompute immediately in case start_offset != trigger_offset @@ -393,14 +380,25 @@ impl Completion { completion } + /// Synchronously resolve the given completion item. This is used when + /// accepting a completion. fn resolve_completion_item( language_server: &helix_lsp::Client, completion_item: lsp::CompletionItem, ) -> Option { - let future = language_server.resolve_completion_item(completion_item)?; + if !matches!( + language_server.capabilities().completion_provider, + Some(lsp::CompletionOptions { + resolve_provider: Some(true), + .. + }) + ) { + return None; + } + let future = language_server.resolve_completion_item(&completion_item); let response = helix_lsp::block_on(future); match response { - Ok(value) => serde_json::from_value(value).ok(), + Ok(item) => Some(item), Err(err) => { log::error!("Failed to resolve completion item: {}", err); None @@ -430,66 +428,10 @@ impl Completion { self.popup.contents().is_empty() } - fn replace_item(&mut self, old_item: CompletionItem, new_item: CompletionItem) { + pub fn replace_item(&mut self, old_item: &CompletionItem, new_item: CompletionItem) { self.popup.contents_mut().replace_option(old_item, new_item); } - /// Asynchronously requests that the currently selection completion item is - /// resolved through LSP `completionItem/resolve`. - pub fn ensure_item_resolved(&mut self, cx: &mut commands::Context) -> bool { - // > If computing full completion items is expensive, servers can additionally provide a - // > handler for the completion item resolve request. ... - // > A typical use case is for example: the `textDocument/completion` request doesn't fill - // > in the `documentation` property for returned completion items since it is expensive - // > to compute. When the item is selected in the user interface then a - // > 'completionItem/resolve' request is sent with the selected completion item as a parameter. - // > The returned completion item should have the documentation property filled in. - // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion - let current_item = match self.popup.contents().selection() { - Some(item) if !item.resolved => item.clone(), - _ => return false, - }; - - let Some(language_server) = cx - .editor - .language_server_by_id(current_item.language_server_id) - else { - return false; - }; - - // This method should not block the compositor so we handle the response asynchronously. - let Some(future) = language_server.resolve_completion_item(current_item.item.clone()) - else { - return false; - }; - - cx.callback( - future, - move |_editor, compositor, response: Option| { - let resolved_item = match response { - Some(item) => item, - None => return, - }; - - if let Some(completion) = &mut compositor - .find::() - .unwrap() - .completion - { - let resolved_item = CompletionItem { - item: resolved_item, - language_server_id: current_item.language_server_id, - resolved: true, - }; - - completion.replace_item(current_item, resolved_item); - } - }, - ); - - true - } - pub fn area(&mut self, viewport: Rect, editor: &Editor) -> Rect { self.popup.area(viewport, editor) } @@ -508,10 +450,13 @@ impl Component for Completion { self.popup.render(area, surface, cx); // if we have a selection, render a markdown popup on top/below with info - let option = match self.popup.contents().selection() { + let option = match self.popup.contents_mut().selection_mut() { Some(option) => option, None => return, }; + if !option.resolved { + self.resolve_handler.ensure_item_resolved(cx.editor, option); + } // need to render: // option.detail // --- @@ -559,12 +504,7 @@ impl Component for Completion { None => return, }; - let popup_area = { - let (popup_x, popup_y) = self.popup.get_rel_position(area, cx.editor); - let (popup_width, popup_height) = self.popup.get_size(); - Rect::new(popup_x, popup_y, popup_width, popup_height) - }; - + let popup_area = self.popup.area(area, cx.editor); let doc_width_available = area.width.saturating_sub(popup_area.right()); let doc_area = if doc_width_available > 30 { let mut doc_width = doc_width_available; diff --git a/helix-term/src/ui/document.rs b/helix-term/src/ui/document.rs index a13e9428b..40db5dabf 100644 --- a/helix-term/src/ui/document.rs +++ b/helix-term/src/ui/document.rs @@ -7,6 +7,7 @@ use helix_core::syntax::Highlight; use helix_core::syntax::HighlightEvent; use helix_core::text_annotations::TextAnnotations; use helix_core::{visual_offset_from_block, Position, RopeSlice}; +use helix_stdx::rope::RopeSliceExt; use helix_view::editor::{WhitespaceConfig, WhitespaceRenderValue}; use helix_view::graphics::Rect; use helix_view::theme::Style; @@ -32,14 +33,27 @@ impl LineDecoration for F { } } +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum StyleIterKind { + /// base highlights (usually emitted by TS), byte indices (potentially not codepoint aligned) + BaseHighlights, + /// overlay highlights (emitted by custom code from selections), char indices + Overlay, +} + /// A wrapper around a HighlightIterator /// that merges the layered highlights to create the final text style /// and yields the active text style and the char_idx where the active /// style will have to be recomputed. +/// +/// TODO(ropey2): hopefully one day helix and ropey will operate entirely +/// on byte ranges and we can remove this struct StyleIter<'a, H: Iterator> { text_style: Style, active_highlights: Vec, highlight_iter: H, + kind: StyleIterKind, + text: RopeSlice<'a>, theme: &'a Theme, } @@ -54,7 +68,7 @@ impl> Iterator for StyleIter<'_, H> { HighlightEvent::HighlightEnd => { self.active_highlights.pop(); } - HighlightEvent::Source { start, end } => { + HighlightEvent::Source { start, mut end } => { if start == end { continue; } @@ -64,7 +78,9 @@ impl> Iterator for StyleIter<'_, H> { .fold(self.text_style, |acc, span| { acc.patch(self.theme.highlight(span.0)) }); - + if self.kind == StyleIterKind::BaseHighlights { + end = self.text.byte_to_next_char(end); + } return Some((style, end)); } } @@ -188,13 +204,17 @@ pub fn render_text<'t>( text_style: renderer.text_style, active_highlights: Vec::with_capacity(64), highlight_iter: syntax_highlight_iter, + kind: StyleIterKind::BaseHighlights, theme, + text, }; let mut overlay_styles = StyleIter { text_style: Style::default(), active_highlights: Vec::with_capacity(64), highlight_iter: overlay_highlight_iter, + kind: StyleIterKind::Overlay, theme, + text, }; let mut last_line_pos = LinePos { @@ -344,6 +364,7 @@ pub struct TextRenderer<'a> { pub indent_guide_style: Style, pub newline: String, pub nbsp: String, + pub nnbsp: String, pub space: String, pub tab: String, pub virtual_tab: String, @@ -398,6 +419,11 @@ impl<'a> TextRenderer<'a> { } else { " ".to_owned() }; + let nnbsp = if ws_render.nnbsp() == WhitespaceRenderValue::All { + ws_chars.nnbsp.into() + } else { + " ".to_owned() + }; let text_style = theme.get("ui.text"); @@ -408,6 +434,7 @@ impl<'a> TextRenderer<'a> { indent_guide_char: editor_config.indent_guides.character.into(), newline, nbsp, + nnbsp, space, tab, virtual_tab, @@ -451,6 +478,7 @@ impl<'a> TextRenderer<'a> { let width = grapheme.width(); let space = if is_virtual { " " } else { &self.space }; let nbsp = if is_virtual { " " } else { &self.nbsp }; + let nnbsp = if is_virtual { " " } else { &self.nnbsp }; let tab = if is_virtual { &self.virtual_tab } else { @@ -464,6 +492,7 @@ impl<'a> TextRenderer<'a> { // TODO special rendering for other whitespaces? Grapheme::Other { ref g } if g == " " => space, Grapheme::Other { ref g } if g == "\u{00A0}" => nbsp, + Grapheme::Other { ref g } if g == "\u{202F}" => nnbsp, Grapheme::Other { ref g } => g, Grapheme::Newline => &self.newline, }; diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 1c7c18ddd..7f28110ad 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -12,9 +12,7 @@ use crate::{ use helix_core::{ diagnostic::NumberOrString, - graphemes::{ - ensure_grapheme_boundary_next_byte, next_grapheme_boundary, prev_grapheme_boundary, - }, + graphemes::{next_grapheme_boundary, prev_grapheme_boundary}, movement::Direction, syntax::{self, HighlightEvent}, text_annotations::TextAnnotations, @@ -315,26 +313,14 @@ impl EditorView { let iter = syntax // TODO: range doesn't actually restrict source, just highlight range .highlight_iter(text.slice(..), Some(range), None) - .map(|event| event.unwrap()) - .map(move |event| match event { - // TODO: use byte slices directly - // convert byte offsets to char offset - HighlightEvent::Source { start, end } => { - let start = - text.byte_to_char(ensure_grapheme_boundary_next_byte(text, start)); - let end = - text.byte_to_char(ensure_grapheme_boundary_next_byte(text, end)); - HighlightEvent::Source { start, end } - } - event => event, - }); + .map(|event| event.unwrap()); Box::new(iter) } None => Box::new( [HighlightEvent::Source { - start: text.byte_to_char(range.start), - end: text.byte_to_char(range.end), + start: range.start, + end: range.end, }] .into_iter(), ), @@ -350,7 +336,8 @@ impl EditorView { let text = doc.text().slice(..); let row = text.char_to_line(anchor.min(text.len_chars())); - let range = Self::viewport_byte_range(text, row, height); + let mut range = Self::viewport_byte_range(text, row, height); + range = text.byte_to_char(range.start)..text.byte_to_char(range.end); text_annotations.collect_overlay_highlights(range) } @@ -359,8 +346,8 @@ impl EditorView { pub fn doc_diagnostics_highlights( doc: &Document, theme: &Theme, - ) -> [Vec<(usize, std::ops::Range)>; 5] { - use helix_core::diagnostic::Severity; + ) -> [Vec<(usize, std::ops::Range)>; 7] { + use helix_core::diagnostic::{DiagnosticTag, Range, Severity}; let get_scope_of = |scope| { theme .find_scope_index_exact(scope) @@ -380,11 +367,34 @@ impl EditorView { let error = get_scope_of("diagnostic.error"); let r#default = get_scope_of("diagnostic"); // this is a bit redundant but should be fine + // Diagnostic tags + let unnecessary = theme.find_scope_index_exact("diagnostic.unnecessary"); + let deprecated = theme.find_scope_index_exact("diagnostic.deprecated"); + let mut default_vec: Vec<(usize, std::ops::Range)> = Vec::new(); let mut info_vec = Vec::new(); let mut hint_vec = Vec::new(); let mut warning_vec = Vec::new(); let mut error_vec = Vec::new(); + let mut unnecessary_vec = Vec::new(); + let mut deprecated_vec = Vec::new(); + + let push_diagnostic = + |vec: &mut Vec<(usize, std::ops::Range)>, scope, range: Range| { + // If any diagnostic overlaps ranges with the prior diagnostic, + // merge the two together. Otherwise push a new span. + match vec.last_mut() { + Some((_, existing_range)) if range.start <= existing_range.end => { + // This branch merges overlapping diagnostics, assuming that the current + // diagnostic starts on range.start or later. If this assertion fails, + // we will discard some part of `diagnostic`. This implies that + // `doc.diagnostics()` is not sorted by `diagnostic.range`. + debug_assert!(existing_range.start <= range.start); + existing_range.end = range.end.max(existing_range.end) + } + _ => vec.push((scope, range.start..range.end)), + } + }; for diagnostic in doc.diagnostics() { // Separate diagnostics into different Vecs by severity. @@ -396,22 +406,44 @@ impl EditorView { _ => (&mut default_vec, r#default), }; - // If any diagnostic overlaps ranges with the prior diagnostic, - // merge the two together. Otherwise push a new span. - match vec.last_mut() { - Some((_, range)) if diagnostic.range.start <= range.end => { - // This branch merges overlapping diagnostics, assuming that the current - // diagnostic starts on range.start or later. If this assertion fails, - // we will discard some part of `diagnostic`. This implies that - // `doc.diagnostics()` is not sorted by `diagnostic.range`. - debug_assert!(range.start <= diagnostic.range.start); - range.end = diagnostic.range.end.max(range.end) + // If the diagnostic has tags and a non-warning/error severity, skip rendering + // the diagnostic as info/hint/default and only render it as unnecessary/deprecated + // instead. For warning/error diagnostics, render both the severity highlight and + // the tag highlight. + if diagnostic.tags.is_empty() + || matches!( + diagnostic.severity, + Some(Severity::Warning | Severity::Error) + ) + { + push_diagnostic(vec, scope, diagnostic.range); + } + + for tag in &diagnostic.tags { + match tag { + DiagnosticTag::Unnecessary => { + if let Some(scope) = unnecessary { + push_diagnostic(&mut unnecessary_vec, scope, diagnostic.range) + } + } + DiagnosticTag::Deprecated => { + if let Some(scope) = deprecated { + push_diagnostic(&mut deprecated_vec, scope, diagnostic.range) + } + } } - _ => vec.push((scope, diagnostic.range.start..diagnostic.range.end)), } } - [default_vec, info_vec, hint_vec, warning_vec, error_vec] + [ + default_vec, + unnecessary_vec, + deprecated_vec, + info_vec, + hint_vec, + warning_vec, + error_vec, + ] } /// Get highlight spans for selections in a document view. @@ -716,7 +748,8 @@ impl EditorView { } } - let paragraph = Paragraph::new(lines) + let text = Text::from(lines); + let paragraph = Paragraph::new(&text) .alignment(Alignment::Right) .wrap(Wrap { trim: true }); let width = 100.min(viewport.width); @@ -906,11 +939,15 @@ impl EditorView { fn command_mode(&mut self, mode: Mode, cxt: &mut commands::Context, event: KeyEvent) { match (event, cxt.editor.count) { - // count handling - (key!(i @ '0'), Some(_)) | (key!(i @ '1'..='9'), _) => { + // If the count is already started and the input is a number, always continue the count. + (key!(i @ '0'..='9'), Some(count)) => { let i = i.to_digit(10).unwrap() as usize; - cxt.editor.count = - std::num::NonZeroUsize::new(cxt.editor.count.map_or(i, |c| c.get() * 10 + i)); + cxt.editor.count = NonZeroUsize::new(count.get() * 10 + i); + } + // A non-zero digit will start the count if that number isn't used by a keymap. + (key!(i @ '1'..='9'), None) if !self.keymaps.contains_key(mode, event) => { + let i = i.to_digit(10).unwrap() as usize; + cxt.editor.count = NonZeroUsize::new(i); } // special handling for repeat operator (key!('.'), _) if self.keymaps.pending().is_empty() => { @@ -1001,7 +1038,6 @@ impl EditorView { self.last_insert.1.push(InsertEvent::TriggerCompletion); // TODO : propagate required size on resize to completion too - completion.required_size((size.width, size.height)); self.completion = Some(completion); Some(area) } @@ -1029,26 +1065,38 @@ impl EditorView { pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult { commands::compute_inlay_hints_for_all_views(cx.editor, cx.jobs); - if let Some(completion) = &mut self.completion { - return if completion.ensure_item_resolved(cx) { - EventResult::Consumed(None) - } else { - EventResult::Ignored(None) - }; - } - EventResult::Ignored(None) } } impl EditorView { + /// must be called whenever the editor processed input that + /// is not a `KeyEvent`. In these cases any pending keys/on next + /// key callbacks must be canceled. + fn handle_non_key_input(&mut self, cxt: &mut commands::Context) { + cxt.editor.status_msg = None; + cxt.editor.reset_idle_timer(); + // HACKS: create a fake key event that will never trigger any actual map + // and therefore simply acts as "dismiss" + let null_key_event = KeyEvent { + code: KeyCode::Null, + modifiers: KeyModifiers::empty(), + }; + // dismiss any pending keys + if let Some(on_next_key) = self.on_next_key.take() { + on_next_key(cxt, null_key_event); + } + self.handle_keymap_event(cxt.editor.mode, cxt, null_key_event); + self.pseudo_pending.clear(); + } + fn handle_mouse_event( &mut self, event: &MouseEvent, cxt: &mut commands::Context, ) -> EventResult { if event.kind != MouseEventKind::Moved { - cxt.editor.reset_idle_timer(); + self.handle_non_key_input(cxt) } let config = cxt.editor.config(); @@ -1090,6 +1138,15 @@ impl EditorView { if modifiers == KeyModifiers::ALT { let selection = doc.selection(view_id).clone(); doc.set_selection(view_id, selection.push(Range::point(pos))); + } else if editor.mode == Mode::Select { + // Discards non-primary selections for consistent UX with normal mode + let primary = doc.selection(view_id).primary().put_cursor( + doc.text().slice(..), + pos, + true, + ); + editor.mouse_down_range = Some(primary); + doc.set_selection(view_id, Selection::single(primary.anchor, primary.head)); } else { doc.set_selection(view_id, Selection::point(pos)); } @@ -1158,7 +1215,7 @@ impl EditorView { } let offset = config.scroll_lines.unsigned_abs(); - commands::scroll(cxt, offset, direction); + commands::scroll(cxt, offset, direction, false); cxt.editor.tree.focus = current_view; cxt.editor.ensure_cursor_in_view(current_view); @@ -1173,40 +1230,51 @@ impl EditorView { let (view, doc) = current!(cxt.editor); - if doc - .selection(view.id) - .primary() - .slice(doc.text().slice(..)) - .len_chars() - <= 1 - { - return EventResult::Ignored(None); - } - - commands::MappableCommand::yank_main_selection_to_primary_clipboard.execute(cxt); + let should_yank = match cxt.editor.mouse_down_range.take() { + Some(down_range) => doc.selection(view.id).primary() != down_range, + None => { + // This should not happen under normal cases. We fall back to the original + // behavior of yanking on non-single-char selections. + doc.selection(view.id) + .primary() + .slice(doc.text().slice(..)) + .len_chars() + > 1 + } + }; - EventResult::Consumed(None) + if should_yank { + commands::MappableCommand::yank_main_selection_to_primary_clipboard + .execute(cxt); + EventResult::Consumed(None) + } else { + EventResult::Ignored(None) + } } MouseEventKind::Up(MouseButton::Right) => { - if let Some((coords, view_id)) = gutter_coords_and_view(cxt.editor, row, column) { + if let Some((pos, view_id)) = gutter_coords_and_view(cxt.editor, row, column) { cxt.editor.focus(view_id); - let (view, doc) = current!(cxt.editor); - if let Some(pos) = - view.pos_at_visual_coords(doc, coords.row as u16, coords.col as u16, true) - { - doc.set_selection(view_id, Selection::point(pos)); - if modifiers == KeyModifiers::ALT { - commands::MappableCommand::dap_edit_log.execute(cxt); - } else { - commands::MappableCommand::dap_edit_condition.execute(cxt); - } + if let Some((pos, _)) = pos_and_view(cxt.editor, row, column, true) { + doc_mut!(cxt.editor).set_selection(view_id, Selection::point(pos)); + } else { + let (view, doc) = current!(cxt.editor); - return EventResult::Consumed(None); + if let Some(pos) = view.pos_at_visual_coords(doc, pos.row as u16, 0, true) { + doc.set_selection(view_id, Selection::point(pos)); + match modifiers { + KeyModifiers::ALT => { + commands::MappableCommand::dap_edit_log.execute(cxt) + } + _ => commands::MappableCommand::dap_edit_condition.execute(cxt), + }; + } } - } + cxt.editor.ensure_cursor_in_view(view_id); + return EventResult::Consumed(None); + } EventResult::Ignored(None) } @@ -1257,6 +1325,7 @@ impl Component for EditorView { match event { Event::Paste(contents) => { + self.handle_non_key_input(&mut cx); cx.count = cx.editor.count; commands::paste_bracketed_value(&mut cx, contents.clone()); cx.editor.count = None; diff --git a/helix-term/src/ui/info.rs b/helix-term/src/ui/info.rs index cc6b7483f..651e5ca93 100644 --- a/helix-term/src/ui/info.rs +++ b/helix-term/src/ui/info.rs @@ -2,6 +2,7 @@ use crate::compositor::{Component, Context}; use helix_view::graphics::{Margin, Rect}; use helix_view::info::Info; use tui::buffer::Buffer as Surface; +use tui::text::Text; use tui::widgets::{Block, Borders, Paragraph, Widget}; impl Component for Info { @@ -31,7 +32,7 @@ impl Component for Info { let inner = block.inner(area).inner(&margin); block.render(area, surface); - Paragraph::new(self.text.as_str()) + Paragraph::new(&Text::from(self.text.as_str())) .style(text_style) .render(inner, surface); } diff --git a/helix-term/src/ui/lsp.rs b/helix-term/src/ui/lsp.rs index 7037b1552..d845be4a7 100644 --- a/helix-term/src/ui/lsp.rs +++ b/helix-term/src/ui/lsp.rs @@ -1,57 +1,104 @@ use std::sync::Arc; +use arc_swap::ArcSwap; use helix_core::syntax; use helix_view::graphics::{Margin, Rect, Style}; +use helix_view::input::Event; use tui::buffer::Buffer; +use tui::layout::Alignment; +use tui::text::Text; use tui::widgets::{BorderType, Paragraph, Widget, Wrap}; -use crate::compositor::{Component, Compositor, Context}; +use crate::compositor::{Component, Compositor, Context, EventResult}; +use crate::alt; use crate::ui::Markdown; use super::Popup; -pub struct SignatureHelp { - signature: String, - signature_doc: Option, +pub struct Signature { + pub signature: String, + pub signature_doc: Option, /// Part of signature text - active_param_range: Option<(usize, usize)>, + pub active_param_range: Option<(usize, usize)>, +} +pub struct SignatureHelp { language: String, - config_loader: Arc, + config_loader: Arc>, + active_signature: usize, + lsp_signature: Option, + signatures: Vec, } impl SignatureHelp { pub const ID: &'static str = "signature-help"; - pub fn new(signature: String, language: String, config_loader: Arc) -> Self { + pub fn new( + language: String, + config_loader: Arc>, + active_signature: usize, + lsp_signature: Option, + signatures: Vec, + ) -> Self { Self { - signature, - signature_doc: None, - active_param_range: None, language, config_loader, + active_signature, + lsp_signature, + signatures, } } - pub fn set_signature_doc(&mut self, signature_doc: Option) { - self.signature_doc = signature_doc; + pub fn active_signature(&self) -> usize { + self.active_signature } - pub fn set_active_param_range(&mut self, offset: Option<(usize, usize)>) { - self.active_param_range = offset; + pub fn lsp_signature(&self) -> Option { + self.lsp_signature } pub fn visible_popup(compositor: &mut Compositor) -> Option<&mut Popup> { compositor.find_id::>(Self::ID) } + + fn signature_index(&self) -> String { + format!("({}/{})", self.active_signature + 1, self.signatures.len()) + } } impl Component for SignatureHelp { + fn handle_event(&mut self, event: &Event, _cx: &mut Context) -> EventResult { + let Event::Key(event) = event else { + return EventResult::Ignored(None); + }; + + if self.signatures.len() <= 1 { + return EventResult::Ignored(None); + } + + match event { + alt!('p') => { + self.active_signature = self + .active_signature + .checked_sub(1) + .unwrap_or(self.signatures.len() - 1); + EventResult::Consumed(None) + } + alt!('n') => { + self.active_signature = (self.active_signature + 1) % self.signatures.len(); + EventResult::Consumed(None) + } + _ => EventResult::Ignored(None), + } + } + fn render(&mut self, area: Rect, surface: &mut Buffer, cx: &mut Context) { let margin = Margin::horizontal(1); - let active_param_span = self.active_param_range.map(|(start, end)| { + let signature = &self.signatures[self.active_signature]; + + let active_param_span = signature.active_param_range.map(|(start, end)| { vec![( cx.editor .theme @@ -61,21 +108,29 @@ impl Component for SignatureHelp { )] }); + let sig = &self.signatures[self.active_signature]; let sig_text = crate::ui::markdown::highlighted_code_block( - &self.signature, + sig.signature.as_str(), &self.language, Some(&cx.editor.theme), Arc::clone(&self.config_loader), active_param_span, ); + if self.signatures.len() > 1 { + let signature_index = self.signature_index(); + let text = Text::from(signature_index); + let paragraph = Paragraph::new(&text).alignment(Alignment::Right); + paragraph.render(area.clip_top(1).with_height(1).clip_right(1), surface); + } + let (_, sig_text_height) = crate::ui::text::required_size(&sig_text, area.width); let sig_text_area = area.clip_top(1).with_height(sig_text_height); let sig_text_area = sig_text_area.inner(&margin).intersection(surface.area); - let sig_text_para = Paragraph::new(sig_text).wrap(Wrap { trim: false }); + let sig_text_para = Paragraph::new(&sig_text).wrap(Wrap { trim: false }); sig_text_para.render(sig_text_area, surface); - if self.signature_doc.is_none() { + if sig.signature_doc.is_none() { return; } @@ -87,7 +142,7 @@ impl Component for SignatureHelp { } } - let sig_doc = match &self.signature_doc { + let sig_doc = match &sig.signature_doc { None => return, Some(doc) => Markdown::new(doc.clone(), Arc::clone(&self.config_loader)), }; @@ -95,7 +150,7 @@ impl Component for SignatureHelp { let sig_doc_area = area .clip_top(sig_text_area.height + 2) .clip_bottom(u16::from(cx.editor.popup_border())); - let sig_doc_para = Paragraph::new(sig_doc) + let sig_doc_para = Paragraph::new(&sig_doc) .wrap(Wrap { trim: false }) .scroll((cx.scroll.unwrap_or_default() as u16, 0)); sig_doc_para.render(sig_doc_area.inner(&margin), surface); @@ -105,13 +160,12 @@ impl Component for SignatureHelp { const PADDING: u16 = 2; const SEPARATOR_HEIGHT: u16 = 1; - if PADDING >= viewport.1 || PADDING >= viewport.0 { - return None; - } - let max_text_width = (viewport.0 - PADDING).min(120); + let sig = &self.signatures[self.active_signature]; + + let max_text_width = viewport.0.saturating_sub(PADDING).clamp(10, 120); let signature_text = crate::ui::markdown::highlighted_code_block( - &self.signature, + sig.signature.as_str(), &self.language, None, Arc::clone(&self.config_loader), @@ -120,7 +174,7 @@ impl Component for SignatureHelp { let (sig_width, sig_height) = crate::ui::text::required_size(&signature_text, max_text_width); - let (width, height) = match self.signature_doc { + let (width, height) = match sig.signature_doc { Some(ref doc) => { let doc_md = Markdown::new(doc.clone(), Arc::clone(&self.config_loader)); let doc_text = doc_md.parse(None); @@ -134,6 +188,12 @@ impl Component for SignatureHelp { None => (sig_width, sig_height), }; - Some((width + PADDING, height + PADDING)) + let sig_index_width = if self.signatures.len() > 1 { + self.signature_index().len() + 1 + } else { + 0 + }; + + Some((width + PADDING + sig_index_width as u16, height + PADDING)) } } diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index 4d0c0d4a5..81499d039 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -1,4 +1,5 @@ use crate::compositor::{Component, Context}; +use arc_swap::ArcSwap; use tui::{ buffer::Buffer as Surface, text::{Span, Spans, Text}, @@ -6,7 +7,7 @@ use tui::{ use std::sync::Arc; -use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag}; +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use helix_core::{ syntax::{self, HighlightEvent, InjectionLanguageMarker, Syntax}, @@ -31,7 +32,7 @@ pub fn highlighted_code_block<'a>( text: &str, language: &str, theme: Option<&Theme>, - config_loader: Arc, + config_loader: Arc>, additional_highlight_spans: Option)>>, ) -> Text<'a> { let mut spans = Vec::new(); @@ -48,6 +49,7 @@ pub fn highlighted_code_block<'a>( let ropeslice = RopeSlice::from(text); let syntax = config_loader + .load() .language_configuration_for_injection_string(&InjectionLanguageMarker::Name( language.into(), )) @@ -121,7 +123,7 @@ pub fn highlighted_code_block<'a>( pub struct Markdown { contents: String, - config_loader: Arc, + config_loader: Arc>, } // TODO: pre-render and self reference via Pin @@ -140,7 +142,7 @@ impl Markdown { ]; const INDENT: &'static str = " "; - pub fn new(contents: String, config_loader: Arc) -> Self { + pub fn new(contents: String, config_loader: Arc>) -> Self { Self { contents, config_loader, @@ -209,7 +211,7 @@ impl Markdown { list_stack.push(list); } - Event::End(Tag::List(_)) => { + Event::End(TagEnd::List(_)) => { list_stack.pop(); // whenever top-level list closes, empty line @@ -249,7 +251,10 @@ impl Markdown { Event::End(tag) => { tags.pop(); match tag { - Tag::Heading(_, _, _) | Tag::Paragraph | Tag::CodeBlock(_) | Tag::Item => { + TagEnd::Heading(_) + | TagEnd::Paragraph + | TagEnd::CodeBlock + | TagEnd::Item => { push_line(&mut spans, &mut lines); } _ => (), @@ -257,7 +262,7 @@ impl Markdown { // whenever heading, code block or paragraph closes, empty line match tag { - Tag::Heading(_, _, _) | Tag::Paragraph | Tag::CodeBlock(_) => { + TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock => { lines.push(Spans::default()); } _ => (), @@ -279,7 +284,7 @@ impl Markdown { lines.extend(tui_text.lines.into_iter()); } else { let style = match tags.last() { - Some(Tag::Heading(level, ..)) => match level { + Some(Tag::Heading { level, .. }) => match level { HeadingLevel::H1 => heading_styles[0], HeadingLevel::H2 => heading_styles[1], HeadingLevel::H3 => heading_styles[2], @@ -341,7 +346,7 @@ impl Component for Markdown { let text = self.parse(Some(&cx.editor.theme)); - let par = Paragraph::new(text) + let par = Paragraph::new(&text) .wrap(Wrap { trim: false }) .scroll((cx.scroll.unwrap_or_default() as u16, 0)); diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 64127e3af..c5006f958 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -7,18 +7,11 @@ use crate::{ use helix_core::fuzzy::MATCHER; use nucleo::pattern::{Atom, AtomKind, CaseMatching}; use nucleo::{Config, Utf32Str}; -use tui::{ - buffer::Buffer as Surface, - widgets::{Block, Borders, Table, Widget}, -}; +use tui::{buffer::Buffer as Surface, widgets::Table}; pub use tui::widgets::{Cell, Row}; -use helix_view::{ - editor::SmartTabConfig, - graphics::{Margin, Rect}, - Editor, -}; +use helix_view::{editor::SmartTabConfig, graphics::Rect, Editor}; use tui::layout::Constraint; pub trait Item: Sync + Send + 'static { @@ -241,9 +234,9 @@ impl Menu { } impl Menu { - pub fn replace_option(&mut self, old_option: T, new_option: T) { + pub fn replace_option(&mut self, old_option: &T, new_option: T) { for option in &mut self.options { - if old_option == *option { + if old_option == option { *option = new_option; break; } @@ -341,16 +334,8 @@ impl Component for Menu { .try_get("ui.menu") .unwrap_or_else(|| theme.get("ui.text")); let selected = theme.get("ui.menu.selected"); - surface.clear_with(area, style); - - let render_borders = cx.editor.menu_border(); - let area = if render_borders { - Widget::render(Block::default().borders(Borders::ALL), area, surface); - area.inner(&Margin::vertical(1)) - } else { - area - }; + surface.clear_with(area, style); let scroll = self.scroll; @@ -427,6 +412,7 @@ impl Component for Menu { cell.set_fg(scroll_style.fg.unwrap_or(helix_view::theme::Color::Reset)); } else if !render_borders { // Draw scroll track + cell.set_symbol(half_block); cell.set_fg(scroll_style.bg.unwrap_or(helix_view::theme::Color::Reset)); } } diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 4c837aff1..2048b3fc8 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -14,11 +14,12 @@ mod spinner; mod statusline; mod text; -use crate::compositor::{Component, Compositor}; +use crate::compositor::Compositor; use crate::filter_picker_entry; use crate::job::{self, Callback}; pub use completion::{Completion, CompletionItem}; pub use editor::EditorView; +use helix_stdx::rope; pub use markdown::Markdown; pub use menu::Menu; pub use picker::{DynamicPicker, FileLocation, Picker}; @@ -27,8 +28,6 @@ pub use prompt::{Prompt, PromptEvent}; pub use spinner::{ProgressSpinners, Spinner}; pub use text::Text; -use helix_core::regex::Regex; -use helix_core::regex::RegexBuilder; use helix_view::Editor; use std::path::PathBuf; @@ -64,7 +63,22 @@ pub fn regex_prompt( prompt: std::borrow::Cow<'static, str>, history_register: Option, completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, - fun: impl Fn(&mut crate::compositor::Context, Regex, PromptEvent) + 'static, + fun: impl Fn(&mut crate::compositor::Context, rope::Regex, PromptEvent) + 'static, +) { + raw_regex_prompt( + cx, + prompt, + history_register, + completion_fn, + move |cx, regex, _, event| fun(cx, regex, event), + ); +} +pub fn raw_regex_prompt( + cx: &mut crate::commands::Context, + prompt: std::borrow::Cow<'static, str>, + history_register: Option, + completion_fn: impl FnMut(&Editor, &str) -> Vec + 'static, + fun: impl Fn(&mut crate::compositor::Context, rope::Regex, &str, PromptEvent) + 'static, ) { let (view, doc) = current!(cx.editor); let doc_id = view.doc; @@ -95,10 +109,13 @@ pub fn regex_prompt( false }; - match RegexBuilder::new(input) - .case_insensitive(case_insensitive) - .multi_line(true) - .build() + match rope::RegexBuilder::new() + .syntax( + rope::Config::new() + .case_insensitive(case_insensitive) + .multi_line(true), + ) + .build(input) { Ok(regex) => { let (view, doc) = current!(cx.editor); @@ -111,7 +128,7 @@ pub fn regex_prompt( view.jumps.push((doc_id, snapshot.clone())); } - fun(cx, regex, event); + fun(cx, regex, input, event); let (view, doc) = current!(cx.editor); view.ensure_cursor_in_view(doc, config.scrolloff); @@ -127,14 +144,12 @@ pub fn regex_prompt( move |_editor: &mut Editor, compositor: &mut Compositor| { let contents = Text::new(format!("{}", err)); let size = compositor.size(); - let mut popup = Popup::new("invalid-regex", contents) + let popup = Popup::new("invalid-regex", contents) .position(Some(helix_core::Position::new( size.height as usize - 2, // 2 = statusline + commandline 0, ))) .auto_close(true); - popup.required_size((size.width, size.height)); - compositor.replace_or_push("invalid-regex", popup); }, )); @@ -338,8 +353,8 @@ pub mod completers { pub fn language(editor: &Editor, input: &str) -> Vec { let text: String = "text".into(); - let language_ids = editor - .syn_loader + let loader = editor.syn_loader.load(); + let language_ids = loader .language_configs() .map(|config| &config.language_id) .chain(std::iter::once(&text)); @@ -430,9 +445,9 @@ pub mod completers { path } else { match path.parent() { - Some(path) if !path.as_os_str().is_empty() => path.to_path_buf(), + Some(path) if !path.as_os_str().is_empty() => Cow::Borrowed(path), // Path::new("h")'s parent is Some("")... - _ => helix_stdx::env::current_working_dir(), + _ => Cow::Owned(helix_stdx::env::current_working_dir()), } }; @@ -494,4 +509,18 @@ pub mod completers { files } } + + pub fn register(editor: &Editor, input: &str) -> Vec { + let iter = editor + .registers + .iter_preview() + // Exclude special registers that shouldn't be written to + .filter(|(ch, _)| !matches!(ch, '%' | '#' | '.')) + .map(|(ch, _)| ch.to_string()); + + fuzzy_match(input, iter, false) + .into_iter() + .map(|(name, _)| ((0..), name.into())) + .collect() + } } diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 4be5a11ef..c2728888a 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -461,14 +461,17 @@ impl Picker { // Then attempt to highlight it if it has no language set if doc.language_config().is_none() { - if let Some(language_config) = doc.detect_language_config(&cx.editor.syn_loader) { + if let Some(language_config) = doc.detect_language_config(&cx.editor.syn_loader.load()) + { doc.language = Some(language_config.clone()); let text = doc.text().clone(); let loader = cx.editor.syn_loader.clone(); let job = tokio::task::spawn_blocking(move || { - let syntax = language_config.highlight_config(&loader.scopes()).and_then( - |highlight_config| Syntax::new(text.slice(..), highlight_config, loader), - ); + let syntax = language_config + .highlight_config(&loader.load().scopes()) + .and_then(|highlight_config| { + Syntax::new(text.slice(..), highlight_config, loader) + }); let callback = move |editor: &mut Editor, compositor: &mut Compositor| { let Some(syntax) = syntax else { log::info!("highlighting picker item failed"); diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index 7a6ffe9dd..5362bdc7f 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -11,20 +11,30 @@ use tui::{ use helix_core::Position; use helix_view::{ graphics::{Margin, Rect}, + input::{MouseEvent, MouseEventKind}, Editor, }; +const MIN_HEIGHT: u16 = 6; +const MAX_HEIGHT: u16 = 26; +const MAX_WIDTH: u16 = 120; + +struct RenderInfo { + area: Rect, + child_height: u16, + render_borders: bool, + is_menu: bool, +} + // TODO: share logic with Menu, it's essentially Popup(render_fn), but render fn needs to return // a width/height hint. maybe Popup(Box) pub struct Popup { contents: T, position: Option, - margin: Margin, - size: (u16, u16), - child_size: (u16, u16), + area: Rect, position_bias: Open, - scroll: usize, + scroll_half_pages: usize, auto_close: bool, ignore_escape_key: bool, id: &'static str, @@ -36,11 +46,9 @@ impl Popup { Self { contents, position: None, - margin: Margin::none(), - size: (0, 0), position_bias: Open::Below, - child_size: (0, 0), - scroll: 0, + area: Rect::new(0, 0, 0, 0), + scroll_half_pages: 0, auto_close: false, ignore_escape_key: false, id, @@ -70,11 +78,6 @@ impl Popup { self } - pub fn margin(mut self, margin: Margin) -> Self { - self.margin = margin; - self - } - pub fn auto_close(mut self, auto_close: bool) -> Self { self.auto_close = auto_close; self @@ -92,28 +95,65 @@ impl Popup { self } - /// Calculate the position where the popup should be rendered and return the coordinates of the - /// top left corner. - pub fn get_rel_position(&mut self, viewport: Rect, editor: &Editor) -> (u16, u16) { - let position = self + pub fn scroll_half_page_down(&mut self) { + self.scroll_half_pages += 1; + } + + pub fn scroll_half_page_up(&mut self) { + self.scroll_half_pages = self.scroll_half_pages.saturating_sub(1); + } + + /// Toggles the Popup's scrollbar. + /// Consider disabling the scrollbar in case the child + /// already has its own. + pub fn with_scrollbar(mut self, enable_scrollbar: bool) -> Self { + self.has_scrollbar = enable_scrollbar; + self + } + + pub fn contents(&self) -> &T { + &self.contents + } + + pub fn contents_mut(&mut self) -> &mut T { + &mut self.contents + } + + pub fn area(&mut self, viewport: Rect, editor: &Editor) -> Rect { + self.render_info(viewport, editor).area + } + + fn render_info(&mut self, viewport: Rect, editor: &Editor) -> RenderInfo { + let mut position = editor.cursor().0.unwrap_or_default(); + if let Some(old_position) = self .position - .get_or_insert_with(|| editor.cursor().0.unwrap_or_default()); + .filter(|old_position| old_position.row == position.row) + { + position = old_position; + } else { + self.position = Some(position); + } - let (width, height) = self.size; + let is_menu = self + .contents + .type_name() + .starts_with("helix_term::ui::menu::Menu"); - // if there's a orientation preference, use that - // if we're on the top part of the screen, do below - // if we're on the bottom part, do above + let mut render_borders = if is_menu { + editor.menu_border() + } else { + editor.popup_border() + }; // -- make sure frame doesn't stick out of bounds let mut rel_x = position.col as u16; let mut rel_y = position.row as u16; - if viewport.width <= rel_x + width { - rel_x = rel_x.saturating_sub((rel_x + width).saturating_sub(viewport.width)); - } - let can_put_below = viewport.height > rel_y + height; - let can_put_above = rel_y.checked_sub(height).is_some(); + // if there's a orientation preference, use that + // if we're on the top part of the screen, do below + // if we're on the bottom part, do above + let can_put_below = viewport.height > rel_y + MIN_HEIGHT; + let can_put_above = rel_y.checked_sub(MIN_HEIGHT).is_some(); let final_pos = match self.position_bias { Open::Below => match can_put_below { true => Open::Below, @@ -125,51 +165,87 @@ impl Popup { }, }; - rel_y = match final_pos { - Open::Above => rel_y.saturating_sub(height), - Open::Below => rel_y + 1, + // compute maximum space available for child + let mut max_height = match final_pos { + Open::Above => rel_y, + Open::Below => viewport.height.saturating_sub(1 + rel_y), }; + max_height = max_height.min(MAX_HEIGHT); + let mut max_width = viewport.width.saturating_sub(2).min(MAX_WIDTH); + render_borders = render_borders && max_height > 3 && max_width > 3; + if render_borders { + max_width -= 2; + max_height -= 2; + } - (rel_x, rel_y) - } - - pub fn get_size(&self) -> (u16, u16) { - (self.size.0, self.size.1) - } + // compute required child size and reclamp + let (mut width, child_height) = self + .contents + .required_size((max_width, max_height)) + .expect("Component needs required_size implemented in order to be embedded in a popup"); - pub fn scroll(&mut self, offset: usize, direction: bool) { - if direction { - let max_offset = self.child_size.1.saturating_sub(self.size.1); - self.scroll = (self.scroll + offset).min(max_offset as usize); + width = width.min(MAX_WIDTH); + let height = if render_borders { + (child_height + 2).min(MAX_HEIGHT) } else { - self.scroll = self.scroll.saturating_sub(offset); + child_height.min(MAX_HEIGHT) + }; + if render_borders { + width += 2; + } + if viewport.width <= rel_x + width + 2 { + rel_x = viewport.width.saturating_sub(width + 2); + width = viewport.width.saturating_sub(rel_x + 2) } - } - - /// Toggles the Popup's scrollbar. - /// Consider disabling the scrollbar in case the child - /// already has its own. - pub fn with_scrollbar(mut self, enable_scrollbar: bool) -> Self { - self.has_scrollbar = enable_scrollbar; - self - } - - pub fn contents(&self) -> &T { - &self.contents - } - pub fn contents_mut(&mut self) -> &mut T { - &mut self.contents + let area = match final_pos { + Open::Above => { + rel_y = rel_y.saturating_sub(height); + Rect::new(rel_x, rel_y, width, position.row as u16 - rel_y) + } + Open::Below => { + rel_y += 1; + let y_max = viewport.bottom().min(height + rel_y); + Rect::new(rel_x, rel_y, width, y_max - rel_y) + } + }; + RenderInfo { + area, + child_height, + render_borders, + is_menu, + } } - pub fn area(&mut self, viewport: Rect, editor: &Editor) -> Rect { - // trigger required_size so we recalculate if the child changed - self.required_size((viewport.width, viewport.height)); - - let (rel_x, rel_y) = self.get_rel_position(viewport, editor); + fn handle_mouse_event( + &mut self, + &MouseEvent { + kind, + column: x, + row: y, + .. + }: &MouseEvent, + ) -> EventResult { + let mouse_is_within_popup = x >= self.area.left() + && x < self.area.right() + && y >= self.area.top() + && y < self.area.bottom(); + + if !mouse_is_within_popup { + return EventResult::Ignored(None); + } - // clip to viewport - viewport.intersection(Rect::new(rel_x, rel_y, self.size.0, self.size.1)) + match kind { + MouseEventKind::ScrollDown if self.has_scrollbar => { + self.scroll_half_page_down(); + EventResult::Consumed(None) + } + MouseEventKind::ScrollUp if self.has_scrollbar => { + self.scroll_half_page_up(); + EventResult::Consumed(None) + } + _ => EventResult::Ignored(None), + } } } @@ -177,6 +253,7 @@ impl Component for Popup { fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { let key = match event { Event::Key(event) => *event, + Event::Mouse(event) => return self.handle_mouse_event(event), Event::Resize(_, _) => { // TODO: calculate inner area, call component's handle_event with that area return EventResult::Ignored(None); @@ -200,11 +277,11 @@ impl Component for Popup { EventResult::Consumed(Some(close_fn)) } ctrl!('d') => { - self.scroll(self.size.1 as usize / 2, true); + self.scroll_half_page_down(); EventResult::Consumed(None) } ctrl!('u') => { - self.scroll(self.size.1 as usize / 2, false); + self.scroll_half_page_up(); EventResult::Consumed(None) } _ => { @@ -223,63 +300,48 @@ impl Component for Popup { // tab/enter/ctrl-k or whatever will confirm the selection/ ctrl-n/ctrl-p for scroll. } - fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { - let max_width = 120.min(viewport.0); - let max_height = 26.min(viewport.1.saturating_sub(2)); // add some spacing in the viewport - - let inner = Rect::new(0, 0, max_width, max_height).inner(&self.margin); - - let (width, height) = self - .contents - .required_size((inner.width, inner.height)) - .expect("Component needs required_size implemented in order to be embedded in a popup"); - - self.child_size = (width, height); - self.size = ( - (width + self.margin.width()).min(max_width), - (height + self.margin.height()).min(max_height), - ); - - // re-clamp scroll offset - let max_offset = self.child_size.1.saturating_sub(self.size.1); - self.scroll = self.scroll.min(max_offset as usize); - - Some(self.size) - } - fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { - let area = self.area(viewport, cx.editor); - cx.scroll = Some(self.scroll); + let RenderInfo { + area, + child_height, + render_borders, + is_menu, + } = self.render_info(viewport, cx.editor); + self.area = area; // clear area - let background = cx.editor.theme.get("ui.popup"); - surface.clear_with(area, background); - - let render_borders = cx.editor.popup_border(); - - let inner = if self - .contents - .type_name() - .starts_with("helix_term::ui::menu::Menu") - { - area + let background = if is_menu { + // TODO: consistently style menu + cx.editor + .theme + .try_get("ui.menu") + .unwrap_or_else(|| cx.editor.theme.get("ui.text")) } else { - area.inner(&self.margin) + cx.editor.theme.get("ui.popup") }; + surface.clear_with(area, background); - let border = usize::from(render_borders); + let mut inner = area; if render_borders { + inner = area.inner(&Margin::all(1)); Widget::render(Block::default().borders(Borders::ALL), area, surface); } + let border = usize::from(render_borders); + let max_offset = child_height.saturating_sub(inner.height) as usize; + let half_page_size = (inner.height / 2) as usize; + let scroll = max_offset.min(self.scroll_half_pages * half_page_size); + if half_page_size > 0 { + self.scroll_half_pages = scroll / half_page_size; + } + cx.scroll = Some(scroll); self.contents.render(inner, surface, cx); // render scrollbar if contents do not fit if self.has_scrollbar { - let win_height = (inner.height as usize).saturating_sub(2 * border); - let len = (self.child_size.1 as usize).saturating_sub(2 * border); + let win_height = inner.height as usize; + let len = child_height as usize; let fits = len <= win_height; - let scroll = self.scroll; let scroll_style = cx.editor.theme.get("ui.menu.scroll"); const fn div_ceil(a: usize, b: usize) -> usize { @@ -293,7 +355,8 @@ impl Component for Popup { let mut cell; for i in 0..win_height { - cell = &mut surface[(inner.right() - 1, inner.top() + (border + i) as u16)]; + cell = + &mut surface[(inner.right() - 1 + border as u16, inner.top() + i as u16)]; let half_block = if render_borders { "▌" } else { "▐" }; @@ -303,6 +366,7 @@ impl Component for Popup { cell.set_fg(scroll_style.fg.unwrap_or(helix_view::theme::Color::Reset)); } else if !render_borders { // Draw scroll track + cell.set_symbol(half_block); cell.set_fg(scroll_style.bg.unwrap_or(helix_view::theme::Color::Reset)); } } diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 702a6e671..a6ee7f05d 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -1,5 +1,6 @@ use crate::compositor::{Component, Compositor, Context, Event, EventResult}; use crate::{alt, ctrl, key, shift, ui}; +use arc_swap::ArcSwap; use helix_core::syntax; use helix_view::input::KeyEvent; use helix_view::keyboard::KeyCode; @@ -34,7 +35,7 @@ pub struct Prompt { callback_fn: CallbackFn, pub doc_fn: DocFn, next_char_handler: Option, - language: Option<(&'static str, Arc)>, + language: Option<(&'static str, Arc>)>, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -98,7 +99,11 @@ impl Prompt { self } - pub fn with_language(mut self, language: &'static str, loader: Arc) -> Self { + pub fn with_language( + mut self, + language: &'static str, + loader: Arc>, + ) -> Self { self.language = Some((language, loader)); self } @@ -393,7 +398,7 @@ impl Prompt { height, ); - if !self.completion.is_empty() { + if completion_area.height > 0 && !self.completion.is_empty() { let area = completion_area; let background = theme.get("ui.menu"); diff --git a/helix-term/src/ui/spinner.rs b/helix-term/src/ui/spinner.rs index 379c4489f..9ce610555 100644 --- a/helix-term/src/ui/spinner.rs +++ b/helix-term/src/ui/spinner.rs @@ -1,16 +1,18 @@ use std::{collections::HashMap, time::Instant}; +use helix_lsp::LanguageServerId; + #[derive(Default, Debug)] pub struct ProgressSpinners { - inner: HashMap, + inner: HashMap, } impl ProgressSpinners { - pub fn get(&self, id: usize) -> Option<&Spinner> { + pub fn get(&self, id: LanguageServerId) -> Option<&Spinner> { self.inner.get(&id) } - pub fn get_or_create(&mut self, id: usize) -> &mut Spinner { + pub fn get_or_create(&mut self, id: LanguageServerId) -> &mut Spinner { self.inner.entry(id).or_default() } } diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index 963940625..ace51dbd2 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -4,7 +4,6 @@ use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::{ document::{Mode, SCRATCH_BUFFER_NAME}, graphics::Rect, - theme::Style, Document, Editor, View, }; @@ -20,7 +19,6 @@ pub struct RenderContext<'a> { pub view: &'a View, pub focused: bool, pub spinners: &'a ProgressSpinners, - pub parts: RenderBuffer<'a>, } impl<'a> RenderContext<'a> { @@ -37,18 +35,10 @@ impl<'a> RenderContext<'a> { view, focused, spinners, - parts: RenderBuffer::default(), } } } -#[derive(Default)] -pub struct RenderBuffer<'a> { - pub left: Spans<'a>, - pub center: Spans<'a>, - pub right: Spans<'a>, -} - pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface) { let base_style = if context.focused { context.editor.theme.get("ui.statusline") @@ -58,90 +48,93 @@ pub fn render(context: &mut RenderContext, viewport: Rect, surface: &mut Surface surface.set_style(viewport.with_height(1), base_style); - let write_left = |context: &mut RenderContext, text, style| { - append(&mut context.parts.left, text, &base_style, style) - }; - let write_center = |context: &mut RenderContext, text, style| { - append(&mut context.parts.center, text, &base_style, style) - }; - let write_right = |context: &mut RenderContext, text, style| { - append(&mut context.parts.right, text, &base_style, style) - }; - - // Left side of the status line. - - let config = context.editor.config(); - - let element_ids = &config.statusline.left; - element_ids - .iter() - .map(|element_id| get_render_function(*element_id)) - .for_each(|render| render(context, write_left)); + let statusline = render_statusline(context, viewport.width as usize); surface.set_spans( viewport.x, viewport.y, - &context.parts.left, - context.parts.left.width() as u16, + &statusline, + statusline.width() as u16, ); +} - // Right side of the status line. +pub fn render_statusline<'a>(context: &mut RenderContext, width: usize) -> Spans<'a> { + let config = context.editor.config(); - let element_ids = &config.statusline.right; - element_ids + let element_ids = &config.statusline.left; + let mut left = element_ids .iter() .map(|element_id| get_render_function(*element_id)) - .for_each(|render| render(context, write_right)); - - surface.set_spans( - viewport.x - + viewport - .width - .saturating_sub(context.parts.right.width() as u16), - viewport.y, - &context.parts.right, - context.parts.right.width() as u16, - ); - - // Center of the status line. + .flat_map(|render| render(context).0) + .collect::>(); let element_ids = &config.statusline.center; - element_ids + let mut center = element_ids .iter() .map(|element_id| get_render_function(*element_id)) - .for_each(|render| render(context, write_center)); + .flat_map(|render| render(context).0) + .collect::>(); - // Width of the empty space between the left and center area and between the center and right area. - let spacing = 1u16; - - let edge_width = context.parts.left.width().max(context.parts.right.width()) as u16; - let center_max_width = viewport.width.saturating_sub(2 * edge_width + 2 * spacing); - let center_width = center_max_width.min(context.parts.center.width() as u16); - - surface.set_spans( - viewport.x + viewport.width / 2 - center_width / 2, - viewport.y, - &context.parts.center, - center_width, - ); -} + let element_ids = &config.statusline.right; + let mut right = element_ids + .iter() + .map(|element_id| get_render_function(*element_id)) + .flat_map(|render| render(context).0) + .collect::>(); + + let left_area_width: usize = left.iter().map(|s| s.width()).sum(); + let center_area_width: usize = center.iter().map(|s| s.width()).sum(); + let right_area_width: usize = right.iter().map(|s| s.width()).sum(); + + let min_spacing_between_areas = 1usize; + let sides_space_required = left_area_width + right_area_width + min_spacing_between_areas; + let total_space_required = sides_space_required + center_area_width + min_spacing_between_areas; + + let mut statusline: Vec = vec![]; + + if center_area_width > 0 && total_space_required <= width { + // SAFETY: this subtraction cannot underflow because `left_area_width + center_area_width + right_area_width` + // is smaller than `total_space_required`, which is smaller than `width` in this branch. + let total_spacers = width - (left_area_width + center_area_width + right_area_width); + // This is how much padding space it would take on either side to align the center area to the middle. + let center_margin = (width - center_area_width) / 2; + let left_spacers = if left_area_width < center_margin && right_area_width < center_margin { + // Align the center area to the middle if there is enough space on both sides. + center_margin - left_area_width + } else { + // Otherwise split the available space evenly and use it as margin. + // The center element won't be aligned to the middle but it will be evenly + // spaced between the left and right areas. + total_spacers / 2 + }; + let right_spacers = total_spacers - left_spacers; + + statusline.append(&mut left); + statusline.push(" ".repeat(left_spacers).into()); + statusline.append(&mut center); + statusline.push(" ".repeat(right_spacers).into()); + statusline.append(&mut right); + } else if right_area_width > 0 && sides_space_required <= width { + let side_areas_width = left_area_width + right_area_width; + statusline.append(&mut left); + statusline.push(" ".repeat(width - side_areas_width).into()); + statusline.append(&mut right); + } else if left_area_width <= width { + statusline.append(&mut left); + } -fn append(buffer: &mut Spans, text: String, base_style: &Style, style: Option