diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9b7c22e7a..958407bb8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -17,7 +17,7 @@ Please search on the issue tracker before creating one. --> ### Environment - Platform: -- Helix version: +- Helix version:
~/.cache/helix/helix.log diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d4822f706..216291804 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,19 +25,19 @@ jobs: override: true - name: Cache cargo registry - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ~/.cargo/registry key: ${{ runner.os }}-v2-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ~/.cargo/git key: ${{ runner.os }}-v2-cargo-index-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo target dir - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: target key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} @@ -64,19 +64,19 @@ jobs: override: true - name: Cache cargo registry - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ~/.cargo/registry key: ${{ runner.os }}-v2-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ~/.cargo/git key: ${{ runner.os }}-v2-cargo-index-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo target dir - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: target key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} @@ -109,19 +109,19 @@ jobs: components: rustfmt, clippy - name: Cache cargo registry - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ~/.cargo/registry key: ${{ runner.os }}-v2-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: ~/.cargo/git key: ${{ runner.os }}-v2-cargo-index-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo target dir - uses: actions/cache@v2.1.6 + uses: actions/cache@v2.1.7 with: path: target key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} diff --git a/.gitmodules b/.gitmodules index 7ed34ad39..6295b9e95 100644 --- a/.gitmodules +++ b/.gitmodules @@ -134,3 +134,19 @@ path = helix-syntax/languages/tree-sitter-cmake url = https://github.com/uyha/tree-sitter-cmake shallow = true +[submodule "helix-syntax/languages/tree-sitter-glsl"] + path = helix-syntax/languages/tree-sitter-glsl + url = https://github.com/theHamsta/tree-sitter-glsl.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-perl"] + path = helix-syntax/languages/tree-sitter-perl + url = https://github.com/ganezdragon/tree-sitter-perl + shallow = true +[submodule "helix-syntax/languages/tree-sitter-wgsl"] + path = helix-syntax/languages/tree-sitter-wgsl + url = https://github.com/szebniok/tree-sitter-wgsl + shallow = true +[submodule "helix-syntax/tree-sitter-llvm"] + path = helix-syntax/languages/tree-sitter-llvm + url = https://github.com/benwilliamgraham/tree-sitter-llvm + shallow = true diff --git a/Cargo.lock b/Cargo.lock index e036828a7..47a6c01e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,15 +13,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.44" +version = "1.0.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1" +checksum = "8b26702f315f53b6071259e15dd9d64528213b44d61de1ec926eca7715d62203" [[package]] name = "arc-swap" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6df5aef5c5830360ce5218cecb8f018af3438af5686ae945094affc86fdec63" +checksum = "c5d78ce20460b82d3fa150275ed9d55e21064fc7951177baacf86a145c4a4b1f" [[package]] name = "autocfg" @@ -66,9 +66,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cc" -version = "1.0.71" +version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79c2681d6594606957bbb8631c4b90a7fcaaa72cdb714743a437b156d6a7eedd" +checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee" [[package]] name = "cfg-if" @@ -258,15 +258,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d1c26957f23603395cd326b0ffe64124b818f4449552f960d815cfba83a53d" +checksum = "629316e42fe7c2a0b9a65b47d159ceaa5453ab14e8f0a3c5eedbb8cd55b4a445" [[package]] name = "futures-executor" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45025be030969d763025784f7f355043dc6bc74093e4ecc5000ca4dc50d8745c" +checksum = "7b808bf53348a36cab739d7e04755909b9fcaaa69b7d7e588b37b6ec62704c97" dependencies = [ "futures-core", "futures-task", @@ -275,17 +275,16 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3d00f4eddb73e498a54394f228cd55853bdf059259e8e7bc6e69d408892e99" +checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12" [[package]] name = "futures-util" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36568465210a3a6ee45e1f165136d68671471a501e632e9a98d96872222b5481" +checksum = "41d22213122356472061ac0f1ab2cee28d2bac8491410fd68c2af53d1cedb83e" dependencies = [ - "autocfg", "futures-core", "futures-task", "pin-project-lite", @@ -370,6 +369,7 @@ name = "helix-core" version = "0.5.0" dependencies = [ "arc-swap", + "chrono", "etcetera", "helix-syntax", "log", @@ -566,9 +566,9 @@ checksum = "7b2f96d100e1cf1929e7719b7edb3b90ab5298072638fccd77be9ce942ecdfce" [[package]] name = "libloading" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cf036d15402bea3c5d4de17b3fce76b3e4a56ebc1f577be0e7a72f7c607cf0" +checksum = "afe203d669ec979b7128619bae5a63b7b42e9203c1b29146079ee05e2f604b52" dependencies = [ "cfg-if", "winapi", @@ -897,9 +897,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.68" +version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f690853975602e1bfe1ccbf50504d67174e3bcf340f23b5ea9992e0587a52d8" +checksum = "d0ffa0837f2dfa6fb90868c2b5468cad482e175f7dad97e7421951e663f2b527" dependencies = [ "itoa", "ryu", @@ -1069,9 +1069,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588b2d10a336da58d877567cd8fb8a14b463e2104910f8132cd054b4b96e29ee" +checksum = "70e992e41e0d2fb9f755b37446f20900f64446ef54874f40a60c78f021ac6144" dependencies = [ "autocfg", "bytes", @@ -1089,9 +1089,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2dd85aeaba7b68df939bd357c6afb36c87951be9e80bf9c859f2fc3e9fca0fd" +checksum = "c9efc1aba077437943f7515666aa2b882dfabfbfdf89c819ea75a8d6e9eaba5e" dependencies = [ "proc-macro2", "quote", @@ -1120,9 +1120,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63ec02a07a782abef91279b72fe8fd2bee4c168a22112cedec5d3b0d49b9e4f9" +checksum = "9394e9dbfe967b5f3d6ab79e302e78b5fb7b530c368d634ff3b8d67ede138bf1" dependencies = [ "cc", "regex", diff --git a/Cargo.toml b/Cargo.toml index 22d292600..580cccd64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ members = [ [profile.dev] split-debuginfo = "unpacked" + +[profile.release] +lto = "thin" diff --git a/README.md b/README.md index faf5851ed..3f4087b9f 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Contributors are very welcome! **No contribution is too small and all contributi Some suggestions to get started: -- You can look at the [good first issue](https://github.com/helix-editor/helix/labels/E-easy) label on the issue tracker. +- You can look at the [good first issue](https://github.com/helix-editor/helix/issues?q=is%3Aopen+label%3AE-easy+label%3AE-good-first-issue) label on the issue tracker. - Help with packaging on various distributions needed! - To use print debugging to the [Helix log file](https://github.com/helix-editor/helix/wiki/FAQ#access-the-log-file), you must: * Print using `log::info!`, `warn!`, or `error!`. (`log::info!("helix!")`) diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 56f50e212..8cadb663f 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -8,5 +8,7 @@ - [Keymap](./keymap.md) - [Key Remapping](./remapping.md) - [Hooks](./hooks.md) + - [Languages](./languages.md) - [Guides](./guides/README.md) + - [Adding Languages](./guides/adding_languages.md) - [Adding Textobject Queries](./guides/textobject.md) diff --git a/book/src/configuration.md b/book/src/configuration.md index be25441f5..2ed48d51f 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -24,6 +24,18 @@ To override global configuration parameters, create a `config.toml` file located | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `auto-info` | Whether to display infoboxes | `true` | +`[editor.filepicker]` section of the config. Sets options for file picker and global search. All but the last key listed in the default file-picker configuration below are IgnoreOptions: whether hidden files and files listed within ignore files are ignored by (not visible in) the helix file picker and global search. There is also one other key, `max-depth` available, which is not defined by default. + +| Key | Description | Default | +|--|--|---------| +|`hidden` | Enables ignoring hidden files. | true +|`parents` | Enables reading ignore files from parent directories. | true +|`ignore` | Enables reading `.ignore` files. | true +|`git-ignore` | Enables reading `.gitignore` files. | true +|`git-global` | Enables reading global .gitignore, whose path is specified in git's config: `core.excludefile` 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`. + ## LSP To display all language server messages in the status line add the following to your `config.toml`: diff --git a/book/src/guides/adding_languages.md b/book/src/guides/adding_languages.md new file mode 100644 index 000000000..9ad2c2859 --- /dev/null +++ b/book/src/guides/adding_languages.md @@ -0,0 +1,60 @@ +# Adding languages + +## Submodules + +To add a new language, you should first add a tree-sitter submodule. To do this, +you can run the command +```sh +git submodule add -f helix-syntax/languages/tree-sitter- +``` +For example, to add tree-sitter-ocaml you would run +```sh +git submodule add -f https://github.com/tree-sitter/tree-sitter-ocaml helix-syntax/languages/tree-sitter-ocaml +``` +Make sure the submodule is shallow by doing +```sh +git config -f .gitmodules submodule.helix-syntax/languages/tree-sitter-.shallow true +``` + +or you can manually add `shallow = true` to `.gitmodules`. + +## languages.toml + +Next, you need to add the language to the [`languages.toml`][languages.toml] found in the root of +the repository; this `languages.toml` file is included at compilation time, and +is distinct from the `language.toml` file in the user's [configuration +directory](../configuration.md). + +These are the available keys and descriptions for the file. + +| Key | Description | +| ---- | ----------- | +| name | The name of the language | +| scope | A string like `source.js` that identifies the language. Currently, we strive to match the scope names used by popular TextMate grammars and by the Linguist library. Usually `source.` or `text.` in case of markup languages | +| injection-regex | regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. | +| file-types | The filetypes of the language, for example `["yml", "yaml"]` | +| shebangs | The interpreters from the shebang line, for example `["sh", "bash"]` | +| roots | A set of marker files to look for when trying to find the workspace root. For example `Cargo.lock`, `yarn.lock` | +| auto-format | Whether to autoformat this language when saving | +| comment-token | The token to use as a comment-token | +| indent | The indent to use. Has sub keys `tab-width` and `unit` | +| config | Language server configuration | + +## Queries + +For a language to have syntax-highlighting and indentation among other things, you have to add queries. Add a directory for your language with the path `runtime/queries//`. The tree-sitter [website](https://tree-sitter.github.io/tree-sitter/syntax-highlighting#queries) gives more info on how to write queries. + +## Common Issues + +- If you get errors when building after switching branches, you may have to remove or update tree-sitter submodules. You can update submodules by running + ```sh + git submodule sync; git submodule update --init + ``` +- Make sure to not use the `--remote` flag. To remove submodules look inside the `.gitmodules` and remove directories that are not present inside of it. + +- If a parser is segfaulting or you want to remove the parser, make sure to remove the submodule *and* the compiled parser in `runtime/grammar/.so` + +- The indents query is `indents.toml`, *not* `indents.scm`. See [this](https://github.com/helix-editor/helix/issues/114) issue for more information. + +[treesitter-language-injection]: https://tree-sitter.github.io/tree-sitter/syntax-highlighting#language-injection +[languages.toml]: https://github.com/helix-editor/helix/blob/master/languages.toml diff --git a/book/src/guides/textobject.md b/book/src/guides/textobject.md index 50b3b574a..dd726b7c9 100644 --- a/book/src/guides/textobject.md +++ b/book/src/guides/textobject.md @@ -5,7 +5,7 @@ require an accompanying tree-sitter grammar and a `textobjects.scm` query file to work properly. Tree-sitter allows us to query the source code syntax tree and capture specific parts of it. The queries are written in a lisp dialect. More information on how to write queries can be found in the [official tree-sitter -documentation](tree-sitter-queries). +documentation][tree-sitter-queries]. Query files should be placed in `runtime/queries/{language}/textobjects.scm` when contributing. Note that to test the query files locally you should put diff --git a/book/src/install.md b/book/src/install.md index b9febbccd..d831934c4 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -25,9 +25,7 @@ shell for working on Helix. Releases are available in the `community` repository. -Packages are also available on AUR: -- [helix-bin](https://aur.archlinux.org/packages/helix-bin/) contains the pre-built release -- [helix-git](https://aur.archlinux.org/packages/helix-git/) builds the master branch +A [helix-git](https://aur.archlinux.org/packages/helix-git/) package is also available on the AUR, which builds the master branch. ## Build from source diff --git a/book/src/keymap.md b/book/src/keymap.md index 5a6aee411..59353db90 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -1,5 +1,8 @@ # Keymap +- Mappings marked (**LSP**) require an active language server for the file. +- Mappings marked (**TS**) require a tree-sitter grammar for the filetype. + ## Normal mode ### Movement @@ -42,40 +45,46 @@ ### Changes -| Key | Description | Command | -| ----- | ----------- | ------- | -| `r` | Replace with a character | `replace` | -| `R` | Replace with yanked text | `replace_with_yanked` | -| `~` | Switch case of the selected text | `switch_case` | -| `` ` `` | Set the selected text to lower case | `switch_to_lowercase` | -| `` Alt-` `` | Set the selected text to upper case | `switch_to_uppercase` | -| `i` | Insert before selection | `insert_mode` | -| `a` | Insert after selection (append) | `append_mode` | -| `I` | Insert at the start of the line | `prepend_to_line` | -| `A` | Insert at the end of the line | `append_to_line` | -| `o` | Open new line below selection | `open_below` | -| `O` | Open new line above selection | `open_above` | -| `.` | Repeat last change | N/A | -| `u` | Undo change | `undo` | -| `U` | Redo change | `redo` | -| `y` | Yank selection | `yank` | -| `p` | Paste after selection | `paste_after` | -| `P` | Paste before selection | `paste_before` | -| `"` `` | Select a register to yank to or paste from | `select_register` | -| `>` | Indent selection | `indent` | -| `<` | Unindent selection | `unindent` | -| `=` | Format selection | `format_selections` | -| `d` | Delete selection | `delete_selection` | -| `c` | Change selection (delete and enter insert mode) | `change_selection` | +| Key | Description | Command | +| ----- | ----------- | ------- | +| `r` | Replace with a character | `replace` | +| `R` | Replace with yanked text | `replace_with_yanked` | +| `~` | Switch case of the selected text | `switch_case` | +| `` ` `` | Set the selected text to lower case | `switch_to_lowercase` | +| `` Alt-` `` | Set the selected text to upper case | `switch_to_uppercase` | +| `i` | Insert before selection | `insert_mode` | +| `a` | Insert after selection (append) | `append_mode` | +| `I` | Insert at the start of the line | `prepend_to_line` | +| `A` | Insert at the end of the line | `append_to_line` | +| `o` | Open new line below selection | `open_below` | +| `O` | Open new line above selection | `open_above` | +| `.` | Repeat last change | N/A | +| `u` | Undo change | `undo` | +| `U` | Redo change | `redo` | +| `Alt-u` | Move backward in history | `earlier` | +| `Alt-U` | Move forward in history | `later` | +| `y` | Yank selection | `yank` | +| `p` | Paste after selection | `paste_after` | +| `P` | Paste before selection | `paste_before` | +| `"` `` | Select a register to yank to or paste from | `select_register` | +| `>` | Indent selection | `indent` | +| `<` | Unindent selection | `unindent` | +| `=` | Format selection (**LSP**) | `format_selections` | +| `d` | Delete selection | `delete_selection` | +| `Alt-d` | Delete selection, without yanking | `delete_selection_noyank` | +| `c` | Change selection (delete and enter insert mode) | `change_selection` | +| `Alt-c` | Change selection (delete and enter insert mode, without yanking) | `change_selection_noyank` | +| `Ctrl-a` | Increment object (number) under cursor | `increment` | +| `Ctrl-x` | Decrement object (number) under cursor | `decrement` | #### Shell -| Key | Description | Command | -| ------ | ----------- | ------- | -| | | Pipe each selection through shell command, replacing with output | `shell_pipe` | -| A-| | Pipe each selection into shell command, ignoring output | `shell_pipe_to` | -| `!` | Run shell command, inserting output before each selection | `shell_insert_output` | -| `A-!` | Run shell command, appending output after each selection | `shell_append_output` | +| Key | Description | Command | +| ------ | ----------- | ------- | +| | | Pipe each selection through shell command, replacing with output | `shell_pipe` | +| Alt-| | Pipe each selection into shell command, ignoring output | `shell_pipe_to` | +| `!` | Run shell command, inserting output before each selection | `shell_insert_output` | +| `Alt-!` | Run shell command, appending output after each selection | `shell_append_output` | ### Selection manipulation @@ -85,6 +94,8 @@ | `s` | Select all regex matches inside selections | `select_regex` | | `S` | Split selection into subselections on regex matches | `split_selection` | | `Alt-s` | Split selection on newlines | `split_selection_on_newline` | +| `&` | Align selection in columns | `align_selections` | +| `_` | Trim whitespace from the selection | `trim_selections` | | `;` | Collapse selection onto a single cursor | `collapse_selection` | | `Alt-;` | Flip selection cursor and anchor | `flip_selections` | | `,` | Keep only the primary selection | `keep_primary_selection` | @@ -98,9 +109,10 @@ | `%` | Select entire file | `select_all` | | `x` | Select current line, if already selected, extend to next line | `extend_line` | | `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` | -| | Expand selection to parent syntax node TODO: pick a key | `expand_selection` | +| | Expand selection to parent syntax node TODO: pick a key (**TS**) | `expand_selection` | | `J` | Join lines inside selection | `join_selections` | | `K` | Keep selections matching the regex | `keep_selections` | +| `Alt-K` | Remove selections matching the regex | `remove_selections` | | `$` | Pipe each selection into shell command, keep selections where command returned 0 | `shell_keep_pipe` | | `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` | @@ -127,42 +139,43 @@ key to return to normal mode after usage (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` | Scroll the view downwards | `scroll_down` | -| `k` | Scroll the view upwards | `scroll_up` | -| `f` | Move page down | `page_down` | -| `b` | Move page up | `page_up` | -| `d` | Move half page down | `half_page_down` | -| `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` | +| `f` | Move page down | `page_down` | +| `b` | Move page up | `page_up` | +| `d` | Move half page down | `half_page_down` | +| `u` | Move half page up | `half_page_up` | #### Goto mode Jumps to various locations. -> NOTE: Some of these features are only available with the LSP present. - | Key | Description | Command | | ----- | ----------- | ------- | | `g` | Go to the start of the file | `goto_file_start` | | `e` | Go to the end of the file | `goto_last_line` | +| `f` | Go to files in the selection | `goto_file` | | `h` | Go to the start of the line | `goto_line_start` | | `l` | Go to the end of the line | `goto_line_end` | | `s` | Go to first non-whitespace character of the line | `goto_first_nonwhitespace` | | `t` | Go to the top of the screen | `goto_window_top` | -| `m` | Go to the middle of the screen | `goto_window_middle` | +| `c` | Go to the middle of the screen | `goto_window_center` | | `b` | Go to the bottom of the screen | `goto_window_bottom` | -| `d` | Go to definition | `goto_definition` | -| `y` | Go to type definition | `goto_type_definition` | -| `r` | Go to references | `goto_reference` | -| `i` | Go to implementation | `goto_implementation` | +| `d` | Go to definition (**LSP**) | `goto_definition` | +| `y` | Go to type definition (**LSP**) | `goto_type_definition` | +| `r` | Go to references (**LSP**) | `goto_reference` | +| `i` | Go to implementation (**LSP**) | `goto_implementation` | | `a` | Go to the last accessed/alternate file | `goto_last_accessed_file` | +| `m` | Go to the last modified/alternate file | `goto_last_modified_file` | | `n` | Go to next buffer | `goto_next_buffer` | | `p` | Go to previous buffer | `goto_previous_buffer` | +| `.` | Go to last modification in current file | `goto_last_modification` | #### Match mode @@ -172,7 +185,7 @@ and [textobject](./usage.md#textobject) usage. | Key | Description | Command | | ----- | ----------- | ------- | -| `m` | Goto matching bracket | `match_brackets` | +| `m` | Goto matching bracket (**TS**) | `match_brackets` | | `s` `` | Surround current selection with `` | `surround_add` | | `r` `` | Replace surround character `` with `` | `surround_replace` | | `d` `` | Delete surround character `` | `surround_delete` | @@ -191,53 +204,82 @@ This layer is similar to vim keybindings as kakoune does not support window. | `v`, `Ctrl-v` | Vertical right split | `vsplit` | | `s`, `Ctrl-s` | Horizontal bottom split | `hsplit` | | `h`, `Ctrl-h`, `left` | Move to left split | `jump_view_left` | +| `f` | Go to files in the selection in horizontal splits | `goto_file` | +| `F` | Go to files in the selection in vertical splits | `goto_file` | | `j`, `Ctrl-j`, `down` | Move to split below | `jump_view_down` | | `k`, `Ctrl-k`, `up` | Move to split above | `jump_view_up` | | `l`, `Ctrl-l`, `right` | Move to right split | `jump_view_right` | | `q`, `Ctrl-q` | Close current window | `wclose` | +| `o`, `Ctrl-o` | Only keep the current window, closing all the others | `wonly` | #### Space mode This layer is a kludge of mappings, mostly pickers. -| Key | Description | Command | -| ----- | ----------- | ------- | -| `k` | Show documentation for the item under the cursor | `hover` | -| `f` | Open file picker | `file_picker` | -| `b` | Open buffer picker | `buffer_picker` | -| `s` | Open symbol picker (current document) | `symbol_picker` | -| `a` | Apply code action | `code_action` | -| `'` | Open last fuzzy picker | `last_picker` | -| `w` | Enter [window mode](#window-mode) | N/A | -| `p` | Paste system clipboard after selections | `paste_clipboard_after` | -| `P` | Paste system clipboard before selections | `paste_clipboard_before` | -| `y` | Join and yank selections to clipboard | `yank_joined_to_clipboard` | -| `Y` | Yank main selection to clipboard | `yank_main_selection_to_clipboard` | -| `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` | -| `/` | Global search in workspace folder | `global_search` | - -> NOTE: Global search display results in a fuzzy picker, use `space + '` to bring it back up after opening a file. + +| Key | Description | Command | +| ----- | ----------- | ------- | +| `f` | Open file picker | `file_picker` | +| `b` | Open buffer picker | `buffer_picker` | +| `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` | +| `r` | Rename symbol (**LSP**) | `rename_symbol` | +| `a` | Apply code action (**LSP**) | `code_action` | +| `'` | Open last fuzzy picker | `last_picker` | +| `w` | Enter [window mode](#window-mode) | N/A | +| `p` | Paste system clipboard after selections | `paste_clipboard_after` | +| `P` | Paste system clipboard before selections | `paste_clipboard_before` | +| `y` | Join and yank selections to clipboard | `yank_joined_to_clipboard` | +| `Y` | Yank main selection to clipboard | `yank_main_selection_to_clipboard` | +| `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` | +| `/` | Global search in workspace folder | `global_search` | + +> TIP: Global search displays results in a fuzzy picker, use `space + '` to bring it back up after opening a file. + +##### Popup + +Displays documentation for item under cursor. + +| Key | Description | +| ---- | ----------- | +| `Ctrl-u` | Scroll up | +| `Ctrl-d` | Scroll down | #### Unimpaired Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaired). -| Key | Description | Command | -| ----- | ----------- | ------- | -| `[d` | Go to previous diagnostic | `goto_prev_diag` | -| `]d` | Go to next diagnostic | `goto_next_diag` | -| `[D` | Go to first diagnostic in document | `goto_first_diag` | -| `]D` | Go to last diagnostic in document | `goto_last_diag` | -| `[space` | Add newline above | `add_newline_above` | -| `]space` | Add newline below | `add_newline_below` | +| Key | Description | Command | +| ----- | ----------- | ------- | +| `[d` | Go to previous diagnostic (**LSP**) | `goto_prev_diag` | +| `]d` | Go to next diagnostic (**LSP**) | `goto_next_diag` | +| `[D` | Go to first diagnostic in document (**LSP**) | `goto_first_diag` | +| `]D` | Go to last diagnostic in document (**LSP**) | `goto_last_diag` | +| `[space` | Add newline above | `add_newline_above` | +| `]space` | Add newline below | `add_newline_below` | ## Insert Mode -| Key | Description | Command | -| ----- | ----------- | ------- | -| `Escape` | Switch to normal mode | `normal_mode` | -| `Ctrl-x` | Autocomplete | `completion` | -| `Ctrl-w` | Delete previous word | `delete_word_backward` | +| Key | Description | Command | +| ----- | ----------- | ------- | +| `Escape` | Switch to normal mode | `normal_mode` | +| `Ctrl-x` | Autocomplete | `completion` | +| `Ctrl-r` | Insert a register content | `insert_register` | +| `Ctrl-w` | Delete previous word | `delete_word_backward` | +| `Alt-d` | Delete next word | `delete_word_forward` | +| `Alt-b`, `Alt-Left` | Backward a word | `move_prev_word_end` | +| `Ctrl-b`, `Left` | Backward a char | `move_char_left` | +| `Alt-f`, `Alt-Right` | Forward a word | `move_next_word_start` | +| `Ctrl-f`, `Right` | Forward a char | `move_char_right` | +| `Ctrl-e`, `End` | move to line end | `goto_line_end_newline` | +| `Ctrl-a`, `Home` | move to line start | `goto_line_start` | +| `Ctrl-u` | delete to start of line | `kill_to_line_start` | +| `Ctrl-k` | delete to end of line | `kill_to_line_end` | +| `backspace`, `Ctrl-h` | delete previous char | `delete_char_backward` | +| `delete`, `Ctrl-d` | delete previous char | `delete_char_forward` | +| `Ctrl-p`, `Up` | move to previous line | `move_line_up` | +| `Ctrl-n`, `Down` | move to next line | `move_line_down` | ## Select / extend mode @@ -262,7 +304,9 @@ Keys to use within picker. Remapping currently not supported. | `Escape`, `Ctrl-c` | Close picker | # Prompt + Keys to use within prompt, Remapping currently not supported. + | Key | Description | | ----- | ------------- | | `Escape`, `Ctrl-c` | Close prompt | @@ -270,15 +314,18 @@ Keys to use within prompt, Remapping currently not supported. | `Ctrl-b`, `Left` | Backward a char | | `Alt-f`, `Alt-Right` | Forward a word | | `Ctrl-f`, `Right` | Forward a char | -| `Ctrl-e`, `End` | move prompt end | -| `Ctrl-a`, `Home` | move prompt start | -| `Ctrl-w` | delete previous word | -| `Ctrl-k` | delete to end of line | -| `backspace` | delete previous char | -| `Ctrl-s` | insert a word under doc cursor, may be changed to Ctrl-r Ctrl-w later | -| `Ctrl-p`, `Up` | select previous history | -| `Ctrl-n`, `Down` | select next history | -| `Tab` | slect next completion item | -| `BackTab` | slect previous completion item | +| `Ctrl-e`, `End` | Move prompt end | +| `Ctrl-a`, `Home` | Move prompt start | +| `Ctrl-w` | Delete previous word | +| `Alt-d` | Delete next word | +| `Ctrl-u` | Delete to start of line | +| `Ctrl-k` | Delete to end of line | +| `backspace`, `Ctrl-h` | Delete previous char | +| `delete`, `Ctrl-d` | Delete next char | +| `Ctrl-s` | Insert a word under doc cursor, may be changed to Ctrl-r Ctrl-w later | +| `Ctrl-p`, `Up` | Select previous history | +| `Ctrl-n`, `Down` | Select next history | +| `Tab` | Select next completion item | +| `BackTab` | Select previous completion item | | `Enter` | Open selected | diff --git a/book/src/languages.md b/book/src/languages.md new file mode 100644 index 000000000..cef61501f --- /dev/null +++ b/book/src/languages.md @@ -0,0 +1,14 @@ +# Languages + +Language-specific settings and settings for particular language servers can be configured in a `languages.toml` file placed in your [configuration directory](./configuration.md). Helix actually uses two `languages.toml` files, the [first one](https://github.com/helix-editor/helix/blob/master/languages.toml) is in the main helix repository; it contains the default settings for each language and is included in the helix binary at compile time. Users who want to see the available settings and options can either reference the helix repo's `languages.toml` file, or consult the table in the [adding languages](./guides/adding_languages.md) section. + +Changes made to the `languages.toml` file in a user's [configuration directory](./configuration.md) are merged with helix's defaults on start-up, such that a user's settings will take precedence over defaults in the event of a collision. For example, the default `languages.toml` sets rust's `auto-format` to `true`. If a user wants to disable auto-format, they can change the `languages.toml` in their [configuration directory](./configuration.md) to make the rust entry read like the example below; the new key/value pair `auto-format = false` will override the default when the two sets of settings are merged on start-up: + +``` +# in /helix/languages.toml + +[[language]] +name = "rust" +auto-format = false +``` + diff --git a/book/src/remapping.md b/book/src/remapping.md index 3369f0315..fffd189b7 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -15,6 +15,7 @@ a = "move_char_left" # Maps the 'a' key to the move_char_left command w = "move_line_up" # Maps the 'w' key move_line_up "C-S-esc" = "extend_line" # Maps Control-Shift-Escape to extend_line g = { a = "code_action" } # Maps `ga` to show possible code actions +"ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode [keys.insert] "A-x" = "normal_mode" # Maps Alt-X to enter normal mode @@ -38,6 +39,7 @@ Control, Shift and Alt modifiers are encoded respectively with the prefixes | Left | `"left"` | | Right | `"right"` | | Up | `"up"` | +| Down | `"down"` | | Home | `"home"` | | End | `"end"` | | Page | `"pageup"` | @@ -51,4 +53,5 @@ Control, Shift and Alt modifiers are encoded respectively with the prefixes Keys can be disabled by binding them to the `no_op` command. -Commands can be found in the source code at [`helix-term/src/commands.rs`](https://github.com/helix-editor/helix/blob/master/helix-term/src/commands.rs) +Commands can be found at [Keymap](https://docs.helix-editor.com/keymap.html) Commands. +> Commands can also be found in the source code at [`helix-term/src/commands.rs`](https://github.com/helix-editor/helix/blob/master/helix-term/src/commands.rs) at the invocation of `commands!` macro. diff --git a/book/src/themes.md b/book/src/themes.md index ecbbb6e97..fd3f5b1eb 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -145,11 +145,12 @@ We use a similar set of scopes as - `conditional` - `if`, `else` - `repeat` - `for`, `while`, `loop` - `import` - `import`, `export` - - (TODO: return?) + - `return` + - `operator` - `or`, `in` - `directive` - Preprocessor directives (`#if` in C) - `function` - `fn`, `func` -- `operator` - `||`, `+=`, `>`, `or` +- `operator` - `||`, `+=`, `>` - `function` - `builtin` diff --git a/book/src/usage.md b/book/src/usage.md index 71730fa8b..cf7d9d488 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -23,8 +23,10 @@ If there is a selected register before invoking a change or delete command, the | `/` | Last search | | `:` | Last executed command | | `"` | Last yanked text | +| `_` | Black hole | > There is no special register for copying to system clipboard, instead special commands and keybindings are provided. See the [keymap](keymap.md#space-mode) for the specifics. +> The black hole register works as a no-op register, meaning no data will be written to / read from it. ## Surround @@ -62,6 +64,7 @@ Currently supported: `word`, `surround`, `function`, `class`, `parameter`. | Key after `mi` or `ma` | Textobject selected | | --- | --- | | `w` | Word | +| `W` | WORD | | `(`, `[`, `'`, etc | Specified surround pairs | | `f` | Function | | `c` | Class | diff --git a/flake.lock b/flake.lock index 2029d5809..2c59f9931 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "devshell": { "locked": { - "lastModified": 1632436039, - "narHash": "sha256-OtITeVWcKXn1SpVEnImpTGH91FycCskGBPqmlxiykv4=", + "lastModified": 1637575296, + "narHash": "sha256-ZY8YR5u8aglZPe27+AJMnPTG6645WuavB+w0xmhTarw=", "owner": "numtide", "repo": "devshell", - "rev": "7a7a7aa0adebe5488e5abaec688fd9ae0f8ea9c6", + "rev": "0e56ef21ba1a717169953122c7415fa6a8cd2618", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "flake-utils": { "locked": { - "lastModified": 1623875721, - "narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=", + "lastModified": 1637014545, + "narHash": "sha256-26IZAc5yzlD9FlDT54io1oqG/bBoyka+FJk5guaX4x4=", "owner": "numtide", "repo": "flake-utils", - "rev": "f7e004a55b120c02ecb6219596820fcd32ca8772", + "rev": "bba5dcc8e0b20ab664967ad83d24d64cb64ec4f4", "type": "github" }, "original": { @@ -30,22 +30,6 @@ "type": "github" } }, - "flakeCompat": { - "flake": false, - "locked": { - "lastModified": 1627913399, - "narHash": "sha256-hY8g6H2KFL8ownSiFeMOjwPC8P0ueXpCVEbxgda3pko=", - "owner": "edolstra", - "repo": "flake-compat", - "rev": "12c64ca55c1014cdc1b16ed5a804aa8576601ff2", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, "nixCargoIntegration": { "inputs": { "devshell": "devshell", @@ -57,11 +41,11 @@ ] }, "locked": { - "lastModified": 1634796585, - "narHash": "sha256-CW4yx6omk5qCXUIwXHp/sztA7u0SpyLq9NEACPnkiz8=", + "lastModified": 1638425401, + "narHash": "sha256-xc8ayvR3u90hSCMEy0zHHKav7lEgljAFXL4oIkWRp3M=", "owner": "yusdacra", "repo": "nix-cargo-integration", - "rev": "a84a2137a396f303978f1d48341e0390b0e16a8b", + "rev": "1f8b511bb30f7d7b9051dfbb4784390bc0d48d37", "type": "github" }, "original": { @@ -72,11 +56,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1634782485, - "narHash": "sha256-psfh4OQSokGXG0lpq3zKFbhOo3QfoeudRcaUnwMRkQo=", + "lastModified": 1638376152, + "narHash": "sha256-ucgLpVqhFnClH7YRUHBHnmiOd82RZdFR3XJt36ks5fE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "34ad3ffe08adfca17fcb4e4a47bb5f3b113687be", + "rev": "6daa4a5c045d40e6eae60a3b6e427e8700f1c07f", "type": "github" }, "original": { @@ -88,22 +72,22 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1628186154, - "narHash": "sha256-r2d0wvywFnL9z4iptztdFMhaUIAaGzrSs7kSok0PgmE=", + "lastModified": 1637453606, + "narHash": "sha256-Gy6cwUswft9xqsjWxFYEnx/63/qzaFUwatcbV5GF/GQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "06552b72346632b6943c8032e57e702ea12413bf", + "rev": "8afc4e543663ca0a6a4f496262cd05233737e732", "type": "github" }, "original": { "owner": "NixOS", + "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { - "flakeCompat": "flakeCompat", "nixCargoIntegration": "nixCargoIntegration", "nixpkgs": "nixpkgs", "rust-overlay": "rust-overlay" @@ -115,11 +99,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1634869268, - "narHash": "sha256-RVAcEFlFU3877Mm4q/nbXGEYTDg/wQNhzmXGMTV6wBs=", + "lastModified": 1638497756, + "narHash": "sha256-zKOvMKqGp71ZBnR+hBlPcv4TwNN82COW9EF+6ygrFs8=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c02c2d86354327317546501af001886fbb53d374", + "rev": "783722a22ee5d762ac5c1c7b418b57b3010c827a", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 296a68d5a..cbf10c975 100644 --- a/flake.nix +++ b/flake.nix @@ -9,10 +9,6 @@ inputs.nixpkgs.follows = "nixpkgs"; inputs.rustOverlay.follows = "rust-overlay"; }; - flakeCompat = { - url = "github:edolstra/flake-compat"; - flake = false; - }; }; outputs = inputs@{ self, nixCargoIntegration, ... }: @@ -63,7 +59,7 @@ ''; }; shell = common: prev: { - packages = prev.packages ++ (with common.pkgs; [ lld_12 lldb cargo-tarpaulin ]); + packages = prev.packages ++ (with common.pkgs; [ lld_13 lldb cargo-tarpaulin ]); env = prev.env ++ [ { name = "HELIX_RUNTIME"; eval = "$PWD/runtime"; } { name = "RUST_BACKTRACE"; value = "1"; } diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index ea695d34a..0a2a56d9e 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -36,5 +36,7 @@ similar = "2.1" etcetera = "0.3" +chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] } + [dev-dependencies] quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/comment.rs b/helix-core/src/comment.rs index 4072a5323..b22a95a65 100644 --- a/helix-core/src/comment.rs +++ b/helix-core/src/comment.rs @@ -63,7 +63,7 @@ pub fn toggle_line_comments(doc: &Rope, selection: &Selection, token: Option<&st let token = token.unwrap_or("//"); let comment = Tendril::from(format!("{} ", token)); - let mut lines: Vec = Vec::new(); + let mut lines: Vec = Vec::with_capacity(selection.len()); let mut min_next_line = 0; for selection in selection { diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index ad1ba16ab..4fcf51c9c 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -1,7 +1,7 @@ //! LSP diagnostic utility types. /// Describes the severity level of a [`Diagnostic`]. -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum Severity { Error, Warning, @@ -17,7 +17,7 @@ pub struct Range { } /// Corresponds to [`lsp_types::Diagnostic`](https://docs.rs/lsp-types/0.91.0/lsp_types/struct.Diagnostic.html) -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Diagnostic { pub range: Range, pub line: usize, diff --git a/helix-core/src/history.rs b/helix-core/src/history.rs index b53c01fe7..4b1c8d3b3 100644 --- a/helix-core/src/history.rs +++ b/helix-core/src/history.rs @@ -1,4 +1,4 @@ -use crate::{ChangeSet, Rope, State, Transaction}; +use crate::{Assoc, ChangeSet, Range, Rope, State, Transaction}; use once_cell::sync::Lazy; use regex::Regex; use std::num::NonZeroUsize; @@ -133,6 +133,32 @@ impl History { Some(&self.revisions[last_child.get()].transaction) } + // Get the position of last change + pub fn last_edit_pos(&self) -> Option { + if self.current == 0 { + return None; + } + let current_revision = &self.revisions[self.current]; + let primary_selection = current_revision + .inversion + .selection() + .expect("inversion always contains a selection") + .primary(); + let (_from, to, _fragment) = current_revision + .transaction + .changes_iter() + // find a change that matches the primary selection + .find(|(from, to, _fragment)| Range::new(*from, *to).overlaps(&primary_selection)) + // or use the first change + .or_else(|| current_revision.transaction.changes_iter().next()) + .unwrap(); + let pos = current_revision + .transaction + .changes() + .map_pos(to, Assoc::After); + Some(pos) + } + fn lowest_common_ancestor(&self, mut a: usize, mut b: usize) -> usize { use std::collections::HashSet; let mut a_path_set = HashSet::new(); @@ -256,7 +282,7 @@ impl History { } /// Whether to undo by a number of edits or a duration of time. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone, Copy)] pub enum UndoKind { Steps(usize), TimePeriod(std::time::Duration), diff --git a/helix-core/src/increment/date_time.rs b/helix-core/src/increment/date_time.rs new file mode 100644 index 000000000..e3cfe107d --- /dev/null +++ b/helix-core/src/increment/date_time.rs @@ -0,0 +1,490 @@ +use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; +use once_cell::sync::Lazy; +use regex::Regex; +use ropey::RopeSlice; + +use std::borrow::Cow; +use std::cmp; + +use super::Increment; +use crate::{Range, Tendril}; + +#[derive(Debug, PartialEq, Eq)] +pub struct DateTimeIncrementor { + date_time: NaiveDateTime, + range: Range, + fmt: &'static str, + field: DateField, +} + +impl DateTimeIncrementor { + pub fn from_range(text: RopeSlice, range: Range) -> Option { + let range = if range.is_empty() { + if range.anchor < text.len_chars() { + // Treat empty range as a cursor range. + range.put_cursor(text, range.anchor + 1, true) + } else { + // The range is empty and at the end of the text. + return None; + } + } else { + range + }; + + FORMATS.iter().find_map(|format| { + let from = range.from().saturating_sub(format.max_len); + let to = (range.from() + format.max_len).min(text.len_chars()); + + let (from_in_text, to_in_text) = (range.from() - from, range.to() - from); + let text: Cow = text.slice(from..to).into(); + + let captures = format.regex.captures(&text)?; + if captures.len() - 1 != format.fields.len() { + return None; + } + + let date_time = captures.get(0)?; + let offset = range.from() - from_in_text; + let range = Range::new(date_time.start() + offset, date_time.end() + offset); + + let field = captures + .iter() + .skip(1) + .enumerate() + .find_map(|(i, capture)| { + let capture = capture?; + let capture_range = capture.range(); + + if capture_range.contains(&from_in_text) + && capture_range.contains(&(to_in_text - 1)) + { + Some(format.fields[i]) + } else { + None + } + })?; + + let has_date = format.fields.iter().any(|f| f.unit.is_date()); + let has_time = format.fields.iter().any(|f| f.unit.is_time()); + + let date_time = &text[date_time.start()..date_time.end()]; + let date_time = match (has_date, has_time) { + (true, true) => NaiveDateTime::parse_from_str(date_time, format.fmt).ok()?, + (true, false) => { + let date = NaiveDate::parse_from_str(date_time, format.fmt).ok()?; + + date.and_hms(0, 0, 0) + } + (false, true) => { + let time = NaiveTime::parse_from_str(date_time, format.fmt).ok()?; + + NaiveDate::from_ymd(0, 1, 1).and_time(time) + } + (false, false) => return None, + }; + + Some(DateTimeIncrementor { + date_time, + range, + fmt: format.fmt, + field, + }) + }) + } +} + +impl Increment for DateTimeIncrementor { + fn increment(&self, amount: i64) -> (Range, Tendril) { + let date_time = match self.field.unit { + DateUnit::Years => add_years(self.date_time, amount), + DateUnit::Months => add_months(self.date_time, amount), + DateUnit::Days => add_duration(self.date_time, Duration::days(amount)), + DateUnit::Hours => add_duration(self.date_time, Duration::hours(amount)), + DateUnit::Minutes => add_duration(self.date_time, Duration::minutes(amount)), + DateUnit::Seconds => add_duration(self.date_time, Duration::seconds(amount)), + DateUnit::AmPm => toggle_am_pm(self.date_time), + } + .unwrap_or(self.date_time); + + (self.range, date_time.format(self.fmt).to_string().into()) + } +} + +static FORMATS: Lazy> = Lazy::new(|| { + vec![ + Format::new("%Y-%m-%d %H:%M:%S"), // 2021-11-24 07:12:23 + Format::new("%Y/%m/%d %H:%M:%S"), // 2021/11/24 07:12:23 + Format::new("%Y-%m-%d %H:%M"), // 2021-11-24 07:12 + Format::new("%Y/%m/%d %H:%M"), // 2021/11/24 07:12 + Format::new("%Y-%m-%d"), // 2021-11-24 + Format::new("%Y/%m/%d"), // 2021/11/24 + Format::new("%a %b %d %Y"), // Wed Nov 24 2021 + Format::new("%d-%b-%Y"), // 24-Nov-2021 + Format::new("%Y %b %d"), // 2021 Nov 24 + Format::new("%b %d, %Y"), // Nov 24, 2021 + Format::new("%-I:%M:%S %P"), // 7:21:53 am + Format::new("%-I:%M %P"), // 7:21 am + Format::new("%-I:%M:%S %p"), // 7:21:53 AM + Format::new("%-I:%M %p"), // 7:21 AM + Format::new("%H:%M:%S"), // 23:24:23 + Format::new("%H:%M"), // 23:24 + ] +}); + +#[derive(Debug)] +struct Format { + fmt: &'static str, + fields: Vec, + regex: Regex, + max_len: usize, +} + +impl Format { + fn new(fmt: &'static str) -> Self { + let mut remaining = fmt; + let mut fields = Vec::new(); + let mut regex = String::new(); + let mut max_len = 0; + + while let Some(i) = remaining.find('%') { + let after = &remaining[i + 1..]; + let mut chars = after.chars(); + let c = chars.next().unwrap(); + + let spec_len = if c == '-' { + 1 + chars.next().unwrap().len_utf8() + } else { + c.len_utf8() + }; + + let specifier = &after[..spec_len]; + let field = DateField::from_specifier(specifier).unwrap(); + fields.push(field); + max_len += field.max_len + remaining[..i].len(); + regex += &remaining[..i]; + regex += &format!("({})", field.regex); + remaining = &after[spec_len..]; + } + + let regex = Regex::new(®ex).unwrap(); + + Self { + fmt, + fields, + regex, + max_len, + } + } +} + +impl PartialEq for Format { + fn eq(&self, other: &Self) -> bool { + self.fmt == other.fmt && self.fields == other.fields && self.max_len == other.max_len + } +} + +impl Eq for Format {} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +struct DateField { + regex: &'static str, + unit: DateUnit, + max_len: usize, +} + +impl DateField { + fn from_specifier(specifier: &str) -> Option { + match specifier { + "Y" => Some(DateField { + regex: r"\d{4}", + unit: DateUnit::Years, + max_len: 5, + }), + "y" => Some(DateField { + regex: r"\d\d", + unit: DateUnit::Years, + max_len: 2, + }), + "m" => Some(DateField { + regex: r"[0-1]\d", + unit: DateUnit::Months, + max_len: 2, + }), + "d" => Some(DateField { + regex: r"[0-3]\d", + unit: DateUnit::Days, + max_len: 2, + }), + "-d" => Some(DateField { + regex: r"[1-3]?\d", + unit: DateUnit::Days, + max_len: 2, + }), + "a" => Some(DateField { + regex: r"Sun|Mon|Tue|Wed|Thu|Fri|Sat", + unit: DateUnit::Days, + max_len: 3, + }), + "A" => Some(DateField { + regex: r"Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday", + unit: DateUnit::Days, + max_len: 9, + }), + "b" | "h" => Some(DateField { + regex: r"Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec", + unit: DateUnit::Months, + max_len: 3, + }), + "B" => Some(DateField { + regex: r"January|February|March|April|May|June|July|August|September|October|November|December", + unit: DateUnit::Months, + max_len: 9, + }), + "H" => Some(DateField { + regex: r"[0-2]\d", + unit: DateUnit::Hours, + max_len: 2, + }), + "M" => Some(DateField { + regex: r"[0-5]\d", + unit: DateUnit::Minutes, + max_len: 2, + }), + "S" => Some(DateField { + regex: r"[0-5]\d", + unit: DateUnit::Seconds, + max_len: 2, + }), + "I" => Some(DateField { + regex: r"[0-1]\d", + unit: DateUnit::Hours, + max_len: 2, + }), + "-I" => Some(DateField { + regex: r"1?\d", + unit: DateUnit::Hours, + max_len: 2, + }), + "P" => Some(DateField { + regex: r"am|pm", + unit: DateUnit::AmPm, + max_len: 2, + }), + "p" => Some(DateField { + regex: r"AM|PM", + unit: DateUnit::AmPm, + max_len: 2, + }), + _ => None, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum DateUnit { + Years, + Months, + Days, + Hours, + Minutes, + Seconds, + AmPm, +} + +impl DateUnit { + fn is_date(self) -> bool { + matches!(self, DateUnit::Years | DateUnit::Months | DateUnit::Days) + } + + fn is_time(self) -> bool { + matches!( + self, + DateUnit::Hours | DateUnit::Minutes | DateUnit::Seconds + ) + } +} + +fn ndays_in_month(year: i32, month: u32) -> u32 { + // The first day of the next month... + let (y, m) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + let d = NaiveDate::from_ymd(y, m, 1); + + // ...is preceded by the last day of the original month. + d.pred().day() +} + +fn add_months(date_time: NaiveDateTime, amount: i64) -> Option { + let month = (date_time.month0() as i64).checked_add(amount)?; + let year = date_time.year() + i32::try_from(month / 12).ok()?; + let year = if month.is_negative() { year - 1 } else { year }; + + // Normalize month + let month = month % 12; + let month = if month.is_negative() { + month + 12 + } else { + month + } as u32 + + 1; + + let day = cmp::min(date_time.day(), ndays_in_month(year, month)); + + Some(NaiveDate::from_ymd(year, month, day).and_time(date_time.time())) +} + +fn add_years(date_time: NaiveDateTime, amount: i64) -> Option { + let year = i32::try_from((date_time.year() as i64).checked_add(amount)?).ok()?; + let ndays = ndays_in_month(year, date_time.month()); + + if date_time.day() > ndays { + let d = NaiveDate::from_ymd(year, date_time.month(), ndays); + Some(d.succ().and_time(date_time.time())) + } else { + date_time.with_year(year) + } +} + +fn add_duration(date_time: NaiveDateTime, duration: Duration) -> Option { + date_time.checked_add_signed(duration) +} + +fn toggle_am_pm(date_time: NaiveDateTime) -> Option { + if date_time.hour() < 12 { + add_duration(date_time, Duration::hours(12)) + } else { + add_duration(date_time, Duration::hours(-12)) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::Rope; + + #[test] + fn test_increment_date_times() { + let tests = [ + // (original, cursor, amount, expected) + ("2020-02-28", 0, 1, "2021-02-28"), + ("2020-02-29", 0, 1, "2021-03-01"), + ("2020-01-31", 5, 1, "2020-02-29"), + ("2020-01-20", 5, 1, "2020-02-20"), + ("2021-01-01", 5, -1, "2020-12-01"), + ("2021-01-31", 5, -2, "2020-11-30"), + ("2020-02-28", 8, 1, "2020-02-29"), + ("2021-02-28", 8, 1, "2021-03-01"), + ("2021-02-28", 0, -1, "2020-02-28"), + ("2021-03-01", 0, -1, "2020-03-01"), + ("2020-02-29", 5, -1, "2020-01-29"), + ("2020-02-20", 5, -1, "2020-01-20"), + ("2020-02-29", 8, -1, "2020-02-28"), + ("2021-03-01", 8, -1, "2021-02-28"), + ("1980/12/21", 8, 100, "1981/03/31"), + ("1980/12/21", 8, -100, "1980/09/12"), + ("1980/12/21", 8, 1000, "1983/09/17"), + ("1980/12/21", 8, -1000, "1978/03/27"), + ("2021-11-24 07:12:23", 0, 1, "2022-11-24 07:12:23"), + ("2021-11-24 07:12:23", 5, 1, "2021-12-24 07:12:23"), + ("2021-11-24 07:12:23", 8, 1, "2021-11-25 07:12:23"), + ("2021-11-24 07:12:23", 11, 1, "2021-11-24 08:12:23"), + ("2021-11-24 07:12:23", 14, 1, "2021-11-24 07:13:23"), + ("2021-11-24 07:12:23", 17, 1, "2021-11-24 07:12:24"), + ("2021/11/24 07:12:23", 0, 1, "2022/11/24 07:12:23"), + ("2021/11/24 07:12:23", 5, 1, "2021/12/24 07:12:23"), + ("2021/11/24 07:12:23", 8, 1, "2021/11/25 07:12:23"), + ("2021/11/24 07:12:23", 11, 1, "2021/11/24 08:12:23"), + ("2021/11/24 07:12:23", 14, 1, "2021/11/24 07:13:23"), + ("2021/11/24 07:12:23", 17, 1, "2021/11/24 07:12:24"), + ("2021-11-24 07:12", 0, 1, "2022-11-24 07:12"), + ("2021-11-24 07:12", 5, 1, "2021-12-24 07:12"), + ("2021-11-24 07:12", 8, 1, "2021-11-25 07:12"), + ("2021-11-24 07:12", 11, 1, "2021-11-24 08:12"), + ("2021-11-24 07:12", 14, 1, "2021-11-24 07:13"), + ("2021/11/24 07:12", 0, 1, "2022/11/24 07:12"), + ("2021/11/24 07:12", 5, 1, "2021/12/24 07:12"), + ("2021/11/24 07:12", 8, 1, "2021/11/25 07:12"), + ("2021/11/24 07:12", 11, 1, "2021/11/24 08:12"), + ("2021/11/24 07:12", 14, 1, "2021/11/24 07:13"), + ("Wed Nov 24 2021", 0, 1, "Thu Nov 25 2021"), + ("Wed Nov 24 2021", 4, 1, "Fri Dec 24 2021"), + ("Wed Nov 24 2021", 8, 1, "Thu Nov 25 2021"), + ("Wed Nov 24 2021", 11, 1, "Thu Nov 24 2022"), + ("24-Nov-2021", 0, 1, "25-Nov-2021"), + ("24-Nov-2021", 3, 1, "24-Dec-2021"), + ("24-Nov-2021", 7, 1, "24-Nov-2022"), + ("2021 Nov 24", 0, 1, "2022 Nov 24"), + ("2021 Nov 24", 5, 1, "2021 Dec 24"), + ("2021 Nov 24", 9, 1, "2021 Nov 25"), + ("Nov 24, 2021", 0, 1, "Dec 24, 2021"), + ("Nov 24, 2021", 4, 1, "Nov 25, 2021"), + ("Nov 24, 2021", 8, 1, "Nov 24, 2022"), + ("7:21:53 am", 0, 1, "8:21:53 am"), + ("7:21:53 am", 3, 1, "7:22:53 am"), + ("7:21:53 am", 5, 1, "7:21:54 am"), + ("7:21:53 am", 8, 1, "7:21:53 pm"), + ("7:21:53 AM", 0, 1, "8:21:53 AM"), + ("7:21:53 AM", 3, 1, "7:22:53 AM"), + ("7:21:53 AM", 5, 1, "7:21:54 AM"), + ("7:21:53 AM", 8, 1, "7:21:53 PM"), + ("7:21 am", 0, 1, "8:21 am"), + ("7:21 am", 3, 1, "7:22 am"), + ("7:21 am", 5, 1, "7:21 pm"), + ("7:21 AM", 0, 1, "8:21 AM"), + ("7:21 AM", 3, 1, "7:22 AM"), + ("7:21 AM", 5, 1, "7:21 PM"), + ("23:24:23", 1, 1, "00:24:23"), + ("23:24:23", 3, 1, "23:25:23"), + ("23:24:23", 6, 1, "23:24:24"), + ("23:24", 1, 1, "00:24"), + ("23:24", 3, 1, "23:25"), + ]; + + for (original, cursor, amount, expected) in tests { + let rope = Rope::from_str(original); + let range = Range::new(cursor, cursor + 1); + assert_eq!( + DateTimeIncrementor::from_range(rope.slice(..), range) + .unwrap() + .increment(amount) + .1, + expected.into() + ); + } + } + + #[test] + fn test_invalid_date_times() { + let tests = [ + "0000-00-00", + "1980-2-21", + "1980-12-1", + "12345", + "2020-02-30", + "1999-12-32", + "19-12-32", + "1-2-3", + "0000/00/00", + "1980/2/21", + "1980/12/1", + "12345", + "2020/02/30", + "1999/12/32", + "19/12/32", + "1/2/3", + "123:456:789", + "11:61", + "2021-55-12 08:12:54", + ]; + + for invalid in tests { + let rope = Rope::from_str(invalid); + let range = Range::new(0, 1); + + assert_eq!(DateTimeIncrementor::from_range(rope.slice(..), range), None) + } + } +} diff --git a/helix-core/src/increment/mod.rs b/helix-core/src/increment/mod.rs new file mode 100644 index 000000000..f59457748 --- /dev/null +++ b/helix-core/src/increment/mod.rs @@ -0,0 +1,8 @@ +pub mod date_time; +pub mod number; + +use crate::{Range, Tendril}; + +pub trait Increment { + fn increment(&self, amount: i64) -> (Range, Tendril); +} diff --git a/helix-core/src/increment/number.rs b/helix-core/src/increment/number.rs new file mode 100644 index 000000000..a19b7e754 --- /dev/null +++ b/helix-core/src/increment/number.rs @@ -0,0 +1,507 @@ +use std::borrow::Cow; + +use ropey::RopeSlice; + +use super::Increment; + +use crate::{ + textobject::{textobject_word, TextObject}, + Range, Tendril, +}; + +#[derive(Debug, PartialEq, Eq)] +pub struct NumberIncrementor<'a> { + value: i64, + radix: u32, + range: Range, + + text: RopeSlice<'a>, +} + +impl<'a> NumberIncrementor<'a> { + /// Return information about number under rang if there is one. + pub fn from_range(text: RopeSlice, range: Range) -> Option { + // If the cursor is on the minus sign of a number we want to get the word textobject to the + // right of it. + let range = if range.to() < text.len_chars() + && range.to() - range.from() <= 1 + && text.char(range.from()) == '-' + { + Range::new(range.from() + 1, range.to() + 1) + } else { + range + }; + + let range = textobject_word(text, range, TextObject::Inside, 1, false); + + // If there is a minus sign to the left of the word object, we want to include it in the range. + let range = if range.from() > 0 && text.char(range.from() - 1) == '-' { + range.extend(range.from() - 1, range.from()) + } else { + range + }; + + let word: String = text + .slice(range.from()..range.to()) + .chars() + .filter(|&c| c != '_') + .collect(); + let (radix, prefixed) = if word.starts_with("0x") { + (16, true) + } else if word.starts_with("0o") { + (8, true) + } else if word.starts_with("0b") { + (2, true) + } else { + (10, false) + }; + + let number = if prefixed { &word[2..] } else { &word }; + + let value = i128::from_str_radix(number, radix).ok()?; + if (value.is_positive() && value.leading_zeros() < 64) + || (value.is_negative() && value.leading_ones() < 64) + { + return None; + } + + let value = value as i64; + Some(NumberIncrementor { + range, + value, + radix, + text, + }) + } +} + +impl<'a> Increment for NumberIncrementor<'a> { + fn increment(&self, amount: i64) -> (Range, Tendril) { + let old_text: Cow = self.text.slice(self.range.from()..self.range.to()).into(); + let old_length = old_text.len(); + let new_value = self.value.wrapping_add(amount); + + // Get separator indexes from right to left. + let separator_rtl_indexes: Vec = old_text + .chars() + .rev() + .enumerate() + .filter_map(|(i, c)| if c == '_' { Some(i) } else { None }) + .collect(); + + let format_length = if self.radix == 10 { + match (self.value.is_negative(), new_value.is_negative()) { + (true, false) => old_length - 1, + (false, true) => old_length + 1, + _ => old_text.len(), + } + } else { + old_text.len() - 2 + } - separator_rtl_indexes.len(); + + let mut new_text = match self.radix { + 2 => format!("0b{:01$b}", new_value, format_length), + 8 => format!("0o{:01$o}", new_value, format_length), + 10 if old_text.starts_with('0') || old_text.starts_with("-0") => { + format!("{:01$}", new_value, format_length) + } + 10 => format!("{}", new_value), + 16 => { + let (lower_count, upper_count): (usize, usize) = + old_text.chars().skip(2).fold((0, 0), |(lower, upper), c| { + ( + lower + c.is_ascii_lowercase().then(|| 1).unwrap_or(0), + upper + c.is_ascii_uppercase().then(|| 1).unwrap_or(0), + ) + }); + if upper_count > lower_count { + format!("0x{:01$X}", new_value, format_length) + } else { + format!("0x{:01$x}", new_value, format_length) + } + } + _ => unimplemented!("radix not supported: {}", self.radix), + }; + + // Add separators from original number. + for &rtl_index in &separator_rtl_indexes { + if rtl_index < new_text.len() { + let new_index = new_text.len() - rtl_index; + new_text.insert(new_index, '_'); + } + } + + // Add in additional separators if necessary. + if new_text.len() > old_length && !separator_rtl_indexes.is_empty() { + let spacing = match separator_rtl_indexes.as_slice() { + [.., b, a] => a - b - 1, + _ => separator_rtl_indexes[0], + }; + + let prefix_length = if self.radix == 10 { 0 } else { 2 }; + if let Some(mut index) = new_text.find('_') { + while index - prefix_length > spacing { + index -= spacing; + new_text.insert(index, '_'); + } + } + } + + (self.range, new_text.into()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::Rope; + + #[test] + fn test_decimal_at_point() { + let rope = Rope::from_str("Test text 12345 more text."); + let range = Range::point(12); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(10, 15), + value: 12345, + radix: 10, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_uppercase_hexadecimal_at_point() { + let rope = Rope::from_str("Test text 0x123ABCDEF more text."); + let range = Range::point(12); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(10, 21), + value: 0x123ABCDEF, + radix: 16, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_lowercase_hexadecimal_at_point() { + let rope = Rope::from_str("Test text 0xfa3b4e more text."); + let range = Range::point(12); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(10, 18), + value: 0xfa3b4e, + radix: 16, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_octal_at_point() { + let rope = Rope::from_str("Test text 0o1074312 more text."); + let range = Range::point(12); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(10, 19), + value: 0o1074312, + radix: 8, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_binary_at_point() { + let rope = Rope::from_str("Test text 0b10111010010101 more text."); + let range = Range::point(12); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(10, 26), + value: 0b10111010010101, + radix: 2, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_negative_decimal_at_point() { + let rope = Rope::from_str("Test text -54321 more text."); + let range = Range::point(12); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(10, 16), + value: -54321, + radix: 10, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_decimal_with_leading_zeroes_at_point() { + let rope = Rope::from_str("Test text 000045326 more text."); + let range = Range::point(12); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(10, 19), + value: 45326, + radix: 10, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_negative_decimal_cursor_on_minus_sign() { + let rope = Rope::from_str("Test text -54321 more text."); + let range = Range::point(10); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(10, 16), + value: -54321, + radix: 10, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_number_under_range_start_of_rope() { + let rope = Rope::from_str("100"); + let range = Range::point(0); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(0, 3), + value: 100, + radix: 10, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_number_under_range_end_of_rope() { + let rope = Rope::from_str("100"); + let range = Range::point(2); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(0, 3), + value: 100, + radix: 10, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_number_surrounded_by_punctuation() { + let rope = Rope::from_str(",100;"); + let range = Range::point(1); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range), + Some(NumberIncrementor { + range: Range::new(1, 4), + value: 100, + radix: 10, + text: rope.slice(..), + }) + ); + } + + #[test] + fn test_not_a_number_point() { + let rope = Rope::from_str("Test text 45326 more text."); + let range = Range::point(6); + assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None); + } + + #[test] + fn test_number_too_large_at_point() { + let rope = Rope::from_str("Test text 0xFFFFFFFFFFFFFFFFF more text."); + let range = Range::point(12); + assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None); + } + + #[test] + fn test_number_cursor_one_right_of_number() { + let rope = Rope::from_str("100 "); + let range = Range::point(3); + assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None); + } + + #[test] + fn test_number_cursor_one_left_of_number() { + let rope = Rope::from_str(" 100"); + let range = Range::point(0); + assert_eq!(NumberIncrementor::from_range(rope.slice(..), range), None); + } + + #[test] + fn test_increment_basic_decimal_numbers() { + let tests = [ + ("100", 1, "101"), + ("100", -1, "99"), + ("99", 1, "100"), + ("100", 1000, "1100"), + ("100", -1000, "-900"), + ("-1", 1, "0"), + ("-1", 2, "1"), + ("1", -1, "0"), + ("1", -2, "-1"), + ]; + + for (original, amount, expected) in tests { + let rope = Rope::from_str(original); + let range = Range::point(0); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range) + .unwrap() + .increment(amount) + .1, + expected.into() + ); + } + } + + #[test] + fn test_increment_basic_hexadedimal_numbers() { + let tests = [ + ("0x0100", 1, "0x0101"), + ("0x0100", -1, "0x00ff"), + ("0x0001", -1, "0x0000"), + ("0x0000", -1, "0xffffffffffffffff"), + ("0xffffffffffffffff", 1, "0x0000000000000000"), + ("0xffffffffffffffff", 2, "0x0000000000000001"), + ("0xffffffffffffffff", -1, "0xfffffffffffffffe"), + ("0xABCDEF1234567890", 1, "0xABCDEF1234567891"), + ("0xabcdef1234567890", 1, "0xabcdef1234567891"), + ]; + + for (original, amount, expected) in tests { + let rope = Rope::from_str(original); + let range = Range::point(0); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range) + .unwrap() + .increment(amount) + .1, + expected.into() + ); + } + } + + #[test] + fn test_increment_basic_octal_numbers() { + let tests = [ + ("0o0107", 1, "0o0110"), + ("0o0110", -1, "0o0107"), + ("0o0001", -1, "0o0000"), + ("0o7777", 1, "0o10000"), + ("0o1000", -1, "0o0777"), + ("0o0107", 10, "0o0121"), + ("0o0000", -1, "0o1777777777777777777777"), + ("0o1777777777777777777777", 1, "0o0000000000000000000000"), + ("0o1777777777777777777777", 2, "0o0000000000000000000001"), + ("0o1777777777777777777777", -1, "0o1777777777777777777776"), + ]; + + for (original, amount, expected) in tests { + let rope = Rope::from_str(original); + let range = Range::point(0); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range) + .unwrap() + .increment(amount) + .1, + expected.into() + ); + } + } + + #[test] + fn test_increment_basic_binary_numbers() { + let tests = [ + ("0b00000100", 1, "0b00000101"), + ("0b00000100", -1, "0b00000011"), + ("0b00000100", 2, "0b00000110"), + ("0b00000100", -2, "0b00000010"), + ("0b00000001", -1, "0b00000000"), + ("0b00111111", 10, "0b01001001"), + ("0b11111111", 1, "0b100000000"), + ("0b10000000", -1, "0b01111111"), + ( + "0b0000", + -1, + "0b1111111111111111111111111111111111111111111111111111111111111111", + ), + ( + "0b1111111111111111111111111111111111111111111111111111111111111111", + 1, + "0b0000000000000000000000000000000000000000000000000000000000000000", + ), + ( + "0b1111111111111111111111111111111111111111111111111111111111111111", + 2, + "0b0000000000000000000000000000000000000000000000000000000000000001", + ), + ( + "0b1111111111111111111111111111111111111111111111111111111111111111", + -1, + "0b1111111111111111111111111111111111111111111111111111111111111110", + ), + ]; + + for (original, amount, expected) in tests { + let rope = Rope::from_str(original); + let range = Range::point(0); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range) + .unwrap() + .increment(amount) + .1, + expected.into() + ); + } + } + + #[test] + fn test_increment_with_separators() { + let tests = [ + ("999_999", 1, "1_000_000"), + ("1_000_000", -1, "999_999"), + ("-999_999", -1, "-1_000_000"), + ("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"), + ("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"), + ("0x0000_0000_0001", 0x1_ffff_0000, "0x0001_ffff_0001"), + ("0x0000_0000", -1, "0xffff_ffff_ffff_ffff"), + ("0x0000_0000_0000", -1, "0xffff_ffff_ffff_ffff"), + ("0b01111111_11111111", 1, "0b10000000_00000000"), + ("0b11111111_11111111", 1, "0b1_00000000_00000000"), + ]; + + for (original, amount, expected) in tests { + let rope = Rope::from_str(original); + let range = Range::point(0); + assert_eq!( + NumberIncrementor::from_range(rope.slice(..), range) + .unwrap() + .increment(amount) + .1, + expected.into() + ); + } + } +} diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index 20f034ea7..b6f5081ac 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -450,6 +450,7 @@ where language: vec![LanguageConfiguration { scope: "source.rust".to_string(), file_types: vec!["rs".to_string()], + shebangs: vec![], language_id: "Rust".to_string(), highlight_config: OnceCell::new(), config: None, diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index d1720df00..4ae044cce 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod diagnostic; pub mod diff; pub mod graphemes; pub mod history; +pub mod increment; pub mod indent; pub mod line_ending; pub mod macros; @@ -157,7 +158,7 @@ mod merge_toml_tests { "; let base: Value = toml::from_slice(include_bytes!("../../languages.toml")) - .expect("Couldn't parse built-in langauges config"); + .expect("Couldn't parse built-in languages config"); let user: Value = toml::from_str(USER).unwrap(); let merged = merge_toml_values(base, user); diff --git a/helix-core/src/match_brackets.rs b/helix-core/src/match_brackets.rs index a4b2fb9c8..cd554005a 100644 --- a/helix-core/src/match_brackets.rs +++ b/helix-core/src/match_brackets.rs @@ -1,48 +1,92 @@ +use tree_sitter::Node; + use crate::{Rope, Syntax}; -const PAIRS: &[(char, char)] = &[('(', ')'), ('{', '}'), ('[', ']'), ('<', '>')]; +const PAIRS: &[(char, char)] = &[ + ('(', ')'), + ('{', '}'), + ('[', ']'), + ('<', '>'), + ('\'', '\''), + ('\"', '\"'), +]; + // limit matching pairs to only ( ) { } [ ] < > +// Returns the position of the matching bracket under cursor. +// +// If the cursor is one the opening bracket, the position of +// the closing bracket is returned. If the cursor in the closing +// bracket, the position of the opening bracket is returned. +// +// If the cursor is not on a bracket, `None` is returned. +#[must_use] +pub fn find_matching_bracket(syntax: &Syntax, doc: &Rope, pos: usize) -> Option { + if pos >= doc.len_chars() || !is_valid_bracket(doc.char(pos)) { + return None; + } + find_pair(syntax, doc, pos, false) +} + +// Returns the position of the bracket that is closing the current scope. +// +// If the cursor is on an opening or closing bracket, the function +// behaves equivalent to [`find_matching_bracket`]. +// +// If the cursor position is within a scope, the function searches +// for the surrounding scope that is surrounded by brackets and +// returns the position of the closing bracket for that scope. +// +// If no surrounding scope is found, the function returns `None`. #[must_use] -pub fn find(syntax: &Syntax, doc: &Rope, pos: usize) -> Option { +pub fn find_matching_bracket_fuzzy(syntax: &Syntax, doc: &Rope, pos: usize) -> Option { + find_pair(syntax, doc, pos, true) +} + +fn find_pair(syntax: &Syntax, doc: &Rope, pos: usize, traverse_parents: bool) -> Option { let tree = syntax.tree(); + let pos = doc.char_to_byte(pos); - let byte_pos = doc.char_to_byte(pos); + let mut node = tree.root_node().named_descendant_for_byte_range(pos, pos)?; - // most naive implementation: find the innermost syntax node, if we're at the edge of a node, - // return the other edge. + loop { + 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)); - let node = match tree - .root_node() - .named_descendant_for_byte_range(byte_pos, byte_pos) - { - Some(node) => node, - None => return None, - }; + if is_valid_pair(doc, start_char, end_char) { + if end_byte == pos { + return Some(start_char); + } + // We return the end char if the cursor is either on the start char + // or at some arbitrary position between start and end char. + return Some(end_char); + } - if node.is_error() { - return None; + if traverse_parents { + node = node.parent()?; + } else { + return None; + } } +} +fn is_valid_bracket(c: char) -> bool { + PAIRS.iter().any(|(l, r)| *l == c || *r == c) +} + +fn is_valid_pair(doc: &Rope, start_char: usize, end_char: usize) -> bool { + PAIRS.contains(&(doc.char(start_char), doc.char(end_char))) +} + +fn surrounding_bytes(doc: &Rope, node: &Node) -> Option<(usize, usize)> { let len = doc.len_bytes(); + let start_byte = node.start_byte(); - let end_byte = node.end_byte().saturating_sub(1); // it's end exclusive + let end_byte = node.end_byte().saturating_sub(1); + if start_byte >= len || end_byte >= len { return None; } - let start_char = doc.byte_to_char(start_byte); - let end_char = doc.byte_to_char(end_byte); - - if PAIRS.contains(&(doc.char(start_char), doc.char(end_char))) { - if start_byte == byte_pos { - return Some(end_char); - } - - if end_byte == byte_pos { - return Some(start_char); - } - } - - None + Some((start_byte, end_byte)) } diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 9e85bd217..01a8f890e 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -168,7 +168,7 @@ pub fn backwards_skip_while(slice: RopeSlice, pos: usize, fun: F) -> Option bool, { - let mut chars_starting_from_next = slice.chars_at(pos + 1); + let mut chars_starting_from_next = slice.chars_at(pos); let mut backwards = iter::from_fn(|| chars_starting_from_next.prev()).enumerate(); backwards.find_map(|(i, c)| { if !fun(c) { diff --git a/helix-core/src/path.rs b/helix-core/src/path.rs index 6c37cfa1c..a66444651 100644 --- a/helix-core/src/path.rs +++ b/helix-core/src/path.rs @@ -40,7 +40,6 @@ pub fn expand_tilde(path: &Path) -> PathBuf { /// needs to improve on. /// Copied from cargo: pub fn get_normalized_path(path: &Path) -> PathBuf { - let path = expand_tilde(path); let mut components = path.components().peekable(); let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { components.next(); @@ -72,10 +71,11 @@ pub fn get_normalized_path(path: &Path) -> 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 get_canonicalized_path(path: &Path) -> std::io::Result { + let path = expand_tilde(path); let path = if path.is_relative() { std::env::current_dir().map(|current_dir| current_dir.join(path))? } else { - path.to_path_buf() + path }; Ok(get_normalized_path(path.as_path())) diff --git a/helix-core/src/register.rs b/helix-core/src/register.rs index c5444eb73..b9eb497df 100644 --- a/helix-core/src/register.rs +++ b/helix-core/src/register.rs @@ -15,7 +15,11 @@ impl Register { } pub fn new_with_values(name: char, values: Vec) -> Self { - Self { name, values } + if name == '_' { + Self::new(name) + } else { + Self { name, values } + } } pub const fn name(&self) -> char { @@ -27,11 +31,15 @@ impl Register { } pub fn write(&mut self, values: Vec) { - self.values = values; + if self.name != '_' { + self.values = values; + } } pub fn push(&mut self, value: String) { - self.values.push(value); + if self.name != '_' { + self.values.push(value); + } } } diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index f3b5d2c83..116a1c7c0 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -308,10 +308,10 @@ impl Range { } impl From<(usize, usize)> for Range { - fn from(tuple: (usize, usize)) -> Self { + fn from((anchor, head): (usize, usize)) -> Self { Self { - anchor: tuple.0, - head: tuple.1, + anchor, + head, horiz: None, } } @@ -360,7 +360,7 @@ impl Selection { self.normalize() } - /// Adds a new range to the selection and makes it the primary range. + /// Removes a range from the selection. pub fn remove(mut self, index: usize) -> Self { assert!( self.ranges.len() > 1, @@ -528,14 +528,15 @@ impl<'a> IntoIterator for &'a Selection { // TODO: checkSelection -> check if valid for doc length && sorted -pub fn keep_matches( +pub fn keep_or_remove_matches( text: RopeSlice, selection: &Selection, regex: &crate::regex::Regex, + remove: bool, ) -> Option { let result: SmallVec<_> = selection .iter() - .filter(|range| regex.is_match(&range.fragment(text))) + .filter(|range| regex.is_match(&range.fragment(text)) ^ remove) .copied() .collect(); diff --git a/helix-core/src/surround.rs b/helix-core/src/surround.rs index 32161b700..b53b0a78c 100644 --- a/helix-core/src/surround.rs +++ b/helix-core/src/surround.rs @@ -1,4 +1,4 @@ -use crate::{search, Selection}; +use crate::{search, Range, Selection}; use ropey::RopeSlice; pub const PAIRS: &[(char, char)] = &[ @@ -35,33 +35,27 @@ pub fn get_pair(ch: char) -> (char, char) { pub fn find_nth_pairs_pos( text: RopeSlice, ch: char, - pos: usize, + range: Range, n: usize, ) -> Option<(usize, usize)> { - let (open, close) = get_pair(ch); - - if text.len_chars() < 2 || pos >= text.len_chars() { + if text.len_chars() < 2 || range.to() >= text.len_chars() { return None; } + let (open, close) = get_pair(ch); + let pos = range.cursor(text); + if open == close { if Some(open) == text.get_char(pos) { - // Special case: cursor is directly on a matching char. - match pos { - 0 => Some((pos, search::find_nth_next(text, close, pos + 1, n)?)), - _ if (pos + 1) == text.len_chars() => { - Some((search::find_nth_prev(text, open, pos, n)?, pos)) - } - // We return no match because there's no way to know which - // side of the char we should be searching on. - _ => None, - } - } else { - Some(( - search::find_nth_prev(text, open, pos, n)?, - search::find_nth_next(text, close, pos, n)?, - )) + // Cursor is directly on match char. We return no match + // because there's no way to know which side of the char + // we should be searching on. + return None; } + Some(( + search::find_nth_prev(text, open, pos, n)?, + search::find_nth_next(text, close, pos, n)?, + )) } else { Some(( find_nth_open_pair(text, open, close, pos, n)?, @@ -160,8 +154,8 @@ pub fn get_surround_pos( ) -> Option> { let mut change_pos = Vec::new(); - for range in selection { - let (open_pos, close_pos) = find_nth_pairs_pos(text, ch, range.head, skip)?; + for &range in selection { + let (open_pos, close_pos) = find_nth_pairs_pos(text, ch, range, skip)?; if change_pos.contains(&open_pos) || change_pos.contains(&close_pos) { return None; } @@ -178,67 +172,91 @@ mod test { use ropey::Rope; use smallvec::SmallVec; - #[test] - fn test_find_nth_pairs_pos() { - let doc = Rope::from("some (text) here"); + fn check_find_nth_pair_pos( + text: &str, + cases: Vec<(usize, char, usize, Option<(usize, usize)>)>, + ) { + let doc = Rope::from(text); let slice = doc.slice(..); - // cursor on [t]ext - assert_eq!(find_nth_pairs_pos(slice, '(', 6, 1), Some((5, 10))); - assert_eq!(find_nth_pairs_pos(slice, ')', 6, 1), Some((5, 10))); - // cursor on so[m]e - assert_eq!(find_nth_pairs_pos(slice, '(', 2, 1), None); - // cursor on bracket itself - assert_eq!(find_nth_pairs_pos(slice, '(', 5, 1), Some((5, 10))); - assert_eq!(find_nth_pairs_pos(slice, '(', 10, 1), Some((5, 10))); + for (cursor_pos, ch, n, expected_range) in cases { + let range = find_nth_pairs_pos(slice, ch, (cursor_pos, cursor_pos + 1).into(), n); + assert_eq!( + range, expected_range, + "Expected {:?}, got {:?}", + expected_range, range + ); + } } #[test] - fn test_find_nth_pairs_pos_skip() { - let doc = Rope::from("(so (many (good) text) here)"); - let slice = doc.slice(..); + fn test_find_nth_pairs_pos() { + check_find_nth_pair_pos( + "some (text) here", + vec![ + // cursor on [t]ext + (6, '(', 1, Some((5, 10))), + (6, ')', 1, Some((5, 10))), + // cursor on so[m]e + (2, '(', 1, None), + // cursor on bracket itself + (5, '(', 1, Some((5, 10))), + (10, '(', 1, Some((5, 10))), + ], + ); + } - // cursor on go[o]d - assert_eq!(find_nth_pairs_pos(slice, '(', 13, 1), Some((10, 15))); - assert_eq!(find_nth_pairs_pos(slice, '(', 13, 2), Some((4, 21))); - assert_eq!(find_nth_pairs_pos(slice, '(', 13, 3), Some((0, 27))); + #[test] + fn test_find_nth_pairs_pos_skip() { + check_find_nth_pair_pos( + "(so (many (good) text) here)", + vec![ + // cursor on go[o]d + (13, '(', 1, Some((10, 15))), + (13, '(', 2, Some((4, 21))), + (13, '(', 3, Some((0, 27))), + ], + ); } #[test] fn test_find_nth_pairs_pos_same() { - let doc = Rope::from("'so 'many 'good' text' here'"); - let slice = doc.slice(..); - - // cursor on go[o]d - assert_eq!(find_nth_pairs_pos(slice, '\'', 13, 1), Some((10, 15))); - assert_eq!(find_nth_pairs_pos(slice, '\'', 13, 2), Some((4, 21))); - assert_eq!(find_nth_pairs_pos(slice, '\'', 13, 3), Some((0, 27))); - // cursor on the quotes - assert_eq!(find_nth_pairs_pos(slice, '\'', 10, 1), None); - // this is the best we can do since opening and closing pairs are same - assert_eq!(find_nth_pairs_pos(slice, '\'', 0, 1), Some((0, 4))); - assert_eq!(find_nth_pairs_pos(slice, '\'', 27, 1), Some((21, 27))); + check_find_nth_pair_pos( + "'so 'many 'good' text' here'", + vec![ + // cursor on go[o]d + (13, '\'', 1, Some((10, 15))), + (13, '\'', 2, Some((4, 21))), + (13, '\'', 3, Some((0, 27))), + // cursor on the quotes + (10, '\'', 1, None), + ], + ) } #[test] fn test_find_nth_pairs_pos_step() { - let doc = Rope::from("((so)((many) good (text))(here))"); - let slice = doc.slice(..); - - // cursor on go[o]d - assert_eq!(find_nth_pairs_pos(slice, '(', 15, 1), Some((5, 24))); - assert_eq!(find_nth_pairs_pos(slice, '(', 15, 2), Some((0, 31))); + check_find_nth_pair_pos( + "((so)((many) good (text))(here))", + vec![ + // cursor on go[o]d + (15, '(', 1, Some((5, 24))), + (15, '(', 2, Some((0, 31))), + ], + ) } #[test] fn test_find_nth_pairs_pos_mixed() { - let doc = Rope::from("(so [many {good} text] here)"); - let slice = doc.slice(..); - - // cursor on go[o]d - assert_eq!(find_nth_pairs_pos(slice, '{', 13, 1), Some((10, 15))); - assert_eq!(find_nth_pairs_pos(slice, '[', 13, 1), Some((4, 21))); - assert_eq!(find_nth_pairs_pos(slice, '(', 13, 1), Some((0, 27))); + check_find_nth_pair_pos( + "(so [many {good} text] here)", + vec![ + // cursor on go[o]d + (13, '{', 1, Some((10, 15))), + (13, '[', 1, Some((4, 21))), + (13, '(', 1, Some((0, 27))), + ], + ) } #[test] diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index f3e3f238b..ba78adaaa 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -40,18 +40,21 @@ where } #[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Configuration { pub language: Vec, } // largely based on tree-sitter/cli/src/loader.rs #[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct LanguageConfiguration { #[serde(rename = "name")] pub language_id: String, pub scope: String, // source.rust pub file_types: Vec, // filename ends_with? + #[serde(default)] + pub shebangs: Vec, // interpreter(s) associated with language pub roots: Vec, // these indicate project roots <.git, Cargo.toml> pub comment_token: Option, @@ -254,6 +257,7 @@ pub struct Loader { // highlight_names ? language_configs: Vec>, language_config_ids_by_file_type: HashMap, // Vec + language_config_ids_by_shebang: HashMap, } impl Loader { @@ -261,6 +265,7 @@ impl Loader { let mut loader = Self { language_configs: Vec::new(), language_config_ids_by_file_type: HashMap::new(), + language_config_ids_by_shebang: HashMap::new(), }; for config in config.language { @@ -273,6 +278,11 @@ impl Loader { .language_config_ids_by_file_type .insert(file_type.clone(), language_id); } + for shebang in &config.shebangs { + loader + .language_config_ids_by_shebang + .insert(shebang.clone(), language_id); + } loader.language_configs.push(Arc::new(config)); } @@ -298,6 +308,18 @@ impl Loader { // TODO: content_regex handling conflict resolution } + pub fn language_config_for_shebang(&self, source: &Rope) -> Option> { + let line = Cow::from(source.line(0)); + static SHEBANG_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^#!\s*(?:\S*[/\\](?:env\s+(?:\-\S+\s+)*)?)?([^\s\.\d]+)").unwrap() + }); + let configuration_id = SHEBANG_REGEX + .captures(&line) + .and_then(|cap| self.language_config_ids_by_shebang.get(&cap[1])); + + configuration_id.and_then(|&id| self.language_configs.get(id).cloned()) + } + pub fn language_config_for_scope(&self, scope: &str) -> Option> { self.language_configs .iter() diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs index 975ed115b..21ceec04f 100644 --- a/helix-core/src/textobject.rs +++ b/helix-core/src/textobject.rs @@ -10,7 +10,7 @@ use crate::surround; use crate::syntax::LanguageConfiguration; use crate::Range; -fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction) -> usize { +fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize { use CharCategory::{Eol, Whitespace}; let iter = match direction { @@ -33,7 +33,7 @@ fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction) -> match categorize_char(ch) { Eol | Whitespace => return pos, category => { - if category != prev_category && pos != 0 && pos != slice.len_chars() { + if !long && category != prev_category && pos != 0 && pos != slice.len_chars() { return pos; } else { match direction { @@ -70,13 +70,14 @@ pub fn textobject_word( range: Range, textobject: TextObject, _count: usize, + long: bool, ) -> Range { let pos = range.cursor(slice); - let word_start = find_word_boundary(slice, pos, Direction::Backward); + let word_start = find_word_boundary(slice, pos, Direction::Backward, long); let word_end = match slice.get_char(pos).map(categorize_char) { None | Some(CharCategory::Whitespace | CharCategory::Eol) => pos, - _ => find_word_boundary(slice, pos + 1, Direction::Forward), + _ => find_word_boundary(slice, pos + 1, Direction::Forward, long), }; // Special case. @@ -113,7 +114,7 @@ pub fn textobject_surround( ch: char, count: usize, ) -> Range { - surround::find_nth_pairs_pos(slice, ch, range.head, count) + surround::find_nth_pairs_pos(slice, ch, range, count) .map(|(anchor, head)| match textobject { TextObject::Inside => Range::new(next_grapheme_boundary(slice, anchor), head), TextObject::Around => Range::new(anchor, next_grapheme_boundary(slice, head)), @@ -169,7 +170,7 @@ mod test { #[test] fn test_textobject_word() { - // (text, [(cursor position, textobject, final range), ...]) + // (text, [(char position, textobject, final range), ...]) let tests = &[ ( "cursor at beginning of doc", @@ -268,7 +269,9 @@ mod test { let slice = doc.slice(..); for &case in scenario { let (pos, objtype, expected_range) = case; - let result = textobject_word(slice, Range::point(pos), objtype, 1); + // cursor is a single width selection + let range = Range::new(pos, pos + 1); + let result = textobject_word(slice, range, objtype, 1, false); assert_eq!( result, expected_range.into(), @@ -282,7 +285,7 @@ mod test { #[test] fn test_textobject_surround() { - // (text, [(cursor position, textobject, final range, count), ...]) + // (text, [(cursor position, textobject, final range, surround char, count), ...]) let tests = &[ ( "simple (single) surround pairs", diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index dfc18fbea..b62f4a9bd 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -22,7 +22,7 @@ pub enum Assoc { } // ChangeSpec = Change | ChangeSet | Vec -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ChangeSet { pub(crate) changes: Vec, /// The required document length. Will refuse to apply changes unless it matches. @@ -30,16 +30,6 @@ pub struct ChangeSet { len_after: usize, } -impl Default for ChangeSet { - fn default() -> Self { - Self { - changes: Vec::new(), - len: 0, - len_after: 0, - } - } -} - impl ChangeSet { pub fn with_capacity(capacity: usize) -> Self { Self { @@ -330,7 +320,7 @@ impl ChangeSet { /// `true` when the set is empty. #[inline] pub fn is_empty(&self) -> bool { - self.changes.is_empty() + self.changes.is_empty() || self.changes == [Operation::Retain(self.len)] } /// Map a position through the changes. diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index 920ce0371..5e4619ef8 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -23,5 +23,5 @@ lsp-types = { version = "0.91", features = ["proposed"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" -tokio = { version = "1.13", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } +tokio = { version = "1.14", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } tokio-stream = "0.1.8" diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index b810feef3..271fd9d59 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -257,6 +257,12 @@ impl Client { content_format: Some(vec![lsp::MarkupKind::Markdown]), ..Default::default() }), + rename: Some(lsp::RenameClientCapabilities { + dynamic_registration: Some(false), + prepare_support: Some(false), + prepare_support_default_behavior: None, + honors_change_annotations: Some(false), + }), code_action: Some(lsp::CodeActionClientCapabilities { code_action_literal_support: Some(lsp::CodeActionLiteralSupport { code_action_kind: lsp::CodeActionKindLiteralSupport { @@ -773,4 +779,25 @@ impl Client { self.call::(params) } + + pub async fn rename_symbol( + &self, + text_document: lsp::TextDocumentIdentifier, + position: lsp::Position, + new_name: String, + ) -> anyhow::Result { + let params = lsp::RenameParams { + text_document_position: lsp::TextDocumentPositionParams { + text_document, + position, + }, + new_name, + work_done_progress_params: lsp::WorkDoneProgressParams { + work_done_token: None, + }, + }; + + let response = self.request::(params).await?; + Ok(response.unwrap_or_default()) + } } diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 7fa65928b..15cae582b 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -337,7 +337,10 @@ impl Registry { }) .await; - value.expect("failed to initialize capabilities"); + if let Err(e) = value { + log::error!("failed to initialize language server: {}", e); + return; + } // next up, notify _client diff --git a/helix-syntax/languages/tree-sitter-glsl b/helix-syntax/languages/tree-sitter-glsl new file mode 160000 index 000000000..88408ffc5 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-glsl @@ -0,0 +1 @@ +Subproject commit 88408ffc5e27abcffced7010fc77396ae3636d7e diff --git a/helix-syntax/languages/tree-sitter-perl b/helix-syntax/languages/tree-sitter-perl new file mode 160000 index 000000000..0ac2c6da5 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-perl @@ -0,0 +1 @@ +Subproject commit 0ac2c6da562c7a2c26ed7e8691d4a590f7e8b90a diff --git a/helix-syntax/languages/tree-sitter-wgsl b/helix-syntax/languages/tree-sitter-wgsl new file mode 160000 index 000000000..f00ff5225 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-wgsl @@ -0,0 +1 @@ +Subproject commit f00ff52251edbd58f4d39c9c3204383253032c11 diff --git a/helix-term/build.rs b/helix-term/build.rs new file mode 100644 index 000000000..61ffa6f4f --- /dev/null +++ b/helix-term/build.rs @@ -0,0 +1,12 @@ +use std::process::Command; + +fn main() { + let git_hash = Command::new("git") + .args(&["describe", "--dirty"]) + .output() + .map(|x| String::from_utf8(x.stdout).ok()) + .ok() + .flatten() + .unwrap_or_else(|| String::from(env!("CARGO_PKG_VERSION"))); + println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", git_hash); +} diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 6037148f6..90330751a 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -7,7 +7,7 @@ use crate::{args::Args, compositor::Compositor, config::Config, job::Jobs, ui}; use log::{error, warn}; use std::{ - io::{stdout, Write}, + io::{stdin, stdout, Write}, sync::Arc, time::{Duration, Instant}, }; @@ -17,6 +17,7 @@ use anyhow::Error; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream}, execute, terminal, + tty::IsTty, }; #[cfg(not(windows))] use { @@ -60,14 +61,19 @@ impl Application { std::sync::Arc::new(theme::Loader::new(&conf_dir, &helix_core::runtime_dir())); // load default and user config, and merge both - let def_lang_conf: toml::Value = toml::from_slice(include_bytes!("../../languages.toml")) - .expect("Could not parse built-in languages.toml, something must be very wrong"); - let user_lang_conf: Option = std::fs::read(conf_dir.join("languages.toml")) + let builtin_err_msg = + "Could not parse built-in languages.toml, something must be very wrong"; + let def_lang_conf: toml::Value = + toml::from_slice(include_bytes!("../../languages.toml")).expect(builtin_err_msg); + let def_syn_loader_conf: helix_core::syntax::Configuration = + def_lang_conf.clone().try_into().expect(builtin_err_msg); + let user_lang_conf = std::fs::read(conf_dir.join("languages.toml")) .ok() - .map(|raw| toml::from_slice(&raw).expect("Could not parse user languages.toml")); + .map(|raw| toml::from_slice(&raw)); let lang_conf = match user_lang_conf { - Some(value) => merge_toml_values(def_lang_conf, value), - None => def_lang_conf, + Some(Ok(value)) => Ok(merge_toml_values(def_lang_conf, value)), + Some(err @ Err(_)) => err, + None => Ok(def_lang_conf), }; let theme = if let Some(theme) = &config.theme { @@ -83,8 +89,15 @@ impl Application { }; let syn_loader_conf: helix_core::syntax::Configuration = lang_conf - .try_into() - .expect("Could not parse merged (built-in + user) languages.toml"); + .and_then(|conf| conf.try_into()) + .unwrap_or_else(|err| { + eprintln!("Bad language config: {}", err); + eprintln!("Press to continue with default language config"); + use std::io::Read; + // This waits for an enter press. + let _ = std::io::stdin().read(&mut []); + def_syn_loader_conf + }); let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); let mut editor = Editor::new( @@ -107,7 +120,7 @@ impl Application { if first.is_dir() { std::env::set_current_dir(&first)?; editor.new_file(Action::VerticalSplit); - compositor.push(Box::new(ui::file_picker(".".into()))); + compositor.push(Box::new(ui::file_picker(".".into(), &config.editor))); } else { let nr_of_files = args.files.len(); editor.open(first.to_path_buf(), Action::VerticalSplit)?; @@ -122,8 +135,17 @@ impl Application { } editor.set_status(format!("Loaded {} files.", nr_of_files)); } - } else { + } else if stdin().is_tty() { editor.new_file(Action::VerticalSplit); + } else if cfg!(target_os = "macos") { + // On Linux and Windows, we allow the output of a command to be piped into the new buffer. + // This doesn't currently work on macOS because of the following issue: + // https://github.com/crossterm-rs/crossterm/issues/500 + anyhow::bail!("Piping into helix-term is currently not supported on macOS"); + } else { + editor + .new_file_from_stdin(Action::VerticalSplit) + .unwrap_or_else(|_| editor.new_file(Action::VerticalSplit)); } editor.set_theme(theme); @@ -243,17 +265,13 @@ impl Application { use crate::commands::{insert::idle_completion, Context}; use helix_view::document::Mode; - if doc_mut!(self.editor).mode != Mode::Insert || !self.config.editor.auto_completion { + if doc!(self.editor).mode != Mode::Insert || !self.config.editor.auto_completion { return; } let editor_view = self .compositor - .find(std::any::type_name::()) + .find::() .expect("expected at least one EditorView"); - let editor_view = editor_view - .as_any_mut() - .downcast_mut::() - .unwrap(); if editor_view.completion.is_some() { return; @@ -418,12 +436,8 @@ impl Application { { let editor_view = self .compositor - .find(std::any::type_name::()) + .find::() .expect("expected at least one EditorView"); - let editor_view = editor_view - .as_any_mut() - .downcast_mut::() - .unwrap(); let lsp::ProgressParams { token, value } = params; let lsp::ProgressParamsValue::WorkDone(work) = value; @@ -537,12 +551,8 @@ impl Application { let editor_view = self .compositor - .find(std::any::type_name::()) + .find::() .expect("expected at least one EditorView"); - let editor_view = editor_view - .as_any_mut() - .downcast_mut::() - .unwrap(); let spinner = editor_view.spinners_mut().get_or_create(server_id); if spinner.is_stopped() { spinner.start(); @@ -577,7 +587,7 @@ impl Application { Ok(()) } - pub async fn run(&mut self) -> Result<(), Error> { + pub async fn run(&mut self) -> Result { self.claim_term().await?; // Exit the alternate screen and disable raw mode before panicking @@ -600,6 +610,6 @@ impl Application { self.restore_term()?; - Ok(()) + Ok(self.editor.exit_code) } } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index fee5a0588..407609f7c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,19 +1,23 @@ use helix_core::{ - comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes, indent, + comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes, + history::UndoKind, + increment::date_time::DateTimeIncrementor, + increment::{number::NumberIncrementor, Increment}, + indent, indent::IndentStyle, line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, match_brackets, movement::{self, Direction}, object, pos_at_coords, regex::{self, Regex, RegexBuilder}, - register::Register, - search, selection, surround, textobject, LineEnding, Position, Range, Rope, RopeGraphemes, - RopeSlice, Selection, SmallVec, Tendril, Transaction, + search, selection, surround, textobject, + unicode::width::UnicodeWidthChar, + LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, SmallVec, Tendril, + Transaction, }; - use helix_view::{ clipboard::ClipboardType, - document::Mode, + document::{Mode, SCRATCH_BUFFER_NAME}, editor::{Action, Motion}, input::KeyEvent, keyboard::KeyCode, @@ -21,9 +25,9 @@ use helix_view::{ Document, DocumentId, Editor, ViewId, }; -use anyhow::{anyhow, bail, Context as _}; +use anyhow::{anyhow, bail, ensure, Context as _}; use helix_lsp::{ - lsp, + block_on, lsp, util::{lsp_pos_to_pos, lsp_range_to_range, pos_to_lsp_pos, range_to_lsp_range}, OffsetEncoding, }; @@ -37,7 +41,7 @@ use crate::{ use crate::job::{self, Job, Jobs}; use futures_util::{FutureExt, StreamExt}; -use std::num::NonZeroUsize; +use std::{collections::HashSet, num::NonZeroUsize}; use std::{fmt, future::Future}; use std::{ @@ -131,47 +135,76 @@ fn align_view(doc: &Document, view: &mut View, align: Align) { view.offset.row = line.saturating_sub(relative); } -/// A command is composed of a static name, and a function that takes the current state plus a count, -/// and does a side-effect on the state (usually by creating and applying a transaction). -#[derive(Copy, Clone)] -pub struct Command { - name: &'static str, - fun: fn(cx: &mut Context), - doc: &'static str, -} - -macro_rules! commands { +/// A MappableCommand is either a static command like "jump_view_up" or a Typable command like +/// :format. It causes a side-effect on the state (usually by creating and applying a transaction). +/// Both of these types of commands can be mapped with keybindings in the config.toml. +#[derive(Clone)] +pub enum MappableCommand { + Typable { + name: String, + args: Vec, + doc: String, + }, + Static { + name: &'static str, + fun: fn(cx: &mut Context), + doc: &'static str, + }, +} + +macro_rules! static_commands { ( $($name:ident, $doc:literal,)* ) => { $( #[allow(non_upper_case_globals)] - pub const $name: Self = Self { + pub const $name: Self = Self::Static { name: stringify!($name), fun: $name, doc: $doc }; )* - pub const COMMAND_LIST: &'static [Self] = &[ + pub const STATIC_COMMAND_LIST: &'static [Self] = &[ $( Self::$name, )* ]; } } -impl Command { +impl MappableCommand { pub fn execute(&self, cx: &mut Context) { - (self.fun)(cx); + match &self { + MappableCommand::Typable { name, args, doc: _ } => { + let args: Vec<&str> = args.iter().map(|arg| arg.as_str()).collect(); + if let Some(command) = cmd::TYPABLE_COMMAND_MAP.get(name.as_str()) { + let mut cx = compositor::Context { + editor: cx.editor, + jobs: cx.jobs, + scroll: None, + }; + if let Err(e) = (command.fun)(&mut cx, &args, PromptEvent::Validate) { + cx.editor.set_error(format!("{}", e)); + } + } + } + MappableCommand::Static { fun, .. } => (fun)(cx), + } } - pub fn name(&self) -> &'static str { - self.name + pub fn name(&self) -> &str { + match &self { + MappableCommand::Typable { name, .. } => name, + MappableCommand::Static { name, .. } => name, + } } - pub fn doc(&self) -> &'static str { - self.doc + pub fn doc(&self) -> &str { + match &self { + MappableCommand::Typable { doc, .. } => doc, + MappableCommand::Static { doc, .. } => doc, + } } #[rustfmt::skip] - commands!( + static_commands!( no_op, "Do nothing", move_char_left, "Move left", move_char_right, "Move right", @@ -185,6 +218,7 @@ impl Command { copy_selection_on_prev_line, "Copy selection on previous line", move_next_word_start, "Move to beginning of next word", move_prev_word_start, "Move to beginning of previous word", + move_prev_word_end, "Move to end of previous word", move_next_word_end, "Move to end of next word", move_next_long_word_start, "Move to beginning of next long word", move_prev_long_word_start, "Move to beginning of previous long word", @@ -227,7 +261,9 @@ impl Command { extend_line, "Select current line, if already selected, extend to next line", extend_to_line_bounds, "Extend selection to line bounds (line-wise selection)", delete_selection, "Delete selection", + delete_selection_noyank, "Delete selection, without yanking", change_selection, "Change selection (delete and enter insert mode)", + change_selection_noyank, "Change selection (delete and enter insert mode, without yanking)", collapse_selection, "Collapse selection onto a single cursor", flip_selections, "Flip selection cursor and anchor", insert_mode, "Insert before selection", @@ -237,6 +273,7 @@ impl Command { code_action, "Perform code action", buffer_picker, "Open buffer picker", symbol_picker, "Open symbol picker", + workspace_symbol_picker, "Open workspace symbol picker", last_picker, "Open last picker", prepend_to_line, "Insert at start of line", append_to_line, "Insert at end of line", @@ -252,11 +289,16 @@ impl Command { goto_implementation, "Goto implementation", goto_file_start, "Goto file start/line", goto_file_end, "Goto file end", + goto_file, "Goto files in the selection", + goto_file_hsplit, "Goto files in the selection in horizontal splits", + goto_file_vsplit, "Goto files in the selection in vertical splits", goto_reference, "Goto references", goto_window_top, "Goto window top", - goto_window_middle, "Goto window middle", + goto_window_center, "Goto window center", goto_window_bottom, "Goto window bottom", goto_last_accessed_file, "Goto last accessed file", + goto_last_modified_file, "Goto last modified file", + goto_last_modification, "Goto last modification", goto_line, "Goto line", goto_last_line, "Goto last line", goto_first_diag, "Goto first diagnostic", @@ -270,6 +312,7 @@ impl Command { // TODO: different description ? goto_line_end_newline, "Goto line end", goto_first_nonwhitespace, "Goto first non-blank in line", + trim_selections, "Trim whitespace from selections", extend_to_line_start, "Extend to line start", extend_to_line_end, "Extend to line end", extend_to_line_end_newline, "Extend to line end", @@ -279,8 +322,13 @@ impl Command { delete_char_backward, "Delete previous char", delete_char_forward, "Delete next char", delete_word_backward, "Delete previous word", + delete_word_forward, "Delete next word", + kill_to_line_start, "Delete content till the start of the line", + kill_to_line_end, "Delete content till the end of the line", undo, "Undo change", redo, "Redo change", + earlier, "Move backward in history", + later, "Move forward in history", yank, "Yank selection", yank_joined_to_clipboard, "Join and yank selections to clipboard", yank_main_selection_to_clipboard, "Yank main selection to clipboard", @@ -300,6 +348,8 @@ impl Command { format_selections, "Format selection", join_selections, "Join lines inside selection", keep_selections, "Keep selections matching regex", + remove_selections, "Remove selections matching regex", + align_selections, "Align selections in column", keep_primary_selection, "Keep primary selection", remove_primary_selection, "Remove primary selection", completion, "Invoke completion popup", @@ -320,7 +370,9 @@ impl Command { hsplit, "Horizontal bottom split", vsplit, "Vertical right split", wclose, "Close window", + wonly, "Current window only", select_register, "Select register", + insert_register, "Insert register", align_view_middle, "Align view middle", align_view_top, "Align view top", align_view_center, "Align view center", @@ -339,36 +391,57 @@ impl Command { shell_append_output, "Append output of shell command after each selection", shell_keep_pipe, "Filter selections with shell predicate", suspend, "Suspend", + rename_symbol, "Rename symbol", + increment, "Increment", + decrement, "Decrement", ); } -impl fmt::Debug for Command { +impl fmt::Debug for MappableCommand { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Command { name, .. } = self; - f.debug_tuple("Command").field(name).finish() + f.debug_tuple("MappableCommand") + .field(&self.name()) + .finish() } } -impl fmt::Display for Command { +impl fmt::Display for MappableCommand { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Command { name, .. } = self; - f.write_str(name) + f.write_str(self.name()) } } -impl std::str::FromStr for Command { +impl std::str::FromStr for MappableCommand { type Err = anyhow::Error; fn from_str(s: &str) -> Result { - Command::COMMAND_LIST - .iter() - .copied() - .find(|cmd| cmd.name == s) - .ok_or_else(|| anyhow!("No command named '{}'", s)) + if let Some(suffix) = s.strip_prefix(':') { + let mut typable_command = suffix.split(' ').into_iter().map(|arg| arg.trim()); + let name = typable_command + .next() + .ok_or_else(|| anyhow!("Expected typable command name"))?; + let args = typable_command + .map(|s| s.to_owned()) + .collect::>(); + cmd::TYPABLE_COMMAND_MAP + .get(name) + .map(|cmd| MappableCommand::Typable { + name: cmd.name.to_owned(), + doc: format!(":{} {:?}", cmd.name, args), + args, + }) + .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s)) + } else { + MappableCommand::STATIC_COMMAND_LIST + .iter() + .cloned() + .find(|cmd| cmd.name() == s) + .ok_or_else(|| anyhow!("No command named '{}'", s)) + } } } -impl<'de> Deserialize<'de> for Command { +impl<'de> Deserialize<'de> for MappableCommand { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -378,9 +451,27 @@ impl<'de> Deserialize<'de> for Command { } } -impl PartialEq for Command { +impl PartialEq for MappableCommand { fn eq(&self, other: &Self) -> bool { - self.name() == other.name() + match (self, other) { + ( + MappableCommand::Typable { + name: first_name, .. + }, + MappableCommand::Typable { + name: second_name, .. + }, + ) => first_name == second_name, + ( + MappableCommand::Static { + name: first_name, .. + }, + MappableCommand::Static { + name: second_name, .. + }, + ) => first_name == second_name, + _ => false, + } } } @@ -562,6 +653,29 @@ fn extend_to_line_start(cx: &mut Context) { goto_line_start_impl(view, doc, Movement::Extend) } +fn kill_to_line_start(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let line = range.cursor_line(text); + range.put_cursor(text, text.line_to_char(line), true) + }); + delete_selection_insert_mode(doc, view, &selection); +} + +fn kill_to_line_end(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id).clone().transform(|range| { + let line = range.cursor_line(text); + let pos = line_end_char_index(&text, line); + range.put_cursor(text, pos, true) + }); + delete_selection_insert_mode(doc, view, &selection); +} + fn goto_first_nonwhitespace(cx: &mut Context) { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -579,11 +693,119 @@ fn goto_first_nonwhitespace(cx: &mut Context) { doc.set_selection(view.id, selection); } +fn trim_selections(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let ranges: SmallVec<[Range; 1]> = doc + .selection(view.id) + .iter() + .filter_map(|range| { + if range.is_empty() || range.fragment(text).chars().all(|ch| ch.is_whitespace()) { + return None; + } + let mut start = range.from(); + let mut end = range.to(); + start = movement::skip_while(text, start, |x| x.is_whitespace()).unwrap_or(start); + end = movement::backwards_skip_while(text, end, |x| x.is_whitespace()).unwrap_or(end); + if range.anchor < range.head { + Some(Range::new(start, end)) + } else { + Some(Range::new(end, start)) + } + }) + .collect(); + + if !ranges.is_empty() { + let primary = doc.selection(view.id).primary(); + let idx = ranges + .iter() + .position(|range| range.overlaps(&primary)) + .unwrap_or(ranges.len() - 1); + doc.set_selection(view.id, Selection::new(ranges, idx)); + } else { + collapse_selection(cx); + keep_primary_selection(cx); + }; +} + +// align text in selection +fn align_selections(cx: &mut Context) { + let align_style = cx.count(); + if align_style > 3 { + cx.editor.set_error( + "align only accept 1,2,3 as count to set left/center/right align".to_string(), + ); + return; + } + + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + let mut column_widths = vec![]; + let mut last_line = text.len_lines(); + let mut column = 0; + // first of all, we need compute all column's width, let use max width of the selections in a column + for sel in selection { + let (l1, l2) = sel.line_range(text); + if l1 != l2 { + cx.editor + .set_error("align cannot work with multi line selections".to_string()); + return; + } + // if the selection is not in the same line with last selection, we set the column to 0 + column = if l1 != last_line { 0 } else { column + 1 }; + last_line = l1; + + if column < column_widths.len() { + if sel.to() - sel.from() > column_widths[column] { + column_widths[column] = sel.to() - sel.from(); + } + } else { + // a new column, current selection width is the temp width of the column + column_widths.push(sel.to() - sel.from()); + } + } + last_line = text.len_lines(); + // once we get the with of each column, we transform each selection with to it's column width based on the align style + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + let l = range.cursor_line(text); + column = if l != last_line { 0 } else { column + 1 }; + last_line = l; + + ( + range.from(), + range.to(), + Some( + align_fragment_to_width(&range.fragment(text), column_widths[column], align_style) + .into(), + ), + ) + }); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); +} + +fn align_fragment_to_width(fragment: &str, width: usize, align_style: usize) -> String { + let trimed = fragment.trim_matches(|c| c == ' '); + let mut s = " ".repeat(width - trimed.chars().count()); + match align_style { + 1 => s.insert_str(0, trimed), // left align + 2 => s.insert_str(s.len() / 2, trimed), // center align + 3 => s.push_str(trimed), // right align + n => unimplemented!("{}", n), + } + s +} + fn goto_window(cx: &mut Context, align: Align) { + let count = cx.count() - 1; let (view, doc) = current!(cx.editor); let height = view.inner_area().height as usize; + // respect user given count if any // - 1 so we have at least one gap in the middle. // a height of 6 with padding of 3 on each side will keep shifting the view back and forth // as we type @@ -592,10 +814,11 @@ fn goto_window(cx: &mut Context, align: Align) { let last_line = view.last_line(doc); let line = match align { - Align::Top => (view.offset.row + scrolloff), - Align::Center => (view.offset.row + (height / 2)), - Align::Bottom => last_line.saturating_sub(scrolloff), + Align::Top => (view.offset.row + scrolloff + count), + Align::Center => (view.offset.row + ((last_line - view.offset.row) / 2)), + Align::Bottom => last_line.saturating_sub(scrolloff + count), } + .max(view.offset.row + scrolloff) .min(last_line.saturating_sub(scrolloff)); let pos = doc.text().line_to_char(line); @@ -607,7 +830,7 @@ fn goto_window_top(cx: &mut Context) { goto_window(cx, Align::Top) } -fn goto_window_middle(cx: &mut Context) { +fn goto_window_center(cx: &mut Context) { goto_window(cx, Align::Center) } @@ -638,6 +861,10 @@ fn move_prev_word_start(cx: &mut Context) { move_word_impl(cx, movement::move_prev_word_start) } +fn move_prev_word_end(cx: &mut Context) { + move_word_impl(cx, movement::move_prev_word_end) +} + fn move_next_word_end(cx: &mut Context) { move_word_impl(cx, movement::move_next_word_end) } @@ -681,6 +908,49 @@ fn goto_file_end(cx: &mut Context) { doc.set_selection(view.id, selection); } +fn goto_file(cx: &mut Context) { + goto_file_impl(cx, Action::Replace); +} + +fn goto_file_hsplit(cx: &mut Context) { + goto_file_impl(cx, Action::HorizontalSplit); +} + +fn goto_file_vsplit(cx: &mut Context) { + goto_file_impl(cx, Action::VerticalSplit); +} + +fn goto_file_impl(cx: &mut Context, action: Action) { + let (view, doc) = current_ref!(cx.editor); + let text = doc.text(); + let selections = doc.selection(view.id); + let mut paths: Vec<_> = selections + .iter() + .map(|r| text.slice(r.from()..r.to()).to_string()) + .collect(); + let primary = selections.primary(); + if selections.len() == 1 && primary.to() - primary.from() == 1 { + let current_word = movement::move_next_long_word_start( + text.slice(..), + movement::move_prev_long_word_start(text.slice(..), primary, 1), + 1, + ); + paths.clear(); + paths.push( + text.slice(current_word.from()..current_word.to()) + .to_string(), + ); + } + for sel in paths { + let p = sel.trim(); + if !p.is_empty() { + if let Err(e) = cx.editor.open(PathBuf::from(p), action) { + cx.editor.set_error(format!("Open file failed: {:?}", e)); + } + } + } +} + fn extend_word_impl(cx: &mut Context, extend_fn: F) where F: Fn(RopeSlice, Range, usize) -> Range, @@ -1174,6 +1444,7 @@ fn search_impl( regex: &Regex, movement: Movement, direction: Direction, + scrolloff: usize, ) { let text = doc.text().slice(..); let selection = doc.selection(view.id); @@ -1232,7 +1503,11 @@ fn search_impl( }; doc.set_selection(view.id, selection); - align_view(doc, view, Align::Center); + if view.is_cursor_in_view(doc, 0) { + view.ensure_cursor_in_view(doc, scrolloff); + } else { + align_view(doc, view, Align::Center) + } }; } @@ -1256,6 +1531,8 @@ fn rsearch(cx: &mut Context) { // TODO: use one function for search vs extend fn searcher(cx: &mut Context, direction: Direction) { let reg = cx.register.unwrap_or('/'); + let scrolloff = cx.editor.config.scrolloff; + let (_, doc) = current!(cx.editor); // TODO: could probably share with select_on_matches? @@ -1280,7 +1557,15 @@ fn searcher(cx: &mut Context, direction: Direction) { if event != PromptEvent::Update { return; } - search_impl(doc, view, &contents, ®ex, Movement::Move, direction); + search_impl( + doc, + view, + &contents, + ®ex, + Movement::Move, + direction, + scrolloff, + ); }, ); @@ -1288,6 +1573,7 @@ fn searcher(cx: &mut Context, direction: Direction) { } fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Direction) { + let scrolloff = cx.editor.config.scrolloff; let (view, doc) = current!(cx.editor); let registers = &cx.editor.registers; if let Some(query) = registers.read('/') { @@ -1302,7 +1588,7 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir .case_insensitive(case_insensitive) .build() { - search_impl(doc, view, &contents, ®ex, movement, direction); + search_impl(doc, view, &contents, ®ex, movement, direction, scrolloff); } else { // get around warning `mutable_borrow_reservation_conflict` // which will be a hard error in the future @@ -1342,11 +1628,12 @@ fn global_search(cx: &mut Context) { let (all_matches_sx, all_matches_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, PathBuf)>(); let smart_case = cx.editor.config.smart_case; + let file_picker_config = cx.editor.config.file_picker.clone(); let completions = search_completions(cx, None); let prompt = ui::regex_prompt( cx, - "global search:".into(), + "global-search:".into(), None, move |input: &str| { completions @@ -1370,41 +1657,55 @@ fn global_search(cx: &mut Context) { let search_root = std::env::current_dir() .expect("Global search error: Failed to get current dir"); - WalkBuilder::new(search_root).build_parallel().run(|| { - let mut searcher_cl = searcher.clone(); - let matcher_cl = matcher.clone(); - let all_matches_sx_cl = all_matches_sx.clone(); - Box::new(move |dent: Result| -> WalkState { - let dent = match dent { - Ok(dent) => dent, - Err(_) => return WalkState::Continue, - }; - - match dent.file_type() { - Some(fi) => { - if !fi.is_file() { - return WalkState::Continue; + WalkBuilder::new(search_root) + .hidden(file_picker_config.hidden) + .parents(file_picker_config.parents) + .ignore(file_picker_config.ignore) + .git_ignore(file_picker_config.git_ignore) + .git_global(file_picker_config.git_global) + .git_exclude(file_picker_config.git_exclude) + .max_depth(file_picker_config.max_depth) + .build_parallel() + .run(|| { + let mut searcher_cl = searcher.clone(); + let matcher_cl = matcher.clone(); + let all_matches_sx_cl = all_matches_sx.clone(); + Box::new(move |dent: Result| -> WalkState { + let dent = match dent { + Ok(dent) => dent, + Err(_) => return WalkState::Continue, + }; + + match dent.file_type() { + Some(fi) => { + if !fi.is_file() { + return WalkState::Continue; + } } + None => return WalkState::Continue, } - None => return WalkState::Continue, - } - let result_sink = sinks::UTF8(|line_num, _| { - match all_matches_sx_cl - .send((line_num as usize - 1, dent.path().to_path_buf())) - { - Ok(_) => Ok(true), - Err(_) => Ok(false), + let result_sink = sinks::UTF8(|line_num, _| { + match all_matches_sx_cl + .send((line_num as usize - 1, dent.path().to_path_buf())) + { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } + }); + let result = + searcher_cl.search_path(&matcher_cl, dent.path(), result_sink); + + if let Err(err) = result { + log::error!( + "Global search error: {}, {}", + dent.path().display(), + err + ); } - }); - let result = searcher_cl.search_path(&matcher_cl, dent.path(), result_sink); - - if let Err(err) = result { - log::error!("Global search error: {}, {}", dent.path().display(), err); - } - WalkState::Continue - }) - }); + WalkState::Continue + }) + }); } else { // Otherwise do nothing // log::warn!("Global Search Invalid Pattern") @@ -1509,13 +1810,47 @@ fn extend_to_line_bounds(cx: &mut Context) { ); } -fn delete_selection_impl(reg: &mut Register, doc: &mut Document, view_id: ViewId) { +enum Operation { + Delete, + Change, +} + +fn delete_selection_impl(cx: &mut Context, op: Operation) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); - let selection = doc.selection(view_id); + let selection = doc.selection(view.id); - // first yank the selection - let values: Vec = selection.fragments(text).map(Cow::into_owned).collect(); - reg.write(values); + if cx.register != Some('_') { + // first yank the selection + let values: Vec = selection.fragments(text).map(Cow::into_owned).collect(); + let reg_name = cx.register.unwrap_or('"'); + let registers = &mut cx.editor.registers; + let reg = registers.get_mut(reg_name); + reg.write(values); + }; + + // then delete + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + (range.from(), range.to(), None) + }); + doc.apply(&transaction, view.id); + + match op { + Operation::Delete => { + doc.append_changes_to_history(view.id); + // exit select mode, if currently in select mode + exit_select_mode(cx); + } + Operation::Change => { + enter_insert_mode(doc); + } + } +} + +#[inline] +fn delete_selection_insert_mode(doc: &mut Document, view: &View, selection: &Selection) { + let view_id = view.id; // then delete let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { @@ -1525,25 +1860,21 @@ fn delete_selection_impl(reg: &mut Register, doc: &mut Document, view_id: ViewId } fn delete_selection(cx: &mut Context) { - let reg_name = cx.register.unwrap_or('"'); - let (view, doc) = current!(cx.editor); - let registers = &mut cx.editor.registers; - let reg = registers.get_mut(reg_name); - delete_selection_impl(reg, doc, view.id); - - doc.append_changes_to_history(view.id); + delete_selection_impl(cx, Operation::Delete); +} - // exit select mode, if currently in select mode - exit_select_mode(cx); +fn delete_selection_noyank(cx: &mut Context) { + cx.register = Some('_'); + delete_selection_impl(cx, Operation::Delete); } fn change_selection(cx: &mut Context) { - let reg_name = cx.register.unwrap_or('"'); - let (view, doc) = current!(cx.editor); - let registers = &mut cx.editor.registers; - let reg = registers.get_mut(reg_name); - delete_selection_impl(reg, doc, view.id); - enter_insert_mode(doc); + delete_selection_impl(cx, Operation::Change); +} + +fn change_selection_noyank(cx: &mut Context) { + cx.register = Some('_'); + delete_selection_impl(cx, Operation::Change); } fn collapse_selection(cx: &mut Context) { @@ -1638,8 +1969,7 @@ mod cmd { buffers_remaining_impl(cx.editor)? } - cx.editor - .close(view!(cx.editor).id, /* close_buffer */ false); + cx.editor.close(view!(cx.editor).id); Ok(()) } @@ -1649,8 +1979,7 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - cx.editor - .close(view!(cx.editor).id, /* close_buffer */ false); + cx.editor.close(view!(cx.editor).id); Ok(()) } @@ -1660,11 +1989,30 @@ mod cmd { args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - use helix_core::path::expand_tilde; let path = args.get(0).context("wrong argument count")?; - let _ = cx - .editor - .open(expand_tilde(Path::new(path)), Action::Replace)?; + let _ = cx.editor.open(path.into(), Action::Replace)?; + Ok(()) + } + + fn buffer_close( + cx: &mut compositor::Context, + _args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + let view = view!(cx.editor); + let doc_id = view.doc; + cx.editor.close_document(doc_id, false)?; + Ok(()) + } + + fn force_buffer_close( + cx: &mut compositor::Context, + _args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + let view = view!(cx.editor); + let doc_id = view.doc; + cx.editor.close_document(doc_id, true)?; Ok(()) } @@ -1675,7 +2023,7 @@ mod cmd { let jobs = &mut cx.jobs; let (_, doc) = current!(cx.editor); - if let Some(path) = path { + if let Some(ref path) = path { doc.set_path(Some(path.as_ref())) .context("invalid filepath")?; } @@ -1695,6 +2043,11 @@ mod cmd { }); let future = doc.format_and_save(fmt); cx.jobs.add(Job::new(future).wait_before_exiting()); + + if path.is_some() { + let id = doc.id(); + let _ = cx.editor.refresh_language_server(id); + } Ok(()) } @@ -1819,13 +2172,13 @@ mod cmd { args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - let uk = args - .join(" ") - .parse::() - .map_err(|s| anyhow!(s))?; + let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - doc.earlier(view.id, uk); + let success = doc.earlier(view.id, uk); + if !success { + cx.editor.set_status("Already at oldest change".to_owned()); + } Ok(()) } @@ -1835,12 +2188,12 @@ mod cmd { args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - let uk = args - .join(" ") - .parse::() - .map_err(|s| anyhow!(s))?; + let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - doc.later(view.id, uk); + let success = doc.later(view.id, uk); + if !success { + cx.editor.set_status("Already at newest change".to_owned()); + } Ok(()) } @@ -1872,7 +2225,7 @@ mod cmd { .map(|doc| { doc.relative_path() .map(|path| path.to_string_lossy().to_string()) - .unwrap_or_else(|| "[scratch]".into()) + .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()) }) .collect(); if !modified.is_empty() { @@ -1914,7 +2267,7 @@ mod cmd { // close all views let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); for view_id in views { - cx.editor.close(view_id, false); + cx.editor.close(view_id); } } @@ -1958,7 +2311,7 @@ mod cmd { // close all views let views: Vec<_> = editor.tree.views().map(|(view, _)| view.id).collect(); for view_id in views { - editor.close(view_id, false); + editor.close(view_id); } Ok(()) @@ -1969,7 +2322,7 @@ mod cmd { args: &[&str], event: PromptEvent, ) -> anyhow::Result<()> { - quit_all_impl(&mut cx.editor, args, event, false) + quit_all_impl(cx.editor, args, event, false) } fn force_quit_all( @@ -1977,7 +2330,26 @@ mod cmd { args: &[&str], event: PromptEvent, ) -> anyhow::Result<()> { - quit_all_impl(&mut cx.editor, args, event, true) + quit_all_impl(cx.editor, args, event, true) + } + + fn cquit( + cx: &mut compositor::Context, + args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + let exit_code = args + .first() + .and_then(|code| code.parse::().ok()) + .unwrap_or(1); + cx.editor.exit_code = exit_code; + + let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); + for view_id in views { + cx.editor.close(view_id); + } + + Ok(()) } fn theme( @@ -1994,7 +2366,7 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Clipboard) + yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard) } fn yank_joined_to_clipboard( @@ -2007,7 +2379,7 @@ mod cmd { .first() .copied() .unwrap_or_else(|| doc.line_ending.as_str()); - yank_joined_to_clipboard_impl(&mut cx.editor, separator, ClipboardType::Clipboard) + yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Clipboard) } fn yank_main_selection_to_primary_clipboard( @@ -2015,7 +2387,7 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Selection) + yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection) } fn yank_joined_to_primary_clipboard( @@ -2028,7 +2400,7 @@ mod cmd { .first() .copied() .unwrap_or_else(|| doc.line_ending.as_str()); - yank_joined_to_clipboard_impl(&mut cx.editor, separator, ClipboardType::Selection) + yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Selection) } fn paste_clipboard_after( @@ -2036,7 +2408,7 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard) } fn paste_clipboard_before( @@ -2044,7 +2416,7 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard) } fn paste_primary_clipboard_after( @@ -2052,7 +2424,7 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection) } fn paste_primary_clipboard_before( @@ -2060,7 +2432,7 @@ mod cmd { _args: &[&str], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection) } fn replace_selections_with_clipboard_impl( @@ -2229,6 +2601,24 @@ mod cmd { Ok(()) } + pub(super) fn goto_line_number( + cx: &mut compositor::Context, + args: &[&str], + _event: PromptEvent, + ) -> anyhow::Result<()> { + ensure!(!args.is_empty(), "Line number required"); + + let line = args[0].parse::()?; + + goto_line_impl(cx.editor, NonZeroUsize::new(line)); + + let (view, doc) = current!(cx.editor); + + view.ensure_cursor_in_view(doc, line); + + Ok(()) + } + fn help( cx: &mut compositor::Context, args: &[&str], @@ -2240,7 +2630,7 @@ mod cmd { } let command = { - if Command::COMMAND_LIST + if MappableCommand::STATIC_COMMAND_LIST .iter() .any(|command| command.name() == args[0]) { @@ -2290,6 +2680,20 @@ mod cmd { fun: open, completer: Some(completers::filename), }, + TypableCommand { + name: "buffer-close", + aliases: &["bc", "bclose"], + doc: "Close the current buffer.", + fun: buffer_close, + completer: None, // FIXME: buffer completer + }, + TypableCommand { + name: "buffer-close!", + aliases: &["bc!", "bclose!"], + doc: "Close the current buffer forcefully (ignoring unsaved changes).", + fun: force_buffer_close, + completer: None, // FIXME: buffer completer + }, TypableCommand { name: "write", aliases: &["w"], @@ -2388,6 +2792,13 @@ mod cmd { fun: force_quit_all, completer: None, }, + TypableCommand { + name: "cquit", + aliases: &["cq"], + doc: "Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2).", + fun: cquit, + completer: None, + }, TypableCommand { name: "theme", aliases: &[], @@ -2528,6 +2939,13 @@ mod cmd { fun: tutor, completer: None, }, + TypableCommand { + name: "goto", + aliases: &["g"], + doc: "Go to line number.", + fun: goto_line_number, + completer: None, + }, TypableCommand { name: "help", aliases: &["h"], @@ -2537,15 +2955,16 @@ mod cmd { }, ]; - pub static COMMANDS: Lazy> = Lazy::new(|| { - TYPABLE_COMMAND_LIST - .iter() - .flat_map(|cmd| { - std::iter::once((cmd.name, cmd)) - .chain(cmd.aliases.iter().map(move |&alias| (alias, cmd))) - }) - .collect() - }); + pub static TYPABLE_COMMAND_MAP: Lazy> = + Lazy::new(|| { + TYPABLE_COMMAND_LIST + .iter() + .flat_map(|cmd| { + std::iter::once((cmd.name, cmd)) + .chain(cmd.aliases.iter().map(move |&alias| (alias, cmd))) + }) + .collect() + }); } fn command_mode(cx: &mut Context) { @@ -2571,7 +2990,7 @@ fn command_mode(cx: &mut Context) { if let Some(cmd::TypableCommand { completer: Some(completer), .. - }) = cmd::COMMANDS.get(parts[0]) + }) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) { completer(part) .into_iter() @@ -2597,7 +3016,16 @@ fn command_mode(cx: &mut Context) { return; } - if let Some(cmd) = cmd::COMMANDS.get(parts[0]) { + // If command is numeric, interpret as line number and go there. + if parts.len() == 1 && parts[0].parse::().ok().is_some() { + if let Err(e) = cmd::goto_line_number(cx, &parts[0..], event) { + cx.editor.set_error(format!("{}", e)); + } + return; + } + + // Handle typable commands + if let Some(cmd) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) { if let Err(e) = (cmd.fun)(cx, &parts[1..], event) { cx.editor.set_error(format!("{}", e)); } @@ -2610,7 +3038,7 @@ fn command_mode(cx: &mut Context) { prompt.doc_fn = Box::new(|input: &str| { let part = input.split(' ').next().unwrap_or_default(); - if let Some(cmd::TypableCommand { doc, .. }) = cmd::COMMANDS.get(part) { + if let Some(cmd::TypableCommand { doc, .. }) = cmd::TYPABLE_COMMAND_MAP.get(part) { return Some(doc); } @@ -2622,43 +3050,73 @@ fn command_mode(cx: &mut Context) { fn file_picker(cx: &mut Context) { let root = find_root(None).unwrap_or_else(|| PathBuf::from("./")); - let picker = ui::file_picker(root); + let picker = ui::file_picker(root, &cx.editor.config); cx.push_layer(Box::new(picker)); } fn buffer_picker(cx: &mut Context) { let current = view!(cx.editor).doc; + struct BufferMeta { + id: DocumentId, + path: Option, + is_modified: bool, + is_current: bool, + } + + impl BufferMeta { + fn format(&self) -> Cow { + let path = self + .path + .as_deref() + .map(helix_core::path::get_relative_path); + let path = match path.as_deref().and_then(Path::to_str) { + Some(path) => path, + None => return Cow::Borrowed(SCRATCH_BUFFER_NAME), + }; + + let mut flags = Vec::new(); + if self.is_modified { + flags.push("+"); + } + if self.is_current { + flags.push("*"); + } + + let flag = if flags.is_empty() { + "".into() + } else { + format!(" ({})", flags.join("")) + }; + Cow::Owned(format!("{}{}", path, flag)) + } + } + + let new_meta = |doc: &Document| BufferMeta { + id: doc.id(), + path: doc.path().cloned(), + is_modified: doc.is_modified(), + is_current: doc.id() == current, + }; + let picker = FilePicker::new( cx.editor .documents .iter() - .map(|(id, doc)| (*id, doc.path().cloned())) + .map(|(_, doc)| new_meta(doc)) .collect(), - move |(id, path): &(DocumentId, Option)| { - let path = path.as_deref().map(helix_core::path::get_relative_path); - match path.as_ref().and_then(|path| path.to_str()) { - Some(path) => { - if *id == current { - format!("{} (*)", &path).into() - } else { - path.to_owned().into() - } - } - None => "[scratch buffer]".into(), - } - }, - |editor: &mut Editor, (id, _path): &(DocumentId, Option), _action| { - editor.switch(*id, Action::Replace); + BufferMeta::format, + |editor: &mut Editor, meta, _action| { + editor.switch(meta.id, Action::Replace); }, - |editor, (id, path)| { - let doc = &editor.documents.get(id)?; + |editor, meta| { + let doc = &editor.documents.get(&meta.id)?; let &view_id = doc.selections().keys().next()?; let line = doc .selection(view_id) .primary() .cursor_line(doc.text().slice(..)); - Some((path.clone()?, Some((line, line)))) + Some((meta.path.clone()?, Some((line, line)))) }, ); cx.push_layer(Box::new(picker)); @@ -2713,7 +3171,7 @@ fn symbol_picker(cx: &mut Context) { } }; - let picker = FilePicker::new( + let mut picker = FilePicker::new( symbols, |symbol| (&symbol.name).into(), move |editor: &mut Editor, symbol, _action| { @@ -2738,6 +3196,69 @@ fn symbol_picker(cx: &mut Context) { Some((path, line)) }, ); + picker.truncate_start = false; + compositor.push(Box::new(picker)) + } + }, + ) +} + +fn workspace_symbol_picker(cx: &mut Context) { + let (_, doc) = current!(cx.editor); + + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return, + }; + let offset_encoding = language_server.offset_encoding(); + + let future = language_server.workspace_symbols("".to_string()); + + let current_path = doc_mut!(cx.editor).path().cloned(); + cx.callback( + future, + move |_editor: &mut Editor, + compositor: &mut Compositor, + response: Option>| { + if let Some(symbols) = response { + let mut picker = FilePicker::new( + symbols, + move |symbol| { + let path = symbol.location.uri.to_file_path().unwrap(); + if current_path.as_ref().map(|p| p == &path).unwrap_or(false) { + (&symbol.name).into() + } else { + let relative_path = helix_core::path::get_relative_path(path.as_path()) + .to_str() + .unwrap() + .to_owned(); + format!("{} ({})", &symbol.name, relative_path).into() + } + }, + move |editor: &mut Editor, symbol, action| { + let path = symbol.location.uri.to_file_path().unwrap(); + editor.open(path, action).expect("editor.open failed"); + let (view, doc) = current!(editor); + + if let Some(range) = + lsp_range_to_range(doc.text(), symbol.location.range, offset_encoding) + { + // 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(range.head, range.anchor)); + align_view(doc, view, Align::Center); + } + }, + move |_editor, symbol| { + let path = symbol.location.uri.to_file_path().unwrap(); + let line = Some(( + symbol.location.range.start.line as usize, + symbol.location.range.end.line as usize, + )); + Some((path, line)) + }, + ); + picker.truncate_start = false; compositor.push(Box::new(picker)) } }, @@ -2795,14 +3316,104 @@ pub fn code_action(cx: &mut Context) { ) } +pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { + use lsp::ResourceOp; + use std::fs; + match op { + ResourceOp::Create(op) => { + let path = op.uri.to_file_path().unwrap(); + let ignore_if_exists = if let Some(options) = &op.options { + !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) + } else { + false + }; + if ignore_if_exists && path.exists() { + Ok(()) + } else { + fs::write(&path, []) + } + } + ResourceOp::Delete(op) => { + let path = op.uri.to_file_path().unwrap(); + if path.is_dir() { + let recursive = if let Some(options) = &op.options { + options.recursive.unwrap_or(false) + } else { + false + }; + if recursive { + fs::remove_dir_all(&path) + } else { + fs::remove_dir(&path) + } + } else if path.is_file() { + fs::remove_file(&path) + } else { + Ok(()) + } + } + ResourceOp::Rename(op) => { + let from = op.old_uri.to_file_path().unwrap(); + let to = op.new_uri.to_file_path().unwrap(); + let ignore_if_exists = if let Some(options) = &op.options { + !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) + } else { + false + }; + if ignore_if_exists && to.exists() { + Ok(()) + } else { + fs::rename(&from, &to) + } + } + } +} + fn apply_workspace_edit( editor: &mut Editor, offset_encoding: OffsetEncoding, workspace_edit: &lsp::WorkspaceEdit, ) { + let mut apply_edits = |uri: &helix_lsp::Url, text_edits: Vec| { + let path = uri + .to_file_path() + .expect("unable to convert URI to filepath"); + + let current_view_id = view!(editor).id; + let doc_id = editor.open(path, Action::Load).unwrap(); + let doc = editor + .document_mut(doc_id) + .expect("Document for document_changes not found"); + + // Need to determine a view for apply/append_changes_to_history + let selections = doc.selections(); + let view_id = if selections.contains_key(¤t_view_id) { + // use current if possible + current_view_id + } else { + // Hack: we take the first available view_id + selections + .keys() + .next() + .copied() + .expect("No view_id available") + }; + + let transaction = helix_lsp::util::generate_transaction_from_edits( + doc.text(), + text_edits, + offset_encoding, + ); + doc.apply(&transaction, view_id); + doc.append_changes_to_history(view_id); + }; + if let Some(ref changes) = workspace_edit.changes { log::debug!("workspace changes: {:?}", changes); - editor.set_error(String::from("Handling workspace_edit.changes is not implemented yet, see https://github.com/helix-editor/helix/issues/183")); + for (uri, text_edits) in changes { + let text_edits = text_edits.to_vec(); + apply_edits(uri, text_edits); + } return; // Not sure if it works properly, it'll be safer to just panic here to avoid breaking some parts of code on which code actions will be used // TODO: find some example that uses workspace changes, and test it @@ -2820,30 +3431,6 @@ fn apply_workspace_edit( match document_changes { lsp::DocumentChanges::Edits(document_edits) => { for document_edit in document_edits { - let path = document_edit - .text_document - .uri - .to_file_path() - .expect("unable to convert URI to filepath"); - let current_view_id = view!(editor).id; - let doc = editor - .document_by_path_mut(path) - .expect("Document for document_changes not found"); - - // Need to determine a view for apply/append_changes_to_history - let selections = doc.selections(); - let view_id = if selections.contains_key(¤t_view_id) { - // use current if possible - current_view_id - } else { - // Hack: we take the first available view_id - selections - .keys() - .next() - .copied() - .expect("No view_id available") - }; - let edits = document_edit .edits .iter() @@ -2855,19 +3442,33 @@ fn apply_workspace_edit( }) .cloned() .collect(); - - let transaction = helix_lsp::util::generate_transaction_from_edits( - doc.text(), - edits, - offset_encoding, - ); - doc.apply(&transaction, view_id); - doc.append_changes_to_history(view_id); + apply_edits(&document_edit.text_document.uri, edits); } } lsp::DocumentChanges::Operations(operations) => { log::debug!("document changes - operations: {:?}", operations); - editor.set_error(String::from("Handling document operations is not implemented yet, see https://github.com/helix-editor/helix/issues/183")); + for operateion in operations { + match operateion { + lsp::DocumentChangeOperation::Op(op) => { + apply_document_resource_op(op).unwrap(); + } + + lsp::DocumentChangeOperation::Edit(document_edit) => { + let edits = document_edit + .edits + .iter() + .map(|edit| match edit { + lsp::OneOf::Left(text_edit) => text_edit, + lsp::OneOf::Right(annotated_text_edit) => { + &annotated_text_edit.text_edit + } + }) + .cloned() + .collect(); + apply_edits(&document_edit.text_document.uri, edits); + } + } + } } } } @@ -3057,10 +3658,14 @@ fn push_jump(editor: &mut Editor) { } fn goto_line(cx: &mut Context) { - if let Some(count) = cx.count { - push_jump(cx.editor); + goto_line_impl(cx.editor, cx.count) +} - let (view, doc) = current!(cx.editor); +fn goto_line_impl(editor: &mut Editor, count: Option) { + if let Some(count) = count { + push_jump(editor); + + let (view, doc) = current!(editor); let max_line = if doc.text().line(doc.text().len_lines() - 1).len_chars() == 0 { // If the last line is blank, don't jump to it. doc.text().len_lines().saturating_sub(2) @@ -3106,6 +3711,33 @@ fn goto_last_accessed_file(cx: &mut Context) { } } +fn goto_last_modification(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let pos = doc.history.get_mut().last_edit_pos(); + let text = doc.text().slice(..); + if let Some(pos) = pos { + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select)); + doc.set_selection(view.id, selection); + } +} + +fn goto_last_modified_file(cx: &mut Context) { + let view = view!(cx.editor); + let alternate_file = view + .last_modified_docs + .into_iter() + .flatten() + .find(|&id| id != view.doc); + if let Some(alt) = alternate_file { + cx.editor.switch(alt, Action::Replace); + } else { + cx.editor.set_error("no last modified buffer".to_owned()) + } +} + fn select_mode(cx: &mut Context) { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -3706,19 +4338,70 @@ pub mod insert { doc.apply(&transaction, view.id); } - // TODO: handle indent-aware delete pub fn delete_char_backward(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); + let indent_unit = doc.indent_unit(); + let tab_size = doc.tab_width(); + let transaction = Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { let pos = range.cursor(text); - ( - graphemes::nth_prev_grapheme_boundary(text, pos, count), - pos, - None, - ) + let line_start_pos = text.line_to_char(range.cursor_line(text)); + // considier to delete by indent level if all characters before `pos` are indent units. + let fragment = Cow::from(text.slice(line_start_pos..pos)); + if !fragment.is_empty() && fragment.chars().all(|ch| ch.is_whitespace()) { + if text.get_char(pos.saturating_sub(1)) == Some('\t') { + // fast path, delete one char + ( + graphemes::nth_prev_grapheme_boundary(text, pos, 1), + pos, + None, + ) + } else { + let unit_len = indent_unit.chars().count(); + // NOTE: indent_unit always contains 'only spaces' or 'only tab' according to `IndentStyle` definition. + let unit_size = if indent_unit.starts_with('\t') { + tab_size * unit_len + } else { + unit_len + }; + let width: usize = fragment + .chars() + .map(|ch| { + if ch == '\t' { + tab_size + } else { + // it can be none if it still meet control characters other than '\t' + // here just set the width to 1 (or some value better?). + ch.width().unwrap_or(1) + } + }) + .sum(); + let mut drop = width % unit_size; // round down to nearest unit + if drop == 0 { + drop = unit_size + }; // if it's already at a unit, consume a whole unit + let mut chars = fragment.chars().rev(); + let mut start = pos; + for _ in 0..drop { + // delete up to `drop` spaces + match chars.next() { + Some(' ') => start -= 1, + _ => break, + } + } + (start, pos, None) // delete! + } + } else { + // delete char + ( + graphemes::nth_prev_grapheme_boundary(text, pos, count), + pos, + None, + ) + } }); doc.apply(&transaction, view.id); } @@ -3748,8 +4431,19 @@ pub mod insert { .selection(view.id) .clone() .transform(|range| movement::move_prev_word_start(text, range, count)); - doc.set_selection(view.id, selection); - delete_selection(cx) + delete_selection_insert_mode(doc, view, &selection); + } + + pub fn delete_word_forward(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc + .selection(view.id) + .clone() + .transform(|range| movement::move_next_word_start(text, range, count)); + delete_selection_insert_mode(doc, view, &selection); } } @@ -3759,20 +4453,48 @@ pub mod insert { // storing it? fn undo(cx: &mut Context) { + let count = cx.count(); let (view, doc) = current!(cx.editor); - let view_id = view.id; - let success = doc.undo(view_id); - if !success { - cx.editor.set_status("Already at oldest change".to_owned()); + for _ in 0..count { + if !doc.undo(view.id) { + cx.editor.set_status("Already at oldest change".to_owned()); + break; + } } } fn redo(cx: &mut Context) { + let count = cx.count(); let (view, doc) = current!(cx.editor); - let view_id = view.id; - let success = doc.redo(view_id); - if !success { - cx.editor.set_status("Already at newest change".to_owned()); + for _ in 0..count { + if !doc.redo(view.id) { + cx.editor.set_status("Already at newest change".to_owned()); + break; + } + } +} + +fn earlier(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + for _ in 0..count { + // rather than doing in batch we do this so get error halfway + if !doc.earlier(view.id, UndoKind::Steps(1)) { + cx.editor.set_status("Already at oldest change".to_owned()); + break; + } + } +} + +fn later(cx: &mut Context) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + for _ in 0..count { + // rather than doing in batch we do this so get error halfway + if !doc.later(view.id, UndoKind::Steps(1)) { + cx.editor.set_status("Already at newest change".to_owned()); + break; + } } } @@ -3835,11 +4557,8 @@ fn yank_joined_to_clipboard_impl( fn yank_joined_to_clipboard(cx: &mut Context) { let line_ending = doc!(cx.editor).line_ending; - let _ = yank_joined_to_clipboard_impl( - &mut cx.editor, - line_ending.as_str(), - ClipboardType::Clipboard, - ); + let _ = + yank_joined_to_clipboard_impl(cx.editor, line_ending.as_str(), ClipboardType::Clipboard); exit_select_mode(cx); } @@ -3864,20 +4583,17 @@ fn yank_main_selection_to_clipboard_impl( } fn yank_main_selection_to_clipboard(cx: &mut Context) { - let _ = yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Clipboard); + let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard); } fn yank_joined_to_primary_clipboard(cx: &mut Context) { let line_ending = doc!(cx.editor).line_ending; - let _ = yank_joined_to_clipboard_impl( - &mut cx.editor, - line_ending.as_str(), - ClipboardType::Selection, - ); + let _ = + yank_joined_to_clipboard_impl(cx.editor, line_ending.as_str(), ClipboardType::Selection); } fn yank_main_selection_to_primary_clipboard(cx: &mut Context) { - let _ = yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Selection); + let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection); exit_select_mode(cx); } @@ -3960,19 +4676,19 @@ fn paste_clipboard_impl( } fn paste_clipboard_after(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard); + let _ = paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard); } fn paste_clipboard_before(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::Before, ClipboardType::Clipboard); + let _ = paste_clipboard_impl(cx.editor, Paste::Before, ClipboardType::Clipboard); } fn paste_primary_clipboard_after(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection); + let _ = paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection); } fn paste_primary_clipboard_before(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::Before, ClipboardType::Selection); + let _ = paste_clipboard_impl(cx.editor, Paste::Before, ClipboardType::Selection); } fn replace_with_yanked(cx: &mut Context) { @@ -4029,11 +4745,11 @@ fn replace_selections_with_clipboard_impl( } fn replace_selections_with_clipboard(cx: &mut Context) { - let _ = replace_selections_with_clipboard_impl(&mut cx.editor, ClipboardType::Clipboard); + let _ = replace_selections_with_clipboard_impl(cx.editor, ClipboardType::Clipboard); } fn replace_selections_with_primary_clipboard(cx: &mut Context) { - let _ = replace_selections_with_clipboard_impl(&mut cx.editor, ClipboardType::Selection); + let _ = replace_selections_with_clipboard_impl(cx.editor, ClipboardType::Selection); } fn paste_after(cx: &mut Context) { @@ -4230,12 +4946,12 @@ fn join_selections(cx: &mut Context) { doc.append_changes_to_history(view.id); } -fn keep_selections(cx: &mut Context) { - // keep selections matching regex +fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { + // keep or remove selections matching regex let reg = cx.register.unwrap_or('/'); let prompt = ui::regex_prompt( cx, - "keep:".into(), + if !remove { "keep:" } else { "remove:" }.into(), Some(reg), |_input: &str| Vec::new(), move |view, doc, regex, event| { @@ -4244,7 +4960,9 @@ fn keep_selections(cx: &mut Context) { } let text = doc.text().slice(..); - if let Some(selection) = selection::keep_matches(text, doc.selection(view.id), ®ex) { + if let Some(selection) = + selection::keep_or_remove_matches(text, doc.selection(view.id), ®ex, remove) + { doc.set_selection(view.id, selection); } }, @@ -4253,6 +4971,14 @@ fn keep_selections(cx: &mut Context) { cx.push_layer(Box::new(prompt)); } +fn keep_selections(cx: &mut Context) { + keep_or_remove_selections_impl(cx, false) +} + +fn remove_selections(cx: &mut Context) { + keep_or_remove_selections_impl(cx, true) +} + fn keep_primary_selection(cx: &mut Context) { let (view, doc) = current!(cx.editor); // TODO: handle count @@ -4378,19 +5104,15 @@ pub fn completion(cx: &mut Context) { return; } let size = compositor.size(); - let ui = compositor - .find(std::any::type_name::()) - .unwrap(); - if let Some(ui) = ui.as_any_mut().downcast_mut::() { - ui.set_completion( - editor, - items, - offset_encoding, - start_offset, - trigger_offset, - size, - ); - }; + let ui = compositor.find::().unwrap(); + ui.set_completion( + editor, + items, + offset_encoding, + start_offset, + trigger_offset, + size, + ); }, ); } @@ -4420,18 +5142,27 @@ fn hover(cx: &mut Context) { move |editor: &mut Editor, compositor: &mut Compositor, response: Option| { if let Some(hover) = response { // hover.contents / .range <- used for visualizing - let contents = match hover.contents { - lsp::HoverContents::Scalar(contents) => { - // markedstring(string/languagestring to be highlighted) - // TODO - log::error!("hover contents {:?}", contents); - return; - } - lsp::HoverContents::Array(contents) => { - log::error!("hover contents {:?}", contents); - return; + + fn marked_string_to_markdown(contents: lsp::MarkedString) -> String { + match contents { + lsp::MarkedString::String(contents) => contents, + lsp::MarkedString::LanguageString(string) => { + if string.language == "markdown" { + string.value + } else { + format!("```{}\n{}\n```", string.language, string.value) + } + } } - // TODO: render markdown + } + + let contents = match hover.contents { + lsp::HoverContents::Scalar(contents) => marked_string_to_markdown(contents), + lsp::HoverContents::Array(contents) => contents + .into_iter() + .map(marked_string_to_markdown) + .collect::>() + .join("\n\n"), lsp::HoverContents::Markup(contents) => contents.value, }; @@ -4533,7 +5264,7 @@ fn expand_selection(cx: &mut Context) { doc.set_selection(view.id, selection); } }; - motion(&mut cx.editor); + motion(cx.editor); cx.editor.last_motion = Some(Motion(Box::new(motion))); } @@ -4543,7 +5274,9 @@ fn match_brackets(cx: &mut Context) { if let Some(syntax) = doc.syntax() { let text = doc.text().slice(..); let selection = doc.selection(view.id).clone().transform(|range| { - if let Some(pos) = match_brackets::find(syntax, doc.text(), range.anchor) { + if let Some(pos) = + match_brackets::find_matching_bracket_fuzzy(syntax, doc.text(), range.anchor) + { range.put_cursor(text, pos, doc.mode == Mode::Select) } else { range @@ -4639,7 +5372,21 @@ fn wclose(cx: &mut Context) { } let view_id = view!(cx.editor).id; // close current split - cx.editor.close(view_id, /* close_buffer */ false); + cx.editor.close(view_id); +} + +fn wonly(cx: &mut Context) { + let views = cx + .editor + .tree + .views() + .map(|(v, focus)| (v.id, focus)) + .collect::>(); + for (view_id, focus) in views { + if !focus { + cx.editor.close(view_id); + } + } } fn select_register(cx: &mut Context) { @@ -4650,6 +5397,15 @@ fn select_register(cx: &mut Context) { }) } +fn insert_register(cx: &mut Context) { + cx.on_next_key(move |cx, event| { + if let Some(ch) = event.char() { + cx.editor.selected_register = Some(ch); + paste_before(cx); + } + }) +} + fn align_view_top(cx: &mut Context) { let (view, doc) = current!(cx.editor); align_view(doc, view, Align::Top); @@ -4718,10 +5474,19 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { let selection = doc.selection(view.id).clone().transform(|range| { match ch { - 'w' => textobject::textobject_word(text, range, objtype, count), + 'w' => textobject::textobject_word(text, range, objtype, count, false), + 'W' => textobject::textobject_word(text, range, objtype, count, true), 'c' => textobject_treesitter("class", range), 'f' => textobject_treesitter("function", range), 'p' => textobject_treesitter("parameter", range), + 'm' => { + let ch = text.char(range.cursor(text)); + if !ch.is_ascii_alphanumeric() { + textobject::textobject_surround(text, range, objtype, ch, count) + } else { + range + } + } // TODO: cancel new ranges if inconsistent surround matches across lines ch if !ch.is_ascii_alphanumeric() => { textobject::textobject_surround(text, range, objtype, ch, count) @@ -4731,7 +5496,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { }); doc.set_selection(view.id, selection); }; - textobject(&mut cx.editor); + textobject(cx.editor); cx.editor.last_motion = Some(Motion(Box::new(textobject))); } }) @@ -4897,9 +5662,7 @@ fn shell_impl( ) -> anyhow::Result<(Tendril, bool)> { use std::io::Write; use std::process::{Command, Stdio}; - if shell.is_empty() { - bail!("No shell set"); - } + ensure!(!shell.is_empty(), "No shell set"); let mut process = match Command::new(&shell[0]) .args(&shell[1..]) @@ -4983,6 +5746,10 @@ fn shell(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) { doc.apply(&transaction, view.id); doc.append_changes_to_history(view.id); } + + // after replace cursor may be out of bounds, do this to + // make sure cursor is in view and update scroll as well + view.ensure_cursor_in_view(doc, cx.editor.config.scrolloff); }, ); @@ -5027,3 +5794,104 @@ fn add_newline_impl(cx: &mut Context, open: Open) { doc.apply(&transaction, view.id); doc.append_changes_to_history(view.id); } + +fn rename_symbol(cx: &mut Context) { + let prompt = Prompt::new( + "rename-to:".into(), + None, + |_input: &str| Vec::new(), + move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + } + + log::debug!("renaming to: {:?}", input); + + let (view, doc) = current!(cx.editor); + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return, + }; + + let offset_encoding = language_server.offset_encoding(); + + let pos = pos_to_lsp_pos( + doc.text(), + doc.selection(view.id) + .primary() + .cursor(doc.text().slice(..)), + offset_encoding, + ); + + let task = language_server.rename_symbol(doc.identifier(), pos, input.to_string()); + let edits = block_on(task).unwrap_or_default(); + log::debug!("Edits from LSP: {:?}", edits); + apply_workspace_edit(cx.editor, offset_encoding, &edits); + }, + ); + cx.push_layer(Box::new(prompt)); +} + +/// Increment object under cursor by count. +fn increment(cx: &mut Context) { + increment_impl(cx, cx.count() as i64); +} + +/// Decrement object under cursor by count. +fn decrement(cx: &mut Context) { + increment_impl(cx, -(cx.count() as i64)); +} + +/// Decrement object under cursor by `amount`. +fn increment_impl(cx: &mut Context, amount: i64) { + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + let text = doc.text().slice(..); + + let changes: Vec<_> = selection + .ranges() + .iter() + .filter_map(|range| { + let incrementor: Box = + if let Some(incrementor) = DateTimeIncrementor::from_range(text, *range) { + Box::new(incrementor) + } else if let Some(incrementor) = NumberIncrementor::from_range(text, *range) { + Box::new(incrementor) + } else { + return None; + }; + + let (range, new_text) = incrementor.increment(amount); + + Some((range.from(), range.to(), Some(new_text))) + }) + .collect(); + + // Overlapping changes in a transaction will panic, so we need to find and remove them. + // For example, if there are cursors on each of the year, month, and day of `2021-11-29`, + // incrementing will give overlapping changes, with each change incrementing a different part of + // the date. Since these conflict with each other we remove these changes from the transaction + // so nothing happens. + let mut overlapping_indexes = HashSet::new(); + for (i, changes) in changes.windows(2).enumerate() { + if changes[0].1 > changes[1].0 { + overlapping_indexes.insert(i); + overlapping_indexes.insert(i + 1); + } + } + let changes = changes.into_iter().enumerate().filter_map(|(i, change)| { + if overlapping_indexes.contains(&i) { + None + } else { + Some(change) + } + }); + + if changes.clone().count() > 0 { + let transaction = Transaction::change(doc.text(), changes); + let transaction = transaction.with_selection(selection.clone()); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + } +} diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index dc8b91d75..3a644750e 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -177,11 +177,12 @@ impl Compositor { .any(|component| component.type_name() == type_name) } - pub fn find(&mut self, type_name: &str) -> Option<&mut dyn Component> { + pub fn find(&mut self) -> Option<&mut T> { + let type_name = std::any::type_name::(); self.layers .iter_mut() .find(|component| component.type_name() == type_name) - .map(|component| component.as_mut()) + .and_then(|component| component.as_any_mut().downcast_mut()) } } diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index ce50f0ab3..9debbbacf 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -1,4 +1,4 @@ -pub use crate::commands::Command; +pub use crate::commands::MappableCommand; use crate::config::Config; use helix_core::hashmap; use helix_view::{document::Mode, info::Info, input::KeyEvent}; @@ -25,6 +25,54 @@ macro_rules! key { }; } +#[macro_export] +macro_rules! shift { + ($key:ident) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::$key, + modifiers: ::helix_view::keyboard::KeyModifiers::SHIFT, + } + }; + ($($ch:tt)*) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::SHIFT, + } + }; +} + +#[macro_export] +macro_rules! ctrl { + ($key:ident) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::$key, + modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, + } + }; + ($($ch:tt)*) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, + } + }; +} + +#[macro_export] +macro_rules! alt { + ($key:ident) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::$key, + modifiers: ::helix_view::keyboard::KeyModifiers::ALT, + } + }; + ($($ch:tt)*) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::ALT, + } + }; +} + /// Macro for defining the root of a `Keymap` object. Example: /// /// ``` @@ -44,7 +92,7 @@ macro_rules! key { #[macro_export] macro_rules! keymap { (@trie $cmd:ident) => { - $crate::keymap::KeyTrie::Leaf($crate::commands::Command::$cmd) + $crate::keymap::KeyTrie::Leaf($crate::commands::MappableCommand::$cmd) }; (@trie @@ -53,6 +101,10 @@ macro_rules! keymap { keymap!({ $label $(sticky=$sticky)? $($($key)|+ => $value,)+ }) }; + (@trie [$($cmd:ident),* $(,)?]) => { + $crate::keymap::KeyTrie::Sequence(vec![$($crate::commands::Command::$cmd),*]) + }; + ( { $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ } ) => { @@ -64,10 +116,11 @@ macro_rules! keymap { $( $( let _key = $key.parse::<::helix_view::input::KeyEvent>().unwrap(); - _map.insert( + let _duplicate = _map.insert( _key, keymap!(@trie $value) ); + assert!(_duplicate.is_none(), "Duplicate key found: {:?}", _duplicate.unwrap()); _order.push(_key); )+ )* @@ -147,6 +200,7 @@ impl KeyTrieNode { cmd.doc() } KeyTrie::Node(n) => n.name(), + KeyTrie::Sequence(_) => "[Multiple commands]", }; match body.iter().position(|(d, _)| d == &desc) { Some(pos) => { @@ -206,7 +260,8 @@ impl DerefMut for KeyTrieNode { #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(untagged)] pub enum KeyTrie { - Leaf(Command), + Leaf(MappableCommand), + Sequence(Vec), Node(KeyTrieNode), } @@ -214,14 +269,14 @@ impl KeyTrie { pub fn node(&self) -> Option<&KeyTrieNode> { match *self { KeyTrie::Node(ref node) => Some(node), - KeyTrie::Leaf(_) => None, + KeyTrie::Leaf(_) | KeyTrie::Sequence(_) => None, } } pub fn node_mut(&mut self) -> Option<&mut KeyTrieNode> { match *self { KeyTrie::Node(ref mut node) => Some(node), - KeyTrie::Leaf(_) => None, + KeyTrie::Leaf(_) | KeyTrie::Sequence(_) => None, } } @@ -238,7 +293,7 @@ impl KeyTrie { trie = match trie { KeyTrie::Node(map) => map.get(key), // leaf encountered while keys left to process - KeyTrie::Leaf(_) => None, + KeyTrie::Leaf(_) | KeyTrie::Sequence(_) => None, }? } Some(trie) @@ -249,7 +304,9 @@ impl KeyTrie { pub enum KeymapResultKind { /// Needs more keys to execute a command. Contains valid keys for next keystroke. Pending(KeyTrieNode), - Matched(Command), + Matched(MappableCommand), + /// Matched a sequence of commands to execute. + MatchedSequence(Vec), /// Key was not found in the root keymap NotFound, /// Key is invalid in combination with previous keys. Contains keys leading upto @@ -329,8 +386,14 @@ impl Keymap { }; let trie = match trie_node.search(&[*first]) { - Some(&KeyTrie::Leaf(cmd)) => { - return KeymapResult::new(KeymapResultKind::Matched(cmd), self.sticky()) + Some(KeyTrie::Leaf(ref cmd)) => { + return KeymapResult::new(KeymapResultKind::Matched(cmd.clone()), self.sticky()) + } + Some(KeyTrie::Sequence(ref cmds)) => { + return KeymapResult::new( + KeymapResultKind::MatchedSequence(cmds.clone()), + self.sticky(), + ) } None => return KeymapResult::new(KeymapResultKind::NotFound, self.sticky()), Some(t) => t, @@ -345,9 +408,16 @@ impl Keymap { } KeymapResult::new(KeymapResultKind::Pending(map.clone()), self.sticky()) } - Some(&KeyTrie::Leaf(cmd)) => { + Some(&KeyTrie::Leaf(ref cmd)) => { + self.state.clear(); + return KeymapResult::new(KeymapResultKind::Matched(cmd.clone()), self.sticky()); + } + Some(&KeyTrie::Sequence(ref cmds)) => { self.state.clear(); - return KeymapResult::new(KeymapResultKind::Matched(cmd), self.sticky()); + KeymapResult::new( + KeymapResultKind::MatchedSequence(cmds.clone()), + self.sticky(), + ) } None => KeymapResult::new( KeymapResultKind::Cancelled(self.state.drain(..).collect()), @@ -442,6 +512,7 @@ impl Default for Keymaps { "g" => { "Goto" "g" => goto_file_start, "e" => goto_last_line, + "f" => goto_file, "h" => goto_line_start, "l" => goto_line_end, "s" => goto_first_nonwhitespace, @@ -450,11 +521,13 @@ impl Default for Keymaps { "r" => goto_reference, "i" => goto_implementation, "t" => goto_window_top, - "m" => goto_window_middle, + "c" => goto_window_center, "b" => goto_window_bottom, "a" => goto_last_accessed_file, + "m" => goto_last_modified_file, "n" => goto_next_buffer, "p" => goto_previous_buffer, + "." => goto_last_modification, }, ":" => command_mode, @@ -466,9 +539,9 @@ impl Default for Keymaps { "O" => open_above, "d" => delete_selection, - // TODO: also delete without yanking + "A-d" => delete_selection_noyank, "c" => change_selection, - // TODO: also change delete without yanking + "A-c" => change_selection_noyank, "C" => copy_selection_on_next_line, "A-C" => copy_selection_on_prev_line, @@ -511,6 +584,8 @@ impl Default for Keymaps { "u" => undo, "U" => redo, + "A-u" => earlier, + "A-U" => later, "y" => yank, // yank_all @@ -523,7 +598,7 @@ impl Default for Keymaps { "=" => format_selections, "J" => join_selections, "K" => keep_selections, - // TODO: and another method for inverse + "A-K" => remove_selections, "," => keep_primary_selection, "A-," => remove_primary_selection, @@ -531,8 +606,8 @@ impl Default for Keymaps { // "q" => record_macro, // "Q" => replay_macro, - // & align selections - // _ trim selections + "&" => align_selections, + "_" => trim_selections, "(" => rotate_selections_backward, ")" => rotate_selections_forward, @@ -549,7 +624,10 @@ impl Default for Keymaps { "C-w" | "w" => rotate_view, "C-s" | "s" => hsplit, "C-v" | "v" => vsplit, + "f" => goto_file_hsplit, + "F" => goto_file_vsplit, "C-q" | "q" => wclose, + "C-o" | "o" => wonly, "C-h" | "h" | "left" => jump_view_left, "C-j" | "j" | "down" => jump_view_down, "C-k" | "k" | "up" => jump_view_up, @@ -569,13 +647,21 @@ impl Default for Keymaps { "f" => file_picker, "b" => buffer_picker, "s" => symbol_picker, + "S" => workspace_symbol_picker, "a" => code_action, "'" => last_picker, "w" => { "Window" "C-w" | "w" => rotate_view, - "C-h" | "h" => hsplit, + "C-s" | "s" => hsplit, "C-v" | "v" => vsplit, + "f" => goto_file_hsplit, + "F" => goto_file_vsplit, "C-q" | "q" => wclose, + "C-o" | "o" => wonly, + "C-h" | "h" | "left" => jump_view_left, + "C-j" | "j" | "down" => jump_view_down, + "C-k" | "k" | "up" => jump_view_up, + "C-l" | "l" | "right" => jump_view_right, }, "y" => yank_joined_to_clipboard, "Y" => yank_main_selection_to_clipboard, @@ -584,30 +670,31 @@ impl Default for Keymaps { "R" => replace_selections_with_clipboard, "/" => global_search, "k" => hover, + "r" => rename_symbol, }, "z" => { "View" "z" | "c" => align_view_center, "t" => align_view_top, "b" => align_view_bottom, "m" => align_view_middle, - "k" => scroll_up, - "j" => scroll_down, - "b" => page_up, - "f" => page_down, - "u" => half_page_up, - "d" => half_page_down, + "k" | "up" => scroll_up, + "j" | "down" => scroll_down, + "C-b" | "pageup" => page_up, + "C-f" | "pagedown" => page_down, + "C-u" => half_page_up, + "C-d" => half_page_down, }, "Z" => { "View" sticky=true "z" | "c" => align_view_center, "t" => align_view_top, "b" => align_view_bottom, "m" => align_view_middle, - "k" => scroll_up, - "j" => scroll_down, - "b" => page_up, - "f" => page_down, - "u" => half_page_up, - "d" => half_page_down, + "k" | "up" => scroll_up, + "j" | "down" => scroll_down, + "C-b" | "pageup" => page_up, + "C-f" | "pagedown" => page_down, + "C-u" => half_page_up, + "C-d" => half_page_down, }, "\"" => select_register, @@ -617,6 +704,9 @@ impl Default for Keymaps { "A-!" => shell_append_output, "$" => shell_keep_pipe, "C-z" => suspend, + + "C-a" => increment, + "C-x" => decrement, }); let mut select = normal.clone(); select.merge_nodes(keymap!({ "Select mode" @@ -650,21 +740,38 @@ impl Default for Keymaps { "esc" => normal_mode, "backspace" => delete_char_backward, + "C-h" => delete_char_backward, "del" => delete_char_forward, + "C-d" => delete_char_forward, "ret" => insert_newline, "tab" => insert_tab, "C-w" => delete_word_backward, + "A-d" => delete_word_forward, "left" => move_char_left, + "C-b" => move_char_left, "down" => move_line_down, + "C-n" => move_line_down, "up" => move_line_up, + "C-p" => move_line_up, "right" => move_char_right, + "C-f" => move_char_right, + "A-b" => move_prev_word_end, + "A-left" => move_prev_word_end, + "A-f" => move_next_word_start, + "A-right" => move_next_word_start, "pageup" => page_up, "pagedown" => page_down, "home" => goto_line_start, + "C-a" => goto_line_start, "end" => goto_line_end_newline, + "C-e" => goto_line_end_newline, + + "C-k" => kill_to_line_end, + "C-u" => kill_to_line_start, "C-x" => completion, + "C-r" => insert_register, }); Keymaps(hashmap!( Mode::Normal => Keymap::new(normal), @@ -686,6 +793,22 @@ pub fn merge_keys(mut config: Config) -> Config { #[cfg(test)] mod tests { use super::*; + + #[test] + #[should_panic] + fn duplicate_keys_should_panic() { + keymap!({ "Normal mode" + "i" => normal_mode, + "i" => goto_definition, + }); + } + + #[test] + fn check_duplicate_keys_in_default_keymap() { + // will panic on duplicate keys, assumes that `Keymaps` uses keymap! macro + Keymaps::default(); + } + #[test] fn merge_partial_keys() { let config = Config { @@ -710,36 +833,36 @@ mod tests { let keymap = merged_config.keys.0.get_mut(&Mode::Normal).unwrap(); assert_eq!( keymap.get(key!('i')).kind, - KeymapResultKind::Matched(Command::normal_mode), + KeymapResultKind::Matched(MappableCommand::normal_mode), "Leaf should replace leaf" ); assert_eq!( keymap.get(key!('无')).kind, - KeymapResultKind::Matched(Command::insert_mode), + KeymapResultKind::Matched(MappableCommand::insert_mode), "New leaf should be present in merged keymap" ); // Assumes that z is a node in the default keymap assert_eq!( keymap.get(key!('z')).kind, - KeymapResultKind::Matched(Command::jump_backward), + KeymapResultKind::Matched(MappableCommand::jump_backward), "Leaf should replace node" ); // Assumes that `g` is a node in default keymap assert_eq!( keymap.root().search(&[key!('g'), key!('$')]).unwrap(), - &KeyTrie::Leaf(Command::goto_line_end), + &KeyTrie::Leaf(MappableCommand::goto_line_end), "Leaf should be present in merged subnode" ); // Assumes that `gg` is in default keymap assert_eq!( keymap.root().search(&[key!('g'), key!('g')]).unwrap(), - &KeyTrie::Leaf(Command::delete_char_forward), + &KeyTrie::Leaf(MappableCommand::delete_char_forward), "Leaf should replace old leaf in merged subnode" ); // Assumes that `ge` is in default keymap assert_eq!( keymap.root().search(&[key!('g'), key!('e')]).unwrap(), - &KeyTrie::Leaf(Command::goto_last_line), + &KeyTrie::Leaf(MappableCommand::goto_last_line), "Old leaves in subnode should be present in merged node" ); @@ -773,11 +896,27 @@ mod tests { .root() .search(&[key!(' '), key!('s'), key!('v')]) .unwrap(), - &KeyTrie::Leaf(Command::vsplit), + &KeyTrie::Leaf(MappableCommand::vsplit), "Leaf should be present in merged subnode" ); // Make sure an order was set during merge let node = keymap.root().search(&[crate::key!(' ')]).unwrap(); assert!(!node.node().unwrap().order().is_empty()) } + + #[test] + fn aliased_modes_are_same_in_default_keymap() { + let keymaps = Keymaps::default(); + let root = keymaps.get(&Mode::Normal).unwrap().root(); + assert_eq!( + root.search(&[key!(' '), key!('w')]).unwrap(), + root.search(&["C-w".parse::().unwrap()]).unwrap(), + "Mismatch for window mode on `Space-w` and `Ctrl-w`" + ); + assert_eq!( + root.search(&[key!('z')]).unwrap(), + root.search(&[key!('Z')]).unwrap(), + "Mismatch for view mode on `z` and `Z`" + ); + } } diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index e178b339d..881401304 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -16,11 +16,6 @@ fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> { }; // Separate file config so we can include year, month and day in file logs - let file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(logpath)?; let file_config = fern::Dispatch::new() .format(|out, message, record| { out.finish(format_args!( @@ -31,15 +26,20 @@ fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> { message )) }) - .chain(file); + .chain(fern::log_file(logpath)?); base_config.chain(file_config).apply()?; Ok(()) } +fn main() -> Result<()> { + let exit_code = main_impl()?; + std::process::exit(exit_code); +} + #[tokio::main] -async fn main() -> Result<()> { +async fn main_impl() -> Result { let cache_dir = helix_core::cache_dir(); if !cache_dir.exists() { std::fs::create_dir_all(&cache_dir).ok(); @@ -66,7 +66,7 @@ FLAGS: -V, --version Prints version information ", env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION"), + env!("VERSION_AND_GIT_HASH"), env!("CARGO_PKG_AUTHORS"), env!("CARGO_PKG_DESCRIPTION"), logpath.display(), @@ -81,7 +81,7 @@ FLAGS: } if args.display_version { - println!("helix {}", env!("CARGO_PKG_VERSION")); + println!("helix {}", env!("VERSION_AND_GIT_HASH")); std::process::exit(0); } @@ -109,7 +109,8 @@ FLAGS: // TODO: use the thread local executor to spawn the application task separately from the work pool let mut app = Application::new(args, config).context("unable to create new application")?; - app.run().await.unwrap(); - Ok(()) + let exit_code = app.run().await?; + + Ok(exit_code) } diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index a70155773..39ee15b4c 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -16,8 +16,7 @@ use helix_core::{ LineEnding, Position, Range, Selection, }; use helix_view::{ - document::Mode, - editor::LineNumber, + document::{Mode, SCRATCH_BUFFER_NAME}, graphics::{CursorKind, Modifier, Rect, Style}, info::Info, input::KeyEvent, @@ -32,7 +31,7 @@ use tui::buffer::Buffer as Surface; pub struct EditorView { keymaps: Keymaps, on_next_key: Option>, - last_insert: (commands::Command, Vec), + last_insert: (commands::MappableCommand, Vec), pub(crate) completion: Option, spinners: ProgressSpinners, autoinfo: Option, @@ -49,7 +48,7 @@ impl EditorView { Self { keymaps, on_next_key: None, - last_insert: (commands::Command::normal_mode, Vec::new()), + last_insert: (commands::MappableCommand::normal_mode, Vec::new()), completion: None, spinners: ProgressSpinners::default(), autoinfo: None, @@ -305,17 +304,16 @@ impl EditorView { use helix_core::graphemes::{grapheme_width, RopeGraphemes}; - let style = spans.iter().fold(text_style, |acc, span| { - let style = theme.get(theme.scopes()[span.0].as_str()); - acc.patch(style) - }); - for grapheme in RopeGraphemes::new(text) { let out_of_bounds = visual_x < offset.col as u16 || visual_x >= viewport.width + offset.col as u16; if LineEnding::from_rope_slice(&grapheme).is_some() { if !out_of_bounds { + let style = spans.iter().fold(text_style, |acc, span| { + acc.patch(theme.highlight(span.0)) + }); + // we still want to render an empty cell with the style surface.set_string( viewport.x + visual_x - offset.col as u16, @@ -346,6 +344,10 @@ impl EditorView { }; if !out_of_bounds { + let style = spans.iter().fold(text_style, |acc, span| { + acc.patch(theme.highlight(span.0)) + }); + // if we're offscreen just keep going until we hit a new line surface.set_string( viewport.x + visual_x - offset.col as u16, @@ -377,7 +379,7 @@ impl EditorView { use helix_core::match_brackets; let pos = doc.selection(view.id).primary().cursor(text); - let pos = match_brackets::find(syntax, doc.text(), pos) + let pos = match_brackets::find_matching_bracket(syntax, doc.text(), pos) .and_then(|pos| view.screen_coords_at_pos(doc, text, pos)); if let Some(pos) = pos { @@ -412,22 +414,6 @@ impl EditorView { let text = doc.text().slice(..); let last_line = view.last_line(doc); - let linenr = theme.get("ui.linenr"); - let linenr_select: Style = theme.try_get("ui.linenr.selected").unwrap_or(linenr); - - let warning = theme.get("warning"); - let error = theme.get("error"); - let info = theme.get("info"); - let hint = theme.get("hint"); - - // Whether to draw the line number for the last line of the - // document or not. We only draw it if it's not an empty line. - let draw_last = text.line_to_byte(last_line) < text.len_bytes(); - - let current_line = doc - .text() - .char_to_line(doc.selection(view.id).primary().cursor(text)); - // it's used inside an iterator so the collect isn't needless: // https://github.com/rust-lang/rust-clippy/issues/6164 #[allow(clippy::needless_collect)] @@ -437,51 +423,29 @@ impl EditorView { .map(|range| range.cursor_line(text)) .collect(); - for (i, line) in (view.offset.row..(last_line + 1)).enumerate() { - use helix_core::diagnostic::Severity; - if let Some(diagnostic) = doc.diagnostics().iter().find(|d| d.line == line) { - surface.set_stringn( - viewport.x, - viewport.y + i as u16, - "●", - 1, - match diagnostic.severity { - Some(Severity::Error) => error, - Some(Severity::Warning) | None => warning, - Some(Severity::Info) => info, - Some(Severity::Hint) => hint, - }, - ); + let mut offset = 0; + + // avoid lots of small allocations by reusing a text buffer for each line + let mut text = String::with_capacity(8); + + for (constructor, width) in view.gutters() { + let gutter = constructor(doc, view, theme, config, is_focused, *width); + text.reserve(*width); // ensure there's enough space for the gutter + for (i, line) in (view.offset.row..(last_line + 1)).enumerate() { + let selected = cursors.contains(&line); + + if let Some(style) = gutter(line, selected, &mut text) { + surface.set_stringn( + viewport.x + offset, + viewport.y + i as u16, + &text, + *width, + style, + ); + } + text.clear(); } - - let selected = cursors.contains(&line); - - let text = if line == last_line && !draw_last { - " ~".into() - } else { - let line = match config.line_number { - LineNumber::Absolute => line + 1, - LineNumber::Relative => { - if current_line == line { - line + 1 - } else { - abs_diff(current_line, line) - } - } - }; - format!("{:>5}", line) - }; - surface.set_stringn( - viewport.x + 1, - viewport.y + i as u16, - text, - 5, - if selected && is_focused { - linenr_select - } else { - linenr - }, - ); + offset += *width as u16; } } @@ -497,7 +461,7 @@ impl EditorView { use tui::{ layout::Alignment, text::Text, - widgets::{Paragraph, Widget}, + widgets::{Paragraph, Widget, Wrap}, }; let cursor = doc @@ -529,8 +493,10 @@ impl EditorView { lines.extend(text.lines); } - let paragraph = Paragraph::new(lines).alignment(Alignment::Right); - let width = 80.min(viewport.width); + let paragraph = Paragraph::new(lines) + .alignment(Alignment::Right) + .wrap(Wrap { trim: true }); + let width = 100.min(viewport.width); let height = 15.min(viewport.height); paragraph.render( Rect::new(viewport.right() - width, viewport.y + 1, width, height), @@ -580,18 +546,20 @@ impl EditorView { } surface.set_string(viewport.x + 5, viewport.y, progress, base_style); - if let Some(path) = doc.relative_path() { - let path = path.to_string_lossy(); + let rel_path = doc.relative_path(); + let path = rel_path + .as_ref() + .map(|p| p.to_string_lossy()) + .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()); - let title = format!("{}{}", path, if doc.is_modified() { "[+]" } else { "" }); - surface.set_stringn( - viewport.x + 8, - viewport.y, - title, - viewport.width.saturating_sub(6) as usize, - base_style, - ); - } + let title = format!("{}{}", path, if doc.is_modified() { "[+]" } else { "" }); + surface.set_stringn( + viewport.x + 8, + viewport.y, + title, + viewport.width.saturating_sub(6) as usize, + base_style, + ); //------------------------------- // Right side of the status line. @@ -695,6 +663,11 @@ impl EditorView { match &key_result.kind { KeymapResultKind::Matched(command) => command.execute(cxt), KeymapResultKind::Pending(node) => self.autoinfo = Some(node.infobox()), + KeymapResultKind::MatchedSequence(commands) => { + for command in commands { + command.execute(cxt); + } + } KeymapResultKind::NotFound | KeymapResultKind::Cancelled(_) => return Some(key_result), } None @@ -736,7 +709,7 @@ impl EditorView { std::num::NonZeroUsize::new(cxt.editor.count.map_or(i, |c| c.get() * 10 + i)); } // special handling for repeat operator - key!('.') => { + key!('.') if self.keymaps.pending().is_empty() => { // first execute whatever put us into insert mode self.last_insert.0.execute(cxt); // then replay the inputs @@ -902,7 +875,7 @@ impl EditorView { return EventResult::Ignored; } - commands::Command::yank_main_selection_to_primary_clipboard.execute(cxt); + commands::MappableCommand::yank_main_selection_to_primary_clipboard.execute(cxt); EventResult::Consumed(None) } @@ -920,7 +893,8 @@ impl EditorView { } if modifiers == crossterm::event::KeyModifiers::ALT { - commands::Command::replace_selections_with_primary_clipboard.execute(cxt); + commands::MappableCommand::replace_selections_with_primary_clipboard + .execute(cxt); return EventResult::Consumed(None); } @@ -934,7 +908,7 @@ impl EditorView { let doc = editor.document_mut(editor.tree.get(view_id).doc).unwrap(); doc.set_selection(view_id, Selection::point(pos)); editor.tree.focus = view_id; - commands::Command::paste_primary_clipboard_before.execute(cxt); + commands::MappableCommand::paste_primary_clipboard_before.execute(cxt); return EventResult::Consumed(None); } @@ -949,7 +923,7 @@ impl EditorView { impl Component for EditorView { fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { let mut cxt = commands::Context { - editor: &mut cx.editor, + editor: cx.editor, count: None, register: None, callback: None, @@ -1158,12 +1132,3 @@ fn canonicalize_key(key: &mut KeyEvent) { key.modifiers.remove(KeyModifiers::SHIFT) } } - -#[inline] -const fn abs_diff(a: usize, b: usize) -> usize { - if a > b { - a - b - } else { - b - a - } -} diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index 4144ed3c4..ca8303dd9 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -13,7 +13,7 @@ use helix_core::{ Rope, }; use helix_view::{ - graphics::{Color, Margin, Rect, Style}, + graphics::{Margin, Rect}, Theme, }; @@ -55,15 +55,21 @@ fn parse<'a>( fn to_span(text: pulldown_cmark::CowStr) -> Span { use std::ops::Deref; Span::raw::>(match text { - CowStr::Borrowed(s) => s.to_string().into(), // could retain borrow + CowStr::Borrowed(s) => s.into(), CowStr::Boxed(s) => s.to_string().into(), CowStr::Inlined(s) => s.deref().to_owned().into(), }) } - let text_style = Style::default().fg(Color::Rgb(164, 160, 232)); // lavender - let code_style = Style::default().fg(Color::Rgb(255, 255, 255)); // white - let heading_style = Style::default().fg(Color::Rgb(219, 191, 239)); // lilac + let text_style = theme.map(|theme| theme.get("ui.text")).unwrap_or_default(); + + // TODO: use better scopes for these, `markup.raw.block`, `markup.heading` + let code_style = theme + .map(|theme| theme.get("ui.text.focus")) + .unwrap_or_default(); // white + let heading_style = theme + .map(|theme| theme.get("ui.linenr.selected")) + .unwrap_or_default(); // lilac for event in parser { match event { @@ -173,7 +179,9 @@ fn parse<'a>( spans.push(Span::raw(" ")); } Event::Rule => { - lines.push(Spans::from("---")); + let mut span = Span::raw("---"); + span.style = code_style; + lines.push(Spans::from(span)); lines.push(Spans::default()); } // TaskListMarker(bool) true if checked @@ -220,6 +228,7 @@ impl Component for Markdown { return None; } let contents = parse(&self.contents, None, &self.config_loader); + // TODO: account for tab width let max_text_width = (viewport.0 - padding).min(120); let mut text_width = 0; let mut height = padding; diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 3c492d149..e891c1492 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -1,5 +1,8 @@ -use crate::compositor::{Component, Compositor, Context, EventResult}; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use crate::{ + compositor::{Component, Compositor, Context, EventResult}, + ctrl, key, shift, +}; +use crossterm::event::Event; use tui::{buffer::Buffer as Surface, widgets::Table}; pub use tui::widgets::{Cell, Row}; @@ -192,63 +195,25 @@ impl Component for Menu { compositor.pop(); }))); - match event { + match event.into() { // esc or ctrl-c aborts the completion and closes the menu - KeyEvent { - code: KeyCode::Esc, .. - } - | KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - } => { + key!(Esc) | ctrl!('c') => { (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Abort); return close_fn; } // arrow up/ctrl-p/shift-tab prev completion choice (including updating the doc) - KeyEvent { - code: KeyCode::BackTab, - .. - } - | KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - } - | KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::CONTROL, - } => { + shift!(BackTab) | key!(Up) | ctrl!('p') | ctrl!('k') => { self.move_up(); (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); return EventResult::Consumed(None); } - // arrow down/ctrl-n/tab advances completion choice (including updating the doc) - KeyEvent { - code: KeyCode::Tab, - modifiers: KeyModifiers::NONE, - } - | KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - } - | KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::CONTROL, - } => { + key!(Tab) | key!(Down) | ctrl!('n') | ctrl!('j') => { + // arrow down/ctrl-n/tab advances completion choice (including updating the doc) self.move_down(); (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); return EventResult::Consumed(None); } - KeyEvent { - code: KeyCode::Enter, - .. - } => { + key!(Enter) => { if let Some(selection) = self.selection() { (self.callback_fn)(cx.editor, Some(selection), MenuEvent::Validate); } diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index fea4e8b74..df64d9e31 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -35,6 +35,7 @@ pub fn regex_prompt( let (view, doc) = current!(cx.editor); let view_id = view.id; let snapshot = doc.selection(view_id).clone(); + let offset_snapshot = view.offset; Prompt::new( prompt, @@ -45,6 +46,7 @@ pub fn regex_prompt( PromptEvent::Abort => { let (view, doc) = current!(cx.editor); doc.set_selection(view.id, snapshot.clone()); + view.offset = offset_snapshot; } PromptEvent::Validate => { // TODO: push_jump to store selection just before jump @@ -91,13 +93,22 @@ pub fn regex_prompt( ) } -pub fn file_picker(root: PathBuf) -> FilePicker { +pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker { use ignore::{types::TypesBuilder, WalkBuilder}; use std::time; // We want to exclude files that the editor can't handle yet let mut type_builder = TypesBuilder::new(); let mut walk_builder = WalkBuilder::new(&root); + walk_builder + .hidden(config.file_picker.hidden) + .parents(config.file_picker.parents) + .ignore(config.file_picker.ignore) + .git_ignore(config.file_picker.git_ignore) + .git_global(config.file_picker.git_global) + .git_exclude(config.file_picker.git_exclude) + .max_depth(config.file_picker.max_depth); + let walk_builder = match type_builder.add( "compressed", "*.{zip,gz,bz2,zst,lzo,sz,tgz,tbz2,lz,lz4,lzma,lzo,z,Z,xz,7z,rar,cab}", diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 291f1f856..16bf08a3e 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,8 +1,9 @@ use crate::{ compositor::{Component, Compositor, Context, EventResult}, + ctrl, key, shift, ui::EditorView, }; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::Event; use tui::{ buffer::Buffer as Surface, widgets::{Block, BorderType, Borders}, @@ -36,6 +37,7 @@ type FileLocation = (PathBuf, Option<(usize, usize)>); pub struct FilePicker { picker: Picker, + pub truncate_start: bool, /// Caches paths to documents preview_cache: HashMap, read_buffer: Vec, @@ -44,7 +46,7 @@ pub struct FilePicker { } pub enum CachedPreview { - Document(Document), + Document(Box), Binary, LargeFile, NotFound, @@ -89,6 +91,7 @@ impl FilePicker { ) -> Self { Self { picker: Picker::new(false, options, format_fn, callback_fn), + truncate_start: true, preview_cache: HashMap::new(), read_buffer: Vec::with_capacity(1024), file_fn: Box::new(preview_fn), @@ -137,7 +140,7 @@ impl FilePicker { _ => { // TODO: enable syntax highlighting; blocked by async rendering Document::open(path, None, Some(&editor.theme), None) - .map(CachedPreview::Document) + .map(|doc| CachedPreview::Document(Box::new(doc))) .unwrap_or(CachedPreview::NotFound) } }, @@ -171,6 +174,7 @@ impl Component for FilePicker { }; let picker_area = area.with_width(picker_width); + self.picker.truncate_start = self.truncate_start; self.picker.render(picker_area, surface, cx); if !render_preview { @@ -276,6 +280,8 @@ pub struct Picker { prompt: Prompt, /// Whether to render in the middle of the area render_centered: bool, + /// Wheather to truncate the start (default true) + pub truncate_start: bool, format_fn: Box Cow>, callback_fn: Box, @@ -305,6 +311,7 @@ impl Picker { cursor: 0, prompt, render_centered, + truncate_start: true, format_fn: Box::new(format_fn), callback_fn: Box::new(callback_fn), }; @@ -402,81 +409,35 @@ impl Component for Picker { compositor.last_picker = compositor.pop(); }))); - match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::BackTab, - .. - } - | KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::CONTROL, - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - } => { + match key_event.into() { + shift!(BackTab) | key!(Up) | ctrl!('p') | ctrl!('k') => { self.move_up(); } - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Tab, .. - } - | KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::CONTROL, - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - } => { + key!(Tab) | key!(Down) | ctrl!('n') | ctrl!('j') => { self.move_down(); } - KeyEvent { - code: KeyCode::Esc, .. - } - | KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - } => { + key!(Esc) | ctrl!('c') => { return close_fn; } - KeyEvent { - code: KeyCode::Enter, - .. - } => { + key!(Enter) => { if let Some(option) = self.selection() { - (self.callback_fn)(&mut cx.editor, option, Action::Replace); + (self.callback_fn)(cx.editor, option, Action::Replace); } return close_fn; } - KeyEvent { - code: KeyCode::Char('s'), - modifiers: KeyModifiers::CONTROL, - } => { + ctrl!('s') => { if let Some(option) = self.selection() { - (self.callback_fn)(&mut cx.editor, option, Action::HorizontalSplit); + (self.callback_fn)(cx.editor, option, Action::HorizontalSplit); } return close_fn; } - KeyEvent { - code: KeyCode::Char('v'), - modifiers: KeyModifiers::CONTROL, - } => { + ctrl!('v') => { if let Some(option) = self.selection() { - (self.callback_fn)(&mut cx.editor, option, Action::VerticalSplit); + (self.callback_fn)(cx.editor, option, Action::VerticalSplit); } return close_fn; } - KeyEvent { - code: KeyCode::Char(' '), - modifiers: KeyModifiers::CONTROL, - } => { + ctrl!(' ') => { self.save_filter(); } _ => { @@ -566,7 +527,7 @@ impl Component for Picker { text_style }, true, - true, + self.truncate_start, ); } } diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index 1bab1eae4..8f7921a11 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -1,5 +1,8 @@ -use crate::compositor::{Component, Compositor, Context, EventResult}; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use crate::{ + compositor::{Component, Compositor, Context, EventResult}, + ctrl, key, +}; +use crossterm::event::Event; use tui::buffer::Buffer as Surface; use helix_core::Position; @@ -95,27 +98,14 @@ impl Component for Popup { compositor.pop(); }))); - match key { + match key.into() { // esc or ctrl-c aborts the completion and closes the menu - KeyEvent { - code: KeyCode::Esc, .. - } - | KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - } => close_fn, - - KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::CONTROL, - } => { + key!(Esc) | ctrl!('c') => close_fn, + ctrl!('d') => { self.scroll(self.size.1 as usize / 2, true); EventResult::Consumed(None) } - KeyEvent { - code: KeyCode::Char('u'), - modifiers: KeyModifiers::CONTROL, - } => { + ctrl!('u') => { self.scroll(self.size.1 as usize / 2, false); EventResult::Consumed(None) } diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index c999ba140..e90b07727 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -1,6 +1,8 @@ use crate::compositor::{Component, Compositor, Context, EventResult}; -use crate::ui; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use crate::{alt, ctrl, key, shift, ui}; +use crossterm::event::Event; +use helix_view::input::KeyEvent; +use helix_view::keyboard::{KeyCode, KeyModifiers}; use std::{borrow::Cow, ops::RangeFrom}; use tui::buffer::Buffer as Surface; @@ -212,6 +214,14 @@ impl Prompt { self.completion = (self.completion_fn)(&self.line); } + pub fn delete_char_forwards(&mut self) { + let pos = self.eval_movement(Movement::ForwardChar(1)); + self.line.replace_range(self.cursor..pos, ""); + + self.exit_selection(); + self.completion = (self.completion_fn)(&self.line); + } + pub fn delete_word_backwards(&mut self) { let pos = self.eval_movement(Movement::BackwardWord(1)); self.line.replace_range(pos..self.cursor, ""); @@ -221,6 +231,23 @@ impl Prompt { self.completion = (self.completion_fn)(&self.line); } + pub fn delete_word_forwards(&mut self) { + let pos = self.eval_movement(Movement::ForwardWord(1)); + self.line.replace_range(self.cursor..pos, ""); + + self.exit_selection(); + self.completion = (self.completion_fn)(&self.line); + } + + pub fn kill_to_start_of_line(&mut self) { + let pos = self.eval_movement(Movement::StartOfLine); + self.line.replace_range(pos..self.cursor, ""); + self.cursor = pos; + + self.exit_selection(); + self.completion = (self.completion_fn)(&self.line); + } + pub fn kill_to_end_of_line(&mut self) { let pos = self.eval_movement(Movement::EndOfLine); self.line.replace_range(self.cursor..pos, ""); @@ -404,84 +431,30 @@ impl Component for Prompt { compositor.pop(); }))); - match event { - KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - } - | KeyEvent { - code: KeyCode::Esc, .. - } => { + match event.into() { + ctrl!('c') | key!(Esc) => { (self.callback_fn)(cx, &self.line, PromptEvent::Abort); return close_fn; } - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::ALT, - } - | KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::ALT, - } => self.move_cursor(Movement::BackwardWord(1)), - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::ALT, - } - | KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::ALT, - } => self.move_cursor(Movement::ForwardWord(1)), - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::CONTROL, - } - | KeyEvent { - code: KeyCode::Right, - .. - } => self.move_cursor(Movement::ForwardChar(1)), - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::CONTROL, - } - | KeyEvent { - code: KeyCode::Left, - .. - } => self.move_cursor(Movement::BackwardChar(1)), - KeyEvent { - code: KeyCode::End, - modifiers: KeyModifiers::NONE, - } - | KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::CONTROL, - } => self.move_end(), - KeyEvent { - code: KeyCode::Home, - modifiers: KeyModifiers::NONE, - } - | KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - } => self.move_start(), - KeyEvent { - code: KeyCode::Char('w'), - modifiers: KeyModifiers::CONTROL, - } => self.delete_word_backwards(), - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::CONTROL, - } => self.kill_to_end_of_line(), - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::NONE, - } => { + alt!('b') | alt!(Left) => self.move_cursor(Movement::BackwardWord(1)), + alt!('f') | alt!(Right) => self.move_cursor(Movement::ForwardWord(1)), + ctrl!('b') | key!(Left) => self.move_cursor(Movement::BackwardChar(1)), + ctrl!('f') | key!(Right) => self.move_cursor(Movement::ForwardChar(1)), + ctrl!('e') | key!(End) => self.move_end(), + ctrl!('a') | key!(Home) => self.move_start(), + ctrl!('w') => self.delete_word_backwards(), + alt!('d') => self.delete_word_forwards(), + ctrl!('k') => self.kill_to_end_of_line(), + ctrl!('u') => self.kill_to_start_of_line(), + ctrl!('h') | key!(Backspace) => { self.delete_char_backwards(); (self.callback_fn)(cx, &self.line, PromptEvent::Update); } - KeyEvent { - code: KeyCode::Char('s'), - modifiers: KeyModifiers::CONTROL, - } => { + ctrl!('d') | key!(Delete) => { + self.delete_char_forwards(); + (self.callback_fn)(cx, &self.line, PromptEvent::Update); + } + ctrl!('s') => { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -491,6 +464,7 @@ impl Component for Prompt { doc.selection(view.id).primary(), textobject::TextObject::Inside, 1, + false, ); let line = text.slice(range.from()..range.to()).to_string(); if !line.is_empty() { @@ -498,10 +472,7 @@ impl Component for Prompt { (self.callback_fn)(cx, &self.line, PromptEvent::Update); } } - KeyEvent { - code: KeyCode::Enter, - .. - } => { + key!(Enter) => { if self.selection.is_some() && self.line.ends_with('/') { self.completion = (self.completion_fn)(&self.line); self.exit_selection(); @@ -516,50 +487,29 @@ impl Component for Prompt { return close_fn; } } - KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - } - | KeyEvent { - code: KeyCode::Up, .. - } => { + ctrl!('p') | key!(Up) => { if let Some(register) = self.history_register { let register = cx.editor.registers.get_mut(register); self.change_history(register.read(), CompletionDirection::Backward); (self.callback_fn)(cx, &self.line, PromptEvent::Update); } } - KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - } - | KeyEvent { - code: KeyCode::Down, - .. - } => { + ctrl!('n') | key!(Down) => { if let Some(register) = self.history_register { let register = cx.editor.registers.get_mut(register); self.change_history(register.read(), CompletionDirection::Forward); (self.callback_fn)(cx, &self.line, PromptEvent::Update); } } - KeyEvent { - code: KeyCode::Tab, .. - } => { + key!(Tab) => { self.change_completion_selection(CompletionDirection::Forward); (self.callback_fn)(cx, &self.line, PromptEvent::Update) } - KeyEvent { - code: KeyCode::BackTab, - .. - } => { + shift!(BackTab) => { self.change_completion_selection(CompletionDirection::Backward); (self.callback_fn)(cx, &self.line, PromptEvent::Update) } - KeyEvent { - code: KeyCode::Char('q'), - modifiers: KeyModifiers::CONTROL, - } => self.exit_selection(), + ctrl!('q') => self.exit_selection(), // any char event that's not combined with control or mapped to any other combo KeyEvent { code: KeyCode::Char(c), diff --git a/helix-tui/src/buffer.rs b/helix-tui/src/buffer.rs index f480bc2f3..c49a02008 100644 --- a/helix-tui/src/buffer.rs +++ b/helix-tui/src/buffer.rs @@ -102,7 +102,7 @@ impl Default for Cell { /// buf.get_mut(5, 0).set_char('x'); /// assert_eq!(buf.get(5, 0).symbol, "x"); /// ``` -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct Buffer { /// The area represented by this buffer pub area: Rect, @@ -111,15 +111,6 @@ pub struct Buffer { pub content: Vec, } -impl Default for Buffer { - fn default() -> Buffer { - Buffer { - area: Default::default(), - content: Vec::new(), - } - } -} - impl Buffer { /// Returns a Buffer with all cells set to the default one pub fn empty(area: Rect) -> Buffer { diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs index b8e52479f..8a974ddba 100644 --- a/helix-tui/src/text.rs +++ b/helix-tui/src/text.rs @@ -195,15 +195,9 @@ impl<'a> From<&'a str> for Span<'a> { } /// A string composed of clusters of graphemes, each with their own style. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct Spans<'a>(pub Vec>); -impl<'a> Default for Spans<'a> { - fn default() -> Spans<'a> { - Spans(Vec::new()) - } -} - impl<'a> Spans<'a> { /// Returns the width of the underlying string. /// @@ -280,17 +274,11 @@ impl<'a> From> for String { /// text.extend(Text::styled("Some more lines\nnow with more style!", style)); /// assert_eq!(6, text.height()); /// ``` -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct Text<'a> { pub lines: Vec>, } -impl<'a> Default for Text<'a> { - fn default() -> Text<'a> { - Text { lines: Vec::new() } - } -} - impl<'a> Text<'a> { /// Create some text (potentially multiple lines) with no style. /// diff --git a/helix-tui/src/widgets/table.rs b/helix-tui/src/widgets/table.rs index d7caa0b0a..6aee5988c 100644 --- a/helix-tui/src/widgets/table.rs +++ b/helix-tui/src/widgets/table.rs @@ -363,21 +363,12 @@ impl<'a> Table<'a> { } } -#[derive(Debug, Clone)] +#[derive(Debug, Default, Clone)] pub struct TableState { pub offset: usize, pub selected: Option, } -impl Default for TableState { - fn default() -> TableState { - TableState { - offset: 0, - selected: None, - } - } -} - impl TableState { pub fn selected(&self) -> Option { self.selected diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index ce5df8ee8..2cb33fe32 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -25,6 +25,8 @@ const BUF_SIZE: usize = 8192; const DEFAULT_INDENT: IndentStyle = IndentStyle::Spaces(4); +pub const SCRATCH_BUFFER_NAME: &str = "[scratch]"; + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum Mode { Normal, @@ -96,12 +98,13 @@ pub struct Document { // It can be used as a cell where we will take it out to get some parts of the history and put // it back as it separated from the edits. We could split out the parts manually but that will // be more troublesome. - history: Cell, + pub history: Cell, pub savepoint: Option, last_saved_revision: usize, version: i32, // should be usize? + pub(crate) modified_since_accessed: bool, diagnostics: Vec, language_server: Option>, @@ -125,6 +128,7 @@ impl fmt::Debug for Document { // .field("history", &self.history) .field("last_saved_revision", &self.last_saved_revision) .field("version", &self.version) + .field("modified_since_accessed", &self.modified_since_accessed) .field("diagnostics", &self.diagnostics) // .field("language_server", &self.language_server) .finish() @@ -342,6 +346,7 @@ impl Document { history: Cell::new(History::default()), savepoint: None, last_saved_revision: 0, + modified_since_accessed: false, language_server: None, } } @@ -494,7 +499,9 @@ impl Document { /// Detect the programming language based on the file type. pub fn detect_language(&mut self, theme: Option<&Theme>, config_loader: &syntax::Loader) { if let Some(path) = &self.path { - let language_config = config_loader.language_config_for_file_name(path); + let language_config = config_loader + .language_config_for_file_name(path) + .or_else(|| config_loader.language_config_for_shebang(self.text())); self.set_language(theme, language_config); } } @@ -635,6 +642,9 @@ impl Document { selection.clone().ensure_invariants(self.text.slice(..)), ); } + + // set modified since accessed + self.modified_since_accessed = true; } if !transaction.changes().is_empty() { @@ -749,19 +759,35 @@ impl Document { } /// Undo modifications to the [`Document`] according to `uk`. - pub fn earlier(&mut self, view_id: ViewId, uk: helix_core::history::UndoKind) { + pub fn earlier(&mut self, view_id: ViewId, uk: helix_core::history::UndoKind) -> bool { let txns = self.history.get_mut().earlier(uk); + let mut success = false; for txn in txns { - self.apply_impl(&txn, view_id); + if self.apply_impl(&txn, view_id) { + success = true; + } + } + if success { + // reset changeset to fix len + self.changes = ChangeSet::new(self.text()); } + success } /// Redo modifications to the [`Document`] according to `uk`. - pub fn later(&mut self, view_id: ViewId, uk: helix_core::history::UndoKind) { + pub fn later(&mut self, view_id: ViewId, uk: helix_core::history::UndoKind) -> bool { let txns = self.history.get_mut().later(uk); + let mut success = false; for txn in txns { - self.apply_impl(&txn, view_id); + if self.apply_impl(&txn, view_id) { + success = true; + } } + if success { + // reset changeset to fix len + self.changes = ChangeSet::new(self.text()); + } + success } /// Commit pending changes to history diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 17cd3d7b5..9034d12c8 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,5 +1,6 @@ use crate::{ clipboard::{get_clipboard_provider, ClipboardProvider}, + document::SCRATCH_BUFFER_NAME, graphics::{CursorKind, Rect}, theme::{self, Theme}, tree::{self, Tree}, @@ -9,6 +10,8 @@ use crate::{ use futures_util::future; use std::{ collections::BTreeMap, + io::stdin, + num::NonZeroUsize, path::{Path, PathBuf}, pin::Pin, sync::Arc, @@ -16,12 +19,12 @@ use std::{ use tokio::time::{sleep, Duration, Instant, Sleep}; -use anyhow::Error; +use anyhow::{bail, Context, Error}; pub use helix_core::diagnostic::Severity; pub use helix_core::register::Registers; use helix_core::syntax; -use helix_core::Position; +use helix_core::{Position, Selection}; use serde::Deserialize; @@ -33,6 +36,46 @@ where Ok(Duration::from_millis(millis)) } +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +pub struct FilePickerConfig { + /// IgnoreOptions + /// Enables ignoring hidden files. + /// Whether to hide hidden files in file picker and global search results. Defaults to true. + pub hidden: bool, + /// Enables reading ignore files from parent directories. Defaults to true. + pub parents: bool, + /// Enables reading `.ignore` files. + /// Whether to hide files listed in .ignore in file picker and global search results. Defaults to true. + pub ignore: bool, + /// Enables reading `.gitignore` files. + /// Whether to hide files listed in .gitignore in file picker and global search results. Defaults to true. + pub git_ignore: bool, + /// Enables reading global .gitignore, whose path is specified in git's config: `core.excludefile` option. + /// Whether to hide files listed in global .gitignore in file picker and global search results. Defaults to true. + pub git_global: bool, + /// Enables reading `.git/info/exclude` files. + /// Whether to hide files listed in .git/info/exclude in file picker and global search results. Defaults to true. + pub git_exclude: bool, + /// WalkBuilder options + /// Maximum Depth to recurse directories in file picker and global search. Defaults to `None`. + pub max_depth: Option, +} + +impl Default for FilePickerConfig { + fn default() -> Self { + Self { + hidden: true, + parents: true, + ignore: true, + git_ignore: true, + git_global: true, + git_exclude: true, + max_depth: None, + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct Config { @@ -60,9 +103,10 @@ pub struct Config { pub completion_trigger_len: u8, /// Whether to display infoboxes. Defaults to true. pub auto_info: bool, + pub file_picker: FilePickerConfig, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum LineNumber { /// Show absolute line number @@ -91,6 +135,7 @@ impl Default for Config { idle_timeout: Duration::from_millis(400), completion_trigger_len: 2, auto_info: true, + file_picker: FilePickerConfig::default(), } } } @@ -110,7 +155,7 @@ impl std::fmt::Debug for Motion { #[derive(Debug)] pub struct Editor { pub tree: Tree, - pub next_document_id: usize, + pub next_document_id: DocumentId, pub documents: BTreeMap, pub count: Option, pub selected_register: Option, @@ -128,6 +173,8 @@ pub struct Editor { pub idle_timer: Pin>, pub last_motion: Option, + + pub exit_code: i32, } #[derive(Debug, Copy, Clone)] @@ -141,8 +188,8 @@ pub enum Action { impl Editor { pub fn new( mut area: Rect, - themes: Arc, - config_loader: Arc, + theme_loader: Arc, + syn_loader: Arc, config: Config, ) -> Self { let language_servers = helix_lsp::Registry::new(); @@ -152,20 +199,21 @@ impl Editor { Self { tree: Tree::new(area), - next_document_id: 0, + next_document_id: DocumentId::default(), documents: BTreeMap::new(), count: None, selected_register: None, - theme: themes.default(), + theme: theme_loader.default(), language_servers, - syn_loader: config_loader, - theme_loader: themes, + syn_loader, + theme_loader, registers: Registers::default(), clipboard_provider: get_clipboard_provider(), status_msg: None, idle_timer: Box::pin(sleep(config.idle_timeout)), last_motion: None, config, + exit_code: 0, } } @@ -215,7 +263,6 @@ impl Editor { } pub fn set_theme_from_name(&mut self, theme: &str) -> anyhow::Result<()> { - use anyhow::Context; let theme = self .theme_loader .load(theme.as_ref()) @@ -224,6 +271,53 @@ impl Editor { Ok(()) } + /// Refreshes the language server for a given document + pub fn refresh_language_server(&mut self, doc_id: DocumentId) -> Option<()> { + let doc = self.documents.get_mut(&doc_id)?; + doc.detect_language(Some(&self.theme), &self.syn_loader); + Self::launch_language_server(&mut self.language_servers, doc) + } + + /// Launch a language server for a given document + fn launch_language_server(ls: &mut helix_lsp::Registry, doc: &mut Document) -> Option<()> { + // try to find a language server based on the language name + let language_server = doc.language.as_ref().and_then(|language| { + ls.get(language) + .map_err(|e| { + log::error!( + "Failed to initialize the LSP for `{}` {{ {} }}", + language.scope(), + e + ) + }) + .ok() + }); + if let Some(language_server) = language_server { + // only spawn a new lang server if the servers aren't the same + if Some(language_server.id()) != doc.language_server().map(|server| server.id()) { + if let Some(language_server) = doc.language_server() { + tokio::spawn(language_server.text_document_did_close(doc.identifier())); + } + let language_id = doc + .language() + .and_then(|s| s.split('.').last()) // source.rust + .map(ToOwned::to_owned) + .unwrap_or_default(); + + // TODO: this now races with on_init code if the init happens too quickly + tokio::spawn(language_server.text_document_did_open( + doc.url().unwrap(), + doc.version(), + doc.text(), + language_id, + )); + + doc.set_language_server(Some(language_server)); + } + } + Some(()) + } + fn _refresh(&mut self) { for (view, _) in self.tree.views_mut() { let doc = &self.documents[&view.doc]; @@ -231,9 +325,28 @@ impl Editor { } } + fn replace_document_in_view(&mut self, current_view: ViewId, doc_id: DocumentId) { + let view = self.tree.get_mut(current_view); + view.doc = doc_id; + view.offset = Position::default(); + + let doc = self.documents.get_mut(&doc_id).unwrap(); + + // initialize selection for view + doc.selections + .entry(view.id) + .or_insert_with(|| Selection::point(0)); + // TODO: reuse align_view + let pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + let line = doc.text().char_to_line(pos); + view.offset.row = line.saturating_sub(view.inner_area().height as usize / 2); + } + pub fn switch(&mut self, id: DocumentId, action: Action) { use crate::tree::Layout; - use helix_core::Selection; if !self.documents.contains_key(&id) { log::error!("cannot switch to document that does not exist (anymore)"); @@ -256,7 +369,8 @@ impl Editor { .tree .traverse() .any(|(_, v)| v.doc == doc.id && v.id != view.id); - let view = view_mut!(self); + + let (view, doc) = current!(self); if remove_empty_scratch { // Copy `doc.id` into a variable before calling `self.documents.remove`, which requires a mutable // borrow, invalidating direct access to `doc.id`. @@ -265,40 +379,41 @@ impl Editor { } else { let jump = (view.doc, doc.selection(view.id).clone()); view.jumps.push(jump); - view.last_accessed_doc = Some(view.doc); + // Set last accessed doc if it is a different document + if doc.id != id { + view.last_accessed_doc = Some(view.doc); + // Set last modified doc if modified and last modified doc is different + if std::mem::take(&mut doc.modified_since_accessed) + && view.last_modified_docs[0] != Some(id) + { + view.last_modified_docs = [Some(view.doc), view.last_modified_docs[0]]; + } + } } - view.doc = id; - view.offset = Position::default(); - let (view, doc) = current!(self); - - // initialize selection for view - doc.selections - .entry(view.id) - .or_insert_with(|| Selection::point(0)); - // TODO: reuse align_view - let pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - let line = doc.text().char_to_line(pos); - view.offset.row = line.saturating_sub(view.inner_area().height as usize / 2); + let view_id = view.id; + self.replace_document_in_view(view_id, id); return; } Action::Load => { - return; - } - Action::HorizontalSplit => { - let view = View::new(id); - let view_id = self.tree.split(view, Layout::Horizontal); - // initialize selection for view + let view_id = view!(self).id; let doc = self.documents.get_mut(&id).unwrap(); - doc.selections.insert(view_id, Selection::point(0)); + if doc.selections().is_empty() { + doc.selections.insert(view_id, Selection::point(0)); + } + return; } - Action::VerticalSplit => { + Action::HorizontalSplit | Action::VerticalSplit => { let view = View::new(id); - let view_id = self.tree.split(view, Layout::Vertical); + let view_id = self.tree.split( + view, + match action { + Action::HorizontalSplit => Layout::Horizontal, + Action::VerticalSplit => Layout::Vertical, + _ => unreachable!(), + }, + ); // initialize selection for view let doc = self.documents.get_mut(&id).unwrap(); doc.selections.insert(view_id, Selection::point(0)); @@ -308,73 +423,51 @@ impl Editor { self._refresh(); } - pub fn new_file(&mut self, action: Action) -> DocumentId { - let id = DocumentId(self.next_document_id); - self.next_document_id += 1; - let mut doc = Document::default(); + /// Generate an id for a new document and register it. + fn new_document(&mut self, mut doc: Document) -> DocumentId { + let id = self.next_document_id; + // Safety: adding 1 from 1 is fine, probably impossible to reach usize max + self.next_document_id = + DocumentId(unsafe { NonZeroUsize::new_unchecked(self.next_document_id.0.get() + 1) }); doc.id = id; self.documents.insert(id, doc); + id + } + + fn new_file_from_document(&mut self, action: Action, doc: Document) -> DocumentId { + let id = self.new_document(doc); self.switch(id, action); id } + pub fn new_file(&mut self, action: Action) -> DocumentId { + self.new_file_from_document(action, Document::default()) + } + + pub fn new_file_from_stdin(&mut self, action: Action) -> Result { + let (rope, encoding) = crate::document::from_reader(&mut stdin(), None)?; + Ok(self.new_file_from_document(action, Document::from(rope, Some(encoding)))) + } + pub fn open(&mut self, path: PathBuf, action: Action) -> Result { let path = helix_core::path::get_canonicalized_path(&path)?; - - let id = self - .documents() - .find(|doc| doc.path() == Some(&path)) - .map(|doc| doc.id); + let id = self.document_by_path(&path).map(|doc| doc.id); let id = if let Some(id) = id { id } else { let mut doc = Document::open(&path, None, Some(&self.theme), Some(&self.syn_loader))?; - // try to find a language server based on the language name - let language_server = doc.language.as_ref().and_then(|language| { - self.language_servers - .get(language) - .map_err(|e| { - log::error!( - "Failed to initialize the LSP for `{}` {{ {} }}", - language.scope(), - e - ) - }) - .ok() - }); - - if let Some(language_server) = language_server { - let language_id = doc - .language() - .and_then(|s| s.split('.').last()) // source.rust - .map(ToOwned::to_owned) - .unwrap_or_default(); - - // TODO: this now races with on_init code if the init happens too quickly - tokio::spawn(language_server.text_document_did_open( - doc.url().unwrap(), - doc.version(), - doc.text(), - language_id, - )); + let _ = Self::launch_language_server(&mut self.language_servers, &mut doc); - doc.set_language_server(Some(language_server)); - } - - let id = DocumentId(self.next_document_id); - self.next_document_id += 1; - doc.id = id; - self.documents.insert(id, doc); - id + self.new_document(doc) }; self.switch(id, action); Ok(id) } - pub fn close(&mut self, id: ViewId, close_buffer: bool) { + pub fn close(&mut self, id: ViewId) { let view = self.tree.get(self.tree.focus); // remove selection self.documents @@ -383,18 +476,66 @@ impl Editor { .selections .remove(&id); - if close_buffer { - // get around borrowck issues - let doc = &self.documents[&view.doc]; + self.tree.remove(id); + self._refresh(); + } - if let Some(language_server) = doc.language_server() { - tokio::spawn(language_server.text_document_did_close(doc.identifier())); - } - self.documents.remove(&view.doc); + pub fn close_document(&mut self, doc_id: DocumentId, force: bool) -> anyhow::Result<()> { + let doc = match self.documents.get(&doc_id) { + Some(doc) => doc, + None => bail!("document does not exist"), + }; + + if !force && doc.is_modified() { + bail!( + "buffer {:?} is modified", + doc.relative_path() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()) + ); + } + + if let Some(language_server) = doc.language_server() { + tokio::spawn(language_server.text_document_did_close(doc.identifier())); + } + + let views_to_close = self + .tree + .views() + .filter_map(|(view, _focus)| { + if view.doc == doc_id { + Some(view.id) + } else { + None + } + }) + .collect::>(); + + for view_id in views_to_close { + self.close(view_id); + } + + self.documents.remove(&doc_id); + + // If the document we removed was visible in all views, we will have no more views. We don't + // want to close the editor just for a simple buffer close, so we need to create a new view + // containing either an existing document, or a brand new document. + if self.tree.views().next().is_none() { + let doc_id = self + .documents + .iter() + .map(|(&doc_id, _)| doc_id) + .next() + .unwrap_or_else(|| self.new_document(Document::default())); + let view = View::new(doc_id); + let view_id = self.tree.insert(view); + let doc = self.documents.get_mut(&doc_id).unwrap(); + doc.selections.insert(view_id, Selection::point(0)); } - self.tree.remove(id); self._refresh(); + + Ok(()) } pub fn resize(&mut self, area: Rect) { @@ -464,8 +605,7 @@ impl Editor { } pub fn cursor(&self) -> (Option, CursorKind) { - let view = view!(self); - let doc = &self.documents[&view.doc]; + let (view, doc) = current_ref!(self); let cursor = doc .selection(view.id) .primary() diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs index 0bfca04aa..b8e43ba56 100644 --- a/helix-view/src/graphics.rs +++ b/helix-view/src/graphics.rs @@ -25,7 +25,7 @@ pub struct Margin { /// A simple rectangle used in the computation of the layout and to give widgets an hint about the /// area they are supposed to render to. (x, y) = (0, 0) is at the top left corner of the screen. -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq)] pub struct Rect { pub x: u16, pub y: u16, @@ -33,17 +33,6 @@ pub struct Rect { pub height: u16, } -impl Default for Rect { - fn default() -> Rect { - Rect { - x: 0, - y: 0, - width: 0, - height: 0, - } - } -} - impl Rect { /// Creates a new rect, with width and height limited to keep the area under max u16. /// If clipped, aspect ratio will be preserved. diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs new file mode 100644 index 000000000..86773c1db --- /dev/null +++ b/helix-view/src/gutter.rs @@ -0,0 +1,95 @@ +use std::fmt::Write; + +use crate::{editor::Config, graphics::Style, Document, Theme, View}; + +pub type GutterFn<'doc> = Box Option