diff --git a/.cargo/config b/.cargo/config new file mode 100644 index 000000000..35049cbcb --- /dev/null +++ b/.cargo/config @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 958407bb8..41b00230f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -17,6 +17,7 @@ Please search on the issue tracker before creating one. --> ### Environment - Platform: +- Terminal emulator: - Helix version:
~/.cache/helix/helix.log diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 216291804..65c2f9495 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -136,4 +136,52 @@ jobs: uses: actions-rs/cargo@v1 with: command: clippy - args: -- -D warnings + args: --all-targets -- -D warnings + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + with: + submodules: true + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Cache cargo registry + uses: actions/cache@v2.1.6 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-v2-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v2.1.6 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-v2-cargo-index-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo target dir + uses: actions/cache@v2.1.6 + with: + path: target + key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Generate docs + uses: actions-rs/cargo@v1 + with: + command: xtask + args: docgen + + - name: Check uncommitted documentation changes + run: | + git diff + git diff-files --quiet \ + || (echo "Run 'cargo xtask docgen', commit the changes and push again" \ + && exit 1) + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b16fa428a..7b0b7ee24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: fi cp -r runtime dist - - uses: actions/upload-artifact@v2.2.4 + - uses: actions/upload-artifact@v2.3.1 with: name: bins-${{ matrix.build }} path: dist diff --git a/.gitmodules b/.gitmodules index 6295b9e95..55ed97b32 100644 --- a/.gitmodules +++ b/.gitmodules @@ -142,11 +142,87 @@ path = helix-syntax/languages/tree-sitter-perl url = https://github.com/ganezdragon/tree-sitter-perl shallow = true +[submodule "helix-syntax/languages/tree-sitter-comment"] + path = helix-syntax/languages/tree-sitter-comment + url = https://github.com/stsewd/tree-sitter-comment + 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"] +[submodule "helix-syntax/languages/tree-sitter-llvm"] path = helix-syntax/languages/tree-sitter-llvm url = https://github.com/benwilliamgraham/tree-sitter-llvm shallow = true +[submodule "helix-syntax/languages/tree-sitter-markdown"] + path = helix-syntax/languages/tree-sitter-markdown + url = https://github.com/MDeiml/tree-sitter-markdown + shallow = true +[submodule "helix-syntax/languages/tree-sitter-dart"] + path = helix-syntax/languages/tree-sitter-dart + url = https://github.com/UserNobody14/tree-sitter-dart.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-dockerfile"] + path = helix-syntax/languages/tree-sitter-dockerfile + url = https://github.com/camdencheek/tree-sitter-dockerfile.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-fish"] + path = helix-syntax/languages/tree-sitter-fish + url = https://github.com/ram02z/tree-sitter-fish + shallow = true +[submodule "helix-syntax/languages/tree-sitter-git-commit"] + path = helix-syntax/languages/tree-sitter-git-commit + url = https://github.com/the-mikedavis/tree-sitter-git-commit.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-llvm-mir"] + path = helix-syntax/languages/tree-sitter-llvm-mir + url = https://github.com/Flakebi/tree-sitter-llvm-mir.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-git-diff"] + path = helix-syntax/languages/tree-sitter-git-diff + url = https://github.com/the-mikedavis/tree-sitter-git-diff.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-tablegen"] + path = helix-syntax/languages/tree-sitter-tablegen + url = https://github.com/Flakebi/tree-sitter-tablegen + shallow = true +[submodule "helix-syntax/languages/tree-sitter-git-rebase"] + path = helix-syntax/languages/tree-sitter-git-rebase + url = https://github.com/the-mikedavis/tree-sitter-git-rebase.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-lean"] + path = helix-syntax/languages/tree-sitter-lean + url = https://github.com/Julian/tree-sitter-lean + shallow = true +[submodule "helix-syntax/languages/tree-sitter-regex"] + path = helix-syntax/languages/tree-sitter-regex + url = https://github.com/tree-sitter/tree-sitter-regex.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-make"] + path = helix-syntax/languages/tree-sitter-make + url = https://github.com/alemuller/tree-sitter-make + shallow = true +[submodule "helix-syntax/languages/tree-sitter-git-config"] + path = helix-syntax/languages/tree-sitter-git-config + url = https://github.com/the-mikedavis/tree-sitter-git-config.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-graphql"] + path = helix-syntax/languages/tree-sitter-graphql + url = https://github.com/bkegley/tree-sitter-graphql + shallow = true +[submodule "helix-syntax/languages/tree-sitter-elm"] + path = helix-syntax/languages/tree-sitter-elm + url = https://github.com/elm-tooling/tree-sitter-elm + shallow = true +[submodule "helix-syntax/languages/tree-sitter-iex"] + path = helix-syntax/languages/tree-sitter-iex + url = https://github.com/elixir-lang/tree-sitter-iex + shallow = true +[submodule "helix-syntax/languages/tree-sitter-twig"] + path = helix-syntax/languages/tree-sitter-twig + url = https://github.com/eirabben/tree-sitter-twig.git + shallow = true +[submodule "helix-syntax/languages/tree-sitter-rescript"] + path = helix-syntax/languages/tree-sitter-rescript + url = https://github.com/jaredramirez/tree-sitter-rescript + shallow = true diff --git a/CHANGELOG.md b/CHANGELOG.md index 52ca2d602..389279912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,123 @@ +# 0.6.0 (2022-01-04) + +Happy new year and a big shout out to all the contributors! We had 55 contributors in this release. + +Helix has popped up in DPorts and Fedora Linux via COPR ([#1270](https://github.com/helix-editor/helix/pull/1270)) + +As usual the following is a brief summary, refer to the git history for a full log: + +Breaking changes: + +- fix: Normalize backtab into shift-tab + +Features: + +- Macros ([#1234](https://github.com/helix-editor/helix/pull/1234)) +- Add reverse search functionality ([#958](https://github.com/helix-editor/helix/pull/958)) +- Allow keys to be mapped to sequences of commands ([#589](https://github.com/helix-editor/helix/pull/589)) +- Make it possible to keybind TypableCommands ([#1169](https://github.com/helix-editor/helix/pull/1169)) +- Detect workspace root using language markers ([#1370](https://github.com/helix-editor/helix/pull/1370)) +- Add WORD textobject ([#991](https://github.com/helix-editor/helix/pull/991)) +- Add LSP rename_symbol (space-r) ([#1011](https://github.com/helix-editor/helix/pull/1011)) +- Added workspace_symbol_picker ([#1041](https://github.com/helix-editor/helix/pull/1041)) +- Detect filetype from shebang line ([#1001](https://github.com/helix-editor/helix/pull/1001)) +- Allow piping from stdin into a buffer on startup ([#996](https://github.com/helix-editor/helix/pull/996)) +- Add auto pairs for same-char pairs ([#1219](https://github.com/helix-editor/helix/pull/1219)) +- Update settings at runtime ([#798](https://github.com/helix-editor/helix/pull/798)) +- Enable thin LTO (cccc194) + +Commands: +- :wonly -- window only ([#1057](https://github.com/helix-editor/helix/pull/1057)) +- buffer-close (:bc, :bclose) ([#1035](https://github.com/helix-editor/helix/pull/1035)) +- Add : and :goto commands ([#1128](https://github.com/helix-editor/helix/pull/1128)) +- :sort command ([#1288](https://github.com/helix-editor/helix/pull/1288)) +- Add m textobject for pair under cursor ([#961](https://github.com/helix-editor/helix/pull/961)) +- Implement "Goto next buffer / Goto previous buffer" commands ([#950](https://github.com/helix-editor/helix/pull/950)) +- Implement "Goto last modification" command ([#1067](https://github.com/helix-editor/helix/pull/1067)) +- Add trim_selections command ([#1092](https://github.com/helix-editor/helix/pull/1092)) +- Add movement shortcut for history ([#1088](https://github.com/helix-editor/helix/pull/1088)) +- Add command to inc/dec number under cursor ([#1027](https://github.com/helix-editor/helix/pull/1027)) + - Add support for dates for increment/decrement +- Align selections (&) ([#1101](https://github.com/helix-editor/helix/pull/1101)) +- Implement no-yank delete/change ([#1099](https://github.com/helix-editor/helix/pull/1099)) +- Implement black hole register ([#1165](https://github.com/helix-editor/helix/pull/1165)) +- gf as goto_file (gf) ([#1102](https://github.com/helix-editor/helix/pull/1102)) +- Add last modified file (gm) ([#1093](https://github.com/helix-editor/helix/pull/1093)) +- ensure_selections_forward ([#1393](https://github.com/helix-editor/helix/pull/1393)) +- Readline style insert mode ([#1039](https://github.com/helix-editor/helix/pull/1039)) + +Usability improvements and fixes: + +- Detect filetype on :write ([#1141](https://github.com/helix-editor/helix/pull/1141)) +- Add single and double quotes to matching pairs ([#995](https://github.com/helix-editor/helix/pull/995)) +- Launch with defaults upon invalid config/theme (rather than panicking) ([#982](https://github.com/helix-editor/helix/pull/982)) +- If switching away from an empty scratch buffer, remove it ([#935](https://github.com/helix-editor/helix/pull/935)) +- Truncate the starts of file paths instead of the ends in picker ([#951](https://github.com/helix-editor/helix/pull/951)) +- Truncate the start of file paths in the StatusLine ([#1351](https://github.com/helix-editor/helix/pull/1351)) +- Prevent picker from previewing binaries or large file ([#939](https://github.com/helix-editor/helix/pull/939)) +- Inform when reaching undo/redo bounds ([#981](https://github.com/helix-editor/helix/pull/981)) +- search_impl will only align cursor center when it isn't in view ([#959](https://github.com/helix-editor/helix/pull/959)) +- Add , , , Delete in prompt mode ([#1034](https://github.com/helix-editor/helix/pull/1034)) +- Restore screen position when aborting search ([#1047](https://github.com/helix-editor/helix/pull/1047)) +- Buffer picker: show is_modifier flag ([#1020](https://github.com/helix-editor/helix/pull/1020)) +- Add commit hash to version info, if present ([#957](https://github.com/helix-editor/helix/pull/957)) +- Implement indent-aware delete ([#1120](https://github.com/helix-editor/helix/pull/1120)) +- Jump to end char of surrounding pair from any cursor pos ([#1121](https://github.com/helix-editor/helix/pull/1121)) +- File picker configuration ([#988](https://github.com/helix-editor/helix/pull/988)) +- Fix surround cursor position calculation ([#1183](https://github.com/helix-editor/helix/pull/1183)) +- Accept count for goto_window ([#1033](https://github.com/helix-editor/helix/pull/1033)) +- Make kill_to_line_end behave like emacs ([#1235](https://github.com/helix-editor/helix/pull/1235)) +- Only use a single documentation popup ([#1241](https://github.com/helix-editor/helix/pull/1241)) +- ui: popup: Don't allow scrolling past the end of content (3307f44c) +- Open files with spaces in filename, allow opening multiple files ([#1231](https://github.com/helix-editor/helix/pull/1231)) +- Allow paste commands to take a count ([#1261](https://github.com/helix-editor/helix/pull/1261)) +- Auto pairs selection ([#1254](https://github.com/helix-editor/helix/pull/1254)) +- Use a fuzzy matcher for commands ([#1386](https://github.com/helix-editor/helix/pull/1386)) +- Add c-s to pick word under doc cursor to prompt line & search completion ([#831](https://github.com/helix-editor/helix/pull/831)) +- Fix :earlier/:later missing changeset update ([#1069](https://github.com/helix-editor/helix/pull/1069)) +- Support extend for multiple goto ([#909](https://github.com/helix-editor/helix/pull/909)) +- Add arrow-key bindings for window switching ([#933](https://github.com/helix-editor/helix/pull/933)) +- Implement key ordering for info box ([#952](https://github.com/helix-editor/helix/pull/952)) + +LSP: +- Implement MarkedString rendering (e128a8702) +- Don't panic if init fails (d31bef7) +- Configurable diagnostic severity ([#1325](https://github.com/helix-editor/helix/pull/1325)) +- Resolve completion item ([#1315](https://github.com/helix-editor/helix/pull/1315)) +- Code action command support ([#1304](https://github.com/helix-editor/helix/pull/1304)) + +Grammars: + +- Adds mint language server ([#974](https://github.com/helix-editor/helix/pull/974)) +- Perl ([#978](https://github.com/helix-editor/helix/pull/978)) ([#1280](https://github.com/helix-editor/helix/pull/1280)) +- GLSL ([#993](https://github.com/helix-editor/helix/pull/993)) +- Racket ([#1143](https://github.com/helix-editor/helix/pull/1143)) +- WGSL ([#1166](https://github.com/helix-editor/helix/pull/1166)) +- LLVM ([#1167](https://github.com/helix-editor/helix/pull/1167)) ([#1388](https://github.com/helix-editor/helix/pull/1388)) ([#1409](https://github.com/helix-editor/helix/pull/1409)) ([#1398](https://github.com/helix-editor/helix/pull/1398)) +- Markdown (49e06787) +- Scala ([#1278](https://github.com/helix-editor/helix/pull/1278)) +- Dart ([#1250](https://github.com/helix-editor/helix/pull/1250)) +- Fish ([#1308](https://github.com/helix-editor/helix/pull/1308)) +- Dockerfile ([#1303](https://github.com/helix-editor/helix/pull/1303)) +- Git (commit, rebase, diff) ([#1338](https://github.com/helix-editor/helix/pull/1338)) ([#1402](https://github.com/helix-editor/helix/pull/1402)) ([#1373](https://github.com/helix-editor/helix/pull/1373)) +- tree-sitter-comment ([#1300](https://github.com/helix-editor/helix/pull/1300)) +- Highlight comments in c, cpp, cmake and llvm ([#1309](https://github.com/helix-editor/helix/pull/1309)) +- Improve yaml syntax highlighting highlighting ([#1294](https://github.com/helix-editor/helix/pull/1294)) +- Improve rust syntax highlighting ([#1295](https://github.com/helix-editor/helix/pull/1295)) +- Add textobjects and indents to cmake ([#1307](https://github.com/helix-editor/helix/pull/1307)) +- Add textobjects and indents to c and cpp ([#1293](https://github.com/helix-editor/helix/pull/1293)) + +New themes: + +- Solarized dark ([#999](https://github.com/helix-editor/helix/pull/999)) +- Solarized light ([#1010](https://github.com/helix-editor/helix/pull/1010)) +- Spacebones light ([#1131](https://github.com/helix-editor/helix/pull/1131)) +- Monokai Pro ([#1206](https://github.com/helix-editor/helix/pull/1206)) +- Base16 Light and Terminal ([#1078](https://github.com/helix-editor/helix/pull/1078)) + - and a default 16 color theme, truecolor detection +- Dracula ([#1258](https://github.com/helix-editor/helix/pull/1258)) + # 0.5.0 (2021-10-28) A big shout out to all the contributors! We had 46 contributors in this release. diff --git a/Cargo.lock b/Cargo.lock index 89c6388ee..4234c3b5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.51" +version = "1.0.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b26702f315f53b6071259e15dd9d64528213b44d61de1ec926eca7715d62203" +checksum = "94a45b455c14666b85fc40a019e8ab9eb75e3a124e05494f5397122bc9eb06e0" [[package]] name = "arc-swap" @@ -78,9 +78,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chardetng" -version = "0.1.15" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83ee29c16b81c32fbc882ecc568305793338a8353952573db837f4f4a6cd5c2e" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" dependencies = [ "cfg-if", "encoding_rs", @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "4.2.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db8340083d28acb43451166543b98c838299b7e0863621be53a338adceea0ed" +checksum = "2f3e1238132dc01f081e1cbb9dace14e5ef4c3a51ee244bd982275fb514605db" dependencies = [ "error-code", "str-buf", @@ -121,9 +121,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db" +checksum = "b5e5bed1f1c269533fa816a0a5492b3545209a205ca1a54842be180eb63a16a6" dependencies = [ "cfg-if", "lazy_static", @@ -131,16 +131,16 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.22.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85525306c4291d1b73ce93c8acf9c339f9b213aef6c1d85c3830cbf1c16325c" +checksum = "77b75a27dc8d220f1f8521ea69cd55a34d720a200ebb3a624d9aa19193d3b432" dependencies = [ "bitflags", "crossterm_winapi", "futures-core", "libc", "mio", - "parking_lot", + "parking_lot 0.12.0", "signal-hook", "signal-hook-mio", "winapi", @@ -184,9 +184,9 @@ checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" [[package]] name = "encoding_rs" -version = "0.8.29" +version = "0.8.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a74ea89a0a1b98f6332de42c95baff457ada66d1cb4030f9ff151b2041a1c746" +checksum = "7896dc8abb250ffdda33912550faa54c88ec8b998dec0b2c55ab224921ce11df" dependencies = [ "cfg-if", ] @@ -202,9 +202,9 @@ dependencies = [ [[package]] name = "error-code" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5115567ac25674e0043e472be13d14e537f37ea8aa4bdc4aef0c89add1db1ff" +checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" dependencies = [ "libc", "str-buf", @@ -246,27 +246,17 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9c1ce3fa9336301af935ab852c437817d14cd33690446569392e65170aac3b" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures-core" -version = "0.3.18" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629316e42fe7c2a0b9a65b47d159ceaa5453ab14e8f0a3c5eedbb8cd55b4a445" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" [[package]] name = "futures-executor" -version = "0.3.18" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b808bf53348a36cab739d7e04755909b9fcaaa69b7d7e588b37b6ec62704c97" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" dependencies = [ "futures-core", "futures-task", @@ -275,15 +265,15 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.18" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" [[package]] name = "futures-util" -version = "0.3.18" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d22213122356472061ac0f1ab2cee28d2bac8491410fd68c2af53d1cedb83e" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" dependencies = [ "futures-core", "futures-task", @@ -303,9 +293,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" dependencies = [ "cfg-if", "libc", @@ -366,9 +356,11 @@ dependencies = [ [[package]] name = "helix-core" -version = "0.5.0" +version = "0.6.0" dependencies = [ "arc-swap", + "chrono", + "encoding_rs", "etcetera", "helix-syntax", "log", @@ -379,8 +371,9 @@ dependencies = [ "serde", "serde_json", "similar", + "slotmap", "smallvec", - "tendril", + "smartstring", "toml", "tree-sitter", "unicode-general-category", @@ -390,7 +383,7 @@ dependencies = [ [[package]] name = "helix-dap" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "fern", @@ -404,7 +397,7 @@ dependencies = [ [[package]] name = "helix-lsp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "futures-executor", @@ -422,7 +415,7 @@ dependencies = [ [[package]] name = "helix-syntax" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "cc", @@ -433,7 +426,7 @@ dependencies = [ [[package]] name = "helix-term" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "chrono", @@ -465,7 +458,7 @@ dependencies = [ [[package]] name = "helix-tui" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bitflags", "cassowary", @@ -478,14 +471,13 @@ dependencies = [ [[package]] name = "helix-view" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "bitflags", "chardetng", "clipboard-win", "crossterm", - "encoding_rs", "futures-util", "helix-core", "helix-dap", @@ -551,9 +543,9 @@ dependencies = [ [[package]] name = "itoa" -version = "0.4.8" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" [[package]] name = "jsonrpc-core" @@ -576,15 +568,15 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.104" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2f96d100e1cf1929e7719b7edb3b90ab5298072638fccd77be9ce942ecdfce" +checksum = "e74d72e0f9b65b5b4ca49a346af3976df0f9c61d550727f349ecd559f251a26c" [[package]] name = "libloading" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afe203d669ec979b7128619bae5a63b7b42e9203c1b29146079ee05e2f604b52" +checksum = "efbc0f03f9a775e9f6aed295c6a1ba2253c5757a9e03d55c6caa46a681abcddd" dependencies = [ "cfg-if", "winapi", @@ -592,9 +584,9 @@ dependencies = [ [[package]] name = "lock_api" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109" +checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" dependencies = [ "scopeguard", ] @@ -610,9 +602,9 @@ dependencies = [ [[package]] name = "lsp-types" -version = "0.91.1" +version = "0.92.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2368312c59425dd133cb9a327afee65be0a633a8ce471d248e2202a48f8f68ae" +checksum = "e8a69d4142d51b208c9fc3cea68b1a7fcef30354e7aa6ccad07250fd8430fc76" dependencies = [ "bitflags", "serde", @@ -621,12 +613,6 @@ dependencies = [ "url", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "matches" version = "0.1.9" @@ -670,12 +656,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" - [[package]] name = "ntapi" version = "0.3.6" @@ -706,9 +686,9 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" dependencies = [ "hermit-abi", "libc", @@ -716,9 +696,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" +checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5" [[package]] name = "parking_lot" @@ -728,7 +708,17 @@ checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" dependencies = [ "instant", "lock_api", - "parking_lot_core", + "parking_lot_core 0.8.5", +] + +[[package]] +name = "parking_lot" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.1", ] [[package]] @@ -745,6 +735,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking_lot_core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28141e0cc4143da2443301914478dc976a61ffdb3f043058310c70df2fed8954" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -753,9 +756,9 @@ checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" [[package]] name = "pin-project-lite" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443" +checksum = "e280fbe77cc62c91527259e9442153f4688736748d24660126286329742b4c6c" [[package]] name = "pin-utils" @@ -765,18 +768,18 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "proc-macro2" -version = "1.0.30" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc3358ebc67bc8b7fa0c007f945b0b18226f78437d61bec735a9eb96b61ee70" +checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" dependencies = [ "unicode-xid", ] [[package]] name = "pulldown-cmark" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffade02495f22453cd593159ea2f59827aae7f53fa8323f756799b670881dcf8" +checksum = "34f197a544b0c9ab3ae46c359a7ec9cbbb5c7bf97054266fecb7ead794a181d6" dependencies = [ "bitflags", "memchr", @@ -794,9 +797,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.10" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" +checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145" dependencies = [ "proc-macro2", ] @@ -863,18 +866,18 @@ checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" [[package]] name = "ropey" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150aff6deb25b20ed110889f070a678bcd1033e46e5e9d6fb1abeab17947f28" +checksum = "e6b9aa65bcd9f308d37c7158b4a1afaaa32b8450213e20c9b98e7d5b3cc2fec3" dependencies = [ "smallvec", ] [[package]] name = "ryu" -version = "1.0.5" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" [[package]] name = "same-file" @@ -893,18 +896,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.130" +version = "1.0.136" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" +checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.130" +version = "1.0.136" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" +checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" dependencies = [ "proc-macro2", "quote", @@ -913,9 +916,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.72" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0ffa0837f2dfa6fb90868c2b5468cad482e175f7dad97e7421951e663f2b527" +checksum = "d23c1ba4cf0efd44be32017709280b32d1cea5c3f1275c3b6d9e8bc54f758085" dependencies = [ "itoa", "ryu", @@ -935,9 +938,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.10" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c98891d737e271a2954825ef19e46bd16bdb98e2746f2eec4f7a4ef7946efd1" +checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d" dependencies = [ "libc", "signal-hook-registry", @@ -965,9 +968,9 @@ dependencies = [ [[package]] name = "signal-hook-tokio" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c5d32165ff8b94e68e7b3bdecb1b082e958c22434b363482cfb89dcd6f3ff8" +checksum = "213241f76fb1e37e27de3b6aa1b068a2c333233b59cca6634f634b80a27ecf1e" dependencies = [ "futures-core", "libc", @@ -998,9 +1001,24 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" +checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" + +[[package]] +name = "smartstring" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa6a31c0c2b21327ce875f7e8952322acfcfd0c27569a6e18a647281352c9b" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "str-buf" @@ -1010,26 +1028,15 @@ checksum = "d44a3643b4ff9caf57abcee9c2c621d6c03d9135e0d8b589bd9afb5992cb176a" [[package]] name = "syn" -version = "1.0.80" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d010a1623fbd906d51d650a9916aaefc05ffa0e4053ff7fe601167f3e715d194" +checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b" dependencies = [ "proc-macro2", "quote", "unicode-xid", ] -[[package]] -name = "tendril" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9ef557cb397a4f0a5a3a628f06515f78563f2209e64d47055d9dc6052bf5e33" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "thiserror" version = "1.0.30" @@ -1052,9 +1059,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd" +checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" dependencies = [ "once_cell", ] @@ -1070,9 +1077,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83b2a3d4d9091d0abd7eba4dc2710b1718583bd4d8992e2190720ea38f391f7" +checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2" dependencies = [ "tinyvec_macros", ] @@ -1085,18 +1092,17 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.14.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e992e41e0d2fb9f755b37446f20900f64446ef54874f40a60c78f021ac6144" +checksum = "0c27a64b625de6d309e8c57716ba93021dccf1b3b5c97edd6d3dd2d2135afc0a" dependencies = [ - "autocfg", "bytes", "libc", "memchr", "mio", "num_cpus", "once_cell", - "parking_lot", + "parking_lot 0.11.2", "pin-project-lite", "signal-hook-registry", "tokio-macros", @@ -1105,9 +1111,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9efc1aba077437943f7515666aa2b882dfabfbfdf89c819ea75a8d6e9eaba5e" +checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7" dependencies = [ "proc-macro2", "quote", @@ -1136,9 +1142,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.20.1" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9394e9dbfe967b5f3d6ab79e302e78b5fb7b530c368d634ff3b8d67ede138bf1" +checksum = "4e34327f8eac545e3f037382471b2b19367725a242bba7bc45edb9efb49fe39a" dependencies = [ "cc", "regex", @@ -1161,9 +1167,9 @@ checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" [[package]] name = "unicode-general-category" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07547e3ee45e28326cc23faac56d44f58f16ab23e413db526debce3b0bfd2742" +checksum = "1218098468b8085b19a2824104c70d976491d247ce194bbd9dc77181150cdfd6" [[package]] name = "unicode-normalization" @@ -1176,9 +1182,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" +checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" [[package]] name = "unicode-width" @@ -1205,17 +1211,11 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "version_check" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "walkdir" @@ -1236,9 +1236,9 @@ checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] name = "which" -version = "4.2.2" +version = "4.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea187a8ef279bc014ec368c27a920da2024d2a711109bfbe3440585d5cf27ad9" +checksum = "2a5a7e487e921cf220206864a94a89b6c6905bfc19f1057fa26a4cb360e5c1d2" dependencies = [ "either", "lazy_static", @@ -1275,3 +1275,55 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df6e476185f92a12c072be4a189a0210dcdcf512a1891d6dff9edb874deadc6" +dependencies = [ + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5" + +[[package]] +name = "windows_i686_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615" + +[[package]] +name = "windows_i686_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316" + +[[package]] +name = "xtask" +version = "0.6.0" +dependencies = [ + "helix-core", + "helix-term", + "toml", +] diff --git a/Cargo.toml b/Cargo.toml index 6c360ffda..36dcb09f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "helix-syntax", "helix-lsp", "helix-dap", + "xtask", ] # Build helix-syntax in release mode to make the code path faster in development. @@ -18,3 +19,4 @@ split-debuginfo = "unpacked" [profile.release] lto = "thin" +# debug = true diff --git a/README.md b/README.md index 3f4087b9f..71010cc82 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ cargo install --path helix-term This will install the `hx` binary to `$HOME/.cargo/bin`. Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the -config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overriden -via the `HELIX_RUNTIME` environment variable. +config directory (for example `~/.config/helix/runtime` on Linux/macOS, or `%AppData%/helix/runtime` on Windows). +This location can be overriden via the `HELIX_RUNTIME` environment variable. Packages already solve this for you by wrapping the `hx` binary with a wrapper that sets the variable to the install dir. @@ -65,21 +65,7 @@ brew install helix # Contributing -Contributors are very welcome! **No contribution is too small and all contributions are valued.** - -Some suggestions to get started: - -- 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!")`) - * Pass the appropriate verbosity level option for the desired log level. (`hx -v ` for info, more `v`s for higher severity inclusive) -- If your preferred language is missing, integrating a tree-sitter grammar for - it and defining syntax highlight queries for it is straight forward and - doesn't require much knowledge of the internals. - -We provide an [architecture.md](./docs/architecture.md) that should give you -a good overview of the internals. +Contributing guidelines can be found [here](./docs/CONTRIBUTING.md). # Getting help diff --git a/TODO.md b/TODO.md index 80a9be05e..ab94cf9a0 100644 --- a/TODO.md +++ b/TODO.md @@ -1,25 +1,12 @@ -- tree sitter: - - markdown - - regex - - kotlin - - clojure - - erlang - - [ ] completion isIncomplete support - -1 - [ ] respect view fullscreen flag - [ ] Implement marks (superset of Selection/Range) - [ ] = for auto indent line/selection -- [ ] :x for closing buffers - [ ] lsp: signature help 2 -- [ ] macro recording -- [ ] extend selection (treesitter select parent node) (replaces viw, vi(, va( etc ) -- [ ] selection align - [ ] store some state between restarts: file positions, prompt history - [ ] highlight matched characters in picker diff --git a/base16_theme.toml b/base16_theme.toml new file mode 100644 index 000000000..42e02a98a --- /dev/null +++ b/base16_theme.toml @@ -0,0 +1,51 @@ +# Author: NNB + +"ui.menu" = "black" +"ui.menu.selected" = { modifiers = ["reversed"] } +"ui.linenr" = { fg = "gray", bg = "black" } +"ui.popup" = { modifiers = ["reversed"] } +"ui.linenr.selected" = { fg = "white", bg = "black", modifiers = ["bold"] } +"ui.selection" = { fg = "black", bg = "blue" } +"ui.selection.primary" = { fg = "white", bg = "blue" } +"comment" = { fg = "gray" } +"ui.statusline" = { fg = "black", bg = "white" } +"ui.statusline.inactive" = { fg = "gray", bg = "white" } +"ui.help" = { modifiers = ["reversed"] } +"ui.cursor" = { fg = "white", modifiers = ["reversed"] } +"variable" = "red" +"constant.numeric" = "yellow" +"constant" = "yellow" +"attributes" = "yellow" +"type" = "yellow" +"ui.cursor.match" = { fg = "yellow", modifiers = ["underlined"] } +"string" = "green" +"variable.other.member" = "green" +"constant.character.escape" = "cyan" +"function" = "blue" +"constructor" = "blue" +"special" = "blue" +"keyword" = "magenta" +"label" = "magenta" +"namespace" = "magenta" +"ui.help" = { fg = "white", bg = "black" } + +"markup.heading" = "blue" +"markup.list" = "red" +"markup.bold" = { fg = "yellow", modifiers = ["bold"] } +"markup.italic" = { fg = "magenta", modifiers = ["italic"] } +"markup.link.url" = { fg = "yellow", modifiers = ["underlined"] } +"markup.link.text" = "red" +"markup.quote" = "cyan" +"markup.raw" = "green" + +"diff.plus" = "green" +"diff.delta" = "yellow" +"diff.minus" = "red" + +"diagnostic" = { modifiers = ["underlined"] } +"ui.gutter" = { bg = "black" } +"info" = "blue" +"hint" = "gray" +"debug" = "gray" +"warning" = "yellow" +"error" = "red" diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 8cadb663f..a8f165c01 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -2,10 +2,12 @@ - [Installation](./install.md) - [Usage](./usage.md) + - [Keymap](./keymap.md) + - [Commands](./commands.md) + - [Language Support](./lang-support.md) - [Migrating from Vim](./from-vim.md) - [Configuration](./configuration.md) - [Themes](./themes.md) - - [Keymap](./keymap.md) - [Key Remapping](./remapping.md) - [Hooks](./hooks.md) - [Languages](./languages.md) diff --git a/book/src/commands.md b/book/src/commands.md new file mode 100644 index 000000000..4c4a5c05c --- /dev/null +++ b/book/src/commands.md @@ -0,0 +1,5 @@ +# Commands + +Command mode can be activated by pressing `:`, similar to vim. Built-in commands: + +{{#include ./generated/typable-cmd.md}} diff --git a/book/src/configuration.md b/book/src/configuration.md index 2ed48d51f..8048f5484 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -5,9 +5,27 @@ To override global configuration parameters, create a `config.toml` file located * Linux and Mac: `~/.config/helix/config.toml` * Windows: `%AppData%\helix\config.toml` +Example config: + +```toml +theme = "onedark" + +[editor] +line-number = "relative" +mouse = false + +[editor.cursor-shape] +insert = "bar" +normal = "block" +select = "underline" + +[editor.file-picker] +hidden = false +``` + ## Editor -`[editor]` section of the config. +### `[editor]` Section | Key | Description | Default | |--|--|---------| @@ -16,15 +34,37 @@ To override global configuration parameters, create a `config.toml` file located | `middle-click-paste` | Middle click paste support. | `true` | | `scroll-lines` | Number of lines to scroll per scroll wheel step. | `3` | | `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` | -| `line-number` | Line number display (`absolute`, `relative`) | `absolute` | +| `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` | | `smart-case` | Enable smart case regex searching (case insensitive unless pattern contains upper case characters) | `true` | | `auto-pairs` | Enable automatic insertion of pairs to parenthese, brackets, etc. | `true` | | `auto-completion` | Enable automatic pop up of auto-completion. | `true` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `auto-info` | Whether to display infoboxes | `true` | +| `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. | `false` | + +### `[editor.cursor-shape]` Section + +Defines the shape of cursor in each mode. Note that due to limitations +of the terminal environment, only the primary cursor can change shape. + +| Key | Description | Default | +| --- | ----------- | ------- | +| `normal` | Cursor shape in [normal mode][normal mode] | `block` | +| `insert` | Cursor shape in [insert mode][insert mode] | `block` | +| `select` | Cursor shape in [select mode][select mode] | `block` | + +[normal mode]: ./keymap.md#normal-mode +[insert mode]: ./keymap.md#insert-mode +[select mode]: ./keymap.md#select--extend-mode + +### `[editor.file-picker]` Section -`[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. +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 | |--|--|---------| diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md new file mode 100644 index 000000000..64dab6d33 --- /dev/null +++ b/book/src/generated/lang-support.md @@ -0,0 +1,63 @@ +| Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default LSP | +| --- | --- | --- | --- | --- | +| bash | ✓ | | | `bash-language-server` | +| c | ✓ | ✓ | ✓ | `clangd` | +| c-sharp | ✓ | | | | +| cmake | ✓ | ✓ | ✓ | `cmake-language-server` | +| comment | ✓ | | | | +| cpp | ✓ | ✓ | ✓ | `clangd` | +| css | ✓ | | | | +| dart | ✓ | | ✓ | `dart` | +| dockerfile | ✓ | | | `docker-langserver` | +| elixir | ✓ | | | `elixir-ls` | +| elm | ✓ | | | `elm-language-server` | +| fish | ✓ | ✓ | ✓ | | +| git-commit | ✓ | | | | +| git-config | ✓ | | | | +| git-diff | ✓ | | | | +| git-rebase | ✓ | | | | +| glsl | ✓ | | ✓ | | +| go | ✓ | ✓ | ✓ | `gopls` | +| graphql | ✓ | | | | +| haskell | ✓ | | | `haskell-language-server-wrapper` | +| html | ✓ | | | | +| iex | ✓ | | | | +| java | ✓ | | | | +| javascript | ✓ | | ✓ | `typescript-language-server` | +| json | ✓ | | ✓ | | +| julia | ✓ | | | `julia` | +| latex | ✓ | | | | +| lean | ✓ | | | `lean` | +| ledger | ✓ | | | | +| llvm | ✓ | ✓ | ✓ | | +| llvm-mir | ✓ | ✓ | ✓ | | +| llvm-mir-yaml | ✓ | | ✓ | | +| lua | ✓ | | ✓ | | +| make | ✓ | | | | +| markdown | ✓ | | | | +| mint | | | | `mint` | +| nix | ✓ | | ✓ | `rnix-lsp` | +| ocaml | ✓ | | ✓ | | +| ocaml-interface | ✓ | | | | +| perl | ✓ | ✓ | ✓ | | +| php | ✓ | ✓ | ✓ | | +| prolog | | | | `swipl` | +| protobuf | ✓ | | ✓ | | +| python | ✓ | ✓ | ✓ | `pylsp` | +| racket | | | | `racket` | +| regex | ✓ | | | | +| rescript | ✓ | ✓ | | `rescript-language-server` | +| ruby | ✓ | | ✓ | `solargraph` | +| rust | ✓ | ✓ | ✓ | `rust-analyzer` | +| scala | ✓ | | ✓ | `metals` | +| svelte | ✓ | | ✓ | `svelteserver` | +| tablegen | ✓ | ✓ | ✓ | | +| toml | ✓ | | | | +| tsq | ✓ | | | | +| tsx | ✓ | | | `typescript-language-server` | +| twig | ✓ | | | | +| typescript | ✓ | | ✓ | `typescript-language-server` | +| vue | ✓ | | | | +| wgsl | ✓ | | | | +| yaml | ✓ | | ✓ | | +| zig | ✓ | | ✓ | `zls` | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md new file mode 100644 index 000000000..aed75cbd1 --- /dev/null +++ b/book/src/generated/typable-cmd.md @@ -0,0 +1,48 @@ +| Name | Description | +| --- | --- | +| `:quit`, `:q` | Close the current view. | +| `:quit!`, `:q!` | Close the current view forcefully (ignoring unsaved changes). | +| `:open`, `:o` | Open a file from disk into the current view. | +| `:buffer-close`, `:bc`, `:bclose` | Close the current buffer. | +| `:buffer-close!`, `:bc!`, `:bclose!` | Close the current buffer forcefully (ignoring unsaved changes). | +| `:write`, `:w` | Write changes to disk. Accepts an optional path (:write some/path.txt) | +| `:new`, `:n` | Create a new scratch buffer. | +| `:format`, `:fmt` | Format the file using the LSP formatter. | +| `:indent-style` | Set the indentation style for editing. ('t' for tabs or 1-8 for number of spaces.) | +| `:line-ending` | Set the document's default line ending. Options: crlf, lf, cr, ff, nel. | +| `:earlier`, `:ear` | Jump back to an earlier point in edit history. Accepts a number of steps or a time span. | +| `:later`, `:lat` | Jump to a later point in edit history. Accepts a number of steps or a time span. | +| `:write-quit`, `:wq`, `:x` | Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt) | +| `:write-quit!`, `:wq!`, `:x!` | Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt) | +| `:write-all`, `:wa` | Write changes from all views to disk. | +| `:write-quit-all`, `:wqa`, `:xa` | Write changes from all views to disk and close all views. | +| `:write-quit-all!`, `:wqa!`, `:xa!` | Write changes from all views to disk and close all views forcefully (ignoring unsaved changes). | +| `:quit-all`, `:qa` | Close all views. | +| `:quit-all!`, `:qa!` | Close all views forcefully (ignoring unsaved changes). | +| `:cquit`, `:cq` | Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2). | +| `:cquit!`, `:cq!` | Quit with exit code (default 1) forcefully (ignoring unsaved changes). Accepts an optional integer exit code (:cq! 2). | +| `:theme` | Change the editor theme. | +| `:clipboard-yank` | Yank main selection into system clipboard. | +| `:clipboard-yank-join` | Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline. | +| `:primary-clipboard-yank` | Yank main selection into system primary clipboard. | +| `:primary-clipboard-yank-join` | Yank joined selections into system primary clipboard. A separator can be provided as first argument. Default value is newline. | +| `:clipboard-paste-after` | Paste system clipboard after selections. | +| `:clipboard-paste-before` | Paste system clipboard before selections. | +| `:clipboard-paste-replace` | Replace selections with content of system clipboard. | +| `:primary-clipboard-paste-after` | Paste primary clipboard after selections. | +| `:primary-clipboard-paste-before` | Paste primary clipboard before selections. | +| `:primary-clipboard-paste-replace` | Replace selections with content of system primary clipboard. | +| `:show-clipboard-provider` | Show clipboard provider name in status bar. | +| `:change-current-directory`, `:cd` | Change the current working directory. | +| `:show-directory`, `:pwd` | Show the current working directory. | +| `:encoding` | Set encoding based on `https://encoding.spec.whatwg.org` | +| `:reload` | Discard changes and reload from the source file. | +| `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. | +| `:vsplit`, `:vs` | Open the file in a vertical split. | +| `:hsplit`, `:hs`, `:sp` | Open the file in a horizontal split. | +| `:tutor` | Open the tutorial. | +| `:goto`, `:g` | Go to line number. | +| `:set-option`, `:set` | Set a config option at runtime | +| `:sort` | Sort ranges in selection. | +| `:rsort` | Sort ranges in selection in reverse order. | +| `:tree-sitter-subtree`, `:ts-subtree` | Display tree sitter subtree under cursor, primarily for debugging queries. | diff --git a/book/src/guides/adding_languages.md b/book/src/guides/adding_languages.md index 446eb479d..5844a48ee 100644 --- a/book/src/guides/adding_languages.md +++ b/book/src/guides/adding_languages.md @@ -2,7 +2,7 @@ ## Submodules -To add a new langauge, you should first add a tree-sitter submodule. To do this, +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- @@ -27,22 +27,32 @@ 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 | +| 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 | +| diagnostic-severity | Minimal severity of diagnostic for it to be displayed. (Allowed values: `Error`, `Warning`, `Info`, `Hint`) | +| comment-token | The token to use as a comment-token | +| indent | The indent to use. Has sub keys `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. +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. + +> NOTE: When evaluating queries, the first matching query takes +precedence, which is different from other editors like neovim where +the last matching query supercedes the ones before it. See +[this issue][neovim-query-precedence] for an example. ## Common Issues @@ -58,3 +68,4 @@ For a language to have syntax-highlighting and indentation among other things, y [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 +[neovim-query-precedence]: https://github.com/helix-editor/helix/pull/1170#issuecomment-997294090 diff --git a/book/src/install.md b/book/src/install.md index d831934c4..1a5a9daa9 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -27,6 +27,15 @@ Releases are available in the `community` repository. A [helix-git](https://aur.archlinux.org/packages/helix-git/) package is also available on the AUR, which builds the master branch. +### Fedora Linux + +You can install the COPR package for Helix via + +``` +sudo dnf copr enable varlad/helix +sudo dnf install helix +``` + ## Build from source ``` diff --git a/book/src/keymap.md b/book/src/keymap.md index 865a700b4..19fd21bbc 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -25,7 +25,9 @@ | `f` | Find next char | `find_next_char` | | `T` | Find 'till previous char | `till_prev_char` | | `F` | Find previous char | `find_prev_char` | +| `G` | Go to line number `` | `goto_line` | | `Alt-.` | Repeat last motion (`f`, `t` or `m`) | `repeat_last_motion` | +| `Alt-:` | Ensures the selection is in forward direction | `ensure_selections_forward` | | `Home` | Move to the start of the line | `goto_line_start` | | `End` | Move to the end of the line | `goto_line_end` | | `PageUp` | Move page up | `page_up` | @@ -34,6 +36,7 @@ | `Ctrl-d` | Move half page down | `half_page_down` | | `Ctrl-i` | Jump forward on the jumplist | `jump_forward` | | `Ctrl-o` | Jump backward on the jumplist | `jump_backward` | +| `Ctrl-s` | Save the current selection to the jumplist | `save_selection` | | `v` | Enter [select (extend) mode](#select--extend-mode) | `select_mode` | | `g` | Enter [goto mode](#goto-mode) | N/A | | `m` | Enter [match mode](#match-mode) | N/A | @@ -45,37 +48,39 @@ ### 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` | -| `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` | +| 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 (currently nonfunctional/disabled) (**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` | +| `Q` | Start/stop macro recording to the selected register (experimental) | `record_macro` | +| `q` | Play back a recorded macro from the selected register (experimental) | `replay_macro` | #### Shell @@ -85,6 +90,7 @@ | 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` | +| `$` | Pipe each selection into shell command, keep selections where command returned 0 | `shell_keep_pipe` | ### Selection manipulation @@ -109,12 +115,14 @@ | `%` | 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 (**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` | +| `Alt-k` | Expand selection to parent syntax node (**TS**) | `expand_selection` | +| `Alt-j` | Shrink syntax tree object selection (**TS**) | `shrink_selection` | +| `Alt-h` | Select previous sibling node in syntax tree (**TS**) | `select_prev_sibling` | +| `Alt-l` | Select next sibling node in syntax tree (**TS**) | `select_next_sibling` | ### Search @@ -147,10 +155,10 @@ over text and not actively editing it). | `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` | +| `Ctrl-f` | Move page down | `page_down` | +| `Ctrl-b` | Move page up | `page_up` | +| `Ctrl-d` | Move half page down | `half_page_down` | +| `Ctrl-u` | Move half page up | `half_page_up` | #### Goto mode @@ -158,20 +166,21 @@ Jumps to various locations. | Key | Description | Command | | ----- | ----------- | ------- | -| `g` | Go to the start of the file | `goto_file_start` | +| `g` | Go to line number `` else start of 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 (**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` | diff --git a/book/src/lang-support.md b/book/src/lang-support.md new file mode 100644 index 000000000..3920f3424 --- /dev/null +++ b/book/src/lang-support.md @@ -0,0 +1,10 @@ +# Language Support + +For more information like arguments passed to default LSP server, +extensions assosciated with a filetype, custom LSP settings, filetype +specific indent settings, etc see the default +[`languages.toml`][languages.toml] file. + +{{#include ./generated/lang-support.md}} + +[languages.toml]: https://github.com/helix-editor/helix/blob/master/languages.toml diff --git a/book/src/languages.md b/book/src/languages.md index cef61501f..4c4dc326d 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -11,4 +11,3 @@ Changes made to the `languages.toml` file in a user's [configuration directory]( name = "rust" auto-format = false ``` - diff --git a/book/src/remapping.md b/book/src/remapping.md index fffd189b7..1cdf9b1f2 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -11,6 +11,8 @@ this: ```toml # At most one section each of 'keys.normal', 'keys.insert' and 'keys.select' [keys.normal] +C-s = ":w" # Maps the Control-s to the typable command :w which is an alias for :write (save file) +C-o = ":open ~/.config/helix/config.toml" # Maps the Control-o to opening of the helix config file 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 @@ -21,6 +23,7 @@ g = { a = "code_action" } # Maps `ga` to show possible code actions "A-x" = "normal_mode" # Maps Alt-X to enter normal mode j = { k = "normal_mode" } # Maps `jk` to exit insert mode ``` +> NOTE: Typable commands can also be remapped, remember to keep the `:` prefix to indicate it's a typable command. Control, Shift and Alt modifiers are encoded respectively with the prefixes `C-`, `S-` and `A-`. Special keys are encoded as follows: @@ -42,10 +45,9 @@ Control, Shift and Alt modifiers are encoded respectively with the prefixes | Down | `"down"` | | Home | `"home"` | | End | `"end"` | -| Page | `"pageup"` | -| Page | `"pagedown"` | +| Page Up | `"pageup"` | +| Page Down | `"pagedown"` | | Tab | `"tab"` | -| Back | `"backtab"` | | Delete | `"del"` | | Insert | `"ins"` | | Null | `"null"` | @@ -54,4 +56,4 @@ 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 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. +> 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 `static_commands!` macro and the `TypableCommandList`. diff --git a/book/src/themes.md b/book/src/themes.md index fd3f5b1eb..9abcfe8c1 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -1,14 +1,14 @@ # Themes -First you'll need to place selected themes in your `themes` directory (i.e `~/.config/helix/themes`), the directory might have to be created beforehand. +To use a theme add `theme = ""` to your [`config.toml`](./configuration.md) at the very top of the file before the first section or select it during runtime using `:theme `. -To use a custom theme add `theme = ` to your [`config.toml`](./configuration.md) or override it during runtime using `:theme `. +## Creating a theme -The default theme.toml can be found [here](https://github.com/helix-editor/helix/blob/master/theme.toml), and user submitted themes [here](https://github.com/helix-editor/helix/blob/master/runtime/themes). +Create a file with the name of your theme as file name (i.e `mytheme.toml`) and place it in your `themes` directory (i.e `~/.config/helix/themes`). The directory might have to be created beforehand. -## Creating a theme +The names "default" and "base16_default" are reserved for the builtin themes and cannot be overridden by user defined themes. -First create a file with the name of your theme as file name (i.e `mytheme.toml`) and place it in your `themes` directory (i.e `~/.config/helix/themes`). +The default theme.toml can be found [here](https://github.com/helix-editor/helix/blob/master/theme.toml), and user submitted themes [here](https://github.com/helix-editor/helix/blob/master/runtime/themes). Each line in the theme file is specified as below: @@ -105,6 +105,7 @@ We use a similar set of scopes as - `type` - Types - `builtin` - Primitive types provided by the language (`int`, `usize`) +- `constructor` - `constant` (TODO: constant.other.placeholder for %v) - `builtin` Special constants provided by the language (`true`, `false`, `nil` etc) @@ -146,6 +147,7 @@ We use a similar set of scopes as - `repeat` - `for`, `while`, `loop` - `import` - `import`, `export` - `return` + - `exception` - `operator` - `or`, `in` - `directive` - Preprocessor directives (`#if` in C) - `function` - `fn`, `func` @@ -162,10 +164,44 @@ We use a similar set of scopes as - `namespace` +- `markup` + - `heading` + - `list` + - `unnumbered` + - `numbered` + - `bold` + - `italic` + - `link` + - `url` - urls pointed to by links + - `label` - non-url link references + - `text` - url and image descriptions in links + - `quote` + - `raw` + - `inline` + - `block` + +- `diff` - version control changes + - `plus` - additions + - `minus` - deletions + - `delta` - modifications + - `moved` - renamed or moved files/changes + #### Interface These scopes are used for theming the editor interface. +- `markup` + - `normal` + - `completion` - for completion doc popup ui + - `hover` - for hover popup ui + - `heading` + - `completion` - for completion doc popup ui + - `hover` - for hover popup ui + - `raw` + - `inline` + - `completion` - for completion doc popup ui + - `hover` - for hover popup ui + | Key | Notes | | --- | --- | diff --git a/book/src/usage.md b/book/src/usage.md index cf7d9d488..a76bfafcc 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -42,7 +42,7 @@ helix. The keymappings have been inspired from [vim-sandwich](https://github.com `ms` acts on a selection, so select the text first and use `ms`. `mr` and `md` work on the closest pairs found and selections are not required; use counts to act in outer pairs. -It can also act on multiple seletions (yay!). For example, to change every occurance of `(use)` to `[use]`: +It can also act on multiple selections (yay!). For example, to change every occurrence of `(use)` to `[use]`: - `%` to select the whole file - `s` to split the selections on a search term diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 000000000..bdd771aaf --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing + +Contributors are very welcome! **No contribution is too small and all contributions are valued.** + +Some suggestions to get started: + +- You can look at the [good first issue][good-first-issue] label on the issue tracker. +- Help with packaging on various distributions needed! +- To use print debugging to the [Helix log file][log-file], you must: + * Print using `log::info!`, `warn!`, or `error!`. (`log::info!("helix!")`) + * Pass the appropriate verbosity level option for the desired log level. (`hx -v ` for info, more `v`s for higher severity inclusive) +- If your preferred language is missing, integrating a tree-sitter grammar for + it and defining syntax highlight queries for it is straight forward and + doesn't require much knowledge of the internals. + +We provide an [architecture.md][architecture.md] that should give you +a good overview of the internals. + +# Auto generated documentation + +Some parts of [the book][docs] are autogenerated from the code itself, +like the list of `:commands` and supported languages. To generate these +files, run + +```shell +cargo xtask docgen +``` + +inside the project. We use [xtask][xtask] as an ad-hoc task runner and +thus do not require any dependencies other than `cargo` (You don't have +to `cargo install` anything either). + +[good-first-issue]: https://github.com/helix-editor/helix/labels/E-easy +[log-file]: https://github.com/helix-editor/helix/wiki/FAQ#access-the-log-file +[architecture.md]: ./architecture.md +[docs]: https://docs.helix-editor.com/ +[xtask]: https://github.com/matklad/cargo-xtask diff --git a/flake.lock b/flake.lock index db0fface5..94e443e3a 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "devshell": { "locked": { - "lastModified": 1632436039, - "narHash": "sha256-OtITeVWcKXn1SpVEnImpTGH91FycCskGBPqmlxiykv4=", + "lastModified": 1641980203, + "narHash": "sha256-RiWJ3+6V267Ji+P54K1Xrj1Nsah9BfG/aLfIhqgVyBY=", "owner": "numtide", "repo": "devshell", - "rev": "7a7a7aa0adebe5488e5abaec688fd9ae0f8ea9c6", + "rev": "d897c1ddb4eab66cc2b783c7868d78555b9880ad", "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": { @@ -41,11 +41,11 @@ ] }, "locked": { - "lastModified": 1634796585, - "narHash": "sha256-CW4yx6omk5qCXUIwXHp/sztA7u0SpyLq9NEACPnkiz8=", + "lastModified": 1642054253, + "narHash": "sha256-kHh9VmaB7gbS6pheheC4x0uT84LEmhfbsbWEQJgU2E4=", "owner": "yusdacra", "repo": "nix-cargo-integration", - "rev": "a84a2137a396f303978f1d48341e0390b0e16a8b", + "rev": "f8fa9af990195a3f63fe2dde84aa187e193da793", "type": "github" }, "original": { @@ -56,11 +56,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1634782485, - "narHash": "sha256-psfh4OQSokGXG0lpq3zKFbhOo3QfoeudRcaUnwMRkQo=", + "lastModified": 1641887635, + "narHash": "sha256-kDGpufwzVaiGe5e1sBUBPo9f1YN+nYHJlYqCaVpZTQQ=", "owner": "nixos", "repo": "nixpkgs", - "rev": "34ad3ffe08adfca17fcb4e4a47bb5f3b113687be", + "rev": "b2737d4980a17cc2b7d600d7d0b32fd7333aca88", "type": "github" }, "original": { @@ -72,15 +72,16 @@ }, "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" } @@ -98,11 +99,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1634869268, - "narHash": "sha256-RVAcEFlFU3877Mm4q/nbXGEYTDg/wQNhzmXGMTV6wBs=", + "lastModified": 1642128126, + "narHash": "sha256-av8JUACdrTfQYl/ftZJvKpZEmZfa0avCq7tt5Usdoq0=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c02c2d86354327317546501af001886fbb53d374", + "rev": "ce4ef6f2d74f2b68f7547df1de22d1b0037ce4ad", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index cbf10c975..0d22c5c1a 100644 --- a/flake.nix +++ b/flake.nix @@ -20,50 +20,63 @@ # Set default package to helix-term release build defaultOutputs = { app = "hx"; package = "helix"; }; overrides = { - crateOverrides = common: _: { - helix-term = prev: { - # link languages and theme toml files since helix-term expects them (for tests) - preConfigure = "ln -s ${common.root}/{languages.toml,theme.toml} .."; - buildInputs = (prev.buildInputs or [ ]) ++ [ common.cCompiler.cc.lib ]; - }; + crateOverrides = common: _: rec { # link languages and theme toml files since helix-view expects them - helix-view = _: { preConfigure = "ln -s ${common.root}/{languages.toml,theme.toml} .."; }; - helix-syntax = _prev: { + helix-view = _: { preConfigure = "ln -s ${common.root}/{languages.toml,theme.toml,base16_theme.toml} .."; }; + helix-syntax = prev: { + src = + let + pkgs = common.pkgs; + helix = pkgs.fetchgit { + url = "https://github.com/helix-editor/helix.git"; + rev = "a8fd33ac012a79069ef1409503a2edcf3a585153"; + fetchSubmodules = true; + sha256 = "sha256-5AtOC55ttWT+7RYMboaFxpGZML51ix93wAkYJTt+8JI="; + }; + in + pkgs.runCommand prev.src.name { } '' + mkdir -p $out + ln -s ${prev.src}/* $out + ln -sf ${helix}/helix-syntax/languages $out + ''; preConfigure = "mkdir -p ../runtime/grammars"; postInstall = "cp -r ../runtime $out/runtime"; }; - }; - mainBuild = common: prev: - let - inherit (common) pkgs lib; - helixSyntax = lib.buildCrate { - root = self; - memberName = "helix-syntax"; - defaultCrateOverrides = { - helix-syntax = common.crateOverrides.helix-syntax; + helix-term = prev: + let + inherit (common) pkgs lib; + helixSyntax = lib.buildCrate { + root = self; + memberName = "helix-syntax"; + defaultCrateOverrides = { + helix-syntax = helix-syntax; + }; + release = false; }; - release = false; + runtimeDir = pkgs.runCommand "helix-runtime" { } '' + mkdir -p $out + ln -s ${common.root}/runtime/* $out + ln -sf ${helixSyntax}/runtime/grammars $out + ''; + in + { + # link languages and theme toml files since helix-term expects them (for tests) + preConfigure = "ln -s ${common.root}/{languages.toml,theme.toml,base16_theme.toml} .."; + buildInputs = (prev.buildInputs or [ ]) ++ [ common.cCompiler.cc.lib ]; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postFixup = '' + if [ -f "$out/bin/hx" ]; then + wrapProgram "$out/bin/hx" --set HELIX_RUNTIME "${runtimeDir}" + fi + ''; }; - runtimeDir = pkgs.runCommand "helix-runtime" { } '' - mkdir -p $out - ln -s ${common.root}/runtime/* $out - ln -sf ${helixSyntax}/runtime/grammars $out - ''; - in - lib.optionalAttrs (common.memberName == "helix-term") { - nativeBuildInputs = [ pkgs.makeWrapper ]; - postFixup = '' - if [ -f "$out/bin/hx" ]; then - wrapProgram "$out/bin/hx" --set HELIX_RUNTIME "${runtimeDir}" - fi - ''; - }; + }; shell = common: prev: { - packages = prev.packages ++ (with common.pkgs; [ lld_13 lldb cargo-tarpaulin ]); + packages = prev.packages ++ (with common.pkgs; [ lld_13 lldb cargo-tarpaulin cargo-flamegraph ]); env = prev.env ++ [ { name = "HELIX_RUNTIME"; eval = "$PWD/runtime"; } { name = "RUST_BACKTRACE"; value = "1"; } - { name = "RUSTFLAGS"; value = "-C link-arg=-fuse-ld=lld -C target-cpu=native"; } + { name = "RUSTFLAGS"; value = "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment"; } ]; }; }; diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index ea695d34a..7ff91cfda 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-core" -version = "0.5.0" +version = "0.6.0" authors = ["Blaž Hrastnik "] edition = "2021" license = "MPL-2.0" @@ -13,17 +13,18 @@ include = ["src/**/*", "README.md"] [features] [dependencies] -helix-syntax = { version = "0.5", path = "../helix-syntax" } +helix-syntax = { version = "0.6", path = "../helix-syntax" } ropey = "1.3" -smallvec = "1.7" -tendril = "0.4.2" -unicode-segmentation = "1.8" +smallvec = "1.8" +smartstring = "0.2.9" +unicode-segmentation = "1.9" unicode-width = "0.1" -unicode-general-category = "0.4" +unicode-general-category = "0.5" # slab = "0.4.2" +slotmap = "1.0" tree-sitter = "0.20" -once_cell = "1.8" +once_cell = "1.9" arc-swap = "1" regex = "1" @@ -35,6 +36,9 @@ toml = "0.5" similar = "2.1" etcetera = "0.3" +encoding_rs = "0.8" + +chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] } [dev-dependencies] quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index cc9668529..f4359a342 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -1,7 +1,10 @@ //! When typing the opening character of one of the possible pairs defined below, //! this module provides the functionality to insert the paired closing character. -use crate::{Range, Rope, Selection, Tendril, Transaction}; +use crate::{ + graphemes, movement::Direction, Range, Rope, RopeGraphemes, Selection, Tendril, Transaction, +}; +use log::debug; use smallvec::SmallVec; // Heavily based on https://github.com/codemirror/closebrackets/ @@ -15,7 +18,9 @@ pub const PAIRS: &[(char, char)] = &[ ('`', '`'), ]; -const CLOSE_BEFORE: &str = ")]}'\":;> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; // includes space and newlines +// [TODO] build this dynamically in language config. see #992 +const OPEN_BEFORE: &str = "([{'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; +const CLOSE_BEFORE: &str = ")]}'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; // includes space and newlines // insert hook: // Fn(doc, selection, char) => Option @@ -25,14 +30,19 @@ const CLOSE_BEFORE: &str = ")]}'\":;> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{202 // // to simplify, maybe return Option and just reimplement the default -// TODO: delete implementation where it erases the whole bracket (|) -> | +// [TODO] +// * delete implementation where it erases the whole bracket (|) -> | +// * change to multi character pairs to handle cases like placing the cursor in the +// middle of triple quotes, and more exotic pairs like Jinja's {% %} #[must_use] pub fn hook(doc: &Rope, selection: &Selection, ch: char) -> Option { + debug!("autopairs hook selection: {:#?}", selection); + for &(open, close) in PAIRS { if open == ch { if open == close { - return handle_same(doc, selection, open); + return Some(handle_same(doc, selection, open, CLOSE_BEFORE, OPEN_BEFORE)); } else { return Some(handle_open(doc, selection, open, close, CLOSE_BEFORE)); } @@ -47,18 +57,145 @@ pub fn hook(doc: &Rope, selection: &Selection, ch: char) -> Option None } -// TODO: special handling for lifetimes in rust: if preceeded by & or < don't auto close ' -// for example "&'a mut", or "fn<'a>" - -fn next_char(doc: &Rope, pos: usize) -> Option { - if pos >= doc.len_chars() { +fn prev_char(doc: &Rope, pos: usize) -> Option { + if pos == 0 { return None; } - Some(doc.char(pos)) + + doc.get_char(pos - 1) +} + +fn is_single_grapheme(doc: &Rope, range: &Range) -> bool { + let mut graphemes = RopeGraphemes::new(doc.slice(range.from()..range.to())); + let first = graphemes.next(); + let second = graphemes.next(); + debug!("first: {:#?}, second: {:#?}", first, second); + first.is_some() && second.is_none() +} + +/// calculate what the resulting range should be for an auto pair insertion +fn get_next_range( + doc: &Rope, + start_range: &Range, + offset: usize, + typed_char: char, + len_inserted: usize, +) -> Range { + // When the character under the cursor changes due to complete pair + // insertion, we must look backward a grapheme and then add the length + // of the insertion to put the resulting cursor in the right place, e.g. + // + // foo[\r\n] - anchor: 3, head: 5 + // foo([)]\r\n - anchor: 4, head: 5 + // + // foo[\r\n] - anchor: 3, head: 5 + // foo'[\r\n] - anchor: 4, head: 6 + // + // foo([)]\r\n - anchor: 4, head: 5 + // foo()[\r\n] - anchor: 5, head: 7 + // + // [foo]\r\n - anchor: 0, head: 3 + // [foo(])\r\n - anchor: 0, head: 5 + + // inserting at the very end of the document after the last newline + if start_range.head == doc.len_chars() && start_range.anchor == doc.len_chars() { + return Range::new( + start_range.anchor + offset + typed_char.len_utf8(), + start_range.head + offset + typed_char.len_utf8(), + ); + } + + let single_grapheme = is_single_grapheme(doc, start_range); + let doc_slice = doc.slice(..); + + // just skip over graphemes + if len_inserted == 0 { + let end_anchor = if single_grapheme { + graphemes::next_grapheme_boundary(doc_slice, start_range.anchor) + offset + + // even for backward inserts with multiple grapheme selections, + // we want the anchor to stay where it is so that the relative + // selection does not change, e.g.: + // + // foo([) wor]d -> insert ) -> foo()[ wor]d + } else { + start_range.anchor + offset + }; + + return Range::new( + end_anchor, + graphemes::next_grapheme_boundary(doc_slice, start_range.head) + offset, + ); + } + + // trivial case: only inserted a single-char opener, just move the selection + if len_inserted == 1 { + let end_anchor = if single_grapheme || start_range.direction() == Direction::Backward { + start_range.anchor + offset + typed_char.len_utf8() + } else { + start_range.anchor + offset + }; + + return Range::new( + end_anchor, + start_range.head + offset + typed_char.len_utf8(), + ); + } + + // If the head = 0, then we must be in insert mode with a backward + // cursor, which implies the head will just move + let end_head = if start_range.head == 0 || start_range.direction() == Direction::Backward { + start_range.head + offset + typed_char.len_utf8() + } else { + // We must have a forward cursor, which means we must move to the + // other end of the grapheme to get to where the new characters + // are inserted, then move the head to where it should be + let prev_bound = graphemes::prev_grapheme_boundary(doc_slice, start_range.head); + debug!( + "prev_bound: {}, offset: {}, len_inserted: {}", + prev_bound, offset, len_inserted + ); + prev_bound + offset + len_inserted + }; + + let end_anchor = match (start_range.len(), start_range.direction()) { + // if we have a zero width cursor, it shifts to the same number + (0, _) => end_head, + + // If we are inserting for a regular one-width cursor, the anchor + // moves with the head. This is the fast path for ASCII. + (1, Direction::Forward) => end_head - 1, + (1, Direction::Backward) => end_head + 1, + + (_, Direction::Forward) => { + if single_grapheme { + graphemes::prev_grapheme_boundary(doc.slice(..), start_range.head) + + typed_char.len_utf8() + + // if we are appending, the anchor stays where it is; only offset + // for multiple range insertions + } else { + start_range.anchor + offset + } + } + + (_, Direction::Backward) => { + if single_grapheme { + // if we're backward, then the head is at the first char + // of the typed char, so we need to add the length of + // the closing char + graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + len_inserted + } else { + // when we are inserting in front of a selection, we need to move + // the anchor over by however many characters were inserted overall + start_range.anchor + offset + len_inserted + } + } + }; + + Range::new(end_anchor, end_head) } -// TODO: selections should be extended if range, moved if point. -// TODO: if not cursor but selection, wrap on both sides of selection (surround) fn handle_open( doc: &Rope, selection: &Selection, @@ -66,98 +203,584 @@ fn handle_open( close: char, close_before: &str, ) -> Transaction { - let mut ranges = SmallVec::with_capacity(selection.len()); - + let mut end_ranges = SmallVec::with_capacity(selection.len()); let mut offs = 0; - let transaction = Transaction::change_by_selection(doc, selection, |range| { - let pos = range.head; - let next = next_char(doc, pos); - - let head = pos + offs + open.len_utf8(); - // if selection, retain anchor, if cursor, move over - ranges.push(Range::new( - if range.is_empty() { - head - } else { - range.anchor + offs - }, - head, - )); + let transaction = Transaction::change_by_selection(doc, selection, |start_range| { + let cursor = start_range.cursor(doc.slice(..)); + let next_char = doc.get_char(cursor); + let len_inserted; - match next { + let change = match next_char { Some(ch) if !close_before.contains(ch) => { - offs += 1; - // TODO: else return (use default handler that inserts open) - (pos, pos, Some(Tendril::from_char(open))) + len_inserted = open.len_utf8(); + let mut tendril = Tendril::new(); + tendril.push(open); + (cursor, cursor, Some(tendril)) } // None | Some(ch) if close_before.contains(ch) => {} _ => { // insert open & close - let mut pair = Tendril::with_capacity(2); - pair.push_char(open); - pair.push_char(close); + let pair = Tendril::from_iter([open, close]); + len_inserted = open.len_utf8() + close.len_utf8(); + (cursor, cursor, Some(pair)) + } + }; - offs += 2; + let next_range = get_next_range(doc, start_range, offs, open, len_inserted); + end_ranges.push(next_range); + offs += len_inserted; - (pos, pos, Some(pair)) - } - } + change }); - transaction.with_selection(Selection::new(ranges, selection.primary_index())) + let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index())); + debug!("auto pair transaction: {:#?}", t); + t } fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> Transaction { - let mut ranges = SmallVec::with_capacity(selection.len()); + let mut end_ranges = SmallVec::with_capacity(selection.len()); let mut offs = 0; - let transaction = Transaction::change_by_selection(doc, selection, |range| { - let pos = range.head; - let next = next_char(doc, pos); + let transaction = Transaction::change_by_selection(doc, selection, |start_range| { + let cursor = start_range.cursor(doc.slice(..)); + let next_char = doc.get_char(cursor); + let mut len_inserted = 0; - let head = pos + offs + close.len_utf8(); - // if selection, retain anchor, if cursor, move over - ranges.push(Range::new( - if range.is_empty() { - head - } else { - range.anchor + offs - }, - head, - )); + let change = if next_char == Some(close) { + // return transaction that moves past close + (cursor, cursor, None) // no-op + } else { + len_inserted += close.len_utf8(); + let mut tendril = Tendril::new(); + tendril.push(close); + (cursor, cursor, Some(tendril)) + }; + + let next_range = get_next_range(doc, start_range, offs, close, len_inserted); + end_ranges.push(next_range); + offs += len_inserted; + + change + }); + + let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index())); + debug!("auto pair transaction: {:#?}", t); + t +} + +/// handle cases where open and close is the same, or in triples ("""docstring""") +fn handle_same( + doc: &Rope, + selection: &Selection, + token: char, + close_before: &str, + open_before: &str, +) -> Transaction { + let mut end_ranges = SmallVec::with_capacity(selection.len()); + + let mut offs = 0; - if next == Some(close) { + let transaction = Transaction::change_by_selection(doc, selection, |start_range| { + let cursor = start_range.cursor(doc.slice(..)); + let mut len_inserted = 0; + + let next_char = doc.get_char(cursor); + let prev_char = prev_char(doc, cursor); + + let change = if next_char == Some(token) { // return transaction that moves past close - (pos, pos, None) // no-op + (cursor, cursor, None) // no-op } else { - offs += close.len_utf8(); + let mut pair = Tendril::new(); + pair.push(token); - // TODO: else return (use default handler that inserts close) - (pos, pos, Some(Tendril::from_char(close))) - } + // for equal pairs, don't insert both open and close if either + // side has a non-pair char + if (next_char.is_none() || close_before.contains(next_char.unwrap())) + && (prev_char.is_none() || open_before.contains(prev_char.unwrap())) + { + pair.push(token); + } + + len_inserted += pair.len(); + (cursor, cursor, Some(pair)) + }; + + let next_range = get_next_range(doc, start_range, offs, token, len_inserted); + end_ranges.push(next_range); + offs += len_inserted; + + change }); - transaction.with_selection(Selection::new(ranges, selection.primary_index())) + let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index())); + debug!("auto pair transaction: {:#?}", t); + t } -// handle cases where open and close is the same, or in triples ("""docstring""") -fn handle_same(_doc: &Rope, _selection: &Selection, _token: char) -> Option { - // if not cursor but selection, wrap - // let next = next char - - // if next == bracket { - // // if start of syntax node, insert token twice (new pair because node is complete) - // // elseif colsedBracketAt - // // is_triple == allow triple && next 3 is equal - // // cursor jump over - // } - //} else if allow_triple && followed by triple { - //} - //} else if next != word char && prev != bracket && prev != word char { - // // condition checks for cases like I' where you don't want I'' (or I'm) - // insert pair ("") - //} - None +#[cfg(test)] +mod test { + use super::*; + use smallvec::smallvec; + + const LINE_END: &str = crate::DEFAULT_LINE_ENDING.as_str(); + + fn differing_pairs() -> impl Iterator { + PAIRS.iter().filter(|(open, close)| open != close) + } + + fn matching_pairs() -> impl Iterator { + PAIRS.iter().filter(|(open, close)| open == close) + } + + fn test_hooks( + in_doc: &Rope, + in_sel: &Selection, + ch: char, + expected_doc: &Rope, + expected_sel: &Selection, + ) { + let trans = hook(in_doc, in_sel, ch).unwrap(); + let mut actual_doc = in_doc.clone(); + assert!(trans.apply(&mut actual_doc)); + assert_eq!(expected_doc, &actual_doc); + assert_eq!(expected_sel, trans.selection().unwrap()); + } + + fn test_hooks_with_pairs( + in_doc: &Rope, + in_sel: &Selection, + pairs: I, + get_expected_doc: F, + actual_sel: &Selection, + ) where + I: IntoIterator, + F: Fn(char, char) -> R, + R: Into, + Rope: From, + { + pairs.into_iter().for_each(|(open, close)| { + test_hooks( + in_doc, + in_sel, + *open, + &Rope::from(get_expected_doc(*open, *close)), + actual_sel, + ) + }); + } + + // [] indicates range + + /// [] -> insert ( -> ([]) + #[test] + fn test_insert_blank() { + test_hooks_with_pairs( + &Rope::from(LINE_END), + &Selection::single(1, 0), + PAIRS, + |open, close| format!("{}{}{}", open, close, LINE_END), + &Selection::single(2, 1), + ); + + let empty_doc = Rope::from(format!("{line_end}{line_end}", line_end = LINE_END)); + + test_hooks_with_pairs( + &empty_doc, + &Selection::single(empty_doc.len_chars(), LINE_END.len()), + PAIRS, + |open, close| { + format!( + "{line_end}{open}{close}{line_end}", + open = open, + close = close, + line_end = LINE_END + ) + }, + &Selection::single(LINE_END.len() + 2, LINE_END.len() + 1), + ); + } + + #[test] + fn test_insert_before_multi_code_point_graphemes() { + test_hooks_with_pairs( + &Rope::from(format!("hello 👨‍👩‍👧‍👦 goodbye{}", LINE_END)), + &Selection::single(13, 6), + PAIRS, + |open, _| format!("hello {}👨‍👩‍👧‍👦 goodbye{}", open, LINE_END), + &Selection::single(14, 7), + ); + } + + #[test] + fn test_insert_at_end_of_document() { + test_hooks_with_pairs( + &Rope::from(LINE_END), + &Selection::single(LINE_END.len(), LINE_END.len()), + PAIRS, + |open, close| format!("{}{}{}", LINE_END, open, close), + &Selection::single(LINE_END.len() + 1, LINE_END.len() + 1), + ); + + test_hooks_with_pairs( + &Rope::from(format!("foo{}", LINE_END)), + &Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()), + PAIRS, + |open, close| format!("foo{}{}{}", LINE_END, open, close), + &Selection::single(LINE_END.len() + 4, LINE_END.len() + 4), + ); + } + + /// [] -> append ( -> ([]) + #[test] + fn test_append_blank() { + test_hooks_with_pairs( + // this is what happens when you have a totally blank document and then append + &Rope::from(format!("{line_end}{line_end}", line_end = LINE_END)), + // before inserting the pair, the cursor covers all of both empty lines + &Selection::single(0, LINE_END.len() * 2), + PAIRS, + |open, close| { + format!( + "{line_end}{open}{close}{line_end}", + line_end = LINE_END, + open = open, + close = close + ) + }, + // after inserting pair, the cursor covers the first new line and the open char + &Selection::single(0, LINE_END.len() + 2), + ); + } + + /// [] ([]) + /// [] -> insert -> ([]) + /// [] ([]) + #[test] + fn test_insert_blank_multi_cursor() { + test_hooks_with_pairs( + &Rope::from("\n\n\n"), + &Selection::new( + smallvec!(Range::new(1, 0), Range::new(2, 1), Range::new(3, 2),), + 0, + ), + PAIRS, + |open, close| { + format!( + "{open}{close}\n{open}{close}\n{open}{close}\n", + open = open, + close = close + ) + }, + &Selection::new( + smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),), + 0, + ), + ); + } + + /// fo[o] -> append ( -> fo[o(]) + #[test] + fn test_append() { + test_hooks_with_pairs( + &Rope::from("foo\n"), + &Selection::single(2, 4), + differing_pairs(), + |open, close| format!("foo{}{}\n", open, close), + &Selection::single(2, 5), + ); + } + + /// foo[] -> append to end of line ( -> foo([]) + #[test] + fn test_append_single_cursor() { + test_hooks_with_pairs( + &Rope::from(format!("foo{}", LINE_END)), + &Selection::single(3, 3 + LINE_END.len()), + differing_pairs(), + |open, close| format!("foo{}{}{}", open, close, LINE_END), + &Selection::single(4, 5), + ); + } + + /// fo[o] fo[o(]) + /// fo[o] -> append ( -> fo[o(]) + /// fo[o] fo[o(]) + #[test] + fn test_append_multi() { + test_hooks_with_pairs( + &Rope::from("foo\nfoo\nfoo\n"), + &Selection::new( + smallvec!(Range::new(2, 4), Range::new(6, 8), Range::new(10, 12)), + 0, + ), + differing_pairs(), + |open, close| { + format!( + "foo{open}{close}\nfoo{open}{close}\nfoo{open}{close}\n", + open = open, + close = close + ) + }, + &Selection::new( + smallvec!(Range::new(2, 5), Range::new(8, 11), Range::new(14, 17)), + 0, + ), + ); + } + + /// ([)] -> insert ) -> ()[] + #[test] + fn test_insert_close_inside_pair() { + for (open, close) in PAIRS { + let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); + + test_hooks( + &doc, + &Selection::single(2, 1), + *close, + &doc, + &Selection::single(2 + LINE_END.len(), 2), + ); + } + } + + /// [(]) -> append ) -> [()] + #[test] + fn test_append_close_inside_pair() { + for (open, close) in PAIRS { + let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); + + test_hooks( + &doc, + &Selection::single(0, 2), + *close, + &doc, + &Selection::single(0, 2 + LINE_END.len()), + ); + } + } + + /// ([]) ()[] + /// ([]) -> insert ) -> ()[] + /// ([]) ()[] + #[test] + fn test_insert_close_inside_pair_multi_cursor() { + let sel = Selection::new( + smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),), + 0, + ); + + let expected_sel = Selection::new( + smallvec!(Range::new(3, 2), Range::new(6, 5), Range::new(9, 8),), + 0, + ); + + for (open, close) in PAIRS { + let doc = Rope::from(format!( + "{open}{close}\n{open}{close}\n{open}{close}\n", + open = open, + close = close + )); + + test_hooks(&doc, &sel, *close, &doc, &expected_sel); + } + } + + /// [(]) [()] + /// [(]) -> append ) -> [()] + /// [(]) [()] + #[test] + fn test_append_close_inside_pair_multi_cursor() { + let sel = Selection::new( + smallvec!(Range::new(0, 2), Range::new(3, 5), Range::new(6, 8),), + 0, + ); + + let expected_sel = Selection::new( + smallvec!(Range::new(0, 3), Range::new(3, 6), Range::new(6, 9),), + 0, + ); + + for (open, close) in PAIRS { + let doc = Rope::from(format!( + "{open}{close}\n{open}{close}\n{open}{close}\n", + open = open, + close = close + )); + + test_hooks(&doc, &sel, *close, &doc, &expected_sel); + } + } + + /// ([]) -> insert ( -> (([])) + #[test] + fn test_insert_open_inside_pair() { + let sel = Selection::single(2, 1); + let expected_sel = Selection::single(3, 2); + + for (open, close) in differing_pairs() { + let doc = Rope::from(format!("{}{}", open, close)); + let expected_doc = Rope::from(format!( + "{open}{open}{close}{close}", + open = open, + close = close + )); + + test_hooks(&doc, &sel, *open, &expected_doc, &expected_sel); + } + } + + /// [word(]) -> append ( -> [word((])) + #[test] + fn test_append_open_inside_pair() { + let sel = Selection::single(0, 6); + let expected_sel = Selection::single(0, 7); + + for (open, close) in differing_pairs() { + let doc = Rope::from(format!("word{}{}", open, close)); + let expected_doc = Rope::from(format!( + "word{open}{open}{close}{close}", + open = open, + close = close + )); + + test_hooks(&doc, &sel, *open, &expected_doc, &expected_sel); + } + } + + /// ([]) -> insert " -> ("[]") + #[test] + fn test_insert_nested_open_inside_pair() { + let sel = Selection::single(2, 1); + let expected_sel = Selection::single(3, 2); + + for (outer_open, outer_close) in differing_pairs() { + let doc = Rope::from(format!("{}{}", outer_open, outer_close,)); + + for (inner_open, inner_close) in matching_pairs() { + let expected_doc = Rope::from(format!( + "{}{}{}{}", + outer_open, inner_open, inner_close, outer_close + )); + + test_hooks(&doc, &sel, *inner_open, &expected_doc, &expected_sel); + } + } + } + + /// [(]) -> append " -> [("]") + #[test] + fn test_append_nested_open_inside_pair() { + let sel = Selection::single(0, 2); + let expected_sel = Selection::single(0, 3); + + for (outer_open, outer_close) in differing_pairs() { + let doc = Rope::from(format!("{}{}", outer_open, outer_close,)); + + for (inner_open, inner_close) in matching_pairs() { + let expected_doc = Rope::from(format!( + "{}{}{}{}", + outer_open, inner_open, inner_close, outer_close + )); + + test_hooks(&doc, &sel, *inner_open, &expected_doc, &expected_sel); + } + } + } + + /// []word -> insert ( -> ([]word + #[test] + fn test_insert_open_before_non_pair() { + test_hooks_with_pairs( + &Rope::from("word"), + &Selection::single(1, 0), + PAIRS, + |open, _| format!("{}word", open), + &Selection::single(2, 1), + ) + } + + /// [wor]d -> insert ( -> ([wor]d + #[test] + fn test_insert_open_with_selection() { + test_hooks_with_pairs( + &Rope::from("word"), + &Selection::single(3, 0), + PAIRS, + |open, _| format!("{}word", open), + &Selection::single(4, 1), + ) + } + + /// [wor]d -> append ) -> [wor)]d + #[test] + fn test_append_close_inside_non_pair_with_selection() { + let sel = Selection::single(0, 4); + let expected_sel = Selection::single(0, 5); + + for (_, close) in PAIRS { + let doc = Rope::from("word"); + let expected_doc = Rope::from(format!("wor{}d", close)); + test_hooks(&doc, &sel, *close, &expected_doc, &expected_sel); + } + } + + /// foo[ wor]d -> insert ( -> foo([) wor]d + #[test] + fn test_insert_open_trailing_word_with_selection() { + test_hooks_with_pairs( + &Rope::from("foo word"), + &Selection::single(7, 3), + differing_pairs(), + |open, close| format!("foo{}{} word", open, close), + &Selection::single(9, 4), + ) + } + + /// foo([) wor]d -> insert ) -> foo()[ wor]d + #[test] + fn test_insert_close_inside_pair_trailing_word_with_selection() { + for (open, close) in differing_pairs() { + test_hooks( + &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), + &Selection::single(9, 4), + *close, + &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), + &Selection::single(9, 5), + ) + } + } + + /// we want pairs that are *not* the same char to be inserted after + /// a non-pair char, for cases like functions, but for pairs that are + /// the same char, we want to *not* insert a pair to handle cases like "I'm" + /// + /// word[] -> insert ( -> word([]) + /// word[] -> insert ' -> word'[] + #[test] + fn test_insert_open_after_non_pair() { + let doc = Rope::from(format!("word{}", LINE_END)); + let sel = Selection::single(5, 4); + let expected_sel = Selection::single(6, 5); + + test_hooks_with_pairs( + &doc, + &sel, + differing_pairs(), + |open, close| format!("word{}{}{}", open, close, LINE_END), + &expected_sel, + ); + + test_hooks_with_pairs( + &doc, + &sel, + matching_pairs(), + |open, _| format!("word{}{}", open, LINE_END), + &expected_sel, + ); + } } diff --git a/helix-core/src/chars.rs b/helix-core/src/chars.rs index c8e5efbde..549915740 100644 --- a/helix-core/src/chars.rs +++ b/helix-core/src/chars.rs @@ -91,12 +91,11 @@ mod test { #[test] fn test_categorize() { - const EOL_TEST_CASE: &'static str = "\n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; - const WORD_TEST_CASE: &'static str = - "_hello_world_あいうえおー12345678901234567890"; - const PUNCTUATION_TEST_CASE: &'static str = + const EOL_TEST_CASE: &str = "\n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; + const WORD_TEST_CASE: &str = "_hello_world_あいうえおー12345678901234567890"; + const PUNCTUATION_TEST_CASE: &str = "!\"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~!”#$%&’()*+、。:;<=>?@「」^`{|}~"; - const WHITESPACE_TEST_CASE: &'static str = "      "; + const WHITESPACE_TEST_CASE: &str = "      "; for ch in EOL_TEST_CASE.chars() { assert_eq!(CharCategory::Eol, categorize_char(ch)); diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index 4fcf51c9c..210ad6391 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -1,12 +1,19 @@ //! LSP diagnostic utility types. +use serde::{Deserialize, Serialize}; /// Describes the severity level of a [`Diagnostic`]. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Deserialize, Serialize)] pub enum Severity { - Error, - Warning, - Info, Hint, + Info, + Warning, + Error, +} + +impl Default for Severity { + fn default() -> Self { + Self::Hint + } } /// A range of `char`s within the text. diff --git a/helix-core/src/diff.rs b/helix-core/src/diff.rs index a83db3338..6960c679c 100644 --- a/helix-core/src/diff.rs +++ b/helix-core/src/diff.rs @@ -11,10 +11,6 @@ pub fn compare_ropes(old: &Rope, new: &Rope) -> Transaction { // A timeout is set so after 1 seconds, the algorithm will start // approximating. This is especially important for big `Rope`s or // `Rope`s that are extremely dissimilar to each other. - // - // Note: Ignore the clippy warning, as the trait bounds of - // `Transaction::change()` require an iterator implementing - // `ExactIterator`. let mut config = similar::TextDiff::configure(); config.timeout(std::time::Duration::from_secs(1)); @@ -62,7 +58,7 @@ mod tests { let mut old = Rope::from(a); let new = Rope::from(b); compare_ropes(&old, &new).apply(&mut old); - old.to_string() == new.to_string() + old == new } } } diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index c63988757..aa8986844 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -120,6 +120,43 @@ pub fn nth_next_grapheme_boundary(slice: RopeSlice, char_idx: usize, n: usize) - chunk_char_idx + tmp } +#[must_use] +pub fn nth_next_grapheme_boundary_byte(slice: RopeSlice, mut byte_idx: usize, n: usize) -> usize { + // Bounds check + debug_assert!(byte_idx <= slice.len_bytes()); + + // Get the chunk with our byte index in it. + let (mut chunk, mut chunk_byte_idx, mut _chunk_char_idx, _) = slice.chunk_at_byte(byte_idx); + + // Set up the grapheme cursor. + let mut gc = GraphemeCursor::new(byte_idx, slice.len_bytes(), true); + + // Find the nth next grapheme cluster boundary. + for _ in 0..n { + loop { + match gc.next_boundary(chunk, chunk_byte_idx) { + Ok(None) => return slice.len_bytes(), + Ok(Some(n)) => { + byte_idx = n; + break; + } + Err(GraphemeIncomplete::NextChunk) => { + chunk_byte_idx += chunk.len(); + let (a, _, _c, _) = slice.chunk_at_byte(chunk_byte_idx); + chunk = a; + // chunk_char_idx = c; + } + Err(GraphemeIncomplete::PreContext(n)) => { + let ctx_chunk = slice.chunk_at_byte(n - 1).0; + gc.provide_context(ctx_chunk, n - ctx_chunk.len()); + } + _ => unreachable!(), + } + } + } + byte_idx +} + /// Finds the next grapheme boundary after the given char position. #[must_use] #[inline(always)] @@ -127,6 +164,13 @@ pub fn next_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> usize { nth_next_grapheme_boundary(slice, char_idx, 1) } +/// Finds the next grapheme boundary after the given byte position. +#[must_use] +#[inline(always)] +pub fn next_grapheme_boundary_byte(slice: RopeSlice, byte_idx: usize) -> usize { + nth_next_grapheme_boundary_byte(slice, byte_idx, 1) +} + /// Returns the passed char index if it's already a grapheme boundary, /// or the next grapheme boundary char index if not. #[must_use] @@ -151,6 +195,23 @@ pub fn ensure_grapheme_boundary_prev(slice: RopeSlice, char_idx: usize) -> usize } } +/// Returns the passed byte index if it's already a grapheme boundary, +/// or the next grapheme boundary byte index if not. +#[must_use] +#[inline] +pub fn ensure_grapheme_boundary_next_byte(slice: RopeSlice, byte_idx: usize) -> usize { + if byte_idx == 0 { + byte_idx + } else { + // TODO: optimize so we're not constructing grapheme cursor twice + if is_grapheme_boundary_byte(slice, byte_idx) { + byte_idx + } else { + next_grapheme_boundary_byte(slice, byte_idx) + } + } +} + /// Returns whether the given char position is a grapheme boundary. #[must_use] pub fn is_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> bool { @@ -179,6 +240,31 @@ pub fn is_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> bool { } } +/// Returns whether the given byte position is a grapheme boundary. +#[must_use] +pub fn is_grapheme_boundary_byte(slice: RopeSlice, byte_idx: usize) -> bool { + // Bounds check + debug_assert!(byte_idx <= slice.len_bytes()); + + // Get the chunk with our byte index in it. + let (chunk, chunk_byte_idx, _, _) = slice.chunk_at_byte(byte_idx); + + // Set up the grapheme cursor. + let mut gc = GraphemeCursor::new(byte_idx, slice.len_bytes(), true); + + // Determine if the given position is a grapheme cluster boundary. + loop { + match gc.is_boundary(chunk, chunk_byte_idx) { + Ok(n) => return n, + Err(GraphemeIncomplete::PreContext(n)) => { + let (ctx_chunk, ctx_byte_start, _, _) = slice.chunk_at_byte(n - 1); + gc.provide_context(ctx_chunk, ctx_byte_start); + } + Err(_) => unreachable!(), + } + } +} + /// An iterator over the graphemes of a `RopeSlice`. #[derive(Clone)] pub struct RopeGraphemes<'a> { diff --git a/helix-core/src/history.rs b/helix-core/src/history.rs index 4b1c8d3b3..bb95213c1 100644 --- a/helix-core/src/history.rs +++ b/helix-core/src/history.rs @@ -448,8 +448,8 @@ mod test { change: crate::transaction::Change, instant: Instant, ) { - let txn = Transaction::change(&state.doc, vec![change.clone()].into_iter()); - history.commit_revision_at_timestamp(&txn, &state, instant); + let txn = Transaction::change(&state.doc, vec![change].into_iter()); + history.commit_revision_at_timestamp(&txn, state, instant); txn.apply(&mut state.doc); } diff --git a/helix-core/src/increment/date_time.rs b/helix-core/src/increment/date_time.rs new file mode 100644 index 000000000..91fa59631 --- /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(Self { + regex: r"\d{4}", + unit: DateUnit::Years, + max_len: 5, + }), + "y" => Some(Self { + regex: r"\d\d", + unit: DateUnit::Years, + max_len: 2, + }), + "m" => Some(Self { + regex: r"[0-1]\d", + unit: DateUnit::Months, + max_len: 2, + }), + "d" => Some(Self { + regex: r"[0-3]\d", + unit: DateUnit::Days, + max_len: 2, + }), + "-d" => Some(Self { + regex: r"[1-3]?\d", + unit: DateUnit::Days, + max_len: 2, + }), + "a" => Some(Self { + regex: r"Sun|Mon|Tue|Wed|Thu|Fri|Sat", + unit: DateUnit::Days, + max_len: 3, + }), + "A" => Some(Self { + regex: r"Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday", + unit: DateUnit::Days, + max_len: 9, + }), + "b" | "h" => Some(Self { + regex: r"Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec", + unit: DateUnit::Months, + max_len: 3, + }), + "B" => Some(Self { + regex: r"January|February|March|April|May|June|July|August|September|October|November|December", + unit: DateUnit::Months, + max_len: 9, + }), + "H" => Some(Self { + regex: r"[0-2]\d", + unit: DateUnit::Hours, + max_len: 2, + }), + "M" => Some(Self { + regex: r"[0-5]\d", + unit: DateUnit::Minutes, + max_len: 2, + }), + "S" => Some(Self { + regex: r"[0-5]\d", + unit: DateUnit::Seconds, + max_len: 2, + }), + "I" => Some(Self { + regex: r"[0-1]\d", + unit: DateUnit::Hours, + max_len: 2, + }), + "-I" => Some(Self { + regex: r"1?\d", + unit: DateUnit::Hours, + max_len: 2, + }), + "P" => Some(Self { + regex: r"am|pm", + unit: DateUnit::AmPm, + max_len: 2, + }), + "p" => Some(Self { + 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, + Tendril::from(expected) + ); + } + } + + #[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/numbers.rs b/helix-core/src/increment/number.rs similarity index 95% rename from helix-core/src/numbers.rs rename to helix-core/src/increment/number.rs index e9f3c898d..57171f671 100644 --- a/helix-core/src/numbers.rs +++ b/helix-core/src/increment/number.rs @@ -2,6 +2,8 @@ use std::borrow::Cow; use ropey::RopeSlice; +use super::Increment; + use crate::{ textobject::{textobject_word, TextObject}, Range, Tendril, @@ -9,9 +11,9 @@ use crate::{ #[derive(Debug, PartialEq, Eq)] pub struct NumberIncrementor<'a> { - pub range: Range, - pub value: i64, - pub radix: u32, + value: i64, + radix: u32, + range: Range, text: RopeSlice<'a>, } @@ -71,9 +73,10 @@ impl<'a> NumberIncrementor<'a> { text, }) } +} - /// Add `amount` to the number and return the formatted text. - pub fn incremented_text(&self, amount: i64) -> Tendril { +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); @@ -144,7 +147,7 @@ impl<'a> NumberIncrementor<'a> { } } - new_text.into() + (self.range, new_text.into()) } } @@ -366,8 +369,9 @@ mod test { assert_eq!( NumberIncrementor::from_range(rope.slice(..), range) .unwrap() - .incremented_text(amount), - expected.into() + .increment(amount) + .1, + Tendril::from(expected) ); } } @@ -392,8 +396,9 @@ mod test { assert_eq!( NumberIncrementor::from_range(rope.slice(..), range) .unwrap() - .incremented_text(amount), - expected.into() + .increment(amount) + .1, + Tendril::from(expected) ); } } @@ -419,8 +424,9 @@ mod test { assert_eq!( NumberIncrementor::from_range(rope.slice(..), range) .unwrap() - .incremented_text(amount), - expected.into() + .increment(amount) + .1, + Tendril::from(expected) ); } } @@ -464,8 +470,9 @@ mod test { assert_eq!( NumberIncrementor::from_range(rope.slice(..), range) .unwrap() - .incremented_text(amount), - expected.into() + .increment(amount) + .1, + Tendril::from(expected) ); } } @@ -491,8 +498,9 @@ mod test { assert_eq!( NumberIncrementor::from_range(rope.slice(..), range) .unwrap() - .incremented_text(amount), - expected.into() + .increment(amount) + .1, + Tendril::from(expected) ); } } diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index 8ccc0120a..5d20edc1a 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -1,6 +1,5 @@ use crate::{ chars::{char_is_line_ending, char_is_whitespace}, - find_first_non_whitespace_char, syntax::{IndentQuery, LanguageConfiguration, Syntax}, tree_sitter::Node, Rope, RopeSlice, @@ -174,8 +173,7 @@ pub fn auto_detect_indent_style(document_text: &Rope) -> Option { /// To determine indentation of a newly inserted line, figure out the indentation at the last col /// of the previous line. -#[allow(dead_code)] -fn indent_level_for_line(line: RopeSlice, tab_width: usize) -> usize { +pub fn indent_level_for_line(line: RopeSlice, tab_width: usize) -> usize { let mut len = 0; for ch in line.chars() { match ch { @@ -207,10 +205,15 @@ fn get_highest_syntax_node_at_bytepos(syntax: &Syntax, pos: usize) -> Option, newline: bool) -> usize { - // NOTE: can't use contains() on query because of comparing Vec and &str - // https://doc.rust-lang.org/std/vec/struct.Vec.html#method.contains - +/// Calculate the indentation at a given treesitter node. +/// If newline is false, then any "indent" nodes on the line are ignored ("outdent" still applies). +/// This is because the indentation is only increased starting at the second line of the node. +fn calculate_indentation( + query: &IndentQuery, + node: Option, + line: usize, + newline: bool, +) -> usize { let mut increment: isize = 0; let mut node = match node { @@ -218,70 +221,45 @@ fn calculate_indentation(query: &IndentQuery, node: Option, newline: bool) None => return 0, }; - let mut prev_start = node.start_position().row; - - // if we're calculating indentation for a brand new line then the current node will become the - // parent node. We need to take it's indentation level into account too. - let node_kind = node.kind(); - if newline && query.indent.contains(node_kind) { - increment += 1; - } - - while let Some(parent) = node.parent() { - let parent_kind = parent.kind(); - let start = parent.start_position().row; - - // detect deeply nested indents in the same line - // .map(|a| { <-- ({ is two scopes - // let len = 1; <-- indents one level - // }) <-- }) is two scopes - let starts_same_line = start == prev_start; - - if query.outdent.contains(node.kind()) && !starts_same_line { - // we outdent by skipping the rules for the current level and jumping up - // node = parent; - increment -= 1; - // continue; + let mut current_line = line; + let mut consider_indent = newline; + let mut increment_from_line: isize = 0; + + loop { + let node_kind = node.kind(); + let start = node.start_position().row; + if current_line != start { + // Indent/dedent by at most one per line: + // .map(|a| { <-- ({ is two scopes + // let len = 1; <-- indents one level + // }) <-- }) is two scopes + if consider_indent || increment_from_line < 0 { + increment += increment_from_line.signum(); + } + increment_from_line = 0; + current_line = start; + consider_indent = true; } - if query.indent.contains(parent_kind) // && not_first_or_last_sibling - && !starts_same_line - { - // println!("is_scope {}", parent_kind); - prev_start = start; - increment += 1 + if query.outdent.contains(node_kind) { + increment_from_line -= 1; + } + if query.indent.contains(node_kind) { + increment_from_line += 1; } - // if last_scope && increment > 0 && ...{ ignore } - - node = parent; + if let Some(parent) = node.parent() { + node = parent; + } else { + break; + } + } + if consider_indent || increment_from_line < 0 { + increment += increment_from_line.signum(); } - increment.max(0) as usize } -#[allow(dead_code)] -fn suggested_indent_for_line( - language_config: &LanguageConfiguration, - syntax: Option<&Syntax>, - text: RopeSlice, - line_num: usize, - _tab_width: usize, -) -> usize { - if let Some(start) = find_first_non_whitespace_char(text.line(line_num)) { - return suggested_indent_for_pos( - Some(language_config), - syntax, - text, - start + text.line_to_char(line_num), - false, - ); - }; - - // if the line is blank, indent should be zero - 0 -} - // TODO: two usecases: if we are triggering this for a new, blank line: // - it should return 0 when mass indenting stuff // - it should look up the wrapper node and count it too when we press o/O @@ -290,23 +268,20 @@ pub fn suggested_indent_for_pos( syntax: Option<&Syntax>, text: RopeSlice, pos: usize, + line: usize, new_line: bool, -) -> usize { +) -> Option { if let (Some(query), Some(syntax)) = ( language_config.and_then(|config| config.indent_query()), syntax, ) { let byte_start = text.char_to_byte(pos); let node = get_highest_syntax_node_at_bytepos(syntax, byte_start); - - // let config = load indentation query config from Syntax(should contain language_config) - // TODO: special case for comments // TODO: if preserve_leading_whitespace - calculate_indentation(query, node, new_line) + Some(calculate_indentation(query, node, line, new_line)) } else { - // TODO: heuristics for non-tree sitter grammars - 0 + None } } @@ -438,7 +413,8 @@ where ", ); - let doc = Rope::from(doc); + let doc = doc; + use crate::diagnostic::Severity; use crate::syntax::{ Configuration, IndentationConfiguration, LanguageConfiguration, Loader, }; @@ -456,6 +432,8 @@ where roots: vec![], comment_token: None, auto_format: false, + diagnostic_severity: Severity::Warning, + tree_sitter_library: None, language_server: None, indent: Some(IndentationConfiguration { tab_width: 4, @@ -474,20 +452,29 @@ where let language_config = loader.language_config_for_scope("source.rust").unwrap(); let highlight_config = language_config.highlight_config(&[]).unwrap(); - let syntax = Syntax::new(&doc, highlight_config.clone()); + let syntax = Syntax::new(&doc, highlight_config, std::sync::Arc::new(loader)); let text = doc.slice(..); let tab_width = 4; for i in 0..doc.len_lines() { let line = text.line(i); - let indent = indent_level_for_line(line, tab_width); - assert_eq!( - suggested_indent_for_line(&language_config, Some(&syntax), text, i, tab_width), - indent, - "line {}: {}", - i, - line - ); + if let Some(pos) = crate::find_first_non_whitespace_char(line) { + let indent = indent_level_for_line(line, tab_width); + assert_eq!( + suggested_indent_for_pos( + Some(&language_config), + Some(&syntax), + text, + text.line_to_char(i) + pos, + i, + false + ), + Some(indent), + "line {}: \"{}\"", + i, + line + ); + } } } } diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 7d7904061..fa8566ab4 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -1,3 +1,5 @@ +pub use encoding_rs as encoding; + pub mod auto_pairs; pub mod chars; pub mod comment; @@ -5,18 +7,19 @@ 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; pub mod match_brackets; pub mod movement; -pub mod numbers; pub mod object; pub mod path; mod position; pub mod register; pub mod search; pub mod selection; +pub mod shellwords; mod state; pub mod surround; pub mod syntax; @@ -36,8 +39,14 @@ pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option { line.chars().position(|ch| !ch.is_whitespace()) } -/// Find `.git` root. -pub fn find_root(root: Option<&str>) -> Option { +/// Find project root. +/// +/// Order of detection: +/// * Top-most folder containing a root marker in current git repository +/// * Git repostory root if no marker detected +/// * Top-most folder containing a root marker if not git repository detected +/// * Current working directory as fallback +pub fn find_root(root: Option<&str>, root_markers: &[String]) -> Option { let current_dir = std::env::current_dir().expect("unable to determine current directory"); let root = match root { @@ -49,16 +58,30 @@ pub fn find_root(root: Option<&str>) -> Option { current_dir.join(root) } } - None => current_dir, + None => current_dir.clone(), }; + let mut top_marker = None; for ancestor in root.ancestors() { - // TODO: also use defined roots if git isn't found + for marker in root_markers { + if ancestor.join(marker).exists() { + top_marker = Some(ancestor); + break; + } + } + // don't go higher than repo if ancestor.join(".git").is_dir() { - return Some(ancestor.to_path_buf()); + // Use workspace if detected from marker + return Some(top_marker.unwrap_or(ancestor).to_path_buf()); } } - None + + // In absence of git repo, use workspace if detected + if top_marker.is_some() { + top_marker.map(|a| a.to_path_buf()) + } else { + Some(current_dir) + } } pub fn runtime_dir() -> std::path::PathBuf { @@ -158,7 +181,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); @@ -189,7 +212,10 @@ use etcetera::base_strategy::{choose_base_strategy, BaseStrategy}; pub use ropey::{Rope, RopeBuilder, RopeSlice}; -pub use tendril::StrTendril as Tendril; +// pub use tendril::StrTendril as Tendril; +pub use smartstring::SmartString; + +pub type Tendril = SmartString; #[doc(inline)] pub use {regex, tree_sitter}; diff --git a/helix-core/src/line_ending.rs b/helix-core/src/line_ending.rs index 3541305c3..8eb426e1e 100644 --- a/helix-core/src/line_ending.rs +++ b/helix-core/src/line_ending.rs @@ -250,7 +250,7 @@ mod line_ending_tests { assert_eq!(get_line_ending_of_str(&text[..6]), Some(LineEnding::CR)); assert_eq!(get_line_ending_of_str(&text[..12]), Some(LineEnding::LF)); assert_eq!(get_line_ending_of_str(&text[..17]), Some(LineEnding::Crlf)); - assert_eq!(get_line_ending_of_str(&text[..]), None); + assert_eq!(get_line_ending_of_str(text), None); } #[test] diff --git a/helix-core/src/match_brackets.rs b/helix-core/src/match_brackets.rs index cd554005a..0189deddb 100644 --- a/helix-core/src/match_brackets.rs +++ b/helix-core/src/match_brackets.rs @@ -11,7 +11,7 @@ const PAIRS: &[(char, char)] = &[ ('\"', '\"'), ]; -// limit matching pairs to only ( ) { } [ ] < > +// limit matching pairs to only ( ) { } [ ] < > ' ' " " // Returns the position of the matching bracket under cursor. // diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 01a8f890e..47fe68272 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -307,8 +307,6 @@ fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> boo #[cfg(test)] mod test { - use std::array::{self, IntoIter}; - use ropey::Rope; use super::*; @@ -360,7 +358,7 @@ mod test { ((Direction::Backward, 999usize), (0, 0)), // |This is a simple alphabetic line ]; - for ((direction, amount), coordinates) in IntoIter::new(moves_and_expected_coordinates) { + for ((direction, amount), coordinates) in moves_and_expected_coordinates { range = move_horizontally(slice, range, direction, amount, Movement::Move); assert_eq!(coords_at_pos(slice, range.head), coordinates.into()) } @@ -374,7 +372,7 @@ mod test { let mut range = Range::point(position); - let moves_and_expected_coordinates = IntoIter::new([ + let moves_and_expected_coordinates = [ ((Direction::Forward, 11usize), (1, 1)), // Multiline\nt|ext sample\n... ((Direction::Backward, 1usize), (1, 0)), // Multiline\n|text sample\n... ((Direction::Backward, 5usize), (0, 5)), // Multi|line\ntext sample\n... @@ -384,7 +382,7 @@ mod test { ((Direction::Backward, 0usize), (0, 3)), // Mul|tiline\ntext sample\n... ((Direction::Forward, 999usize), (5, 0)), // ...and whitespaced\n| ((Direction::Forward, 999usize), (5, 0)), // ...and whitespaced\n| - ]); + ]; for ((direction, amount), coordinates) in moves_and_expected_coordinates { range = move_horizontally(slice, range, direction, amount, Movement::Move); @@ -402,11 +400,11 @@ mod test { let mut range = Range::point(position); let original_anchor = range.anchor; - let moves = IntoIter::new([ + let moves = [ (Direction::Forward, 1usize), (Direction::Forward, 5usize), (Direction::Backward, 3usize), - ]); + ]; for (direction, amount) in moves { range = move_horizontally(slice, range, direction, amount, Movement::Extend); @@ -420,7 +418,7 @@ mod test { let slice = text.slice(..); let position = pos_at_coords(slice, (0, 0).into(), true); let mut range = Range::point(position); - let moves_and_expected_coordinates = IntoIter::new([ + let moves_and_expected_coordinates = [ ((Direction::Forward, 1usize), (1, 0)), ((Direction::Forward, 2usize), (3, 0)), ((Direction::Forward, 1usize), (4, 0)), @@ -430,7 +428,7 @@ mod test { ((Direction::Backward, 0usize), (4, 0)), ((Direction::Forward, 5), (5, 0)), ((Direction::Forward, 999usize), (5, 0)), - ]); + ]; for ((direction, amount), coordinates) in moves_and_expected_coordinates { range = move_vertically(slice, range, direction, amount, Movement::Move); @@ -450,7 +448,7 @@ mod test { H, V, } - let moves_and_expected_coordinates = IntoIter::new([ + let moves_and_expected_coordinates = [ // Places cursor at the end of line ((Axis::H, Direction::Forward, 8usize), (0, 8)), // First descent preserves column as the target line is wider @@ -463,7 +461,7 @@ mod test { ((Axis::V, Direction::Backward, 999usize), (0, 8)), ((Axis::V, Direction::Forward, 4usize), (4, 8)), ((Axis::V, Direction::Forward, 999usize), (5, 0)), - ]); + ]; for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates { range = match axis { @@ -489,7 +487,7 @@ mod test { H, V, } - let moves_and_expected_coordinates = IntoIter::new([ + let moves_and_expected_coordinates = [ // Places cursor at the fourth kana. ((Axis::H, Direction::Forward, 4), (0, 4)), // Descent places cursor at the 4th character. @@ -498,7 +496,7 @@ mod test { ((Axis::H, Direction::Backward, 1usize), (1, 3)), // Jumping back up 1 line. ((Axis::V, Direction::Backward, 1usize), (0, 3)), - ]); + ]; for ((axis, direction, amount), coordinates) in moves_and_expected_coordinates { range = match axis { @@ -530,7 +528,7 @@ mod test { #[test] fn test_behaviour_when_moving_to_start_of_next_words() { - let tests = array::IntoIter::new([ + let tests = [ ("Basic forward motion stops at the first space", vec![(1, Range::new(0, 0), Range::new(0, 6))]), (" Starting from a boundary advances the anchor", @@ -604,7 +602,7 @@ mod test { vec![ (1, Range::new(0, 0), Range::new(0, 6)), ]), - ]); + ]; for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { @@ -616,7 +614,7 @@ mod test { #[test] fn test_behaviour_when_moving_to_start_of_next_long_words() { - let tests = array::IntoIter::new([ + let tests = [ ("Basic forward motion stops at the first space", vec![(1, Range::new(0, 0), Range::new(0, 6))]), (" Starting from a boundary advances the anchor", @@ -688,7 +686,7 @@ mod test { vec![ (1, Range::new(0, 0), Range::new(0, 8)), ]), - ]); + ]; for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { @@ -700,7 +698,7 @@ mod test { #[test] fn test_behaviour_when_moving_to_start_of_previous_words() { - let tests = array::IntoIter::new([ + let tests = [ ("Basic backward motion from the middle of a word", vec![(1, Range::new(3, 3), Range::new(4, 0))]), @@ -773,7 +771,7 @@ mod test { vec![ (1, Range::new(0, 6), Range::new(6, 0)), ]), - ]); + ]; for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { @@ -785,7 +783,7 @@ mod test { #[test] fn test_behaviour_when_moving_to_start_of_previous_long_words() { - let tests = array::IntoIter::new([ + let tests = [ ( "Basic backward motion from the middle of a word", vec![(1, Range::new(3, 3), Range::new(4, 0))], @@ -870,7 +868,7 @@ mod test { vec![ (1, Range::new(0, 8), Range::new(8, 0)), ]), - ]); + ]; for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { @@ -882,7 +880,7 @@ mod test { #[test] fn test_behaviour_when_moving_to_end_of_next_words() { - let tests = array::IntoIter::new([ + let tests = [ ("Basic forward motion from the start of a word to the end of it", vec![(1, Range::new(0, 0), Range::new(0, 5))]), ("Basic forward motion from the end of a word to the end of the next", @@ -954,7 +952,7 @@ mod test { vec![ (1, Range::new(0, 0), Range::new(0, 5)), ]), - ]); + ]; for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { @@ -966,7 +964,7 @@ mod test { #[test] fn test_behaviour_when_moving_to_end_of_previous_words() { - let tests = array::IntoIter::new([ + let tests = [ ("Basic backward motion from the middle of a word", vec![(1, Range::new(9, 9), Range::new(10, 5))]), ("Starting from after boundary retreats the anchor", @@ -1036,7 +1034,7 @@ mod test { vec![ (1, Range::new(0, 10), Range::new(10, 4)), ]), - ]); + ]; for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { @@ -1048,7 +1046,7 @@ mod test { #[test] fn test_behaviour_when_moving_to_end_of_next_long_words() { - let tests = array::IntoIter::new([ + let tests = [ ("Basic forward motion from the start of a word to the end of it", vec![(1, Range::new(0, 0), Range::new(0, 5))]), ("Basic forward motion from the end of a word to the end of the next", @@ -1118,7 +1116,7 @@ mod test { vec![ (1, Range::new(0, 0), Range::new(0, 7)), ]), - ]); + ]; for (sample, scenario) in tests { for (count, begin, expected_end) in scenario.into_iter() { diff --git a/helix-core/src/object.rs b/helix-core/src/object.rs index 717c59947..b06f41444 100644 --- a/helix-core/src/object.rs +++ b/helix-core/src/object.rs @@ -1,31 +1,72 @@ use crate::{Range, RopeSlice, Selection, Syntax}; +use tree_sitter::Node; -// TODO: to contract_selection we'd need to store the previous ranges before expand. -// Maybe just contract to the first child node? -pub fn expand_selection(syntax: &Syntax, text: RopeSlice, selection: &Selection) -> Selection { +pub fn expand_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { + select_node_impl(syntax, text, selection, |descendant, from, to| { + if descendant.start_byte() == from && descendant.end_byte() == to { + descendant.parent() + } else { + Some(descendant) + } + }) +} + +pub fn shrink_selection(syntax: &Syntax, text: RopeSlice, selection: Selection) -> Selection { + select_node_impl(syntax, text, selection, |descendant, _from, _to| { + descendant.child(0).or(Some(descendant)) + }) +} + +pub fn select_sibling( + syntax: &Syntax, + text: RopeSlice, + selection: Selection, + sibling_fn: &F, +) -> Selection +where + F: Fn(Node) -> Option, +{ + select_node_impl(syntax, text, selection, |descendant, _from, _to| { + find_sibling_recursive(descendant, sibling_fn) + }) +} + +fn find_sibling_recursive(node: Node, sibling_fn: F) -> Option +where + F: Fn(Node) -> Option, +{ + sibling_fn(node).or_else(|| { + node.parent() + .and_then(|node| find_sibling_recursive(node, sibling_fn)) + }) +} + +fn select_node_impl( + syntax: &Syntax, + text: RopeSlice, + selection: Selection, + select_fn: F, +) -> Selection +where + F: Fn(Node, usize, usize) -> Option, +{ let tree = syntax.tree(); - selection.clone().transform(|range| { + selection.transform(|range| { let from = text.char_to_byte(range.from()); let to = text.char_to_byte(range.to()); - // find parent of a descendant that matches the range - let parent = match tree + let node = match tree .root_node() .descendant_for_byte_range(from, to) - .and_then(|node| { - if node.child_count() == 0 || (node.start_byte() == from && node.end_byte() == to) { - node.parent() - } else { - Some(node) - } - }) { - Some(parent) => parent, + .and_then(|node| select_fn(node, from, to)) + { + Some(node) => node, None => return range, }; - let from = text.byte_to_char(parent.start_byte()); - let to = text.byte_to_char(parent.end_byte()); + let from = text.byte_to_char(node.start_byte()); + let to = text.byte_to_char(node.end_byte()); if range.head < range.anchor { Range::new(to, from) diff --git a/helix-core/src/position.rs b/helix-core/src/position.rs index c6018ce69..93362c775 100644 --- a/helix-core/src/position.rs +++ b/helix-core/src/position.rs @@ -109,7 +109,10 @@ pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Po /// TODO: this should be changed to work in terms of visual row/column, not /// graphemes. pub fn pos_at_coords(text: RopeSlice, coords: Position, limit_before_line_ending: bool) -> usize { - let Position { row, col } = coords; + let Position { mut row, col } = coords; + if limit_before_line_ending { + row = row.min(text.len_lines() - 1); + }; let line_start = text.line_to_char(row); let line_end = if limit_before_line_ending { line_end_char_index(&text, row) @@ -290,5 +293,12 @@ mod test { assert_eq!(pos_at_coords(slice, (0, 0).into(), false), 0); assert_eq!(pos_at_coords(slice, (0, 1).into(), false), 1); assert_eq!(pos_at_coords(slice, (0, 2).into(), false), 2); + + // Test out of bounds. + let text = Rope::new(); + let slice = text.slice(..); + assert_eq!(pos_at_coords(slice, (10, 0).into(), true), 0); + assert_eq!(pos_at_coords(slice, (0, 10).into(), true), 0); + assert_eq!(pos_at_coords(slice, (10, 10).into(), true), 0); } } diff --git a/helix-core/src/register.rs b/helix-core/src/register.rs index b9eb497df..b39e4034e 100644 --- a/helix-core/src/register.rs +++ b/helix-core/src/register.rs @@ -68,4 +68,8 @@ impl Registers { pub fn read(&self, name: char) -> Option<&[String]> { self.get(name).map(|reg| reg.read()) } + + pub fn inner(&self) -> &HashMap { + &self.inner + } } diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 116a1c7c0..c6eceb4b5 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -7,6 +7,7 @@ use crate::{ ensure_grapheme_boundary_next, ensure_grapheme_boundary_prev, next_grapheme_boundary, prev_grapheme_boundary, }, + movement::Direction, Assoc, ChangeSet, RopeSlice, }; use smallvec::{smallvec, SmallVec}; @@ -82,6 +83,13 @@ impl Range { std::cmp::max(self.anchor, self.head) } + /// Total length of the range. + #[inline] + #[must_use] + pub fn len(&self) -> usize { + self.to() - self.from() + } + /// The (inclusive) range of lines that the range overlaps. #[inline] #[must_use] @@ -102,6 +110,27 @@ impl Range { self.anchor == self.head } + /// `Direction::Backward` when head < anchor. + /// `Direction::Backward` otherwise. + #[inline] + #[must_use] + pub fn direction(&self) -> Direction { + if self.head < self.anchor { + Direction::Backward + } else { + Direction::Forward + } + } + + // flips the direction of the selection + pub fn flip(&self) -> Self { + Self { + anchor: self.head, + head: self.anchor, + horiz: self.horiz, + } + } + /// Check two ranges for overlap. #[must_use] pub fn overlaps(&self, other: &Self) -> bool { @@ -111,6 +140,11 @@ impl Range { self.from() == other.from() || (self.to() > other.from() && other.to() > self.from()) } + #[inline] + pub fn contains_range(&self, other: &Self) -> bool { + self.from() <= other.from() && self.to() >= other.to() + } + pub fn contains(&self, pos: usize) -> bool { self.from() <= pos && pos < self.to() } @@ -515,6 +549,39 @@ impl Selection { pub fn len(&self) -> usize { self.ranges.len() } + + // returns true if self ⊇ other + pub fn contains(&self, other: &Selection) -> bool { + // can't contain other if it is larger + if other.len() > self.len() { + return false; + } + + let (mut iter_self, mut iter_other) = (self.iter(), other.iter()); + let (mut ele_self, mut ele_other) = (iter_self.next(), iter_other.next()); + + loop { + match (ele_self, ele_other) { + (Some(ra), Some(rb)) => { + if !ra.contains_range(rb) { + // `self` doesn't contain next element from `other`, advance `self`, we need to match all from `other` + ele_self = iter_self.next(); + } else { + // matched element from `other`, advance `other` + ele_other = iter_other.next(); + }; + } + (None, Some(_)) => { + // exhausted `self`, we can't match the reminder of `other` + return false; + } + (_, None) => { + // no elements from `other` left to match, `self` contains `other` + return true; + } + } + } + } } impl<'a> IntoIterator for &'a Selection { @@ -699,16 +766,16 @@ mod test { fn test_contains() { let range = Range::new(10, 12); - assert_eq!(range.contains(9), false); - assert_eq!(range.contains(10), true); - assert_eq!(range.contains(11), true); - assert_eq!(range.contains(12), false); - assert_eq!(range.contains(13), false); + assert!(!range.contains(9)); + assert!(range.contains(10)); + assert!(range.contains(11)); + assert!(!range.contains(12)); + assert!(!range.contains(13)); let range = Range::new(9, 6); - assert_eq!(range.contains(9), false); - assert_eq!(range.contains(7), true); - assert_eq!(range.contains(6), true); + assert!(!range.contains(9)); + assert!(range.contains(7)); + assert!(range.contains(6)); } #[test] @@ -953,4 +1020,30 @@ mod test { &["", "abcd", "efg", "rs", "xyz"] ); } + #[test] + fn test_selection_contains() { + fn contains(a: Vec<(usize, usize)>, b: Vec<(usize, usize)>) -> bool { + let sela = Selection::new(a.iter().map(|a| Range::new(a.0, a.1)).collect(), 0); + let selb = Selection::new(b.iter().map(|b| Range::new(b.0, b.1)).collect(), 0); + sela.contains(&selb) + } + + // exact match + assert!(contains(vec!((1, 1)), vec!((1, 1)))); + + // larger set contains smaller + assert!(contains(vec!((1, 1), (2, 2), (3, 3)), vec!((2, 2)))); + + // multiple matches + assert!(contains(vec!((1, 1), (2, 2)), vec!((1, 1), (2, 2)))); + + // smaller set can't contain bigger + assert!(!contains(vec!((1, 1)), vec!((1, 1), (2, 2)))); + + assert!(contains( + vec!((1, 1), (2, 4), (5, 6), (7, 9), (10, 13)), + vec!((3, 4), (7, 9)) + )); + assert!(!contains(vec!((1, 1), (5, 6)), vec!((1, 6)))); + } } diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs new file mode 100644 index 000000000..13f6f3e99 --- /dev/null +++ b/helix-core/src/shellwords.rs @@ -0,0 +1,164 @@ +use std::borrow::Cow; + +/// Get the vec of escaped / quoted / doublequoted filenames from the input str +pub fn shellwords(input: &str) -> Vec> { + enum State { + Normal, + NormalEscaped, + Quoted, + QuoteEscaped, + Dquoted, + DquoteEscaped, + } + + use State::*; + + let mut state = Normal; + let mut args: Vec> = Vec::new(); + let mut escaped = String::with_capacity(input.len()); + + let mut start = 0; + let mut end = 0; + + for (i, c) in input.char_indices() { + state = match state { + Normal => match c { + '\\' => { + escaped.push_str(&input[start..i]); + start = i + 1; + NormalEscaped + } + '"' => { + end = i; + Dquoted + } + '\'' => { + end = i; + Quoted + } + c if c.is_ascii_whitespace() => { + end = i; + Normal + } + _ => Normal, + }, + NormalEscaped => Normal, + Quoted => match c { + '\\' => { + escaped.push_str(&input[start..i]); + start = i + 1; + QuoteEscaped + } + '\'' => { + end = i; + Normal + } + _ => Quoted, + }, + QuoteEscaped => Quoted, + Dquoted => match c { + '\\' => { + escaped.push_str(&input[start..i]); + start = i + 1; + DquoteEscaped + } + '"' => { + end = i; + Normal + } + _ => Dquoted, + }, + DquoteEscaped => Dquoted, + }; + + if i >= input.len() - 1 && end == 0 { + end = i + 1; + } + + if end > 0 { + let esc_trim = escaped.trim(); + let inp = &input[start..end]; + + if !(esc_trim.is_empty() && inp.trim().is_empty()) { + if esc_trim.is_empty() { + args.push(inp.into()); + } else { + args.push([escaped, inp.into()].concat().into()); + escaped = "".to_string(); + } + } + start = i + 1; + end = 0; + } + } + args +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_normal() { + let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#; + let result = shellwords(input); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó"), + Cow::from("wörds"), + Cow::from(r#"three "with escaping\"#), + ]; + // TODO test is_owned and is_borrowed, once they get stabilized. + assert_eq!(expected, result); + } + + #[test] + fn test_quoted() { + let quoted = + r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#; + let result = shellwords(quoted); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó wörds"), + Cow::from(r#"three' "with escaping\"#), + Cow::from("quote incomplete"), + ]; + assert_eq!(expected, result); + } + + #[test] + fn test_dquoted() { + let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#; + let result = shellwords(dquoted); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó wörds"), + Cow::from(r#"three' "with escaping\"#), + Cow::from("dquote incomplete"), + ]; + assert_eq!(expected, result); + } + + #[test] + fn test_mixed() { + let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#; + let result = shellwords(dquoted); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó wörds"), + Cow::from("three' \"with escaping\\"), + Cow::from("no space before"), + Cow::from("and after"), + Cow::from("$#%^@"), + Cow::from("%^&(%^"), + Cow::from(")(*&^%"), + Cow::from(r#"a\\b"#), + //last ' just changes to quoted but since we dont have anything after it, it should be ignored + ]; + assert_eq!(expected, result); + } +} diff --git a/helix-core/src/surround.rs b/helix-core/src/surround.rs index b53b0a78c..58eb23cf2 100644 --- a/helix-core/src/surround.rs +++ b/helix-core/src/surround.rs @@ -172,6 +172,7 @@ mod test { use ropey::Rope; use smallvec::SmallVec; + #[allow(clippy::type_complexity)] fn check_find_nth_pair_pos( text: &str, cases: Vec<(usize, char, usize, Option<(usize, usize)>)>, diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index f1c399d2c..a5c5e4983 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -1,5 +1,6 @@ use crate::{ chars::char_is_line_ending, + diagnostic::Severity, regex::Regex, transaction::{ChangeSet, Operation}, Rope, RopeSlice, Tendril, @@ -7,12 +8,13 @@ use crate::{ pub use helix_syntax::get_language; -use arc_swap::ArcSwap; +use arc_swap::{ArcSwap, Guard}; +use slotmap::{DefaultKey as LayerId, HopSlotMap}; use std::{ borrow::Cow, cell::RefCell, - collections::{HashMap, HashSet}, + collections::{HashMap, HashSet, VecDeque}, fmt, path::Path, sync::Arc, @@ -50,7 +52,7 @@ pub struct Configuration { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct LanguageConfiguration { #[serde(rename = "name")] - pub language_id: String, + pub language_id: String, // c-sharp, rust pub scope: String, // source.rust pub file_types: Vec, // filename ends_with? #[serde(default)] @@ -63,6 +65,10 @@ pub struct LanguageConfiguration { #[serde(default)] pub auto_format: bool, + #[serde(default)] + pub diagnostic_severity: Severity, + + pub tree_sitter_library: Option, // tree-sitter library name, defaults to language_id // content_regex #[serde(default, skip_serializing, deserialize_with = "deserialize_regex")] @@ -92,6 +98,7 @@ pub struct LanguageServerConfiguration { #[serde(default)] #[serde(skip_serializing_if = "Vec::is_empty")] pub args: Vec, + pub language_id: Option, } #[derive(Debug, PartialEq, Clone, Deserialize, Serialize)] @@ -245,20 +252,22 @@ impl LanguageConfiguration { if highlights_query.is_empty() { None } else { - let language = get_language(&crate::RUNTIME_DIR, &self.language_id) - .map_err(|e| log::info!("{}", e)) - .ok()?; + let language = get_language( + &crate::RUNTIME_DIR, + self.tree_sitter_library + .as_deref() + .unwrap_or(&self.language_id), + ) + .map_err(|e| log::info!("{}", e)) + .ok()?; let config = HighlightConfiguration::new( language, &highlights_query, &injections_query, &locals_query, - ); + ) + .unwrap(); // TODO: avoid panic - let config = match config { - Ok(config) => config, - Err(err) => panic!("{}", err), - }; // TODO: avoid panic config.configure(scopes); Some(Arc::new(config)) } @@ -308,12 +317,16 @@ impl LanguageConfiguration { } } +// Expose loader as Lazy<> global since it's always static? + #[derive(Debug)] pub struct Loader { // highlight_names ? language_configs: Vec>, language_config_ids_by_file_type: HashMap, // Vec language_config_ids_by_shebang: HashMap, + + scopes: ArcSwap>, } impl Loader { @@ -322,6 +335,7 @@ impl Loader { language_configs: Vec::new(), language_config_ids_by_file_type: HashMap::new(), language_config_ids_by_shebang: HashMap::new(), + scopes: ArcSwap::from_pointee(Vec::new()), }; for config in config.language { @@ -366,8 +380,9 @@ impl Loader { 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\.\d]+)").unwrap()); + 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])); @@ -406,8 +421,22 @@ impl Loader { } None } - pub fn language_configs_iter(&self) -> impl Iterator> { - self.language_configs.iter() + + pub fn set_scopes(&self, scopes: Vec) { + self.scopes.store(Arc::new(scopes)); + + // Reconfigure existing grammars + for config in self + .language_configs + .iter() + .filter(|cfg| cfg.is_highlight_initialized()) + { + config.reconfigure(&self.scopes()); + } + } + + pub fn scopes(&self) -> Guard>> { + self.scopes.load() } } @@ -416,12 +445,6 @@ pub struct TsParser { cursors: Vec, } -impl fmt::Debug for TsParser { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("TsParser").finish() - } -} - // could also just use a pool, or a single instance? thread_local! { pub static PARSER: RefCell = RefCell::new(TsParser { @@ -432,9 +455,9 @@ thread_local! { #[derive(Debug)] pub struct Syntax { - config: Arc, - - root_layer: LanguageLayer, + layers: HopSlotMap, + root: LayerId, + loader: Arc, } fn byte_range_to_str(range: std::ops::Range, source: RopeSlice) -> Cow { @@ -444,38 +467,34 @@ fn byte_range_to_str(range: std::ops::Range, source: RopeSlice) -> Cow, - ) -> Self { - let root_layer = LanguageLayer { tree: None }; + pub fn new(source: &Rope, config: Arc, loader: Arc) -> Self { + let root_layer = LanguageLayer { + tree: None, + config, + depth: 0, + ranges: vec![Range { + start_byte: 0, + end_byte: usize::MAX, + start_point: Point::new(0, 0), + end_point: Point::new(usize::MAX, usize::MAX), + }], + }; - // track markers of injections // track scope_descriptor: a Vec of scopes for item in tree + let mut layers = HopSlotMap::default(); + let root = layers.insert(root_layer); + let mut syntax = Self { - // grammar, - config, - root_layer, + root, + layers, + loader, }; - // update root layer - PARSER.with(|ts_parser| { - // TODO: handle the returned `Result` properly. - let _ = syntax.root_layer.parse( - &mut ts_parser.borrow_mut(), - &syntax.config, - source, - 0, - vec![Range { - start_byte: 0, - end_byte: usize::MAX, - start_point: Point::new(0, 0), - end_point: Point::new(usize::MAX, usize::MAX), - }], - ); - }); + syntax + .update(source, source, &ChangeSet::new(source)) + .unwrap(); + syntax } @@ -485,32 +504,255 @@ impl Syntax { source: &Rope, changeset: &ChangeSet, ) -> Result<(), Error> { + let mut queue = VecDeque::new(); + queue.push_back(self.root); + + let scopes = self.loader.scopes.load(); + let injection_callback = |language: &str| { + self.loader + .language_configuration_for_injection_string(language) + .and_then(|language_config| language_config.highlight_config(&scopes)) + }; + + // Convert the changeset into tree sitter edits. + let edits = generate_edits(old_source, changeset); + + // Use the edits to update all layers markers + if !edits.is_empty() { + fn point_add(a: Point, b: Point) -> Point { + if b.row > 0 { + Point::new(a.row.saturating_add(b.row), b.column) + } else { + Point::new(0, a.column.saturating_add(b.column)) + } + } + fn point_sub(a: Point, b: Point) -> Point { + if a.row > b.row { + Point::new(a.row.saturating_sub(b.row), a.column) + } else { + Point::new(0, a.column.saturating_sub(b.column)) + } + } + + for layer in &mut self.layers.values_mut() { + // The root layer always covers the whole range (0..usize::MAX) + if layer.depth == 0 { + continue; + } + + for range in &mut layer.ranges { + // Roughly based on https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720 + for edit in edits.iter().rev() { + let is_pure_insertion = edit.old_end_byte == edit.start_byte; + + // if edit is after range, skip + if edit.start_byte > range.end_byte { + // TODO: || (is_noop && edit.start_byte == range.end_byte) + continue; + } + + // if edit is before range, shift entire range by len + if edit.old_end_byte < range.start_byte { + range.start_byte = + edit.new_end_byte + (range.start_byte - edit.old_end_byte); + range.start_point = point_add( + edit.new_end_position, + point_sub(range.start_point, edit.old_end_position), + ); + + range.end_byte = edit + .new_end_byte + .saturating_add(range.end_byte - edit.old_end_byte); + range.end_point = point_add( + edit.new_end_position, + point_sub(range.end_point, edit.old_end_position), + ); + } + // if the edit starts in the space before and extends into the range + else if edit.start_byte < range.start_byte { + range.start_byte = edit.new_end_byte; + range.start_point = edit.new_end_position; + + range.end_byte = range + .end_byte + .saturating_sub(edit.old_end_byte) + .saturating_add(edit.new_end_byte); + range.end_point = point_add( + edit.new_end_position, + point_sub(range.end_point, edit.old_end_position), + ); + } + // If the edit is an insertion at the start of the tree, shift + else if edit.start_byte == range.start_byte && is_pure_insertion { + range.start_byte = edit.new_end_byte; + range.start_point = edit.new_end_position; + } else { + range.end_byte = range + .end_byte + .saturating_sub(edit.old_end_byte) + .saturating_add(edit.new_end_byte); + range.end_point = point_add( + edit.new_end_position, + point_sub(range.end_point, edit.old_end_position), + ); + } + } + } + } + } + PARSER.with(|ts_parser| { - self.root_layer.update( - &mut ts_parser.borrow_mut(), - &self.config, - old_source, - source, - changeset, - ) - }) + let ts_parser = &mut ts_parser.borrow_mut(); + let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); + // TODO: might need to set cursor range + cursor.set_byte_range(0..usize::MAX); - // TODO: deal with injections and update them too - } + let source_slice = source.slice(..); - // fn buffer_changed -> call layer.update(range, new_text) on root layer and then all marker layers + let mut touched = HashSet::new(); - // call this on transaction.apply() -> buffer_changed(changes) - // - // fn parse(language, old_tree, ranges) - // - pub fn tree(&self) -> &Tree { - self.root_layer.tree() + // TODO: we should be able to avoid editing & parsing layers with ranges earlier in the document before the edit + + while let Some(layer_id) = queue.pop_front() { + // Mark the layer as touched + touched.insert(layer_id); + + let layer = &mut self.layers[layer_id]; + + // If a tree already exists, notify it of changes. + if let Some(tree) = &mut layer.tree { + for edit in edits.iter().rev() { + // Apply the edits in reverse. + // If we applied them in order then edit 1 would disrupt the positioning of edit 2. + tree.edit(edit); + } + } + + // Re-parse the tree. + layer.parse(&mut ts_parser.parser, source)?; + + // Switch to an immutable borrow. + let layer = &self.layers[layer_id]; + + // Process injections. + let matches = cursor.matches( + &layer.config.injections_query, + layer.tree().root_node(), + RopeProvider(source_slice), + ); + let mut injections = Vec::new(); + for mat in matches { + let (language_name, content_node, include_children) = injection_for_match( + &layer.config, + &layer.config.injections_query, + &mat, + source_slice, + ); + + // Explicitly remove this match so that none of its other captures will remain + // in the stream of captures. + mat.remove(); + + // If a language is found with the given name, then add a new language layer + // to the highlighted document. + if let (Some(language_name), Some(content_node)) = (language_name, content_node) + { + if let Some(config) = (injection_callback)(&language_name) { + let ranges = + intersect_ranges(&layer.ranges, &[content_node], include_children); + + if !ranges.is_empty() { + injections.push((config, ranges)); + } + } + } + } + + // Process combined injections. + if let Some(combined_injections_query) = &layer.config.combined_injections_query { + let mut injections_by_pattern_index = + vec![(None, Vec::new(), false); combined_injections_query.pattern_count()]; + let matches = cursor.matches( + combined_injections_query, + layer.tree().root_node(), + RopeProvider(source_slice), + ); + for mat in matches { + let entry = &mut injections_by_pattern_index[mat.pattern_index]; + let (language_name, content_node, include_children) = injection_for_match( + &layer.config, + combined_injections_query, + &mat, + source_slice, + ); + if language_name.is_some() { + entry.0 = language_name; + } + if let Some(content_node) = content_node { + entry.1.push(content_node); + } + entry.2 = include_children; + } + for (lang_name, content_nodes, includes_children) in injections_by_pattern_index + { + if let (Some(lang_name), false) = (lang_name, content_nodes.is_empty()) { + if let Some(config) = (injection_callback)(&lang_name) { + let ranges = intersect_ranges( + &layer.ranges, + &content_nodes, + includes_children, + ); + if !ranges.is_empty() { + injections.push((config, ranges)); + } + } + } + } + } + + let depth = layer.depth + 1; + // TODO: can't inline this since matches borrows self.layers + for (config, ranges) in injections { + // Find an existing layer + let layer = self + .layers + .iter_mut() + .find(|(_, layer)| { + layer.depth == depth && // TODO: track parent id instead + layer.config.language == config.language && layer.ranges == ranges + }) + .map(|(id, _layer)| id); + + // ...or insert a new one. + let layer_id = layer.unwrap_or_else(|| { + self.layers.insert(LanguageLayer { + tree: None, + config, + depth, + ranges, + }) + }); + + queue.push_back(layer_id); + } + + // TODO: pre-process local scopes at this time, rather than highlight? + // would solve problems with locals not working across boundaries + } + + // Return the cursor back in the pool. + ts_parser.cursors.push(cursor); + + // Remove all untouched layers + self.layers.retain(|id, _| touched.contains(&id)); + + Ok(()) + }) } - // - // - // Highlighting + pub fn tree(&self) -> &Tree { + self.layers[self.root].tree() + } /// Iterate over the highlighted regions for a given slice of source code. pub fn highlight_iter<'a>( @@ -518,65 +760,76 @@ impl Syntax { source: RopeSlice<'a>, range: Option>, cancellation_flag: Option<&'a AtomicUsize>, - injection_callback: impl FnMut(&str) -> Option<&'a HighlightConfiguration> + 'a, ) -> impl Iterator> + 'a { - // The `captures` iterator borrows the `Tree` and the `QueryCursor`, which - // prevents them from being moved. But both of these values are really just - // pointers, so it's actually ok to move them. - - // reuse a cursor from the pool if possible - let mut cursor = PARSER.with(|ts_parser| { - let highlighter = &mut ts_parser.borrow_mut(); - highlighter.cursors.pop().unwrap_or_else(QueryCursor::new) + let mut layers = self + .layers + .iter() + .filter_map(|(_, layer)| { + // TODO: if range doesn't overlap layer range, skip it + + // Reuse a cursor from the pool if available. + let mut cursor = PARSER.with(|ts_parser| { + let highlighter = &mut ts_parser.borrow_mut(); + highlighter.cursors.pop().unwrap_or_else(QueryCursor::new) + }); + + // The `captures` iterator borrows the `Tree` and the `QueryCursor`, which + // prevents them from being moved. But both of these values are really just + // pointers, so it's actually ok to move them. + let cursor_ref = + unsafe { mem::transmute::<_, &'static mut QueryCursor>(&mut cursor) }; + + // if reusing cursors & no range this resets to whole range + cursor_ref.set_byte_range(range.clone().unwrap_or(0..usize::MAX)); + + let mut captures = cursor_ref + .captures( + &layer.config.query, + layer.tree().root_node(), + RopeProvider(source), + ) + .peekable(); + + // If there's no captures, skip the layer + captures.peek()?; + + Some(HighlightIterLayer { + highlight_end_stack: Vec::new(), + scope_stack: vec![LocalScope { + inherits: false, + range: 0..usize::MAX, + local_defs: Vec::new(), + }], + cursor, + _tree: None, + captures, + config: layer.config.as_ref(), // TODO: just reuse `layer` + depth: layer.depth, // TODO: just reuse `layer` + ranges: &layer.ranges, // TODO: temp + }) + }) + .collect::>(); + + // HAXX: arrange layers by byte range, with deeper layers positioned first + layers.sort_by_key(|layer| { + ( + layer.ranges.first().cloned(), + std::cmp::Reverse(layer.depth), + ) }); - let tree_ref = self.tree(); - let cursor_ref = unsafe { mem::transmute::<_, &'static mut QueryCursor>(&mut cursor) }; - let query_ref = &self.config.query; - let config_ref = self.config.as_ref(); - - // if reusing cursors & no range this resets to whole range - cursor_ref.set_byte_range(range.clone().unwrap_or(0..usize::MAX)); - - let captures = cursor_ref - .captures(query_ref, tree_ref.root_node(), RopeProvider(source)) - .peekable(); - - // manually craft the root layer based on the existing tree - let layer = HighlightIterLayer { - highlight_end_stack: Vec::new(), - scope_stack: vec![LocalScope { - inherits: false, - range: 0..usize::MAX, - local_defs: Vec::new(), - }], - cursor, - depth: 0, - _tree: None, - captures, - config: config_ref, - ranges: vec![Range { - start_byte: 0, - end_byte: usize::MAX, - start_point: Point::new(0, 0), - end_point: Point::new(usize::MAX, usize::MAX), - }], - }; let mut result = HighlightIter { source, - byte_offset: range.map_or(0, |r| r.start), // TODO: simplify - injection_callback, + byte_offset: range.map_or(0, |r| r.start), cancellation_flag, iter_count: 0, - layers: vec![layer], + layers, next_event: None, last_highlight_range: None, }; result.sort_layers(); result } - // on_tokenize - // on_change_highlighting // Commenting // comment_strings_for_pos @@ -588,246 +841,157 @@ impl Syntax { // indent_level_for_line // TODO: Folding - - // Syntax APIs - // get_syntax_node_containing_range -> - // ... - // get_syntax_node_at_pos - // buffer_range_for_scope_at_pos } #[derive(Debug)] pub struct LanguageLayer { // mode // grammar - // depth + pub config: Arc, pub(crate) tree: Option, + pub ranges: Vec, + pub depth: usize, } impl LanguageLayer { - // pub fn new() -> Self { - // Self { tree: None } - // } - pub fn tree(&self) -> &Tree { // TODO: no unwrap self.tree.as_ref().unwrap() } - fn parse( - &mut self, - ts_parser: &mut TsParser, - config: &HighlightConfiguration, - source: &Rope, - _depth: usize, - ranges: Vec, - ) -> Result<(), Error> { - if ts_parser.parser.set_included_ranges(&ranges).is_ok() { - ts_parser - .parser - .set_language(config.language) - .map_err(|_| Error::InvalidLanguage)?; - - // unsafe { syntax.parser.set_cancellation_flag(cancellation_flag) }; - let tree = ts_parser - .parser - .parse_with( - &mut |byte, _| { - if byte <= source.len_bytes() { - let (chunk, start_byte, _, _) = source.chunk_at_byte(byte); - chunk[byte - start_byte..].as_bytes() - } else { - // out of range - &[] - } - }, - self.tree.as_ref(), - ) - .ok_or(Error::Cancelled)?; + fn parse(&mut self, parser: &mut Parser, source: &Rope) -> Result<(), Error> { + parser.set_included_ranges(&self.ranges).unwrap(); - self.tree = Some(tree) - } + parser + .set_language(self.config.language) + .map_err(|_| Error::InvalidLanguage)?; + + // unsafe { syntax.parser.set_cancellation_flag(cancellation_flag) }; + let tree = parser + .parse_with( + &mut |byte, _| { + if byte <= source.len_bytes() { + let (chunk, start_byte, _, _) = source.chunk_at_byte(byte); + chunk[byte - start_byte..].as_bytes() + } else { + // out of range + &[] + } + }, + self.tree.as_ref(), + ) + .ok_or(Error::Cancelled)?; + // unsafe { ts_parser.parser.set_cancellation_flag(None) }; + self.tree = Some(tree); Ok(()) } +} - pub(crate) fn generate_edits( - old_text: RopeSlice, - changeset: &ChangeSet, - ) -> Vec { - use Operation::*; - let mut old_pos = 0; +pub(crate) fn generate_edits( + old_text: &Rope, + changeset: &ChangeSet, +) -> Vec { + use Operation::*; + let mut old_pos = 0; - let mut edits = Vec::new(); + let mut edits = Vec::new(); - let mut iter = changeset.changes.iter().peekable(); + if changeset.changes.is_empty() { + return edits; + } - // TODO; this is a lot easier with Change instead of Operation. + let mut iter = changeset.changes.iter().peekable(); - fn point_at_pos(text: RopeSlice, pos: usize) -> (usize, Point) { - let byte = text.char_to_byte(pos); // <- attempted to index past end - let line = text.char_to_line(pos); - let line_start_byte = text.line_to_byte(line); - let col = byte - line_start_byte; + // TODO; this is a lot easier with Change instead of Operation. - (byte, Point::new(line, col)) - } + fn point_at_pos(text: &Rope, pos: usize) -> (usize, Point) { + let byte = text.char_to_byte(pos); // <- attempted to index past end + let line = text.char_to_line(pos); + let line_start_byte = text.line_to_byte(line); + let col = byte - line_start_byte; - fn traverse(point: Point, text: &Tendril) -> Point { - let Point { - mut row, - mut column, - } = point; - - // TODO: there should be a better way here. - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if char_is_line_ending(ch) && !(ch == '\r' && chars.peek() == Some(&'\n')) { - row += 1; - column = 0; - } else { - column += 1; - } + (byte, Point::new(line, col)) + } + + fn traverse(point: Point, text: &Tendril) -> Point { + let Point { + mut row, + mut column, + } = point; + + // TODO: there should be a better way here. + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if char_is_line_ending(ch) && !(ch == '\r' && chars.peek() == Some(&'\n')) { + row += 1; + column = 0; + } else { + column += 1; } - Point { row, column } } + Point { row, column } + } - while let Some(change) = iter.next() { - let len = match change { - Delete(i) | Retain(i) => *i, - Insert(_) => 0, - }; - let mut old_end = old_pos + len; + while let Some(change) = iter.next() { + let len = match change { + Delete(i) | Retain(i) => *i, + Insert(_) => 0, + }; + let mut old_end = old_pos + len; + + match change { + Retain(_) => {} + Delete(_) => { + let (start_byte, start_position) = point_at_pos(old_text, old_pos); + let (old_end_byte, old_end_position) = point_at_pos(old_text, old_end); + + // deletion + edits.push(tree_sitter::InputEdit { + start_byte, // old_pos to byte + old_end_byte, // old_end to byte + new_end_byte: start_byte, // old_pos to byte + start_position, // old pos to coords + old_end_position, // old_end to coords + new_end_position: start_position, // old pos to coords + }); + } + Insert(s) => { + let (start_byte, start_position) = point_at_pos(old_text, old_pos); - match change { - Retain(_) => {} - Delete(_) => { - let (start_byte, start_position) = point_at_pos(old_text, old_pos); + // a subsequent delete means a replace, consume it + if let Some(Delete(len)) = iter.peek() { + old_end = old_pos + len; let (old_end_byte, old_end_position) = point_at_pos(old_text, old_end); - // TODO: Position also needs to be byte based... - // let byte = char_to_byte(old_pos) - // let line = char_to_line(old_pos) - // let line_start_byte = line_to_byte() - // Position::new(line, line_start_byte - byte) + iter.next(); - // deletion + // replacement edits.push(tree_sitter::InputEdit { - start_byte, // old_pos to byte - old_end_byte, // old_end to byte - new_end_byte: start_byte, // old_pos to byte - start_position, // old pos to coords - old_end_position, // old_end to coords - new_end_position: start_position, // old pos to coords + start_byte, // old_pos to byte + old_end_byte, // old_end to byte + new_end_byte: start_byte + s.len(), // old_pos to byte + s.len() + start_position, // old pos to coords + old_end_position, // old_end to coords + new_end_position: traverse(start_position, s), // old pos + chars, newlines matter too (iter over) + }); + } else { + // insert + edits.push(tree_sitter::InputEdit { + start_byte, // old_pos to byte + old_end_byte: start_byte, // same + new_end_byte: start_byte + s.len(), // old_pos + s.len() + start_position, // old pos to coords + old_end_position: start_position, // same + new_end_position: traverse(start_position, s), // old pos + chars, newlines matter too (iter over) }); - } - Insert(s) => { - let (start_byte, start_position) = point_at_pos(old_text, old_pos); - - // a subsequent delete means a replace, consume it - if let Some(Delete(len)) = iter.peek() { - old_end = old_pos + len; - let (old_end_byte, old_end_position) = point_at_pos(old_text, old_end); - - iter.next(); - - // replacement - edits.push(tree_sitter::InputEdit { - start_byte, // old_pos to byte - old_end_byte, // old_end to byte - new_end_byte: start_byte + s.len(), // old_pos to byte + s.len() - start_position, // old pos to coords - old_end_position, // old_end to coords - new_end_position: traverse(start_position, s), // old pos + chars, newlines matter too (iter over) - }); - } else { - // insert - edits.push(tree_sitter::InputEdit { - start_byte, // old_pos to byte - old_end_byte: start_byte, // same - new_end_byte: start_byte + s.len(), // old_pos + s.len() - start_position, // old pos to coords - old_end_position: start_position, // same - new_end_position: traverse(start_position, s), // old pos + chars, newlines matter too (iter over) - }); - } } } - old_pos = old_end; } - edits - } - - fn update( - &mut self, - ts_parser: &mut TsParser, - config: &HighlightConfiguration, - old_source: &Rope, - source: &Rope, - changeset: &ChangeSet, - ) -> Result<(), Error> { - if changeset.is_empty() { - return Ok(()); - } - - let edits = Self::generate_edits(old_source.slice(..), changeset); - - // Notify the tree about all the changes - for edit in edits.iter().rev() { - // apply the edits in reverse. If we applied them in order then edit 1 would disrupt - // the positioning of edit 2 - self.tree.as_mut().unwrap().edit(edit); - } - - self.parse( - ts_parser, - config, - source, - 0, - // TODO: what to do about this range on update - vec![Range { - start_byte: 0, - end_byte: usize::MAX, - start_point: Point::new(0, 0), - end_point: Point::new(usize::MAX, usize::MAX), - }], - ) + old_pos = old_end; } - - // fn highlight_iter() -> same as Mode but for this layer. Mode composits these - // fn buffer_changed - // fn update(range) - // fn update_injections() + edits } -// -- refactored from tree-sitter-highlight to be able to retain state -// TODO: add seek() to iter - -// problem: any time a layer is updated it must update it's injections on the parent (potentially -// removing some from use) -// can't modify to vec and exist in it at the same time since that would violate borrows -// maybe we can do with an arena -// maybe just caching on the top layer and nevermind the injections for now? -// -// Grammar { -// layers: Vec> to prevent memory moves when vec is modified -// } -// injections tracked by marker: -// if marker areas match it's fine and update -// if not found add new layer -// if length 0 then area got removed, clean up the layer -// -// layer update: -// if range.len = 0 then remove the layer -// for change in changes { tree.edit(change) } -// tree = parser.parse(.., tree, ..) -// calculate affected range and update injections -// injection update: -// look for existing injections -// if present, range = (first injection start, last injection end) -// -// For now cheat and just throw out non-root layers if they exist. This should still improve -// parsing in majority of cases. - use std::sync::atomic::{AtomicUsize, Ordering}; use std::{iter, mem, ops, str, usize}; use tree_sitter::{ @@ -864,8 +1028,8 @@ pub enum HighlightEvent { pub struct HighlightConfiguration { pub language: Grammar, pub query: Query, + injections_query: Query, combined_injections_query: Option, - locals_pattern_index: usize, highlights_pattern_index: usize, highlight_indices: ArcSwap>>, non_local_variable_patterns: Vec, @@ -892,13 +1056,9 @@ struct LocalScope<'a> { } #[derive(Debug)] -struct HighlightIter<'a, F> -where - F: FnMut(&str) -> Option<&'a HighlightConfiguration> + 'a, -{ +struct HighlightIter<'a> { source: RopeSlice<'a>, byte_offset: usize, - injection_callback: F, cancellation_flag: Option<&'a AtomicUsize>, layers: Vec>, iter_count: usize, @@ -938,8 +1098,8 @@ struct HighlightIterLayer<'a> { config: &'a HighlightConfiguration, highlight_end_stack: Vec, scope_stack: Vec>, - ranges: Vec, depth: usize, + ranges: &'a [Range], } impl<'a> fmt::Debug for HighlightIterLayer<'a> { @@ -971,38 +1131,32 @@ impl HighlightConfiguration { ) -> Result { // Concatenate the query strings, keeping track of the start offset of each section. let mut query_source = String::new(); - query_source.push_str(injection_query); - let locals_query_offset = query_source.len(); query_source.push_str(locals_query); let highlights_query_offset = query_source.len(); query_source.push_str(highlights_query); // Construct a single query by concatenating the three query strings, but record the // range of pattern indices that belong to each individual string. - let mut query = Query::new(language, &query_source)?; - let mut locals_pattern_index = 0; + let query = Query::new(language, &query_source)?; let mut highlights_pattern_index = 0; for i in 0..(query.pattern_count()) { let pattern_offset = query.start_byte_for_pattern(i); if pattern_offset < highlights_query_offset { - if pattern_offset < highlights_query_offset { - highlights_pattern_index += 1; - } - if pattern_offset < locals_query_offset { - locals_pattern_index += 1; - } + highlights_pattern_index += 1; } } + let mut injections_query = Query::new(language, injection_query)?; + // Construct a separate query just for dealing with the 'combined injections'. // Disable the combined injection patterns in the main query. let mut combined_injections_query = Query::new(language, injection_query)?; let mut has_combined_queries = false; - for pattern_index in 0..locals_pattern_index { - let settings = query.property_settings(pattern_index); + for pattern_index in 0..injections_query.pattern_count() { + let settings = injections_query.property_settings(pattern_index); if settings.iter().any(|s| &*s.key == "injection.combined") { has_combined_queries = true; - query.disable_pattern(pattern_index); + injections_query.disable_pattern(pattern_index); } else { combined_injections_query.disable_pattern(pattern_index); } @@ -1034,8 +1188,6 @@ impl HighlightConfiguration { for (i, name) in query.capture_names().iter().enumerate() { let i = Some(i as u32); match name.as_str() { - "injection.content" => injection_content_capture_index = i, - "injection.language" => injection_language_capture_index = i, "local.definition" => local_def_capture_index = i, "local.definition-value" => local_def_value_capture_index = i, "local.reference" => local_ref_capture_index = i, @@ -1044,12 +1196,21 @@ impl HighlightConfiguration { } } + for (i, name) in injections_query.capture_names().iter().enumerate() { + let i = Some(i as u32); + match name.as_str() { + "injection.content" => injection_content_capture_index = i, + "injection.language" => injection_language_capture_index = i, + _ => {} + } + } + let highlight_indices = ArcSwap::from_pointee(vec![None; query.capture_names().len()]); Ok(Self { language, query, + injections_query, combined_injections_query, - locals_pattern_index, highlights_pattern_index, highlight_indices, non_local_variable_patterns, @@ -1114,238 +1275,6 @@ impl HighlightConfiguration { } impl<'a> HighlightIterLayer<'a> { - /// Create a new 'layer' of highlighting for this document. - /// - /// In the even that the new layer contains "combined injections" (injections where multiple - /// disjoint ranges are parsed as one syntax tree), these will be eagerly processed and - /// added to the returned vector. - fn new Option<&'a HighlightConfiguration> + 'a>( - source: RopeSlice<'a>, - cancellation_flag: Option<&'a AtomicUsize>, - injection_callback: &mut F, - mut config: &'a HighlightConfiguration, - mut depth: usize, - mut ranges: Vec, - ) -> Result, Error> { - let mut result = Vec::with_capacity(1); - let mut queue = Vec::new(); - loop { - // --> Tree parsing part - - PARSER.with(|ts_parser| { - let highlighter = &mut ts_parser.borrow_mut(); - - if highlighter.parser.set_included_ranges(&ranges).is_ok() { - highlighter - .parser - .set_language(config.language) - .map_err(|_| Error::InvalidLanguage)?; - - unsafe { highlighter.parser.set_cancellation_flag(cancellation_flag) }; - let tree = highlighter - .parser - .parse_with( - &mut |byte, _| { - if byte <= source.len_bytes() { - let (chunk, start_byte, _, _) = source.chunk_at_byte(byte); - chunk[byte - start_byte..].as_bytes() - } else { - // out of range - &[] - } - }, - None, - ) - .ok_or(Error::Cancelled)?; - unsafe { highlighter.parser.set_cancellation_flag(None) }; - let mut cursor = highlighter.cursors.pop().unwrap_or_else(QueryCursor::new); - - // Process combined injections. - if let Some(combined_injections_query) = &config.combined_injections_query { - let mut injections_by_pattern_index = vec![ - (None, Vec::new(), false); - combined_injections_query - .pattern_count() - ]; - let matches = cursor.matches( - combined_injections_query, - tree.root_node(), - RopeProvider(source), - ); - for mat in matches { - let entry = &mut injections_by_pattern_index[mat.pattern_index]; - let (language_name, content_node, include_children) = - injection_for_match( - config, - combined_injections_query, - &mat, - source, - ); - if language_name.is_some() { - entry.0 = language_name; - } - if let Some(content_node) = content_node { - entry.1.push(content_node); - } - entry.2 = include_children; - } - for (lang_name, content_nodes, includes_children) in - injections_by_pattern_index - { - if let (Some(lang_name), false) = (lang_name, content_nodes.is_empty()) - { - if let Some(next_config) = (injection_callback)(&lang_name) { - let ranges = Self::intersect_ranges( - &ranges, - &content_nodes, - includes_children, - ); - if !ranges.is_empty() { - queue.push((next_config, depth + 1, ranges)); - } - } - } - } - } - - // --> Highlighting query part - - // The `captures` iterator borrows the `Tree` and the `QueryCursor`, which - // prevents them from being moved. But both of these values are really just - // pointers, so it's actually ok to move them. - let tree_ref = unsafe { mem::transmute::<_, &'static Tree>(&tree) }; - let cursor_ref = - unsafe { mem::transmute::<_, &'static mut QueryCursor>(&mut cursor) }; - let captures = cursor_ref - .captures(&config.query, tree_ref.root_node(), RopeProvider(source)) - .peekable(); - - result.push(HighlightIterLayer { - highlight_end_stack: Vec::new(), - scope_stack: vec![LocalScope { - inherits: false, - range: 0..usize::MAX, - local_defs: Vec::new(), - }], - cursor, - depth, - _tree: Some(tree), - captures, - config, - ranges, - }); - } - - Ok(()) // so we can use the try operator - })?; - - if queue.is_empty() { - break; - } - - let (next_config, next_depth, next_ranges) = queue.remove(0); - config = next_config; - depth = next_depth; - ranges = next_ranges; - } - - Ok(result) - } - - // Compute the ranges that should be included when parsing an injection. - // This takes into account three things: - // * `parent_ranges` - The ranges must all fall within the *current* layer's ranges. - // * `nodes` - Every injection takes place within a set of nodes. The injection ranges - // are the ranges of those nodes. - // * `includes_children` - For some injections, the content nodes' children should be - // excluded from the nested document, so that only the content nodes' *own* content - // is reparsed. For other injections, the content nodes' entire ranges should be - // reparsed, including the ranges of their children. - fn intersect_ranges( - parent_ranges: &[Range], - nodes: &[Node], - includes_children: bool, - ) -> Vec { - let mut cursor = nodes[0].walk(); - let mut result = Vec::new(); - let mut parent_range_iter = parent_ranges.iter(); - let mut parent_range = parent_range_iter - .next() - .expect("Layers should only be constructed with non-empty ranges vectors"); - for node in nodes.iter() { - let mut preceding_range = Range { - start_byte: 0, - start_point: Point::new(0, 0), - end_byte: node.start_byte(), - end_point: node.start_position(), - }; - let following_range = Range { - start_byte: node.end_byte(), - start_point: node.end_position(), - end_byte: usize::MAX, - end_point: Point::new(usize::MAX, usize::MAX), - }; - - for excluded_range in node - .children(&mut cursor) - .filter_map(|child| { - if includes_children { - None - } else { - Some(child.range()) - } - }) - .chain([following_range].iter().cloned()) - { - let mut range = Range { - start_byte: preceding_range.end_byte, - start_point: preceding_range.end_point, - end_byte: excluded_range.start_byte, - end_point: excluded_range.start_point, - }; - preceding_range = excluded_range; - - if range.end_byte < parent_range.start_byte { - continue; - } - - while parent_range.start_byte <= range.end_byte { - if parent_range.end_byte > range.start_byte { - if range.start_byte < parent_range.start_byte { - range.start_byte = parent_range.start_byte; - range.start_point = parent_range.start_point; - } - - if parent_range.end_byte < range.end_byte { - if range.start_byte < parent_range.end_byte { - result.push(Range { - start_byte: range.start_byte, - start_point: range.start_point, - end_byte: parent_range.end_byte, - end_point: parent_range.end_point, - }); - } - range.start_byte = parent_range.end_byte; - range.start_point = parent_range.end_point; - } else { - if range.start_byte < range.end_byte { - result.push(range); - } - break; - } - } - - if let Some(next_range) = parent_range_iter.next() { - parent_range = next_range; - } else { - return result; - } - } - } - } - result - } - // First, sort scope boundaries by their byte offset in the document. At a // given position, emit scope endings before scope beginnings. Finally, emit // scope boundaries from deeper layers first. @@ -1371,10 +1300,101 @@ impl<'a> HighlightIterLayer<'a> { } } -impl<'a, F> HighlightIter<'a, F> -where - F: FnMut(&str) -> Option<&'a HighlightConfiguration> + 'a, -{ +// Compute the ranges that should be included when parsing an injection. +// This takes into account three things: +// * `parent_ranges` - The ranges must all fall within the *current* layer's ranges. +// * `nodes` - Every injection takes place within a set of nodes. The injection ranges +// are the ranges of those nodes. +// * `includes_children` - For some injections, the content nodes' children should be +// excluded from the nested document, so that only the content nodes' *own* content +// is reparsed. For other injections, the content nodes' entire ranges should be +// reparsed, including the ranges of their children. +fn intersect_ranges( + parent_ranges: &[Range], + nodes: &[Node], + includes_children: bool, +) -> Vec { + let mut cursor = nodes[0].walk(); + let mut result = Vec::new(); + let mut parent_range_iter = parent_ranges.iter(); + let mut parent_range = parent_range_iter + .next() + .expect("Layers should only be constructed with non-empty ranges vectors"); + for node in nodes.iter() { + let mut preceding_range = Range { + start_byte: 0, + start_point: Point::new(0, 0), + end_byte: node.start_byte(), + end_point: node.start_position(), + }; + let following_range = Range { + start_byte: node.end_byte(), + start_point: node.end_position(), + end_byte: usize::MAX, + end_point: Point::new(usize::MAX, usize::MAX), + }; + + for excluded_range in node + .children(&mut cursor) + .filter_map(|child| { + if includes_children { + None + } else { + Some(child.range()) + } + }) + .chain([following_range].iter().cloned()) + { + let mut range = Range { + start_byte: preceding_range.end_byte, + start_point: preceding_range.end_point, + end_byte: excluded_range.start_byte, + end_point: excluded_range.start_point, + }; + preceding_range = excluded_range; + + if range.end_byte < parent_range.start_byte { + continue; + } + + while parent_range.start_byte <= range.end_byte { + if parent_range.end_byte > range.start_byte { + if range.start_byte < parent_range.start_byte { + range.start_byte = parent_range.start_byte; + range.start_point = parent_range.start_point; + } + + if parent_range.end_byte < range.end_byte { + if range.start_byte < parent_range.end_byte { + result.push(Range { + start_byte: range.start_byte, + start_point: range.start_point, + end_byte: parent_range.end_byte, + end_point: parent_range.end_point, + }); + } + range.start_byte = parent_range.end_byte; + range.start_point = parent_range.end_point; + } else { + if range.start_byte < range.end_byte { + result.push(range); + } + break; + } + } + + if let Some(next_range) = parent_range_iter.next() { + parent_range = next_range; + } else { + return result; + } + } + } + } + result +} + +impl<'a> HighlightIter<'a> { fn emit_event( &mut self, offset: usize, @@ -1405,6 +1425,12 @@ where i += 1; continue; } + } else { + let layer = self.layers.remove(i + 1); + PARSER.with(|ts_parser| { + let highlighter = &mut ts_parser.borrow_mut(); + highlighter.cursors.push(layer.cursor); + }); } break; } @@ -1421,30 +1447,9 @@ where } } } - - fn insert_layer(&mut self, mut layer: HighlightIterLayer<'a>) { - if let Some(sort_key) = layer.sort_key() { - let mut i = 1; - while i < self.layers.len() { - if let Some(sort_key_i) = self.layers[i].sort_key() { - if sort_key_i > sort_key { - self.layers.insert(i, layer); - return; - } - i += 1; - } else { - self.layers.remove(i); - } - } - self.layers.push(layer); - } - } } -impl<'a, F> Iterator for HighlightIter<'a, F> -where - F: FnMut(&str) -> Option<&'a HighlightConfiguration> + 'a, -{ +impl<'a> Iterator for HighlightIter<'a> { type Item = Result; fn next(&mut self) -> Option { @@ -1504,55 +1509,12 @@ where layer.highlight_end_stack.pop(); return self.emit_event(end_byte, Some(HighlightEvent::HighlightEnd)); } else { - // return self.emit_event(self.source.len(), None); - return None; + return self.emit_event(self.source.len_bytes(), None); }; let (mut match_, capture_index) = layer.captures.next().unwrap(); let mut capture = match_.captures[capture_index]; - // If this capture represents an injection, then process the injection. - if match_.pattern_index < layer.config.locals_pattern_index { - let (language_name, content_node, include_children) = - injection_for_match(layer.config, &layer.config.query, &match_, self.source); - - // Explicitly remove this match so that none of its other captures will remain - // in the stream of captures. - match_.remove(); - - // If a language is found with the given name, then add a new language layer - // to the highlighted document. - if let (Some(language_name), Some(content_node)) = (language_name, content_node) { - if let Some(config) = (self.injection_callback)(&language_name) { - let ranges = HighlightIterLayer::intersect_ranges( - &self.layers[0].ranges, - &[content_node], - include_children, - ); - if !ranges.is_empty() { - match HighlightIterLayer::new( - self.source, - self.cancellation_flag, - &mut self.injection_callback, - config, - self.layers[0].depth + 1, - ranges, - ) { - Ok(layers) => { - for layer in layers { - self.insert_layer(layer); - } - } - Err(e) => return Some(Err(e)), - } - } - } - } - - self.sort_layers(); - continue 'main; - } - // Remove from the local scope stack any local scopes that have already ended. while range.start > layer.scope_stack.last().unwrap().range.end { layer.scope_stack.pop(); @@ -1747,14 +1709,6 @@ fn injection_for_match<'a>( (language_name, content_node, include_children) } -// fn shrink_and_clear(vec: &mut Vec, capacity: usize) { -// if vec.len() > capacity { -// vec.truncate(capacity); -// vec.shrink_to_fit(); -// } -// vec.clear(); -// } - pub struct Merge { iter: I, spans: Box)>>, @@ -1921,6 +1875,8 @@ mod test { .map(String::from) .collect(); + let loader = Loader::new(Configuration { language: vec![] }); + let language = get_language(&crate::RUNTIME_DIR, "Rust").unwrap(); let config = HighlightConfiguration::new( language, @@ -1943,7 +1899,7 @@ mod test { fn main() {} ", ); - let syntax = Syntax::new(&source, Arc::new(config)); + let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)); let tree = syntax.tree(); let root = tree.root_node(); assert_eq!(root.kind(), "source_file"); @@ -1970,7 +1926,7 @@ mod test { &doc, vec![(6, 11, Some("test".into())), (12, 17, None)].into_iter(), ); - let edits = LanguageLayer::generate_edits(doc.slice(..), transaction.changes()); + let edits = generate_edits(&doc, transaction.changes()); // transaction.apply(&mut state); assert_eq!( @@ -1999,7 +1955,7 @@ mod test { let mut doc = Rope::from("fn test() {}"); let transaction = Transaction::change(&doc, vec![(8, 8, Some("a: u32".into()))].into_iter()); - let edits = LanguageLayer::generate_edits(doc.slice(..), transaction.changes()); + let edits = generate_edits(&doc, transaction.changes()); transaction.apply(&mut doc); assert_eq!(doc, "fn test(a: u32) {}"); diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index dfc18fbea..2e34a9864 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 { @@ -95,7 +85,7 @@ impl ChangeSet { let new_last = match self.changes.as_mut_slice() { [.., Insert(prev)] | [.., Insert(prev), Delete(_)] => { - prev.push_tendril(&fragment); + prev.push_str(&fragment); return; } [.., last @ Delete(_)] => std::mem::replace(last, Insert(fragment)), @@ -199,7 +189,7 @@ impl ChangeSet { // TODO: cover this with a test // figure out the byte index of the truncated string end let (pos, _) = s.char_indices().nth(j).unwrap(); - s.pop_front(pos as u32); + s.replace_range(0..pos, ""); head_a = Some(Insert(s)); head_b = changes_b.next(); } @@ -221,9 +211,11 @@ impl ChangeSet { Ordering::Greater => { // figure out the byte index of the truncated string end let (pos, _) = s.char_indices().nth(j).unwrap(); - let pos = pos as u32; - changes.insert(s.subtendril(0, pos)); - head_a = Some(Insert(s.subtendril(pos, s.len() as u32 - pos))); + let mut before = s; + let after = before.split_off(pos); + + changes.insert(before); + head_a = Some(Insert(after)); head_b = changes_b.next(); } } @@ -287,7 +279,7 @@ impl ChangeSet { } Delete(n) => { let text = Cow::from(original_doc.slice(pos..pos + *n)); - changes.insert(Tendril::from_slice(&text)); + changes.insert(Tendril::from(text.as_ref())); pos += n; } Insert(s) => { @@ -330,7 +322,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. @@ -419,7 +411,7 @@ impl ChangeSet { /// Transaction represents a single undoable unit of changes. Several changes can be grouped into /// a single transaction. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Transaction { changes: ChangeSet, selection: Option, @@ -720,19 +712,19 @@ mod test { #[test] fn optimized_composition() { let mut state = State::new("".into()); - let t1 = Transaction::insert(&state.doc, &state.selection, Tendril::from_char('h')); + let t1 = Transaction::insert(&state.doc, &state.selection, Tendril::from("h")); t1.apply(&mut state.doc); state.selection = state.selection.clone().map(t1.changes()); - let t2 = Transaction::insert(&state.doc, &state.selection, Tendril::from_char('e')); + let t2 = Transaction::insert(&state.doc, &state.selection, Tendril::from("e")); t2.apply(&mut state.doc); state.selection = state.selection.clone().map(t2.changes()); - let t3 = Transaction::insert(&state.doc, &state.selection, Tendril::from_char('l')); + let t3 = Transaction::insert(&state.doc, &state.selection, Tendril::from("l")); t3.apply(&mut state.doc); state.selection = state.selection.clone().map(t3.changes()); - let t4 = Transaction::insert(&state.doc, &state.selection, Tendril::from_char('l')); + let t4 = Transaction::insert(&state.doc, &state.selection, Tendril::from("l")); t4.apply(&mut state.doc); state.selection = state.selection.clone().map(t4.changes()); - let t5 = Transaction::insert(&state.doc, &state.selection, Tendril::from_char('o')); + let t5 = Transaction::insert(&state.doc, &state.selection, Tendril::from("o")); t5.apply(&mut state.doc); state.selection = state.selection.clone().map(t5.changes()); @@ -771,7 +763,7 @@ mod test { #[test] fn combine_with_utf8() { - const TEST_CASE: &'static str = "Hello, これはヘリックスエディターです!"; + const TEST_CASE: &str = "Hello, これはヘリックスエディターです!"; let empty = Rope::from(""); let a = ChangeSet::new(&empty); diff --git a/helix-dap/Cargo.toml b/helix-dap/Cargo.toml index 42dd29a89..24288697e 100644 --- a/helix-dap/Cargo.toml +++ b/helix-dap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-dap" -version = "0.5.0" +version = "0.6.0" authors = ["Blaž Hrastnik "] edition = "2018" license = "MPL-2.0" @@ -12,7 +12,7 @@ homepage = "https://helix-editor.com" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -helix-core = { version = "0.5", path = "../helix-core" } +helix-core = { version = "0.6", path = "../helix-core" } anyhow = "1.0" log = "0.4" serde = { version = "1.0", features = ["derive"] } diff --git a/helix-dap/src/transport.rs b/helix-dap/src/transport.rs index 40474e99d..783a6f5d0 100644 --- a/helix-dap/src/transport.rs +++ b/helix-dap/src/transport.rs @@ -36,7 +36,7 @@ pub struct Response { #[serde(tag = "type", rename_all = "camelCase")] pub enum Payload { // type = "event" - Event(Event), + Event(Box), // type = "response" Response(Response), // type = "request" @@ -45,6 +45,7 @@ pub enum Payload { #[derive(Debug)] pub struct Transport { + #[allow(unused)] id: usize, pending_requests: Mutex>>>, } diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index 83b2978dc..39b537063 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-lsp" -version = "0.5.0" +version = "0.6.0" authors = ["Blaž Hrastnik "] edition = "2021" license = "MPL-2.0" @@ -12,16 +12,16 @@ homepage = "https://helix-editor.com" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -helix-core = { version = "0.5", path = "../helix-core" } +helix-core = { version = "0.6", path = "../helix-core" } anyhow = "1.0" futures-executor = "0.3" futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false } jsonrpc-core = { version = "18.0", default-features = false } # don't pull in all of futures log = "0.4" -lsp-types = { version = "0.91", features = ["proposed"] } +lsp-types = { version = "0.92", features = ["proposed"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" -tokio = { version = "1.14", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } +tokio = { version = "1.16", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } tokio-stream = "0.1.8" diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 271fd9d59..15cbca0eb 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -31,6 +31,7 @@ pub struct Client { pub(crate) capabilities: OnceCell, offset_encoding: OffsetEncoding, config: Option, + root_markers: Vec, } impl Client { @@ -39,6 +40,7 @@ impl Client { cmd: &str, args: &[String], config: Option, + root_markers: Vec, id: usize, ) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc)> { let process = Command::new(cmd) @@ -68,6 +70,7 @@ impl Client { capabilities: OnceCell::new(), offset_encoding: OffsetEncoding::Utf8, config, + root_markers, }; Ok((client, server_rx, initialize_notify)) @@ -202,7 +205,7 @@ impl Client { Ok(result) => Output::Success(Success { jsonrpc: Some(Version::V2), id, - result, + result: serde_json::to_value(result)?, }), Err(error) => Output::Failure(Failure { jsonrpc: Some(Version::V2), @@ -225,7 +228,8 @@ impl Client { pub(crate) async fn initialize(&self) -> Result { // TODO: delay any requests that are triggered prior to initialize - let root = find_root(None).and_then(|root| lsp::Url::from_file_path(root).ok()); + let root = find_root(None, &self.root_markers) + .and_then(|root| lsp::Url::from_file_path(root).ok()); if self.config.is_some() { log::info!("Using custom LSP config: {}", self.config.as_ref().unwrap()); @@ -434,7 +438,7 @@ impl Client { changes.push(lsp::TextDocumentContentChangeEvent { range: Some(lsp::Range::new(start, end)), - text: s.into(), + text: s.to_string(), range_length: None, }); } @@ -556,6 +560,14 @@ impl Client { self.call::(params) } + pub async fn resolve_completion_item( + &self, + completion_item: lsp::CompletionItem, + ) -> Result { + self.request::(completion_item) + .await + } + pub fn text_document_signature_help( &self, text_document: lsp::TextDocumentIdentifier, @@ -800,4 +812,16 @@ impl Client { let response = self.request::(params).await?; Ok(response.unwrap_or_default()) } + + pub fn command(&self, command: lsp::Command) -> impl Future> { + let params = lsp::ExecuteCommandParams { + command: command.command, + arguments: command.arguments.unwrap_or_default(), + work_done_progress_params: lsp::WorkDoneProgressParams { + work_done_token: None, + }, + }; + + self.call::(params) + } } diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 7fa65928b..109546d05 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -66,39 +66,26 @@ pub mod util { pos: lsp::Position, offset_encoding: OffsetEncoding, ) -> Option { - let max_line = doc.lines().count().saturating_sub(1); let pos_line = pos.line as usize; - let pos_line = if pos_line > max_line { + if pos_line > doc.len_lines() - 1 { return None; - } else { - pos_line - }; + } + match offset_encoding { OffsetEncoding::Utf8 => { - let max_char = doc - .line_to_char(max_line) - .checked_add(doc.line(max_line).len_chars())?; let line = doc.line_to_char(pos_line); let pos = line.checked_add(pos.character as usize)?; - if pos <= max_char { + if pos <= doc.len_chars() { Some(pos) } else { None } } OffsetEncoding::Utf16 => { - let max_char = doc - .line_to_char(max_line) - .checked_add(doc.line(max_line).len_chars())?; - let max_cu = doc.char_to_utf16_cu(max_char); let line = doc.line_to_char(pos_line); let line_start = doc.char_to_utf16_cu(line); let pos = line_start.checked_add(pos.character as usize)?; - if pos <= max_cu { - Some(doc.utf16_cu_to_char(pos)) - } else { - None - } + doc.try_utf16_cu_to_char(pos).ok() } } } @@ -203,6 +190,7 @@ pub mod util { #[derive(Debug, PartialEq, Clone)] pub enum MethodCall { WorkDoneProgressCreate(lsp::WorkDoneProgressCreateParams), + ApplyWorkspaceEdit(lsp::ApplyWorkspaceEditParams), } impl MethodCall { @@ -215,6 +203,12 @@ impl MethodCall { .expect("Failed to parse WorkDoneCreate params"); Self::WorkDoneProgressCreate(params) } + lsp::request::ApplyWorkspaceEdit::METHOD => { + let params: lsp::ApplyWorkspaceEditParams = params + .parse() + .expect("Failed to parse ApplyWorkspaceEdit params"); + Self::ApplyWorkspaceEdit(params) + } _ => { log::warn!("unhandled lsp request: {}", method); return None; @@ -319,6 +313,7 @@ impl Registry { &config.command, &config.args, language_config.config.clone(), + language_config.roots.clone(), id, )?; self.incoming.push(UnboundedReceiverStream::new(incoming)); @@ -337,7 +332,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/Cargo.toml b/helix-syntax/Cargo.toml index cceec4127..855839be0 100644 --- a/helix-syntax/Cargo.toml +++ b/helix-syntax/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-syntax" -version = "0.5.0" +version = "0.6.0" authors = ["Blaž Hrastnik "] edition = "2021" license = "MPL-2.0" diff --git a/helix-syntax/README.md b/helix-syntax/README.md new file mode 100644 index 000000000..bba2197a3 --- /dev/null +++ b/helix-syntax/README.md @@ -0,0 +1,13 @@ +helix-syntax +============ + +Syntax highlighting for helix, (shallow) submodules resides here. + +Differences from nvim-treesitter +-------------------------------- + +As the syntax are commonly ported from +. + +Note that we do not support the custom `#any-of` predicate which is +supported by neovim so one needs to change it to `#match` with regex. diff --git a/helix-syntax/build.rs b/helix-syntax/build.rs index 28f85e74f..fa8be8b38 100644 --- a/helix-syntax/build.rs +++ b/helix-syntax/build.rs @@ -175,7 +175,6 @@ fn build_dir(dir: &str, language: &str) { fn main() { let ignore = vec![ "tree-sitter-typescript".to_string(), - "tree-sitter-haskell".to_string(), // aarch64 failures: https://github.com/tree-sitter/tree-sitter-haskell/issues/34 "tree-sitter-ocaml".to_string(), ]; let dirs = collect_tree_sitter_dirs(&ignore).unwrap(); diff --git a/helix-syntax/languages/tree-sitter-comment b/helix-syntax/languages/tree-sitter-comment new file mode 160000 index 000000000..5dd3c62f1 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-comment @@ -0,0 +1 @@ +Subproject commit 5dd3c62f1bbe378b220fe16b317b85247898639e diff --git a/helix-syntax/languages/tree-sitter-dart b/helix-syntax/languages/tree-sitter-dart new file mode 160000 index 000000000..6a2537668 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-dart @@ -0,0 +1 @@ +Subproject commit 6a25376685d1d47968c2cef06d4db8d84a70025e diff --git a/helix-syntax/languages/tree-sitter-dockerfile b/helix-syntax/languages/tree-sitter-dockerfile new file mode 160000 index 000000000..7af32bc04 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-dockerfile @@ -0,0 +1 @@ +Subproject commit 7af32bc04a66ab196f5b9f92ac471f29372ae2ce diff --git a/helix-syntax/languages/tree-sitter-elm b/helix-syntax/languages/tree-sitter-elm new file mode 160000 index 000000000..bd50ccf66 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-elm @@ -0,0 +1 @@ +Subproject commit bd50ccf66b42c55252ac8efc1086af4ac6bab8cd diff --git a/helix-syntax/languages/tree-sitter-fish b/helix-syntax/languages/tree-sitter-fish new file mode 160000 index 000000000..04e54ab65 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-fish @@ -0,0 +1 @@ +Subproject commit 04e54ab6585dfd4fee6ddfe5849af56f101b6d4f diff --git a/helix-syntax/languages/tree-sitter-git-commit b/helix-syntax/languages/tree-sitter-git-commit new file mode 160000 index 000000000..066e395e1 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-git-commit @@ -0,0 +1 @@ +Subproject commit 066e395e1107df17183cf3ae4230f1a1406cc972 diff --git a/helix-syntax/languages/tree-sitter-git-config b/helix-syntax/languages/tree-sitter-git-config new file mode 160000 index 000000000..0e4f0baf9 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-git-config @@ -0,0 +1 @@ +Subproject commit 0e4f0baf90b57e5aeb62dcdbf03062c6315d43ea diff --git a/helix-syntax/languages/tree-sitter-git-diff b/helix-syntax/languages/tree-sitter-git-diff new file mode 160000 index 000000000..c12e6ecb5 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-git-diff @@ -0,0 +1 @@ +Subproject commit c12e6ecb54485f764250556ffd7ccb18f8e2942b diff --git a/helix-syntax/languages/tree-sitter-git-rebase b/helix-syntax/languages/tree-sitter-git-rebase new file mode 160000 index 000000000..332dc528f --- /dev/null +++ b/helix-syntax/languages/tree-sitter-git-rebase @@ -0,0 +1 @@ +Subproject commit 332dc528f27044bc4427024dbb33e6941fc131f2 diff --git a/helix-syntax/languages/tree-sitter-go b/helix-syntax/languages/tree-sitter-go index 2a83dfdd7..0fa917a70 160000 --- a/helix-syntax/languages/tree-sitter-go +++ b/helix-syntax/languages/tree-sitter-go @@ -1 +1 @@ -Subproject commit 2a83dfdd759a632651f852aa4dc0af2525fae5cd +Subproject commit 0fa917a7022d1cd2e9b779a6a8fc5dc7fad69c75 diff --git a/helix-syntax/languages/tree-sitter-graphql b/helix-syntax/languages/tree-sitter-graphql new file mode 160000 index 000000000..5e66e961e --- /dev/null +++ b/helix-syntax/languages/tree-sitter-graphql @@ -0,0 +1 @@ +Subproject commit 5e66e961eee421786bdda8495ed1db045e06b5fe diff --git a/helix-syntax/languages/tree-sitter-haskell b/helix-syntax/languages/tree-sitter-haskell index 237f4eb44..b6ec26f18 160000 --- a/helix-syntax/languages/tree-sitter-haskell +++ b/helix-syntax/languages/tree-sitter-haskell @@ -1 +1 @@ -Subproject commit 237f4eb4417c28f643a29d795ed227246afb66f9 +Subproject commit b6ec26f181dd059eedd506fa5fbeae1b8e5556c8 diff --git a/helix-syntax/languages/tree-sitter-iex b/helix-syntax/languages/tree-sitter-iex new file mode 160000 index 000000000..3ec55082c --- /dev/null +++ b/helix-syntax/languages/tree-sitter-iex @@ -0,0 +1 @@ +Subproject commit 3ec55082cf0be015d03148be8edfdfa8c56e77f9 diff --git a/helix-syntax/languages/tree-sitter-lean b/helix-syntax/languages/tree-sitter-lean new file mode 160000 index 000000000..d98426109 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-lean @@ -0,0 +1 @@ +Subproject commit d98426109258b266e1e92358c5f11716d2e8f638 diff --git a/helix-syntax/languages/tree-sitter-llvm b/helix-syntax/languages/tree-sitter-llvm new file mode 160000 index 000000000..3b213925b --- /dev/null +++ b/helix-syntax/languages/tree-sitter-llvm @@ -0,0 +1 @@ +Subproject commit 3b213925b9c4f42c1acfe2e10bfbb438d9c6834d diff --git a/helix-syntax/languages/tree-sitter-llvm-mir b/helix-syntax/languages/tree-sitter-llvm-mir new file mode 160000 index 000000000..06fabca19 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-llvm-mir @@ -0,0 +1 @@ +Subproject commit 06fabca19454b2dc00c1b211a7cb7ad0bc2585f1 diff --git a/helix-syntax/languages/tree-sitter-make b/helix-syntax/languages/tree-sitter-make new file mode 160000 index 000000000..a4b918741 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-make @@ -0,0 +1 @@ +Subproject commit a4b9187417d6be349ee5fd4b6e77b4172c6827dd diff --git a/helix-syntax/languages/tree-sitter-markdown b/helix-syntax/languages/tree-sitter-markdown new file mode 160000 index 000000000..ad8c32917 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-markdown @@ -0,0 +1 @@ +Subproject commit ad8c32917a16dfbb387d1da567bf0c3fb6fffde2 diff --git a/helix-syntax/languages/tree-sitter-php b/helix-syntax/languages/tree-sitter-php index 0d63eaf94..57f855461 160000 --- a/helix-syntax/languages/tree-sitter-php +++ b/helix-syntax/languages/tree-sitter-php @@ -1 +1 @@ -Subproject commit 0d63eaf94e8d6c0694551b016c802787e61b3fb2 +Subproject commit 57f855461aeeca73bd4218754fb26b5ac143f98f diff --git a/helix-syntax/languages/tree-sitter-regex b/helix-syntax/languages/tree-sitter-regex new file mode 160000 index 000000000..e1cfca3c7 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-regex @@ -0,0 +1 @@ +Subproject commit e1cfca3c79896ff79842f057ea13e529b66af636 diff --git a/helix-syntax/languages/tree-sitter-rescript b/helix-syntax/languages/tree-sitter-rescript new file mode 160000 index 000000000..761eb9126 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-rescript @@ -0,0 +1 @@ +Subproject commit 761eb9126b65e078b1b5770ac296b4af8870f933 diff --git a/helix-syntax/languages/tree-sitter-scala b/helix-syntax/languages/tree-sitter-scala index fb23ed9a9..0a3dd53a7 160000 --- a/helix-syntax/languages/tree-sitter-scala +++ b/helix-syntax/languages/tree-sitter-scala @@ -1 +1 @@ -Subproject commit fb23ed9a99da012d86b7a5059b9d8928607cce29 +Subproject commit 0a3dd53a7fc4b352a538397d054380aaa28be54c diff --git a/helix-syntax/languages/tree-sitter-tablegen b/helix-syntax/languages/tree-sitter-tablegen new file mode 160000 index 000000000..568dd8a93 --- /dev/null +++ b/helix-syntax/languages/tree-sitter-tablegen @@ -0,0 +1 @@ +Subproject commit 568dd8a937347175fd58db83d4c4cdaeb6069bd2 diff --git a/helix-syntax/languages/tree-sitter-twig b/helix-syntax/languages/tree-sitter-twig new file mode 160000 index 000000000..b7444181f --- /dev/null +++ b/helix-syntax/languages/tree-sitter-twig @@ -0,0 +1 @@ +Subproject commit b7444181fb38e603e25ea8fcdac55f9492e49c27 diff --git a/helix-syntax/languages/tree-sitter-zig b/helix-syntax/languages/tree-sitter-zig index 1f27fd1df..93331b8bd 160000 --- a/helix-syntax/languages/tree-sitter-zig +++ b/helix-syntax/languages/tree-sitter-zig @@ -1 +1 @@ -Subproject commit 1f27fd1dfe7f352408f01b4894c7825f3a1d6c47 +Subproject commit 93331b8bd8b4ebee2b575490b2758f16ad4e9f30 diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 43268291b..e62496f29 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-term" -version = "0.5.0" +version = "0.6.0" description = "A post-modern text editor." authors = ["Blaž Hrastnik "] edition = "2021" @@ -9,6 +9,7 @@ categories = ["editor", "command-line-utilities"] repository = "https://github.com/helix-editor/helix" homepage = "https://helix-editor.com" include = ["src/**/*", "README.md"] +default-run = "hx" [package.metadata.nix] build = true @@ -21,18 +22,18 @@ name = "hx" path = "src/main.rs" [dependencies] -helix-core = { version = "0.5", path = "../helix-core" } -helix-view = { version = "0.5", path = "../helix-view" } -helix-lsp = { version = "0.5", path = "../helix-lsp" } -helix-dap = { version = "0.5", path = "../helix-dap" } +helix-core = { version = "0.6", path = "../helix-core" } +helix-view = { version = "0.6", path = "../helix-view" } +helix-lsp = { version = "0.6", path = "../helix-lsp" } +helix-dap = { version = "0.6", path = "../helix-dap" } anyhow = "1" -once_cell = "1.8" +once_cell = "1.9" tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } num_cpus = "1" tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] } -crossterm = { version = "0.22", features = ["event-stream"] } +crossterm = { version = "0.23", features = ["event-stream"] } signal-hook = "0.3" tokio-stream = "0.1" futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false } @@ -46,7 +47,7 @@ log = "0.4" fuzzy-matcher = "0.3" ignore = "0.4" # markdown doc rendering -pulldown-cmark = { version = "0.8", default-features = false } +pulldown-cmark = { version = "0.9", default-features = false } # file type detection content_inspector = "0.2.4" diff --git a/helix-term/build.rs b/helix-term/build.rs index 61ffa6f4f..21dd5612d 100644 --- a/helix-term/build.rs +++ b/helix-term/build.rs @@ -1,12 +1,17 @@ +use std::borrow::Cow; use std::process::Command; fn main() { let git_hash = Command::new("git") - .args(&["describe", "--dirty"]) + .args(&["rev-parse", "HEAD"]) .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); + .and_then(|x| String::from_utf8(x.stdout).ok()); + + let version: Cow<_> = match git_hash { + Some(git_hash) => format!("{} ({})", env!("CARGO_PKG_VERSION"), &git_hash[..8]).into(), + None => env!("CARGO_PKG_VERSION").into(), + }; + + println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", version); } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 55e4bb039..52a5321fc 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,10 +1,16 @@ -use helix_core::{merge_toml_values, syntax}; +use helix_core::{merge_toml_values, pos_at_coords, syntax, Selection}; use helix_dap::{self as dap, Payload, Request}; use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; use helix_view::{editor::Breakpoint, theme, Editor}; +use serde_json::json; use crate::{ - args::Args, commands::fetch_stack_trace, compositor::Compositor, config::Config, job::Jobs, ui, + args::Args, + commands::{align_view, apply_workspace_edit, fetch_stack_trace, Align}, + compositor::Compositor, + config::Config, + job::Jobs, + ui, }; use log::{error, warn}; @@ -78,17 +84,27 @@ impl Application { None => Ok(def_lang_conf), }; - let theme = if let Some(theme) = &config.theme { - match theme_loader.load(theme) { - Ok(theme) => theme, - Err(e) => { - log::warn!("failed to load theme `{}` - {}", theme, e); + let true_color = config.editor.true_color || crate::true_color(); + let theme = config + .theme + .as_ref() + .and_then(|theme| { + theme_loader + .load(theme) + .map_err(|e| { + log::warn!("failed to load theme `{}` - {}", theme, e); + e + }) + .ok() + .filter(|theme| (true_color || theme.is_16_color())) + }) + .unwrap_or_else(|| { + if true_color { theme_loader.default() + } else { + theme_loader.base16_default() } - } - } else { - theme_loader.default() - }; + }); let syn_loader_conf: helix_core::syntax::Configuration = lang_conf .and_then(|conf| conf.try_into()) @@ -118,7 +134,7 @@ impl Application { // Unset path to prevent accidentally saving to the original tutor file. doc_mut!(editor).set_path(None)?; } else if !args.files.is_empty() { - let first = &args.files[0]; // we know it's not empty + let first = &args.files[0].0; // we know it's not empty if first.is_dir() { std::env::set_current_dir(&first)?; editor.new_file(Action::VerticalSplit); @@ -126,16 +142,25 @@ impl Application { } else { let nr_of_files = args.files.len(); editor.open(first.to_path_buf(), Action::VerticalSplit)?; - for file in args.files { + for (file, pos) in args.files { if file.is_dir() { return Err(anyhow::anyhow!( "expected a path to file, found a directory. (to open a directory pass it as first argument)" )); } else { - editor.open(file.to_path_buf(), Action::Load)?; + let doc_id = editor.open(file, Action::Load)?; + // with Action::Load all documents have the same view + let view_id = editor.tree.focus; + let doc = editor.document_mut(doc_id).unwrap(); + let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); + doc.set_selection(view_id, pos); } } editor.set_status(format!("Loaded {} files.", nr_of_files)); + // align the view to center after all files are loaded, + // does not affect views without pos since it is at the top + let (view, doc) = current!(editor); + align_view(doc, view, Align::Center); } } else if stdin().is_tty() { editor.new_file(Action::VerticalSplit); @@ -197,7 +222,6 @@ impl Application { loop { if self.editor.should_close() { - self.jobs.finish(); break; } @@ -328,7 +352,7 @@ impl Application { None => return, }; match payload { - Payload::Event(ev) => match ev { + Payload::Event(ev) => match *ev { Event::Stopped(events::Stopped { thread_id, description, @@ -529,12 +553,8 @@ impl Application { // trigger textDocument/didOpen for docs that are already open for doc in docs { - // TODO: extract and share with editor.open - let language_id = doc - .language() - .and_then(|s| s.split('.').last()) // source.rust - .map(ToOwned::to_owned) - .unwrap_or_default(); + let language_id = + doc.language_id().map(ToOwned::to_owned).unwrap_or_default(); tokio::spawn(language_server.text_document_did_open( doc.url().unwrap(), @@ -549,6 +569,7 @@ impl Application { let doc = self.editor.document_by_path_mut(&path); if let Some(doc) = doc { + let lang_conf = doc.language_config(); let text = doc.text(); let diagnostics = params @@ -586,19 +607,31 @@ impl Application { return None; }; + let severity = + diagnostic.severity.map(|severity| match severity { + DiagnosticSeverity::ERROR => Error, + DiagnosticSeverity::WARNING => Warning, + DiagnosticSeverity::INFORMATION => Info, + DiagnosticSeverity::HINT => Hint, + severity => unreachable!( + "unrecognized diagnostic severity: {:?}", + severity + ), + }); + + if let Some(lang_conf) = lang_conf { + if let Some(severity) = severity { + if severity < lang_conf.diagnostic_severity { + return None; + } + } + }; + Some(Diagnostic { range: Range { start, end }, line: diagnostic.range.start.line as usize, message: diagnostic.message, - severity: diagnostic.severity.map( - |severity| match severity { - DiagnosticSeverity::ERROR => Error, - DiagnosticSeverity::WARNING => Warning, - DiagnosticSeverity::INFORMATION => Info, - DiagnosticSeverity::HINT => Hint, - severity => unimplemented!("{:?}", severity), - }, - ), + severity, // code // source }) @@ -705,14 +738,6 @@ impl Application { Call::MethodCall(helix_lsp::jsonrpc::MethodCall { method, params, id, .. }) => { - let language_server = match self.editor.language_servers.get_by_id(server_id) { - Some(language_server) => language_server, - None => { - warn!("can't find language server with id `{}`", server_id); - return; - } - }; - let call = match MethodCall::parse(&method, params) { Some(call) => call, None => { @@ -742,8 +767,42 @@ impl Application { if spinner.is_stopped() { spinner.start(); } + let language_server = + match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; + } + }; + tokio::spawn(language_server.reply(id, Ok(serde_json::Value::Null))); } + MethodCall::ApplyWorkspaceEdit(params) => { + apply_workspace_edit( + &mut self.editor, + helix_lsp::OffsetEncoding::Utf8, + ¶ms.edit, + ); + + let language_server = + match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; + } + }; + + tokio::spawn(language_server.reply( + id, + Ok(json!(lsp::ApplyWorkspaceEditResponse { + applied: true, + failure_reason: None, + failed_change: None, + })), + )); + } } } e => unreachable!("{:?}", e), @@ -789,6 +848,8 @@ impl Application { self.event_loop().await; + self.jobs.finish().await; + if self.editor.close_language_servers(None).await.is_err() { log::error!("Timed out waiting for language servers to shutdown"); }; diff --git a/helix-term/src/args.rs b/helix-term/src/args.rs index 40113db92..247d5b320 100644 --- a/helix-term/src/args.rs +++ b/helix-term/src/args.rs @@ -1,5 +1,6 @@ use anyhow::{Error, Result}; -use std::path::PathBuf; +use helix_core::Position; +use std::path::{Path, PathBuf}; #[derive(Default)] pub struct Args { @@ -7,7 +8,7 @@ pub struct Args { pub display_version: bool, pub load_tutor: bool, pub verbosity: u64, - pub files: Vec, + pub files: Vec<(PathBuf, Position)>, } impl Args { @@ -41,15 +42,49 @@ impl Args { } } } - arg => args.files.push(PathBuf::from(arg)), + arg => args.files.push(parse_file(arg)), } } // push the remaining args, if any to the files - for filename in iter { - args.files.push(PathBuf::from(filename)); + for arg in iter { + args.files.push(parse_file(arg)); } Ok(args) } } + +/// Parse arg into [`PathBuf`] and position. +pub(crate) fn parse_file(s: &str) -> (PathBuf, Position) { + let def = || (PathBuf::from(s), Position::default()); + if Path::new(s).exists() { + return def(); + } + split_path_row_col(s) + .or_else(|| split_path_row(s)) + .unwrap_or_else(def) +} + +/// Split file.rs:10:2 into [`PathBuf`], row and col. +/// +/// Does not validate if file.rs is a file or directory. +fn split_path_row_col(s: &str) -> Option<(PathBuf, Position)> { + let mut s = s.rsplitn(3, ':'); + let col: usize = s.next()?.parse().ok()?; + let row: usize = s.next()?.parse().ok()?; + let path = s.next()?.into(); + let pos = Position::new(row.saturating_sub(1), col.saturating_sub(1)); + Some((path, pos)) +} + +/// Split file.rs:10 into [`PathBuf`] and row. +/// +/// Does not validate if file.rs is a file or directory. +fn split_path_row(s: &str) -> Option<(PathBuf, Position)> { + let (row, path) = s.rsplit_once(':')?; + let row: usize = row.parse().ok()?; + let path = path.into(); + let pos = Position::new(row.saturating_sub(1), 0); + Some((path, pos)) +} diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 1871c67e1..677943e86 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -5,15 +5,17 @@ pub use dap::*; use helix_core::{ 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}, - numbers::NumberIncrementor, object, pos_at_coords, regex::{self, Regex, RegexBuilder}, - search, selection, surround, textobject, + search, selection, shellwords, surround, textobject, + tree_sitter::Node, unicode::width::UnicodeWidthChar, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, SmallVec, Tendril, Transaction, @@ -22,13 +24,15 @@ use helix_view::{ clipboard::ClipboardType, document::{Mode, SCRATCH_BUFFER_NAME}, editor::{Action, Motion}, + info::Info, input::KeyEvent, keyboard::KeyCode, view::View, Document, DocumentId, Editor, ViewId, }; -use anyhow::{anyhow, bail, Context as _}; +use anyhow::{anyhow, bail, ensure, Context as _}; +use fuzzy_matcher::FuzzyMatcher; use helix_lsp::{ block_on, lsp, util::{lsp_pos_to_pos, lsp_range_to_range, pos_to_lsp_pos, range_to_lsp_range}, @@ -38,14 +42,15 @@ use insert::*; use movement::Movement; use crate::{ + args, compositor::{self, Component, Compositor}, - ui::{self, FilePicker, Picker, Popup, Prompt, PromptEvent}, + ui::{self, FilePicker, Popup, Prompt, PromptEvent}, }; use crate::job::{self, Job, Jobs}; use futures_util::{FutureExt, StreamExt}; -use std::num::NonZeroUsize; use std::{collections::HashMap, fmt, future::Future}; +use std::{collections::HashSet, num::NonZeroUsize}; use std::{ borrow::Cow, @@ -73,7 +78,7 @@ pub struct Context<'a> { impl<'a> Context<'a> { /// Push a new component onto the compositor. pub fn push_layer(&mut self, component: Box) { - self.callback = Some(Box::new(|compositor: &mut Compositor| { + self.callback = Some(Box::new(|compositor: &mut Compositor, _| { compositor.push(component) })); } @@ -138,47 +143,76 @@ pub 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 { + Self::Typable { name, args, doc: _ } => { + let args: Vec> = args.iter().map(Cow::from).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)); + } + } + } + Self::Static { fun, .. } => (fun)(cx), + } } - pub fn name(&self) -> &'static str { - self.name + pub fn name(&self) -> &str { + match &self { + Self::Typable { name, .. } => name, + Self::Static { name, .. } => name, + } } - pub fn doc(&self) -> &'static str { - self.doc + pub fn doc(&self) -> &str { + match &self { + Self::Typable { doc, .. } => doc, + Self::Static { doc, .. } => doc, + } } #[rustfmt::skip] - commands!( + static_commands!( no_op, "Do nothing", move_char_left, "Move left", move_char_right, "Move right", @@ -240,6 +274,7 @@ impl Command { 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", + ensure_selections_forward, "Ensure the selection is in forward direction", insert_mode, "Insert before selection", append_mode, "Insert after selection (append)", command_mode, "Enter command mode", @@ -261,16 +296,17 @@ impl Command { add_newline_below, "Add newline below", goto_type_definition, "Goto type definition", goto_implementation, "Goto implementation", - goto_file_start, "Goto file start/line", + goto_file_start, "Goto line number else file start", 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_file, "Goto files in selection", + goto_file_hsplit, "Goto files in selection (hsplit)", + goto_file_vsplit, "Goto files in selection (vsplit)", 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", @@ -333,8 +369,12 @@ impl Command { rotate_selection_contents_forward, "Rotate selection contents forward", rotate_selection_contents_backward, "Rotate selections contents backward", expand_selection, "Expand selection to parent syntax node", + shrink_selection, "Shrink selection to previously expanded syntax node", + select_next_sibling, "Select the next sibling in the syntax tree", + select_prev_sibling, "Select the previous sibling in the syntax tree", jump_forward, "Jump forward on jumplist", jump_backward, "Jump backward on jumplist", + save_selection, "Save the current selection to the jumplist", jump_view_right, "Jump to the split to the right", jump_view_left, "Jump to the split to the left", jump_view_up, "Jump to the split above", @@ -382,36 +422,56 @@ impl Command { rename_symbol, "Rename symbol", increment, "Increment", decrement, "Decrement", + record_macro, "Record macro", + replay_macro, "Replay macro", ); } -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() + .find(|cmd| cmd.name() == s) + .cloned() + .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>, @@ -421,9 +481,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, + } } } @@ -622,8 +700,15 @@ fn kill_to_line_end(cx: &mut Context) { 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) + let line_end_pos = line_end_char_index(&text, line); + let pos = range.cursor(text); + + let mut new_range = range.put_cursor(text, line_end_pos, true); + // don't want to remove the line separator itself if the cursor doesn't reach the end of line. + if pos != line_end_pos { + new_range.head = line_end_pos; + } + new_range }); delete_selection_insert_mode(doc, view, &selection); } @@ -736,7 +821,6 @@ fn align_selections(cx: &mut Context) { }); 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 { @@ -770,8 +854,8 @@ fn goto_window(cx: &mut Context, align: Align) { Align::Center => (view.offset.row + ((last_line - view.offset.row) / 2)), Align::Bottom => last_line.saturating_sub(scrolloff + count), } - .min(last_line.saturating_sub(scrolloff)) - .max(view.offset.row + scrolloff); + .max(view.offset.row + scrolloff) + .min(last_line.saturating_sub(scrolloff)); let pos = doc.text().line_to_char(line); @@ -782,7 +866,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) } @@ -1139,7 +1223,6 @@ fn replace(cx: &mut Context) { }); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } }) } @@ -1157,7 +1240,6 @@ where }); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } fn switch_case(cx: &mut Context) { @@ -1222,16 +1304,23 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { .max(view.offset.row + scrolloff) .min(last_line.saturating_sub(scrolloff)); - let head = pos_at_coords(text, Position::new(line, cursor.col), true); // this func will properly truncate to line end + // If cursor needs moving, replace primary selection + if line != cursor.row { + let head = pos_at_coords(text, Position::new(line, cursor.col), true); // this func will properly truncate to line end - let anchor = if doc.mode == Mode::Select { - range.anchor - } else { - head - }; + let anchor = if doc.mode == Mode::Select { + range.anchor + } else { + head + }; - // TODO: only manipulate main selection - doc.set_selection(view.id, Selection::single(anchor, head)); + // replace primary selection with an empty selection at cursor pos + let prim_sel = Range::new(anchor, head); + let mut sel = doc.selection(view.id).clone(); + let idx = sel.primary_index(); + sel = sel.replace(idx, prim_sel); + doc.set_selection(view.id, sel); + } } fn page_up(cx: &mut Context) { @@ -1389,6 +1478,7 @@ fn split_selection_on_newline(cx: &mut Context) { doc.set_selection(view.id, selection); } +#[allow(clippy::too_many_arguments)] fn search_impl( doc: &mut Document, view: &mut View, @@ -1397,6 +1487,7 @@ fn search_impl( movement: Movement, direction: Direction, scrolloff: usize, + wrap_around: bool, ) { let text = doc.text().slice(..); let selection = doc.selection(view.id); @@ -1422,16 +1513,22 @@ fn search_impl( // use find_at to find the next match after the cursor, loop around the end // Careful, `Regex` uses `bytes` as offsets, not character indices! - let mat = match direction { - Direction::Forward => regex - .find_at(contents, start) - .or_else(|| regex.find(contents)), - Direction::Backward => regex.find_iter(&contents[..start]).last().or_else(|| { - offset = start; - regex.find_iter(&contents[start..]).last() - }), + let mut mat = match direction { + Direction::Forward => regex.find_at(contents, start), + Direction::Backward => regex.find_iter(&contents[..start]).last(), }; - // TODO: message on wraparound + + if wrap_around && mat.is_none() { + mat = match direction { + Direction::Forward => regex.find(contents), + Direction::Backward => { + offset = start; + regex.find_iter(&contents[start..]).last() + } + } + // TODO: message on wraparound + } + if let Some(mat) = mat { let start = text.byte_to_char(mat.start() + offset); let end = text.byte_to_char(mat.end() + offset); @@ -1483,8 +1580,9 @@ fn rsearch(cx: &mut Context) { fn searcher(cx: &mut Context, direction: Direction) { let reg = cx.register.unwrap_or('/'); let scrolloff = cx.editor.config.scrolloff; + let wrap_around = cx.editor.config.search.wrap_around; - let (_, doc) = current!(cx.editor); + let doc = doc!(cx.editor); // TODO: could probably share with select_on_matches? @@ -1516,6 +1614,7 @@ fn searcher(cx: &mut Context, direction: Direction) { Movement::Move, direction, scrolloff, + wrap_around, ); }, ); @@ -1530,16 +1629,27 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir if let Some(query) = registers.read('/') { let query = query.last().unwrap(); let contents = doc.text().slice(..).to_string(); - let case_insensitive = if cx.editor.config.smart_case { + let search_config = &cx.editor.config.search; + let case_insensitive = if search_config.smart_case { !query.chars().any(char::is_uppercase) } else { false }; + let wrap_around = search_config.wrap_around; if let Ok(regex) = RegexBuilder::new(query) .case_insensitive(case_insensitive) .build() { - search_impl(doc, view, &contents, ®ex, movement, direction, scrolloff); + search_impl( + doc, + view, + &contents, + ®ex, + movement, + direction, + scrolloff, + wrap_around, + ); } else { // get around warning `mutable_borrow_reservation_conflict` // which will be a hard error in the future @@ -1571,14 +1681,14 @@ fn search_selection(cx: &mut Context) { let query = doc.selection(view.id).primary().fragment(contents); let regex = regex::escape(&query); cx.editor.registers.get_mut('/').push(regex); - let msg = format!("register '{}' set to '{}'", '\\', query); + let msg = format!("register '{}' set to '{}'", '/', query); cx.editor.set_status(msg); } 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 smart_case = cx.editor.config.search.smart_case; let file_picker_config = cx.editor.config.file_picker.clone(); let completions = search_completions(cx, None); @@ -1789,7 +1899,6 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) { match op { Operation::Delete => { - doc.append_changes_to_history(view.id); // exit select mode, if currently in select mode exit_select_mode(cx); } @@ -1845,7 +1954,21 @@ fn flip_selections(cx: &mut Context) { let selection = doc .selection(view.id) .clone() - .transform(|range| Range::new(range.head, range.anchor)); + .transform(|range| range.flip()); + doc.set_selection(view.id, selection); +} + +fn ensure_selections_forward(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + let selection = doc + .selection(view.id) + .clone() + .transform(|r| match r.direction() { + Direction::Forward => r, + Direction::Backward => r.flip(), + }); + doc.set_selection(view.id, selection); } @@ -1879,7 +2002,7 @@ fn append_mode(cx: &mut Context) { if !last_range.is_empty() && last_range.head == end { let transaction = Transaction::change( doc.text(), - std::array::IntoIter::new([(end, end, Some(doc.line_ending.as_str().into()))]), + [(end, end, Some(doc.line_ending.as_str().into()))].into_iter(), ); doc.apply(&transaction, view.id); } @@ -1893,7 +2016,7 @@ fn append_mode(cx: &mut Context) { doc.set_selection(view.id, selection); } -mod cmd { +pub mod cmd { use super::*; use helix_view::editor::Action; @@ -1905,13 +2028,13 @@ mod cmd { pub aliases: &'static [&'static str], pub doc: &'static str, // params, flags, helper, completer - pub fun: fn(&mut compositor::Context, &[&str], PromptEvent) -> anyhow::Result<()>, + pub fun: fn(&mut compositor::Context, &[Cow], PromptEvent) -> anyhow::Result<()>, pub completer: Option, } fn quit( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { // last view and we have unsaved changes @@ -1926,7 +2049,7 @@ mod cmd { fn force_quit( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor.close(view!(cx.editor).id); @@ -1936,17 +2059,25 @@ mod cmd { fn open( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - let path = args.get(0).context("wrong argument count")?; - let _ = cx.editor.open(path.into(), Action::Replace)?; + ensure!(!args.is_empty(), "wrong argument count"); + for arg in args { + let (path, pos) = args::parse_file(arg); + let _ = cx.editor.open(path, Action::Replace)?; + let (view, doc) = current!(cx.editor); + let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); + doc.set_selection(view.id, pos); + // does not affect opening a buffer without pos + align_view(doc, view, Align::Center); + } Ok(()) } fn buffer_close( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let view = view!(cx.editor); @@ -1957,7 +2088,7 @@ mod cmd { fn force_buffer_close( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let view = view!(cx.editor); @@ -1966,15 +2097,12 @@ mod cmd { Ok(()) } - fn write_impl>( - cx: &mut compositor::Context, - path: Option

, - ) -> anyhow::Result<()> { + fn write_impl(cx: &mut compositor::Context, path: Option<&Cow>) -> anyhow::Result<()> { let jobs = &mut cx.jobs; - let (_, doc) = current!(cx.editor); + let doc = doc_mut!(cx.editor); if let Some(ref path) = path { - doc.set_path(Some(path.as_ref())) + doc.set_path(Some(path.as_ref().as_ref())) .context("invalid filepath")?; } if doc.path().is_none() { @@ -2003,7 +2131,7 @@ mod cmd { fn write( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first()) @@ -2011,7 +2139,7 @@ mod cmd { fn new_file( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor.new_file(Action::Replace); @@ -2021,11 +2149,10 @@ mod cmd { fn format( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - let (_, doc) = current!(cx.editor); - + let doc = doc!(cx.editor); if let Some(format) = doc.format() { let callback = make_format_callback(doc.id(), doc.version(), Modified::LeaveModified, format); @@ -2036,7 +2163,7 @@ mod cmd { } fn set_indent_style( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { use IndentStyle::*; @@ -2056,7 +2183,7 @@ mod cmd { // Attempt to parse argument as an indent style. let style = match args.get(0) { Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs), - Some(&"0") => Some(Tabs), + Some(Cow::Borrowed("0")) => Some(Tabs), Some(arg) => arg .parse::() .ok() @@ -2075,7 +2202,7 @@ mod cmd { /// Sets or reports the current document's line ending setting. fn set_line_ending( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { use LineEnding::*; @@ -2119,7 +2246,7 @@ mod cmd { fn earlier( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; @@ -2135,7 +2262,7 @@ mod cmd { fn later( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; @@ -2150,7 +2277,7 @@ mod cmd { fn write_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first())?; @@ -2159,7 +2286,7 @@ mod cmd { fn force_write_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first())?; @@ -2190,13 +2317,13 @@ mod cmd { fn write_all_impl( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, quit: bool, force: bool, ) -> anyhow::Result<()> { let mut errors = String::new(); - + let jobs = &mut cx.jobs; // save all documents for doc in &mut cx.editor.documents.values_mut() { if doc.path().is_none() { @@ -2204,9 +2331,23 @@ mod cmd { continue; } - // TODO: handle error. - let handle = doc.save(); - cx.jobs.add(Job::new(handle).wait_before_exiting()); + if !doc.is_modified() { + continue; + } + + let fmt = doc.auto_format().map(|fmt| { + let shared = fmt.shared(); + let callback = make_format_callback( + doc.id(), + doc.version(), + Modified::SetUnmodified, + shared.clone(), + ); + jobs.callback(callback); + shared + }); + let future = doc.format_and_save(fmt); + jobs.add(Job::new(future).wait_before_exiting()); } if quit { @@ -2226,7 +2367,7 @@ mod cmd { fn write_all( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_all_impl(cx, args, event, false, false) @@ -2234,7 +2375,7 @@ mod cmd { fn write_all_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_all_impl(cx, args, event, true, false) @@ -2242,18 +2383,13 @@ mod cmd { fn force_write_all_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_all_impl(cx, args, event, true, true) } - fn quit_all_impl( - editor: &mut Editor, - _args: &[&str], - _event: PromptEvent, - force: bool, - ) -> anyhow::Result<()> { + fn quit_all_impl(editor: &mut Editor, force: bool) -> anyhow::Result<()> { if !force { buffers_remaining_impl(editor)?; } @@ -2269,23 +2405,23 @@ mod cmd { fn quit_all( cx: &mut compositor::Context, - args: &[&str], - event: PromptEvent, + _args: &[Cow], + _event: PromptEvent, ) -> anyhow::Result<()> { - quit_all_impl(&mut cx.editor, args, event, false) + quit_all_impl(cx.editor, false) } fn force_quit_all( cx: &mut compositor::Context, - args: &[&str], - event: PromptEvent, + _args: &[Cow], + _event: PromptEvent, ) -> anyhow::Result<()> { - quit_all_impl(&mut cx.editor, args, event, true) + quit_all_impl(cx.editor, true) } fn cquit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let exit_code = args @@ -2294,95 +2430,110 @@ mod cmd { .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); - } + quit_all_impl(cx.editor, false) + } - Ok(()) + fn force_cquit( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, + ) -> anyhow::Result<()> { + let exit_code = args + .first() + .and_then(|code| code.parse::().ok()) + .unwrap_or(1); + cx.editor.exit_code = exit_code; + + quit_all_impl(cx.editor, true) } fn theme( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - let theme = args.first().context("theme not provided")?; - cx.editor.set_theme_from_name(theme) + let theme = args.first().context("Theme not provided")?; + let theme = cx + .editor + .theme_loader + .load(theme) + .with_context(|| format!("Failed setting theme {}", theme))?; + let true_color = cx.editor.config.true_color || crate::true_color(); + if !(true_color || theme.is_16_color()) { + bail!("Unsupported theme: theme requires true color support"); + } + cx.editor.set_theme(theme); + Ok(()) } fn yank_main_selection_to_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _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( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - let (_, doc) = current!(cx.editor); - let separator = args - .first() - .copied() - .unwrap_or_else(|| doc.line_ending.as_str()); - yank_joined_to_clipboard_impl(&mut cx.editor, separator, ClipboardType::Clipboard) + let doc = doc!(cx.editor); + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); + yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Clipboard) } fn yank_main_selection_to_primary_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _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( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - let (_, doc) = current!(cx.editor); - let separator = args - .first() - .copied() - .unwrap_or_else(|| doc.line_ending.as_str()); - yank_joined_to_clipboard_impl(&mut cx.editor, separator, ClipboardType::Selection) + let doc = doc!(cx.editor); + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); + yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Selection) } fn paste_clipboard_after( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) } fn paste_clipboard_before( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) } fn paste_primary_clipboard_after( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) } fn paste_primary_clipboard_before( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) } fn replace_selections_with_clipboard_impl( @@ -2409,7 +2560,7 @@ mod cmd { fn replace_selections_with_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard) @@ -2417,7 +2568,7 @@ mod cmd { fn replace_selections_with_primary_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { replace_selections_with_clipboard_impl(cx, ClipboardType::Selection) @@ -2425,7 +2576,7 @@ mod cmd { fn show_clipboard_provider( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor @@ -2435,12 +2586,13 @@ mod cmd { fn change_current_directory( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let dir = helix_core::path::expand_tilde( args.first() .context("target directory not provided")? + .as_ref() .as_ref(), ); @@ -2458,7 +2610,7 @@ mod cmd { fn show_current_directory( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; @@ -2470,10 +2622,10 @@ mod cmd { /// Sets the [`Document`]'s encoding.. fn set_encoding( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - let (_, doc) = current!(cx.editor); + let doc = doc_mut!(cx.editor); if let Some(label) = args.first() { doc.set_encoding(label) } else { @@ -2486,7 +2638,7 @@ mod cmd { /// Reload the [`Document`] from its source file. fn reload( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let (view, doc) = current!(cx.editor); @@ -2495,7 +2647,7 @@ mod cmd { fn tree_sitter_scopes( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let (view, doc) = current!(cx.editor); @@ -2509,15 +2661,18 @@ mod cmd { fn vsplit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let id = view!(cx.editor).doc; - if let Some(path) = args.get(0) { - cx.editor.open(path.into(), Action::VerticalSplit)?; - } else { + if args.is_empty() { cx.editor.switch(id, Action::VerticalSplit); + } else { + for arg in args { + cx.editor + .open(PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; + } } Ok(()) @@ -2525,15 +2680,18 @@ mod cmd { fn hsplit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let id = view!(cx.editor).doc; - if let Some(path) = args.get(0) { - cx.editor.open(path.into(), Action::HorizontalSplit)?; - } else { + if args.is_empty() { cx.editor.switch(id, Action::HorizontalSplit); + } else { + for arg in args { + cx.editor + .open(PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; + } } Ok(()) @@ -2541,7 +2699,7 @@ mod cmd { fn debug_eval( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { if let Some(debugger) = cx.editor.debugger.as_mut() { @@ -2563,7 +2721,7 @@ mod cmd { fn debug_start( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let mut args = args.to_owned(); @@ -2571,12 +2729,12 @@ mod cmd { 0 => None, _ => Some(args.remove(0)), }; - dap_start_impl(cx, name, None, Some(args)) + dap_start_impl(cx, name.as_deref(), None, Some(args)) } fn debug_remote( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let mut args = args.to_owned(); @@ -2588,12 +2746,12 @@ mod cmd { 0 => None, _ => Some(args.remove(0)), }; - dap_start_impl(cx, name, address, Some(args)) + dap_start_impl(cx, name.as_deref(), address, Some(args)) } fn tutor( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let path = helix_core::runtime_dir().join("tutor.txt"); @@ -2605,20 +2763,135 @@ mod cmd { pub(super) fn goto_line_number( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - if args.is_empty() { - bail!("Line number required"); - } + ensure!(!args.is_empty(), "Line number required"); let line = args[0].parse::()?; - goto_line_impl(&mut cx.editor, NonZeroUsize::new(line)); + goto_line_impl(cx.editor, NonZeroUsize::new(line)); let (view, doc) = current!(cx.editor); view.ensure_cursor_in_view(doc, line); + Ok(()) + } + + fn setting( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, + ) -> anyhow::Result<()> { + let runtime_config = &mut cx.editor.config; + + if args.len() != 2 { + anyhow::bail!("Bad arguments. Usage: `:set key field`"); + } + + let (key, arg) = (&args[0].to_lowercase(), &args[1]); + + match key.as_ref() { + "scrolloff" => runtime_config.scrolloff = arg.parse()?, + "scroll-lines" => runtime_config.scroll_lines = arg.parse()?, + "mouse" => runtime_config.mouse = arg.parse()?, + "line-number" => runtime_config.line_number = arg.parse()?, + "middle-click_paste" => runtime_config.middle_click_paste = arg.parse()?, + "auto-pairs" => runtime_config.auto_pairs = arg.parse()?, + "auto-completion" => runtime_config.auto_completion = arg.parse()?, + "completion-trigger-len" => runtime_config.completion_trigger_len = arg.parse()?, + "auto-info" => runtime_config.auto_info = arg.parse()?, + "true-color" => runtime_config.true_color = arg.parse()?, + "search.smart-case" => runtime_config.search.smart_case = arg.parse()?, + "search.wrap-around" => runtime_config.search.wrap_around = arg.parse()?, + _ => anyhow::bail!("Unknown key `{}`.", args[0]), + } + + Ok(()) + } + + fn sort( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, + ) -> anyhow::Result<()> { + sort_impl(cx, args, false) + } + + fn sort_reverse( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, + ) -> anyhow::Result<()> { + sort_impl(cx, args, true) + } + + fn sort_impl( + cx: &mut compositor::Context, + _args: &[Cow], + reverse: bool, + ) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id); + + let mut fragments: Vec<_> = selection + .fragments(text) + .map(|fragment| Tendril::from(fragment.as_ref())) + .collect(); + + fragments.sort_by(match reverse { + true => |a: &Tendril, b: &Tendril| b.cmp(a), + false => |a: &Tendril, b: &Tendril| a.cmp(b), + }); + + let transaction = Transaction::change( + doc.text(), + selection + .into_iter() + .zip(fragments) + .map(|(s, fragment)| (s.from(), s.to(), Some(fragment))), + ); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + + Ok(()) + } + + fn tree_sitter_subtree( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, + ) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + + if let Some(syntax) = doc.syntax() { + let primary_selection = doc.selection(view.id).primary(); + let text = doc.text(); + let from = text.char_to_byte(primary_selection.from()); + let to = text.char_to_byte(primary_selection.to()); + if let Some(selected_node) = syntax + .tree() + .root_node() + .descendant_for_byte_range(from, to) + { + let contents = format!("```tsq\n{}\n```", selected_node.to_sexp()); + + let callback = async move { + let call: job::Callback = + Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let popup = Popup::new("hover", contents); + compositor.replace_or_push("hover", Box::new(popup)); + }); + Ok(call) + }; + + cx.jobs.callback(callback); + } + } Ok(()) } @@ -2646,18 +2919,18 @@ mod cmd { completer: Some(completers::filename), }, TypableCommand { - name: "buffer-close", - aliases: &["bc", "bclose"], - doc: "Close the current buffer.", - fun: buffer_close, - completer: None, // FIXME: buffer completer + 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 + 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", @@ -2676,7 +2949,7 @@ mod cmd { TypableCommand { name: "format", aliases: &["fmt"], - doc: "Format the file using a formatter.", + doc: "Format the file using the LSP formatter.", fun: format, completer: None, }, @@ -2764,10 +3037,17 @@ mod cmd { fun: cquit, completer: None, }, + TypableCommand { + name: "cquit!", + aliases: &["cq!"], + doc: "Quit with exit code (default 1) forcefully (ignoring unsaved changes). Accepts an optional integer exit code (:cq! 2).", + fun: force_cquit, + completer: None, + }, TypableCommand { name: "theme", aliases: &[], - doc: "Change the theme of current view. Requires theme name as argument (:theme )", + doc: "Change the editor theme.", fun: theme, completer: Some(completers::theme), }, @@ -2851,7 +3131,7 @@ mod cmd { TypableCommand { name: "change-current-directory", aliases: &["cd"], - doc: "Change the current working directory (:cd

).", + doc: "Change the current working directory.", fun: change_current_directory, completer: Some(completers::directory), }, @@ -2931,18 +3211,47 @@ mod cmd { doc: "Go to line number.", fun: goto_line_number, completer: None, - } + }, + TypableCommand { + name: "set-option", + aliases: &["set"], + doc: "Set a config option at runtime", + fun: setting, + completer: Some(completers::setting), + }, + TypableCommand { + name: "sort", + aliases: &[], + doc: "Sort ranges in selection.", + fun: sort, + completer: None, + }, + TypableCommand { + name: "rsort", + aliases: &[], + doc: "Sort ranges in selection in reverse order.", + fun: sort_reverse, + completer: None, + }, + TypableCommand { + name: "tree-sitter-subtree", + aliases: &["ts-subtree"], + doc: "Display tree sitter subtree under cursor, primarily for debugging queries.", + fun: tree_sitter_subtree, + completer: None, + }, ]; - 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) { @@ -2950,17 +3259,28 @@ fn command_mode(cx: &mut Context) { ":".into(), Some(':'), |input: &str| { + static FUZZY_MATCHER: Lazy = + Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); + // we use .this over split_whitespace() because we care about empty segments let parts = input.split(' ').collect::>(); // simple heuristic: if there's no just one part, complete command name. // if there's a space, per command completion kicks in. if parts.len() <= 1 { - let end = 0..; - cmd::TYPABLE_COMMAND_LIST + let mut matches: Vec<_> = cmd::TYPABLE_COMMAND_LIST .iter() - .filter(|command| command.name.contains(input)) - .map(|command| (end.clone(), Cow::Borrowed(command.name))) + .filter_map(|command| { + FUZZY_MATCHER + .fuzzy_match(command.name, input) + .map(|score| (command.name, score)) + }) + .collect(); + + matches.sort_unstable_by_key(|(_file, score)| std::cmp::Reverse(*score)); + matches + .into_iter() + .map(|(name, _)| (0.., name.into())) .collect() } else { let part = parts.last().unwrap(); @@ -2968,7 +3288,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() @@ -2996,15 +3316,25 @@ fn command_mode(cx: &mut Context) { // 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) { + if let Err(e) = cmd::goto_line_number(cx, &[Cow::from(parts[0])], event) { cx.editor.set_error(format!("{}", e)); } return; } // Handle typable commands - if let Some(cmd) = cmd::COMMANDS.get(parts[0]) { - if let Err(e) = (cmd.fun)(cx, &parts[1..], event) { + if let Some(cmd) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) { + let args = if cfg!(unix) { + shellwords::shellwords(input) + } else { + // Windows doesn't support POSIX, so fallback for now + parts + .into_iter() + .map(|part| part.into()) + .collect::>() + }; + + if let Err(e) = (cmd.fun)(cx, &args[1..], event) { cx.editor.set_error(format!("{}", e)); } } else { @@ -3016,7 +3346,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); } @@ -3027,7 +3357,8 @@ fn command_mode(cx: &mut Context) { } fn file_picker(cx: &mut Context) { - let root = find_root(None).unwrap_or_else(|| PathBuf::from("./")); + // We don't specify language markers, root will be the root of the current git repo + let root = find_root(None, &[]).unwrap_or_else(|| PathBuf::from("./")); let picker = ui::file_picker(root, &cx.editor.config); cx.push_layer(Box::new(picker)); } @@ -3084,8 +3415,8 @@ fn buffer_picker(cx: &mut Context) { .map(|(_, doc)| new_meta(doc)) .collect(), BufferMeta::format, - |cx, meta, _action| { - cx.editor.switch(meta.id, Action::Replace); + |cx, meta, action| { + cx.editor.switch(meta.id, action); }, |editor, meta| { let doc = &editor.documents.get(&meta.id)?; @@ -3119,7 +3450,7 @@ fn symbol_picker(cx: &mut Context) { nested_to_flat(list, file, child); } } - let (_, doc) = current!(cx.editor); + let doc = doc!(cx.editor); let language_server = match doc.language_server() { Some(language_server) => language_server, @@ -3140,7 +3471,7 @@ fn symbol_picker(cx: &mut Context) { let symbols = match symbols { lsp::DocumentSymbolResponse::Flat(symbols) => symbols, lsp::DocumentSymbolResponse::Nested(symbols) => { - let (_view, doc) = current!(editor); + let doc = doc!(editor); let mut flat_symbols = Vec::new(); for symbol in symbols { nested_to_flat(&mut flat_symbols, &doc.identifier(), symbol) @@ -3182,17 +3513,15 @@ fn symbol_picker(cx: &mut Context) { } fn workspace_symbol_picker(cx: &mut Context) { - let (_, doc) = current!(cx.editor); - + let doc = doc!(cx.editor); + let current_path = doc.path().cloned(); 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, @@ -3243,6 +3572,15 @@ fn workspace_symbol_picker(cx: &mut Context) { ) } +impl ui::menu::Item for lsp::CodeActionOrCommand { + fn label(&self) -> &str { + match self { + lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str(), + lsp::CodeActionOrCommand::Command(command) => command.title.as_str(), + } + } +} + pub fn code_action(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -3262,49 +3600,85 @@ pub fn code_action(cx: &mut Context) { cx.callback( future, - move |_editor: &mut Editor, + move |editor: &mut Editor, compositor: &mut Compositor, response: Option| { - if let Some(actions) = response { - let picker = Picker::new( - true, - actions, - |action| match action { - lsp::CodeActionOrCommand::CodeAction(action) => { - action.title.as_str().into() - } - lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), - }, - move |cx, code_action, _action| match code_action { - lsp::CodeActionOrCommand::Command(command) => { - log::debug!("code action command: {:?}", command); - cx.editor.set_error(String::from("Handling code action command is not implemented yet, see https://github.com/helix-editor/helix/issues/183")); + let actions = match response { + Some(a) => a, + None => return, + }; + if actions.is_empty() { + editor.set_status("No code actions available".to_owned()); + return; + } + + let mut picker = ui::Menu::new(actions, move |editor, code_action, event| { + if event != PromptEvent::Validate { + return; + } + + // always present here + let code_action = code_action.unwrap(); + + match code_action { + lsp::CodeActionOrCommand::Command(command) => { + log::debug!("code action command: {:?}", command); + execute_lsp_command(editor, command.clone()); + } + lsp::CodeActionOrCommand::CodeAction(code_action) => { + log::debug!("code action: {:?}", code_action); + if let Some(ref workspace_edit) = code_action.edit { + log::debug!("edit: {:?}", workspace_edit); + apply_workspace_edit(editor, offset_encoding, workspace_edit); } - lsp::CodeActionOrCommand::CodeAction(code_action) => { - log::debug!("code action: {:?}", code_action); - if let Some(ref workspace_edit) = code_action.edit { - apply_workspace_edit(cx.editor, offset_encoding, workspace_edit) - } + + // if code action provides both edit and command first the edit + // should be applied and then the command + if let Some(command) = &code_action.command { + execute_lsp_command(editor, command.clone()); } - }, - ); - compositor.push(Box::new(picker)) - } + } + } + }); + picker.move_down(); // pre-select the first item + + let popup = Popup::new("code-action", picker).margin(helix_view::graphics::Margin { + vertical: 1, + horizontal: 1, + }); + compositor.replace_or_push("code-action", Box::new(popup)); }, ) } +pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { + let doc = doc!(editor); + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => return, + }; + + // the command is executed on the server and communicated back + // to the client asynchronously using workspace edits + let command_future = language_server.command(cmd); + tokio::spawn(async move { + let res = command_future.await; + + if let Err(e) = res { + log::error!("execute LSP command: {}", e); + } + }); +} + 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 { + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) - } else { - false - }; + }); if ignore_if_exists && path.exists() { Ok(()) } else { @@ -3314,11 +3688,12 @@ pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { 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 - }; + let recursive = op + .options + .as_ref() + .and_then(|options| options.recursive) + .unwrap_or(false); + if recursive { fs::remove_dir_all(&path) } else { @@ -3333,11 +3708,9 @@ pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { 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 { + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) - } else { - false - }; + }); if ignore_if_exists && to.exists() { Ok(()) } else { @@ -3347,7 +3720,7 @@ pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { } } -fn apply_workspace_edit( +pub fn apply_workspace_edit( editor: &mut Editor, offset_encoding: OffsetEncoding, workspace_edit: &lsp::WorkspaceEdit, @@ -3454,7 +3827,7 @@ fn apply_workspace_edit( fn last_picker(cx: &mut Context) { // TODO: last picker does not seem to work well with buffer_picker - cx.callback = Some(Box::new(|compositor: &mut Compositor| { + cx.callback = Some(Box::new(|compositor: &mut Compositor, _| { if let Some(picker) = compositor.last_picker.take() { compositor.push(picker); } @@ -3538,22 +3911,22 @@ fn open(cx: &mut Context, open: Open) { let mut offs = 0; let mut transaction = Transaction::change_by_selection(contents, selection, |range| { - let line = range.cursor_line(text); + let cursor_line = range.cursor_line(text); - let line = match open { + let new_line = match open { // adjust position to the end of the line (next line - 1) - Open::Below => line + 1, + Open::Below => cursor_line + 1, // adjust position to the end of the previous line (current line - 1) - Open::Above => line, + Open::Above => cursor_line, }; // Index to insert newlines after, as well as the char width // to use to compensate for those inserted newlines. - let (line_end_index, line_end_offset_width) = if line == 0 { + let (line_end_index, line_end_offset_width) = if new_line == 0 { (0, 0) } else { ( - line_end_char_index(&doc.text().slice(..), line.saturating_sub(1)), + line_end_char_index(&doc.text().slice(..), new_line.saturating_sub(1)), doc.line_ending.len_chars(), ) }; @@ -3564,8 +3937,10 @@ fn open(cx: &mut Context, open: Open) { doc.syntax(), text, line_end_index, + new_line.saturating_sub(1), true, - ); + ) + .unwrap_or_else(|| indent::indent_level_for_line(text.line(cursor_line), doc.tab_width())); let indent = doc.indent_unit().repeat(indent_level); let indent_len = indent.len(); let mut text = String::with_capacity(1 + indent_len); @@ -3611,7 +3986,7 @@ fn normal_mode(cx: &mut Context) { doc.mode = Mode::Normal; - doc.append_changes_to_history(view.id); + try_restore_indent(doc, view.id); // if leaving append mode, move cursor back by 1 if doc.restore_cursor { @@ -3628,6 +4003,40 @@ fn normal_mode(cx: &mut Context) { } } +fn try_restore_indent(doc: &mut Document, view_id: ViewId) { + use helix_core::chars::char_is_whitespace; + use helix_core::Operation; + + fn inserted_a_new_blank_line(changes: &[Operation], pos: usize, line_end_pos: usize) -> bool { + if let [Operation::Retain(move_pos), Operation::Insert(ref inserted_str), Operation::Retain(_)] = + changes + { + move_pos + inserted_str.len() == pos + && inserted_str.starts_with('\n') + && inserted_str.chars().skip(1).all(char_is_whitespace) + && pos == line_end_pos // ensure no characters exists after current position + } else { + false + } + } + + let doc_changes = doc.changes().changes(); + let text = doc.text().slice(..); + let range = doc.selection(view_id).primary(); + let pos = range.cursor(text); + let line_end_pos = line_end_char_index(&text, range.cursor_line(text)); + + if inserted_a_new_blank_line(doc_changes, pos, line_end_pos) { + // Removes tailing whitespaces. + let transaction = + Transaction::change_by_selection(doc.text(), doc.selection(view_id), |range| { + let line_start_pos = text.line_to_char(range.cursor_line(text)); + (line_start_pos, pos, None) + }); + doc.apply(&transaction, view_id); + } +} + // Store a jump on the jumplist. fn push_jump(editor: &mut Editor) { let (view, doc) = current!(editor); @@ -3636,7 +4045,7 @@ fn push_jump(editor: &mut Editor) { } fn goto_line(cx: &mut Context) { - goto_line_impl(&mut cx.editor, cx.count) + goto_line_impl(cx.editor, cx.count) } fn goto_line_impl(editor: &mut Editor, count: Option) { @@ -3702,6 +4111,20 @@ fn goto_last_modification(cx: &mut Context) { } } +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(..); @@ -3979,27 +4402,21 @@ fn goto_pos(editor: &mut Editor, pos: usize) { } fn goto_first_diag(cx: &mut Context) { - let editor = &mut cx.editor; - let (_, doc) = current!(editor); - + let doc = doc!(cx.editor); let pos = match doc.diagnostics().first() { Some(diag) => diag.range.start, None => return, }; - - goto_pos(editor, pos); + goto_pos(cx.editor, pos); } fn goto_last_diag(cx: &mut Context) { - let editor = &mut cx.editor; - let (_, doc) = current!(editor); - + let doc = doc!(cx.editor); let pos = match doc.diagnostics().last() { Some(diag) => diag.range.start, None => return, }; - - goto_pos(editor, pos); + goto_pos(cx.editor, pos); } fn goto_next_diag(cx: &mut Context) { @@ -4089,7 +4506,6 @@ fn signature_help(cx: &mut Context) { ); } -// NOTE: Transactions in this module get appended to history when we switch back to normal mode. pub mod insert { use super::*; pub type Hook = fn(&Rope, &Selection, char) -> Option; @@ -4184,8 +4600,10 @@ pub mod insert { // The default insert hook: simply insert the character #[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option { - let t = Tendril::from_char(ch); - let transaction = Transaction::insert(doc, selection, t); + let cursors = selection.clone().cursors(doc.slice(..)); + let mut t = Tendril::new(); + t.push(ch); + let transaction = Transaction::insert(doc, &cursors, t); Some(transaction) } @@ -4200,11 +4618,11 @@ pub mod insert { }; let text = doc.text(); - let selection = doc.selection(view.id).clone().cursors(text.slice(..)); + let selection = doc.selection(view.id); // run through insert hooks, stopping on the first one that returns Some(t) for hook in hooks { - if let Some(transaction) = hook(text, &selection, c) { + if let Some(transaction) = hook(text, selection, c) { doc.apply(&transaction, view.id); break; } @@ -4254,48 +4672,48 @@ pub mod insert { }; let curr = contents.get_char(pos).unwrap_or(' '); - // TODO: offset range.head by 1? when calculating? + let current_line = text.char_to_line(pos); let indent_level = indent::suggested_indent_for_pos( doc.language_config(), doc.syntax(), text, - pos.saturating_sub(1), + pos, + current_line, true, - ); - let indent = doc.indent_unit().repeat(indent_level); - let mut text = String::with_capacity(1 + indent.len()); - text.push_str(doc.line_ending.as_str()); - text.push_str(&indent); + ) + .unwrap_or_else(|| { + indent::indent_level_for_line(text.line(current_line), doc.tab_width()) + }); - let head = pos + offs + text.chars().count(); + let indent = doc.indent_unit().repeat(indent_level); + let mut text = String::new(); + // If we are between pairs (such as brackets), we want to insert an additional line which is indented one level more and place the cursor there + let new_head_pos = if helix_core::auto_pairs::PAIRS.contains(&(prev, curr)) { + let inner_indent = doc.indent_unit().repeat(indent_level + 1); + text.reserve_exact(2 + indent.len() + inner_indent.len()); + text.push_str(doc.line_ending.as_str()); + text.push_str(&inner_indent); + let new_head_pos = pos + offs + text.chars().count(); + text.push_str(doc.line_ending.as_str()); + text.push_str(&indent); + new_head_pos + } else { + text.reserve_exact(1 + indent.len()); + text.push_str(doc.line_ending.as_str()); + text.push_str(&indent); + pos + offs + text.chars().count() + }; // TODO: range replace or extend // range.replace(|range| range.is_empty(), head); -> fn extend if cond true, new head pos // can be used with cx.mode to do replace or extend on most changes - ranges.push(Range::new( - if range.is_empty() { - head - } else { - range.anchor + offs - }, - head, - )); - - // if between a bracket pair - if helix_core::auto_pairs::PAIRS.contains(&(prev, curr)) { - // another newline, indent the end bracket one level less - let indent = doc.indent_unit().repeat(indent_level.saturating_sub(1)); - text.push_str(doc.line_ending.as_str()); - text.push_str(&indent); - } - + ranges.push(Range::new(new_head_pos, new_head_pos)); offs += text.chars().count(); (pos, pos, Some(text.into())) }); transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); - // doc.apply(&transaction, view.id); } @@ -4519,11 +4937,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); } @@ -4548,20 +4963,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); } @@ -4576,11 +4988,12 @@ fn paste_impl( doc: &mut Document, view: &View, action: Paste, + count: usize, ) -> Option { let repeat = std::iter::repeat( values .last() - .map(|value| Tendril::from_slice(value)) + .map(|value| Tendril::from(value.repeat(count))) .unwrap(), ); @@ -4595,7 +5008,7 @@ fn paste_impl( let mut values = values .iter() .map(|value| REGEX.replace_all(value, doc.line_ending.as_str())) - .map(|value| Tendril::from(value.as_ref())) + .map(|value| Tendril::from(value.as_ref().repeat(count))) .chain(repeat); let text = doc.text(); @@ -4615,7 +5028,7 @@ fn paste_impl( // paste append (Paste::After, false) => range.to(), }; - (pos, pos, Some(values.next().unwrap())) + (pos, pos, values.next()) }); Some(transaction) @@ -4625,13 +5038,14 @@ fn paste_clipboard_impl( editor: &mut Editor, action: Paste, clipboard_type: ClipboardType, + count: usize, ) -> anyhow::Result<()> { let (view, doc) = current!(editor); match editor .clipboard_provider .get_contents(clipboard_type) - .map(|contents| paste_impl(&[contents], doc, view, action)) + .map(|contents| paste_impl(&[contents], doc, view, action, count)) { Ok(Some(transaction)) => { doc.apply(&transaction, view.id); @@ -4644,22 +5058,43 @@ 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, + cx.count(), + ); } 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, + cx.count(), + ); } 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, + cx.count(), + ); } 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, + cx.count(), + ); } fn replace_with_yanked(cx: &mut Context) { + let count = cx.count(); let reg_name = cx.register.unwrap_or('"'); let (view, doc) = current!(cx.editor); let registers = &mut cx.editor.registers; @@ -4669,12 +5104,12 @@ fn replace_with_yanked(cx: &mut Context) { let repeat = std::iter::repeat( values .last() - .map(|value| Tendril::from_slice(value)) + .map(|value| Tendril::from(&value.repeat(count))) .unwrap(), ); let mut values = values .iter() - .map(|value| Tendril::from_slice(value)) + .map(|value| Tendril::from(&value.repeat(count))) .chain(repeat); let selection = doc.selection(view.id); let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { @@ -4686,7 +5121,6 @@ fn replace_with_yanked(cx: &mut Context) { }); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } } } @@ -4694,6 +5128,7 @@ fn replace_with_yanked(cx: &mut Context) { fn replace_selections_with_clipboard_impl( editor: &mut Editor, clipboard_type: ClipboardType, + count: usize, ) -> anyhow::Result<()> { let (view, doc) = current!(editor); @@ -4701,7 +5136,11 @@ fn replace_selections_with_clipboard_impl( Ok(contents) => { let selection = doc.selection(view.id); let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { - (range.from(), range.to(), Some(contents.as_str().into())) + ( + range.from(), + range.to(), + Some(contents.repeat(count).as_str().into()), + ) }); doc.apply(&transaction, view.id); @@ -4713,38 +5152,38 @@ 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, cx.count()); } 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, cx.count()); } fn paste_after(cx: &mut Context) { + let count = cx.count(); let reg_name = cx.register.unwrap_or('"'); let (view, doc) = current!(cx.editor); let registers = &mut cx.editor.registers; if let Some(transaction) = registers .read(reg_name) - .and_then(|values| paste_impl(values, doc, view, Paste::After)) + .and_then(|values| paste_impl(values, doc, view, Paste::After, count)) { doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } } fn paste_before(cx: &mut Context) { + let count = cx.count(); let reg_name = cx.register.unwrap_or('"'); let (view, doc) = current!(cx.editor); let registers = &mut cx.editor.registers; if let Some(transaction) = registers .read(reg_name) - .and_then(|values| paste_impl(values, doc, view, Paste::Before)) + .and_then(|values| paste_impl(values, doc, view, Paste::Before, count)) { doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } } @@ -4780,7 +5219,6 @@ fn indent(cx: &mut Context) { }), ); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } fn unindent(cx: &mut Context) { @@ -4820,7 +5258,6 @@ fn unindent(cx: &mut Context) { let transaction = Transaction::change(doc.text(), changes.into_iter()); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } fn format_selections(cx: &mut Context) { @@ -4867,8 +5304,6 @@ fn format_selections(cx: &mut Context) { // doc.apply(&transaction, view.id); } - - doc.append_changes_to_history(view.id); } fn join_selections(cx: &mut Context) { @@ -4911,7 +5346,6 @@ fn join_selections(cx: &mut Context) { // .with_selection(selection); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { @@ -5039,7 +5473,7 @@ pub fn completion(cx: &mut Context) { move |editor: &mut Editor, compositor: &mut Compositor, response: Option| { - let (_, doc) = current!(editor); + let doc = doc!(editor); if doc.mode() != Mode::Insert { // we're not in insert mode anymore return; @@ -5136,9 +5570,10 @@ fn hover(cx: &mut Context) { // skip if contents empty - let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); - let popup = Popup::new(contents); - compositor.push(Box::new(popup)); + let contents = + ui::Markdown::new(contents, editor.syn_loader.clone()).style_group("hover"); + let popup = Popup::new("hover", contents); + compositor.replace_or_push("hover", Box::new(popup)); } }, ); @@ -5154,7 +5589,6 @@ fn toggle_comments(cx: &mut Context) { let transaction = comment::toggle_line_comments(doc.text(), doc.selection(view.id), token); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); exit_select_mode(cx); } @@ -5185,7 +5619,7 @@ fn rotate_selection_contents(cx: &mut Context, direction: Direction) { let selection = doc.selection(view.id); let mut fragments: Vec<_> = selection .fragments(text) - .map(|fragment| Tendril::from_slice(&fragment)) + .map(|fragment| Tendril::from(fragment.as_ref())) .collect(); let group = count @@ -5211,8 +5645,8 @@ fn rotate_selection_contents(cx: &mut Context, direction: Direction) { ); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } + fn rotate_selection_contents_forward(cx: &mut Context) { rotate_selection_contents(cx, Direction::Forward) } @@ -5228,14 +5662,73 @@ fn expand_selection(cx: &mut Context) { if let Some(syntax) = doc.syntax() { let text = doc.text().slice(..); - let selection = object::expand_selection(syntax, text, doc.selection(view.id)); + + let current_selection = doc.selection(view.id); + + // save current selection so it can be restored using shrink_selection + view.object_selections.push(current_selection.clone()); + + let selection = object::expand_selection(syntax, text, current_selection.clone()); + doc.set_selection(view.id, selection); + } + }; + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); +} + +fn shrink_selection(cx: &mut Context) { + let motion = |editor: &mut Editor| { + let (view, doc) = current!(editor); + let current_selection = doc.selection(view.id); + // try to restore previous selection + if let Some(prev_selection) = view.object_selections.pop() { + if current_selection.contains(&prev_selection) { + // allow shrinking the selection only if current selection contains the previous object selection + doc.set_selection(view.id, prev_selection); + return; + } else { + // clear existing selection as they can't be shrinked to anyway + view.object_selections.clear(); + } + } + // if not previous selection, shrink to first child + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + let selection = object::shrink_selection(syntax, text, current_selection.clone()); doc.set_selection(view.id, selection); } }; - motion(&mut cx.editor); + motion(cx.editor); cx.editor.last_motion = Some(Motion(Box::new(motion))); } +fn select_sibling_impl(cx: &mut Context, sibling_fn: &'static F) +where + F: Fn(Node) -> Option, +{ + let motion = |editor: &mut Editor| { + let (view, doc) = current!(editor); + + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + let current_selection = doc.selection(view.id); + let selection = + object::select_sibling(syntax, text, current_selection.clone(), sibling_fn); + doc.set_selection(view.id, selection); + } + }; + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); +} + +fn select_next_sibling(cx: &mut Context) { + select_sibling_impl(cx, &|node| Node::next_sibling(&node)) +} + +fn select_prev_sibling(cx: &mut Context) { + select_sibling_impl(cx, &|node| Node::prev_sibling(&node)) +} + fn match_brackets(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -5288,6 +5781,12 @@ fn jump_backward(cx: &mut Context) { }; } +fn save_selection(cx: &mut Context) { + push_jump(cx.editor); + cx.editor + .set_status("Selection saved to jumplist".to_owned()); +} + fn rotate_view(cx: &mut Context) { cx.editor.focus_next() } @@ -5358,8 +5857,10 @@ fn wonly(cx: &mut Context) { } fn select_register(cx: &mut Context) { + cx.editor.autoinfo = Some(Info::from_registers(&cx.editor.registers)); cx.on_next_key(move |cx, event| { if let Some(ch) = event.char() { + cx.editor.autoinfo = None; cx.editor.selected_register = Some(ch); } }) @@ -5464,7 +5965,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))); } }) @@ -5479,13 +5980,16 @@ fn surround_add(cx: &mut Context) { let mut changes = Vec::with_capacity(selection.len() * 2); for range in selection.iter() { - changes.push((range.from(), range.from(), Some(Tendril::from_char(open)))); - changes.push((range.to(), range.to(), Some(Tendril::from_char(close)))); + let mut o = Tendril::new(); + o.push(open); + let mut c = Tendril::new(); + c.push(close); + changes.push((range.from(), range.from(), Some(o))); + changes.push((range.to(), range.to(), Some(c))); } let transaction = Transaction::change(doc.text(), changes.into_iter()); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } }) } @@ -5510,15 +6014,12 @@ fn surround_replace(cx: &mut Context) { let transaction = Transaction::change( doc.text(), change_pos.iter().enumerate().map(|(i, &pos)| { - ( - pos, - pos + 1, - Some(Tendril::from_char(if i % 2 == 0 { open } else { close })), - ) + let mut t = Tendril::new(); + t.push(if i % 2 == 0 { open } else { close }); + (pos, pos + 1, Some(t)) }), ); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } }); } @@ -5541,7 +6042,6 @@ fn surround_delete(cx: &mut Context) { let transaction = Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } }) } @@ -5630,9 +6130,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..]) @@ -5658,8 +6156,9 @@ fn shell_impl( log::error!("Shell error: {}", String::from_utf8_lossy(&output.stderr)); } - let tendril = Tendril::try_from_byte_slice(&output.stdout) + let str = std::str::from_utf8(&output.stdout) .map_err(|_| anyhow!("Process did not output valid UTF-8"))?; + let tendril = Tendril::from(str); Ok((tendril, output.status.success())) } @@ -5714,7 +6213,6 @@ fn shell(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) { if behavior != ShellBehavior::Ignore { let transaction = Transaction::change(doc.text(), changes.into_iter()); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } // after replace cursor may be out of bounds, do this to @@ -5762,7 +6260,6 @@ fn add_newline_impl(cx: &mut Context, open: Open) { let transaction = Transaction::change(text, changes); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } fn rename_symbol(cx: &mut Context) { @@ -5796,7 +6293,7 @@ fn rename_symbol(cx: &mut Context) { 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(&mut cx.editor, offset_encoding, &edits); + apply_workspace_edit(cx.editor, offset_encoding, &edits); }, ); cx.push_layer(Box::new(prompt)); @@ -5816,16 +6313,45 @@ fn decrement(cx: &mut Context) { fn increment_impl(cx: &mut Context, amount: i64) { let (view, doc) = current!(cx.editor); let selection = doc.selection(view.id); - let text = doc.text(); + 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 changes = selection.ranges().iter().filter_map(|range| { - let incrementor = NumberIncrementor::from_range(text.slice(..), *range)?; - let new_text = incrementor.incremented_text(amount); - Some(( - incrementor.range.from(), - incrementor.range.to(), - Some(new_text), - )) + 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 { @@ -5833,6 +6359,58 @@ fn increment_impl(cx: &mut Context, amount: i64) { let transaction = transaction.with_selection(selection.clone()); doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); } } + +fn record_macro(cx: &mut Context) { + if let Some((reg, mut keys)) = cx.editor.macro_recording.take() { + // Remove the keypress which ends the recording + keys.pop(); + let s = keys + .into_iter() + .map(|key| { + let s = key.to_string(); + if s.chars().count() == 1 { + s + } else { + format!("<{}>", s) + } + }) + .collect::(); + cx.editor.registers.get_mut(reg).write(vec![s]); + cx.editor + .set_status(format!("Recorded to register [{}]", reg)); + } else { + let reg = cx.register.take().unwrap_or('@'); + cx.editor.macro_recording = Some((reg, Vec::new())); + cx.editor + .set_status(format!("Recording to register [{}]", reg)); + } +} + +fn replay_macro(cx: &mut Context) { + let reg = cx.register.unwrap_or('@'); + let keys: Vec = if let Some([keys_str]) = cx.editor.registers.read(reg) { + match helix_view::input::parse_macro(keys_str) { + Ok(keys) => keys, + Err(err) => { + cx.editor.set_error(format!("Invalid macro: {}", err)); + return; + } + } + } else { + cx.editor.set_error(format!("Register [{}] empty", reg)); + return; + }; + + let count = cx.count(); + cx.callback = Some(Box::new( + move |compositor: &mut Compositor, cx: &mut compositor::Context| { + for _ in 0..count { + for &key in keys.iter() { + compositor.handle_event(crossterm::event::Event::Key(key.into()), cx); + } + } + }, + )); +} diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index 58ef99f52..c73f9611b 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -194,7 +194,7 @@ pub fn dap_start_impl( cx: &mut compositor::Context, name: Option<&str>, socket: Option, - params: Option>, + params: Option>>, ) -> Result<(), anyhow::Error> { let doc = doc!(cx.editor); @@ -242,7 +242,7 @@ pub fn dap_start_impl( let mut param = x.to_string(); if let Some(DebugConfigCompletion::Advanced(cfg)) = template.completion.get(i) { if matches!(cfg.completion.as_deref(), Some("filename" | "directory")) { - param = std::fs::canonicalize(x) + param = std::fs::canonicalize(x.as_ref()) .ok() .and_then(|pb| pb.into_os_string().into_string().ok()) .unwrap_or_else(|| x.to_string()); @@ -408,7 +408,7 @@ fn debug_parameter_prompt( cx, Some(&config_name), None, - Some(params.iter().map(|x| x.as_str()).collect()), + Some(params.iter().map(|x| x.into()).collect()), ) { cx.editor.set_error(e.to_string()); } @@ -651,7 +651,7 @@ pub fn dap_variables(cx: &mut Context) { } let contents = Text::from(tui::text::Text::from(variables)); - let popup = Popup::new(contents); + let popup = Popup::new("dap-variables", contents); cx.push_layer(Box::new(popup)); } diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 3a644750e..dd7ebe1d8 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -7,7 +7,7 @@ use helix_view::graphics::{CursorKind, Rect}; use crossterm::event::Event; use tui::buffer::Buffer as Surface; -pub type Callback = Box; +pub type Callback = Box; // --> EventResult should have a callback that takes a context with methods like .popup(), // .prompt() etc. That way we can abstract it from the renderer. @@ -55,15 +55,20 @@ pub trait Component: Any + AnyComponent { /// May be used by the parent component to compute the child area. /// viewport is the maximum allowed area, and the child should stay within those bounds. + /// + /// The returned size might be larger than the viewport if the child is too big to fit. + /// In this case the parent can use the values to calculate scroll. fn required_size(&mut self, _viewport: (u16, u16)) -> Option<(u16, u16)> { - // TODO: for scrolling, the scroll wrapper should place a size + offset on the Context - // that way render can use it None } fn type_name(&self) -> &'static str { std::any::type_name::() } + + fn id(&self) -> Option<&'static str> { + None + } } use anyhow::Error; @@ -121,17 +126,32 @@ impl Compositor { self.layers.push(layer); } + /// Replace a component that has the given `id` with the new layer and if + /// no component is found, push the layer normally. + pub fn replace_or_push(&mut self, id: &'static str, layer: Box) { + if let Some(component) = self.find_id(id) { + *component = layer; + } else { + self.push(layer) + } + } + pub fn pop(&mut self) -> Option> { self.layers.pop() } pub fn handle_event(&mut self, event: Event, cx: &mut Context) -> bool { + // If it is a key event and a macro is being recorded, push the key event to the recording. + if let (Event::Key(key), Some((_, keys))) = (event, &mut cx.editor.macro_recording) { + keys.push(key.into()); + } + // propagate events through the layers until we either find a layer that consumes it or we // run out of layers (event bubbling) for layer in self.layers.iter_mut().rev() { match layer.handle_event(event, cx) { EventResult::Consumed(Some(callback)) => { - callback(self); + callback(self, cx); return true; } EventResult::Consumed(None) => return true, @@ -184,6 +204,14 @@ impl Compositor { .find(|component| component.type_name() == type_name) .and_then(|component| component.as_any_mut().downcast_mut()) } + + pub fn find_id(&mut self, id: &'static str) -> Option<&mut T> { + let type_name = std::any::type_name::(); + self.layers + .iter_mut() + .find(|component| component.type_name() == type_name && component.id() == Some(id)) + .and_then(|component| component.as_any_mut().downcast_mut()) + } } // View casting, taken straight from Cursive diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index 3745f871a..6b8bbc1b8 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -20,14 +20,18 @@ pub struct LspConfig { pub display_messages: bool, } -#[test] -fn parsing_keymaps_config_file() { - use crate::keymap; - use crate::keymap::Keymap; - use helix_core::hashmap; - use helix_view::document::Mode; - - let sample_keymaps = r#" +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parsing_keymaps_config_file() { + use crate::keymap; + use crate::keymap::Keymap; + use helix_core::hashmap; + use helix_view::document::Mode; + + let sample_keymaps = r#" [keys.insert] y = "move_line_down" S-C-a = "delete_selection" @@ -36,19 +40,20 @@ fn parsing_keymaps_config_file() { A-F12 = "move_next_word_end" "#; - assert_eq!( - toml::from_str::(sample_keymaps).unwrap(), - Config { - keys: Keymaps(hashmap! { - Mode::Insert => Keymap::new(keymap!({ "Insert mode" - "y" => move_line_down, - "S-C-a" => delete_selection, - })), - Mode::Normal => Keymap::new(keymap!({ "Normal mode" - "A-F12" => move_next_word_end, - })), - }), - ..Default::default() - } - ); + assert_eq!( + toml::from_str::(sample_keymaps).unwrap(), + Config { + keys: Keymaps(hashmap! { + Mode::Insert => Keymap::new(keymap!({ "Insert mode" + "y" => move_line_down, + "S-C-a" => delete_selection, + })), + Mode::Normal => Keymap::new(keymap!({ "Normal mode" + "A-F12" => move_next_word_end, + })), + }), + ..Default::default() + } + ); + } } diff --git a/helix-term/src/job.rs b/helix-term/src/job.rs index 4fa381748..a6a770211 100644 --- a/helix-term/src/job.rs +++ b/helix-term/src/job.rs @@ -22,8 +22,8 @@ pub struct Jobs { } impl Job { - pub fn new> + Send + 'static>(f: F) -> Job { - Job { + pub fn new> + Send + 'static>(f: F) -> Self { + Self { future: f.map(|r| r.map(|()| None)).boxed(), wait: false, } @@ -31,22 +31,22 @@ impl Job { pub fn with_callback> + Send + 'static>( f: F, - ) -> Job { - Job { + ) -> Self { + Self { future: f.map(|r| r.map(Some)).boxed(), wait: false, } } - pub fn wait_before_exiting(mut self) -> Job { + pub fn wait_before_exiting(mut self) -> Self { self.wait = true; self } } impl Jobs { - pub fn new() -> Jobs { - Jobs::default() + pub fn new() -> Self { + Self::default() } pub fn spawn> + Send + 'static>(&mut self, f: F) { @@ -93,8 +93,8 @@ impl Jobs { } /// Blocks until all the jobs that need to be waited on are done. - pub fn finish(&mut self) { + pub async fn finish(&mut self) { let wait_futures = std::mem::take(&mut self.wait_futures); - helix_lsp::block_on(wait_futures.for_each(|_| future::ready(()))); + wait_futures.for_each(|_| future::ready(())).await } } diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index b317242da..e08d7e448 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}; @@ -92,7 +92,7 @@ macro_rules! alt { #[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 @@ -120,7 +120,7 @@ macro_rules! keymap { _key, keymap!(@trie $value) ); - debug_assert!(_duplicate.is_none(), "Duplicate key found: {:?}", _duplicate.unwrap()); + assert!(_duplicate.is_none(), "Duplicate key found: {:?}", _duplicate.unwrap()); _order.push(_key); )+ )* @@ -222,9 +222,8 @@ impl KeyTrieNode { .map(|(desc, keys)| (desc.strip_prefix(&prefix).unwrap(), keys)) .collect(); } - Info::new(self.name(), body) + Info::from_keymap(self.name(), body) } - /// Get a reference to the key trie node's order. pub fn order(&self) -> &[KeyEvent] { self.order.as_slice() @@ -260,8 +259,8 @@ impl DerefMut for KeyTrieNode { #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(untagged)] pub enum KeyTrie { - Leaf(Command), - Sequence(Vec), + Leaf(MappableCommand), + Sequence(Vec), Node(KeyTrieNode), } @@ -304,9 +303,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), + MatchedSequence(Vec), /// Key was not found in the root keymap NotFound, /// Key is invalid in combination with previous keys. Contains keys leading upto @@ -344,7 +343,7 @@ pub struct Keymap { impl Keymap { pub fn new(root: KeyTrie) -> Self { - Keymap { + Self { root, state: Vec::new(), sticky: None, @@ -368,7 +367,7 @@ impl Keymap { /// key cancels pending keystrokes. If there are no pending keystrokes but a /// sticky node is in use, it will be cleared. pub fn get(&mut self, key: KeyEvent) -> KeymapResult { - if let key!(Esc) = key { + if key!(Esc) == key { if !self.state.is_empty() { return KeymapResult::new( // Note that Esc is not included here @@ -386,10 +385,10 @@ 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)) => { + Some(KeyTrie::Sequence(ref cmds)) => { return KeymapResult::new( KeymapResultKind::MatchedSequence(cmds.clone()), self.sticky(), @@ -408,9 +407,9 @@ 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), self.sticky()); + return KeymapResult::new(KeymapResultKind::Matched(cmd.clone()), self.sticky()); } Some(&KeyTrie::Sequence(ref cmds)) => { self.state.clear(); @@ -477,7 +476,7 @@ impl DerefMut for Keymaps { } impl Default for Keymaps { - fn default() -> Keymaps { + fn default() -> Self { let normal = keymap!({ "Normal mode" "h" | "left" => move_char_left, "j" | "down" => move_line_down, @@ -521,9 +520,10 @@ 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, @@ -551,6 +551,11 @@ impl Default for Keymaps { "S" => split_selection, ";" => collapse_selection, "A-;" => flip_selections, + "A-k" => expand_selection, + "A-j" => shrink_selection, + "A-h" => select_prev_sibling, + "A-l" => select_next_sibling, + "%" => select_all, "x" => extend_line, "X" => extend_to_line_bounds, @@ -592,6 +597,9 @@ impl Default for Keymaps { // paste_all "P" => paste_before, + "Q" => record_macro, + "q" => replay_macro, + ">" => indent, "<" => unindent, "=" => format_selections, @@ -613,6 +621,8 @@ impl Default for Keymaps { "A-(" => rotate_selection_contents_backward, "A-)" => rotate_selection_contents_forward, + "A-:" => ensure_selections_forward, + "esc" => normal_mode, "C-b" | "pageup" => page_up, "C-f" | "pagedown" => page_down, @@ -640,7 +650,7 @@ impl Default for Keymaps { "tab" => jump_forward, // tab == "C-o" => jump_backward, - // "C-s" => save_selection, + "C-s" => save_selection, "space" => { "Space" "f" => file_picker, @@ -763,8 +773,10 @@ impl Default for Keymaps { "del" => delete_char_forward, "C-d" => delete_char_forward, "ret" => insert_newline, + "C-j" => insert_newline, "tab" => insert_tab, "C-w" => delete_word_backward, + "A-backspace" => delete_word_backward, "A-d" => delete_word_forward, "left" => move_char_left, @@ -779,6 +791,8 @@ impl Default for Keymaps { "A-left" => move_prev_word_end, "A-f" => move_next_word_start, "A-right" => move_next_word_start, + "A-<" => goto_file_start, + "A->" => goto_file_end, "pageup" => page_up, "pagedown" => page_down, "home" => goto_line_start, @@ -792,7 +806,7 @@ impl Default for Keymaps { "C-x" => completion, "C-r" => insert_register, }); - Keymaps(hashmap!( + Self(hashmap!( Mode::Normal => Keymap::new(normal), Mode::Select => Keymap::new(select), Mode::Insert => Keymap::new(insert), @@ -852,36 +866,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" ); @@ -915,7 +929,7 @@ 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 diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index f5e3a8cdd..58cb139c7 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -9,3 +9,14 @@ pub mod config; pub mod job; pub mod keymap; pub mod ui; + +#[cfg(not(windows))] +fn true_color() -> bool { + std::env::var("COLORTERM") + .map(|v| matches!(v.as_str(), "truecolor" | "24bit")) + .unwrap_or(false) +} +#[cfg(windows)] +fn true_color() -> bool { + true +} diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index 881401304..0f504046f 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -56,7 +56,7 @@ USAGE: hx [FLAGS] [files]... ARGS: - ... Sets the input file to use + ... Sets the input file to use, position can also be specified via file[:row[:col]] FLAGS: -h, --help Prints help information diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index dd782d29d..35afe81e9 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -154,8 +154,19 @@ impl Completion { ); doc.apply(&transaction, view.id); - if let Some(additional_edits) = &item.additional_text_edits { - // gopls uses this to add extra imports + // apply additional edits, mostly used to auto import unqualified types + let resolved_additional_text_edits = if item.additional_text_edits.is_some() { + None + } else { + Self::resolve_completion_item(doc, item.clone()) + .and_then(|item| item.additional_text_edits) + }; + + if let Some(additional_edits) = item + .additional_text_edits + .as_ref() + .or_else(|| resolved_additional_text_edits.as_ref()) + { if !additional_edits.is_empty() { let transaction = util::generate_transaction_from_edits( doc.text(), @@ -168,7 +179,7 @@ impl Completion { } }; }); - let popup = Popup::new(menu); + let popup = Popup::new("completion", menu); let mut completion = Self { popup, start_offset, @@ -181,6 +192,31 @@ impl Completion { completion } + fn resolve_completion_item( + doc: &Document, + completion_item: lsp::CompletionItem, + ) -> Option { + let language_server = doc.language_server()?; + let completion_resolve_provider = language_server + .capabilities() + .completion_provider + .as_ref()? + .resolve_provider; + if completion_resolve_provider != Some(true) { + return None; + } + + let future = language_server.resolve_completion_item(completion_item); + let response = helix_lsp::block_on(future); + match response { + Ok(completion_item) => Some(completion_item), + Err(err) => { + log::error!("execute LSP command: {}", err); + None + } + } + } + pub fn recompute_filter(&mut self, editor: &Editor) { // recompute menu based on matches let menu = self.popup.contents_mut(); @@ -268,6 +304,9 @@ impl Component for Completion { let cursor_pos = doc.selection(view.id).primary().cursor(text); let coords = helix_core::visual_coords_at_pos(text, cursor_pos, doc.tab_width()); let cursor_pos = (coords.row - view.offset.row) as u16; + + let markdown_ui = + |content, syn_loader| Markdown::new(content, syn_loader).style_group("completion"); let mut markdown_doc = match &option.documentation { Some(lsp::Documentation::String(contents)) | Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { @@ -275,7 +314,7 @@ impl Component for Completion { value: contents, })) => { // TODO: convert to wrapped text - Markdown::new( + markdown_ui( format!( "```{}\n{}\n```\n{}", language, @@ -290,7 +329,7 @@ impl Component for Completion { value: contents, })) => { // TODO: set language based on doc scope - Markdown::new( + markdown_ui( format!( "```{}\n{}\n```\n{}", language, @@ -304,7 +343,7 @@ impl Component for Completion { // TODO: copied from above // TODO: set language based on doc scope - Markdown::new( + markdown_ui( format!( "```{}\n{}\n```", language, @@ -328,8 +367,8 @@ impl Component for Completion { let y = popup_y; if let Some((rel_width, rel_height)) = markdown_doc.required_size((width, height)) { - width = rel_width; - height = rel_height; + width = rel_width.min(width); + height = rel_height.min(height); } Rect::new(x, y, width, height) } else { diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index ac11d298b..a2131abe3 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -7,8 +7,10 @@ use crate::{ }; use helix_core::{ - coords_at_pos, - graphemes::{ensure_grapheme_boundary_next, next_grapheme_boundary, prev_grapheme_boundary}, + coords_at_pos, encoding, + graphemes::{ + ensure_grapheme_boundary_next_byte, next_grapheme_boundary, prev_grapheme_boundary, + }, movement::Direction, syntax::{self, HighlightEvent}, unicode::segmentation::UnicodeSegmentation, @@ -17,8 +19,8 @@ use helix_core::{ }; use helix_view::{ document::{Mode, SCRATCH_BUFFER_NAME}, + editor::CursorShapeConfig, graphics::{CursorKind, Modifier, Rect, Style}, - info::Info, input::KeyEvent, keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, @@ -31,10 +33,9 @@ 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, } impl Default for EditorView { @@ -48,10 +49,9 @@ 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, } } @@ -106,13 +106,12 @@ impl EditorView { } } - let highlights = - Self::doc_syntax_highlights(doc, view.offset, inner.height, theme, &editor.syn_loader); + let highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); let highlights = syntax::merge(highlights, Self::doc_diagnostics_highlights(doc, theme)); let highlights: Box> = if is_focused { Box::new(syntax::merge( highlights, - Self::doc_selection_highlights(doc, view, theme), + Self::doc_selection_highlights(doc, view, theme, &editor.config.cursor_shape), )) } else { Box::new(highlights) @@ -130,8 +129,7 @@ impl EditorView { let x = area.right(); let border_style = theme.get("ui.window"); for y in area.top()..area.bottom() { - surface - .get_mut(x, y) + surface[(x, y)] .set_symbol(tui::symbols::line::VERTICAL) //.set_symbol(" ") .set_style(border_style); @@ -154,8 +152,7 @@ impl EditorView { doc: &'doc Document, offset: Position, height: u16, - theme: &Theme, - loader: &syntax::Loader, + _theme: &Theme, ) -> Box + 'doc> { let text = doc.text().slice(..); let last_line = std::cmp::min( @@ -172,48 +169,34 @@ impl EditorView { start..end }; - // TODO: range doesn't actually restrict source, just highlight range - let highlights = match doc.syntax() { + match doc.syntax() { Some(syntax) => { - let scopes = theme.scopes(); - syntax - .highlight_iter(text.slice(..), Some(range), None, |language| { - loader.language_configuration_for_injection_string(language) - .and_then(|language_config| { - let config = language_config.highlight_config(scopes)?; - let config_ref = config.as_ref(); - // SAFETY: the referenced `HighlightConfiguration` behind - // the `Arc` is guaranteed to remain valid throughout the - // duration of the highlight. - let config_ref = unsafe { - std::mem::transmute::< - _, - &'static syntax::HighlightConfiguration, - >(config_ref) - }; - Some(config_ref) - }) - }) + let iter = syntax + // TODO: range doesn't actually restrict source, just highlight range + .highlight_iter(text.slice(..), Some(range), None) .map(|event| event.unwrap()) - .collect() // TODO: we collect here to avoid holding the lock, fix later + .map(move |event| match event { + // convert byte offsets to char offset + HighlightEvent::Source { start, end } => { + let start = + text.byte_to_char(ensure_grapheme_boundary_next_byte(text, start)); + let end = + text.byte_to_char(ensure_grapheme_boundary_next_byte(text, end)); + HighlightEvent::Source { start, end } + } + event => event, + }); + + Box::new(iter) } - None => vec![HighlightEvent::Source { - start: range.start, - end: range.end, - }], + None => Box::new( + [HighlightEvent::Source { + start: text.byte_to_char(range.start), + end: text.byte_to_char(range.end), + }] + .into_iter(), + ), } - .into_iter() - .map(move |event| match event { - // convert byte offsets to char offset - HighlightEvent::Source { start, end } => { - let start = ensure_grapheme_boundary_next(text, text.byte_to_char(start)); - let end = ensure_grapheme_boundary_next(text, text.byte_to_char(end)); - HighlightEvent::Source { start, end } - } - event => event, - }); - - Box::new(highlights) } /// Get highlight spans for document diagnostics @@ -245,11 +228,16 @@ impl EditorView { doc: &Document, view: &View, theme: &Theme, + cursor_shape_config: &CursorShapeConfig, ) -> Vec<(usize, std::ops::Range)> { let text = doc.text().slice(..); let selection = doc.selection(view.id); let primary_idx = selection.primary_index(); + let mode = doc.mode(); + let cursorkind = cursor_shape_config.from_mode(mode); + let cursor_is_block = cursorkind == CursorKind::Block; + let selection_scope = theme .find_scope_index("ui.selection") .expect("could not find `ui.selection` scope in the theme!"); @@ -257,7 +245,7 @@ impl EditorView { .find_scope_index("ui.cursor") .unwrap_or(selection_scope); - let cursor_scope = match doc.mode() { + let cursor_scope = match mode { Mode::Insert => theme.find_scope_index("ui.cursor.insert"), Mode::Select => theme.find_scope_index("ui.cursor.select"), Mode::Normal => Some(base_cursor_scope), @@ -273,7 +261,8 @@ impl EditorView { let mut spans: Vec<(usize, std::ops::Range)> = Vec::new(); for (i, range) in selection.iter().enumerate() { - let (cursor_scope, selection_scope) = if i == primary_idx { + let selection_is_primary = i == primary_idx; + let (cursor_scope, selection_scope) = if selection_is_primary { (primary_cursor_scope, primary_selection_scope) } else { (cursor_scope, selection_scope) @@ -281,7 +270,14 @@ impl EditorView { // Special-case: cursor at end of the rope. if range.head == range.anchor && range.head == text.len_chars() { - spans.push((cursor_scope, range.head..range.head + 1)); + if !selection_is_primary || cursor_is_block { + // Bar and underline cursors are drawn by the terminal + // BUG: If the editor area loses focus while having a bar or + // underline cursor (eg. when a regex prompt has focus) then + // the primary cursor will be invisible. This doesn't happen + // with block cursors since we manually draw *all* cursors. + spans.push((cursor_scope, range.head..range.head + 1)); + } continue; } @@ -290,11 +286,15 @@ impl EditorView { // Standard case. let cursor_start = prev_grapheme_boundary(text, range.head); spans.push((selection_scope, range.anchor..cursor_start)); - spans.push((cursor_scope, cursor_start..range.head)); + if !selection_is_primary || cursor_is_block { + spans.push((cursor_scope, cursor_start..range.head)); + } } else { // Reverse case. let cursor_end = next_grapheme_boundary(text, range.head); - spans.push((cursor_scope, range.head..cursor_end)); + if !selection_is_primary || cursor_is_block { + spans.push((cursor_scope, range.head..cursor_end)); + } spans.push((selection_scope, cursor_end..range.anchor)); } } @@ -320,6 +320,10 @@ impl EditorView { let text_style = theme.get("ui.text"); + // It's slightly more efficient to produce a full RopeSlice from the Rope, then slice that a bunch + // of times than it is to always call Rope::slice/get_slice (it will internally always hit RSEnum::Light). + let text = text.slice(..); + 'outer: for event in highlights { match event { HighlightEvent::HighlightStart(span) => { @@ -336,17 +340,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, @@ -377,6 +380,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, @@ -422,8 +429,7 @@ impl EditorView { .add_modifier(Modifier::DIM) }); - surface - .get_mut(viewport.x + pos.col as u16, viewport.y + pos.row as u16) + surface[(viewport.x + pos.col as u16, viewport.y + pos.row as u16)] .set_style(style); } } @@ -453,6 +459,8 @@ impl EditorView { let mut offset = 0; + let gutter_style = theme.get("ui.gutter"); + // avoid lots of small allocations by reusing a text buffer for each line let mut text = String::with_capacity(8); @@ -468,7 +476,7 @@ impl EditorView { viewport.y + i as u16, &text, *width, - style, + gutter_style.patch(style), ); } text.clear(); @@ -574,21 +582,6 @@ impl EditorView { } surface.set_string(viewport.x + 5, viewport.y, progress, base_style); - 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, - ); - //------------------------------- // Right side of the status line. //------------------------------- @@ -662,6 +655,13 @@ impl EditorView { base_style, )); + let enc = doc.encoding(); + if enc != encoding::UTF_8 { + right_side_text + .0 + .push(Span::styled(format!(" {} ", enc.name()), base_style)); + } + // Render to the statusline. surface.set_spans( viewport.x @@ -672,6 +672,31 @@ impl EditorView { &right_side_text, right_side_text.width() as u16, ); + + //------------------------------- + // Middle / File path / Title + //------------------------------- + let title = { + 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()); + format!("{}{}", path, if doc.is_modified() { "[+]" } else { "" }) + }; + + surface.set_string_truncated( + viewport.x + 8, // 8: 1 space + 3 char mode string + 1 space + 1 spinner + 1 space + viewport.y, + title, + viewport + .width + .saturating_sub(6) + .saturating_sub(right_side_text.width() as u16 + 1) as usize, // "+ 1": a space between the title and the selection info + base_style, + true, + true, + ); } /// Handle events by looking them up in `self.keymaps`. Returns None @@ -684,12 +709,13 @@ impl EditorView { cxt: &mut commands::Context, event: KeyEvent, ) -> Option { + cxt.editor.autoinfo = None; let key_result = self.keymaps.get_mut(&mode).unwrap().get(event); - self.autoinfo = key_result.sticky.map(|node| node.infobox()); + cxt.editor.autoinfo = key_result.sticky.map(|node| node.infobox()); match &key_result.kind { KeymapResultKind::Matched(command) => command.execute(cxt), - KeymapResultKind::Pending(node) => self.autoinfo = Some(node.infobox()), + KeymapResultKind::Pending(node) => cxt.editor.autoinfo = Some(node.infobox()), KeymapResultKind::MatchedSequence(commands) => { for command in commands { command.execute(cxt); @@ -789,8 +815,9 @@ impl EditorView { pub fn clear_completion(&mut self, editor: &mut Editor) { self.completion = None; + // Clear any savepoints - let (_, doc) = current!(editor); + let doc = doc_mut!(editor); doc.savepoint = None; editor.clear_idle_timer(); // don't retrigger } @@ -927,7 +954,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) } @@ -953,9 +980,9 @@ impl EditorView { if let Ok(pos) = doc.text().try_line_to_char(line) { doc.set_selection(view_id, Selection::point(pos)); if modifiers == crossterm::event::KeyModifiers::ALT { - commands::Command::dap_edit_log.execute(cxt); + commands::MappableCommand::dap_edit_log.execute(cxt); } else { - commands::Command::dap_edit_condition.execute(cxt); + commands::MappableCommand::dap_edit_condition.execute(cxt); } return EventResult::Consumed(None); @@ -977,7 +1004,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); } @@ -991,7 +1019,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); } @@ -1004,14 +1032,18 @@ 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, + fn handle_event( + &mut self, + event: Event, + context: &mut crate::compositor::Context, + ) -> EventResult { + let mut cx = commands::Context { + editor: context.editor, count: None, register: None, callback: None, on_next_key_callback: None, - jobs: cx.jobs, + jobs: context.jobs, }; match event { @@ -1021,18 +1053,19 @@ impl Component for EditorView { EventResult::Consumed(None) } Event::Key(key) => { - cxt.editor.reset_idle_timer(); + cx.editor.reset_idle_timer(); let mut key = KeyEvent::from(key); canonicalize_key(&mut key); + // clear status - cxt.editor.status_msg = None; + cx.editor.status_msg = None; - let (_, doc) = current!(cxt.editor); + let doc = doc!(cx.editor); let mode = doc.mode(); if let Some(on_next_key) = self.on_next_key.take() { // if there's a command waiting input, do that first - on_next_key(&mut cxt, key); + on_next_key(&mut cx, key); } else { match mode { Mode::Insert => { @@ -1044,8 +1077,8 @@ impl Component for EditorView { if let Some(completion) = &mut self.completion { // use a fake context here let mut cx = Context { - editor: cxt.editor, - jobs: cxt.jobs, + editor: cx.editor, + jobs: cx.jobs, scroll: None, }; let res = completion.handle_event(event, &mut cx); @@ -1055,40 +1088,46 @@ impl Component for EditorView { if callback.is_some() { // assume close_fn - self.clear_completion(cxt.editor); + self.clear_completion(cx.editor); } } } // if completion didn't take the event, we pass it onto commands if !consumed { - self.insert_mode(&mut cxt, key); + self.insert_mode(&mut cx, key); // lastly we recalculate completion if let Some(completion) = &mut self.completion { - completion.update(&mut cxt); + completion.update(&mut cx); if completion.is_empty() { - self.clear_completion(cxt.editor); + self.clear_completion(cx.editor); } } } } - mode => self.command_mode(mode, &mut cxt, key), + mode => self.command_mode(mode, &mut cx, key), } } - self.on_next_key = cxt.on_next_key_callback.take(); + self.on_next_key = cx.on_next_key_callback.take(); // appease borrowck - let callback = cxt.callback.take(); + let callback = cx.callback.take(); // if the command consumed the last view, skip the render. // on the next loop cycle the Application will then terminate. - if cxt.editor.should_close() { + if cx.editor.should_close() { return EventResult::Ignored; } - let (view, doc) = current!(cxt.editor); - view.ensure_cursor_in_view(doc, cxt.editor.config.scrolloff); + let (view, doc) = current!(cx.editor); + view.ensure_cursor_in_view(doc, cx.editor.config.scrolloff); + + // Store a history state if not in insert mode. This also takes care of + // commiting changes when leaving insert mode. + if doc.mode() != Mode::Insert { + doc.append_changes_to_history(view.id); + } // mode transitions match (mode, doc.mode()) { @@ -1117,7 +1156,7 @@ impl Component for EditorView { EventResult::Consumed(callback) } - Event::Mouse(event) => self.handle_mouse_event(event, &mut cxt), + Event::Mouse(event) => self.handle_mouse_event(event, &mut cx), } } @@ -1134,8 +1173,9 @@ impl Component for EditorView { } if cx.editor.config.auto_info { - if let Some(ref mut info) = self.autoinfo { + if let Some(mut info) = cx.editor.autoinfo.take() { info.render(area, surface, cx); + cx.editor.autoinfo = Some(info) } } @@ -1173,13 +1213,31 @@ impl Component for EditorView { disp.push_str(&s); } } + let style = cx.editor.theme.get("ui.text"); + let macro_width = if cx.editor.macro_recording.is_some() { + 3 + } else { + 0 + }; surface.set_string( - area.x + area.width.saturating_sub(key_width), + area.x + area.width.saturating_sub(key_width + macro_width), area.y + area.height.saturating_sub(1), disp.get(disp.len().saturating_sub(key_width as usize)..) .unwrap_or(&disp), - cx.editor.theme.get("ui.text"), + style, ); + if let Some((reg, _)) = cx.editor.macro_recording { + let disp = format!("[{}]", reg); + let style = style + .fg(helix_view::graphics::Color::Yellow) + .add_modifier(Modifier::BOLD); + surface.set_string( + area.x + area.width.saturating_sub(3), + area.y + area.height.saturating_sub(1), + &disp, + style, + ); + } } if let Some(completion) = self.completion.as_mut() { @@ -1188,11 +1246,11 @@ impl Component for EditorView { } fn cursor(&self, _area: Rect, editor: &Editor) -> (Option, CursorKind) { - // match view.doc.mode() { - // Mode::Insert => write!(stdout, "\x1B[6 q"), - // mode => write!(stdout, "\x1B[2 q"), - // }; - editor.cursor() + match editor.cursor() { + // All block cursors are drawn manually + (pos, CursorKind::Block) => (pos, CursorKind::Hidden), + cursor => cursor, + } } } diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index ca8303dd9..6a7b641ad 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -21,6 +21,9 @@ pub struct Markdown { contents: String, config_loader: Arc, + + block_style: String, + heading_style: String, } // TODO: pre-render and self reference via Pin @@ -31,120 +34,137 @@ impl Markdown { Self { contents, config_loader, + block_style: "markup.raw.inline".into(), + heading_style: "markup.heading".into(), } } -} -fn parse<'a>( - contents: &'a str, - theme: Option<&Theme>, - loader: &syntax::Loader, -) -> tui::text::Text<'a> { - // // also 2021-03-04T16:33:58.553 helix_lsp::transport [INFO] <- {"contents":{"kind":"markdown","value":"\n```rust\ncore::num\n```\n\n```rust\npub const fn saturating_sub(self, rhs:Self) ->Self\n```\n\n---\n\n```rust\n```"},"range":{"end":{"character":61,"line":101},"start":{"character":47,"line":101}}} - // let text = "\n```rust\ncore::iter::traits::iterator::Iterator\n```\n\n```rust\nfn collect>(self) -> B\nwhere\n Self: Sized,\n```\n\n---\n\nTransforms an iterator into a collection.\n\n`collect()` can take anything iterable, and turn it into a relevant\ncollection. This is one of the more powerful methods in the standard\nlibrary, used in a variety of contexts.\n\nThe most basic pattern in which `collect()` is used is to turn one\ncollection into another. You take a collection, call [`iter`](https://doc.rust-lang.org/nightly/core/iter/traits/iterator/trait.Iterator.html) on it,\ndo a bunch of transformations, and then `collect()` at the end.\n\n`collect()` can also create instances of types that are not typical\ncollections. For example, a [`String`](https://doc.rust-lang.org/nightly/core/iter/std/string/struct.String.html) can be built from [`char`](type@char)s,\nand an iterator of [`Result`](https://doc.rust-lang.org/nightly/core/result/enum.Result.html) items can be collected\ninto `Result, E>`. See the examples below for more.\n\nBecause `collect()` is so general, it can cause problems with type\ninference. As such, `collect()` is one of the few times you'll see\nthe syntax affectionately known as the 'turbofish': `::<>`. This\nhelps the inference algorithm understand specifically which collection\nyou're trying to collect into.\n\n# Examples\n\nBasic usage:\n\n```rust\nlet a = [1, 2, 3];\n\nlet doubled: Vec = a.iter()\n .map(|&x| x * 2)\n .collect();\n\nassert_eq!(vec![2, 4, 6], doubled);\n```\n\nNote that we needed the `: Vec` on the left-hand side. This is because\nwe could collect into, for example, a [`VecDeque`](https://doc.rust-lang.org/nightly/core/iter/std/collections/struct.VecDeque.html) instead:\n\n```rust\nuse std::collections::VecDeque;\n\nlet a = [1, 2, 3];\n\nlet doubled: VecDeque = a.iter().map(|&x| x * 2).collect();\n\nassert_eq!(2, doubled[0]);\nassert_eq!(4, doubled[1]);\nassert_eq!(6, doubled[2]);\n```\n\nUsing the 'turbofish' instead of annotating `doubled`:\n\n```rust\nlet a = [1, 2, 3];\n\nlet doubled = a.iter().map(|x| x * 2).collect::>();\n\nassert_eq!(vec![2, 4, 6], doubled);\n```\n\nBecause `collect()` only cares about what you're collecting into, you can\nstill use a partial type hint, `_`, with the turbofish:\n\n```rust\nlet a = [1, 2, 3];\n\nlet doubled = a.iter().map(|x| x * 2).collect::>();\n\nassert_eq!(vec![2, 4, 6], doubled);\n```\n\nUsing `collect()` to make a [`String`](https://doc.rust-lang.org/nightly/core/iter/std/string/struct.String.html):\n\n```rust\nlet chars = ['g', 'd', 'k', 'k', 'n'];\n\nlet hello: String = chars.iter()\n .map(|&x| x as u8)\n .map(|x| (x + 1) as char)\n .collect();\n\nassert_eq!(\"hello\", hello);\n```\n\nIf you have a list of [`Result`](https://doc.rust-lang.org/nightly/core/result/enum.Result.html)s, you can use `collect()` to\nsee if any of them failed:\n\n```rust\nlet results = [Ok(1), Err(\"nope\"), Ok(3), Err(\"bad\")];\n\nlet result: Result, &str> = results.iter().cloned().collect();\n\n// gives us the first error\nassert_eq!(Err(\"nope\"), result);\n\nlet results = [Ok(1), Ok(3)];\n\nlet result: Result, &str> = results.iter().cloned().collect();\n\n// gives us the list of answers\nassert_eq!(Ok(vec![1, 3]), result);\n```"; - - let mut options = Options::empty(); - options.insert(Options::ENABLE_STRIKETHROUGH); - let parser = Parser::new_ext(contents, options); - - // TODO: if possible, render links as terminal hyperlinks: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda - let mut tags = Vec::new(); - let mut spans = Vec::new(); - let mut lines = Vec::new(); - - fn to_span(text: pulldown_cmark::CowStr) -> Span { - use std::ops::Deref; - Span::raw::>(match text { - CowStr::Borrowed(s) => s.into(), - CowStr::Boxed(s) => s.to_string().into(), - CowStr::Inlined(s) => s.deref().to_owned().into(), - }) + pub fn style_group(mut self, suffix: &str) -> Self { + self.block_style = format!("markup.raw.inline.{}", suffix); + self.heading_style = format!("markup.heading.{}", suffix); + self } - 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 { - Event::Start(tag) => tags.push(tag), - Event::End(tag) => { - tags.pop(); - match tag { - Tag::Heading(_) | Tag::Paragraph | Tag::CodeBlock(CodeBlockKind::Fenced(_)) => { - // whenever code block or paragraph closes, new line - let spans = std::mem::take(&mut spans); - if !spans.is_empty() { - lines.push(Spans::from(spans)); + fn parse(&self, theme: Option<&Theme>) -> tui::text::Text<'_> { + // // also 2021-03-04T16:33:58.553 helix_lsp::transport [INFO] <- {"contents":{"kind":"markdown","value":"\n```rust\ncore::num\n```\n\n```rust\npub const fn saturating_sub(self, rhs:Self) ->Self\n```\n\n---\n\n```rust\n```"},"range":{"end":{"character":61,"line":101},"start":{"character":47,"line":101}}} + // let text = "\n```rust\ncore::iter::traits::iterator::Iterator\n```\n\n```rust\nfn collect>(self) -> B\nwhere\n Self: Sized,\n```\n\n---\n\nTransforms an iterator into a collection.\n\n`collect()` can take anything iterable, and turn it into a relevant\ncollection. This is one of the more powerful methods in the standard\nlibrary, used in a variety of contexts.\n\nThe most basic pattern in which `collect()` is used is to turn one\ncollection into another. You take a collection, call [`iter`](https://doc.rust-lang.org/nightly/core/iter/traits/iterator/trait.Iterator.html) on it,\ndo a bunch of transformations, and then `collect()` at the end.\n\n`collect()` can also create instances of types that are not typical\ncollections. For example, a [`String`](https://doc.rust-lang.org/nightly/core/iter/std/string/struct.String.html) can be built from [`char`](type@char)s,\nand an iterator of [`Result`](https://doc.rust-lang.org/nightly/core/result/enum.Result.html) items can be collected\ninto `Result, E>`. See the examples below for more.\n\nBecause `collect()` is so general, it can cause problems with type\ninference. As such, `collect()` is one of the few times you'll see\nthe syntax affectionately known as the 'turbofish': `::<>`. This\nhelps the inference algorithm understand specifically which collection\nyou're trying to collect into.\n\n# Examples\n\nBasic usage:\n\n```rust\nlet a = [1, 2, 3];\n\nlet doubled: Vec = a.iter()\n .map(|&x| x * 2)\n .collect();\n\nassert_eq!(vec![2, 4, 6], doubled);\n```\n\nNote that we needed the `: Vec` on the left-hand side. This is because\nwe could collect into, for example, a [`VecDeque`](https://doc.rust-lang.org/nightly/core/iter/std/collections/struct.VecDeque.html) instead:\n\n```rust\nuse std::collections::VecDeque;\n\nlet a = [1, 2, 3];\n\nlet doubled: VecDeque = a.iter().map(|&x| x * 2).collect();\n\nassert_eq!(2, doubled[0]);\nassert_eq!(4, doubled[1]);\nassert_eq!(6, doubled[2]);\n```\n\nUsing the 'turbofish' instead of annotating `doubled`:\n\n```rust\nlet a = [1, 2, 3];\n\nlet doubled = a.iter().map(|x| x * 2).collect::>();\n\nassert_eq!(vec![2, 4, 6], doubled);\n```\n\nBecause `collect()` only cares about what you're collecting into, you can\nstill use a partial type hint, `_`, with the turbofish:\n\n```rust\nlet a = [1, 2, 3];\n\nlet doubled = a.iter().map(|x| x * 2).collect::>();\n\nassert_eq!(vec![2, 4, 6], doubled);\n```\n\nUsing `collect()` to make a [`String`](https://doc.rust-lang.org/nightly/core/iter/std/string/struct.String.html):\n\n```rust\nlet chars = ['g', 'd', 'k', 'k', 'n'];\n\nlet hello: String = chars.iter()\n .map(|&x| x as u8)\n .map(|x| (x + 1) as char)\n .collect();\n\nassert_eq!(\"hello\", hello);\n```\n\nIf you have a list of [`Result`](https://doc.rust-lang.org/nightly/core/result/enum.Result.html)s, you can use `collect()` to\nsee if any of them failed:\n\n```rust\nlet results = [Ok(1), Err(\"nope\"), Ok(3), Err(\"bad\")];\n\nlet result: Result, &str> = results.iter().cloned().collect();\n\n// gives us the first error\nassert_eq!(Err(\"nope\"), result);\n\nlet results = [Ok(1), Ok(3)];\n\nlet result: Result, &str> = results.iter().cloned().collect();\n\n// gives us the list of answers\nassert_eq!(Ok(vec![1, 3]), result);\n```"; + + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + let parser = Parser::new_ext(&self.contents, options); + + // TODO: if possible, render links as terminal hyperlinks: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda + let mut tags = Vec::new(); + let mut spans = Vec::new(); + let mut lines = Vec::new(); + + fn to_span(text: pulldown_cmark::CowStr) -> Span { + use std::ops::Deref; + Span::raw::>(match text { + CowStr::Borrowed(s) => s.into(), + CowStr::Boxed(s) => s.to_string().into(), + CowStr::Inlined(s) => s.deref().to_owned().into(), + }) + } + + macro_rules! get_theme { + ($s1: expr) => { + theme + .map(|theme| theme.try_get($s1.as_str())) + .flatten() + .unwrap_or_default() + }; + } + let text_style = theme.map(|theme| theme.get("ui.text")).unwrap_or_default(); + let code_style = get_theme!(self.block_style); + let heading_style = get_theme!(self.heading_style); + + for event in parser { + match event { + Event::Start(tag) => tags.push(tag), + Event::End(tag) => { + tags.pop(); + match tag { + Tag::Heading(_, _, _) + | Tag::Paragraph + | Tag::CodeBlock(CodeBlockKind::Fenced(_)) => { + // whenever code block or paragraph closes, new line + let spans = std::mem::take(&mut spans); + if !spans.is_empty() { + lines.push(Spans::from(spans)); + } + lines.push(Spans::default()); } - lines.push(Spans::default()); + _ => (), } - _ => (), } - } - Event::Text(text) => { - // TODO: temp workaround - if let Some(Tag::CodeBlock(CodeBlockKind::Fenced(language))) = tags.last() { - if let Some(theme) = theme { - let rope = Rope::from(text.as_ref()); - let syntax = loader - .language_configuration_for_injection_string(language) - .and_then(|config| config.highlight_config(theme.scopes())) - .map(|config| Syntax::new(&rope, config)); - - if let Some(syntax) = syntax { - // if we have a syntax available, highlight_iter and generate spans - let mut highlights = Vec::new(); - - for event in syntax.highlight_iter(rope.slice(..), None, None, |_| None) - { - match event.unwrap() { - HighlightEvent::HighlightStart(span) => { - highlights.push(span); - } - HighlightEvent::HighlightEnd => { - highlights.pop(); - } - HighlightEvent::Source { start, end } => { - let style = match highlights.first() { - Some(span) => theme.get(&theme.scopes()[span.0]), - None => text_style, - }; - - // TODO: replace tabs with indentation - - let mut slice = &text[start..end]; - // TODO: do we need to handle all unicode line endings - // here, or is just '\n' okay? - while let Some(end) = slice.find('\n') { - // emit span up to newline - let text = &slice[..end]; - let text = text.replace('\t', " "); // replace tabs - let span = Span::styled(text, style); - spans.push(span); - - // truncate slice to after newline - slice = &slice[end + 1..]; - - // make a new line - let spans = std::mem::take(&mut spans); - lines.push(Spans::from(spans)); + Event::Text(text) => { + // TODO: temp workaround + if let Some(Tag::CodeBlock(CodeBlockKind::Fenced(language))) = tags.last() { + if let Some(theme) = theme { + let rope = Rope::from(text.as_ref()); + let syntax = self + .config_loader + .language_configuration_for_injection_string(language) + .and_then(|config| config.highlight_config(theme.scopes())) + .map(|config| { + Syntax::new(&rope, config, self.config_loader.clone()) + }); + + if let Some(syntax) = syntax { + // if we have a syntax available, highlight_iter and generate spans + let mut highlights = Vec::new(); + + for event in syntax.highlight_iter(rope.slice(..), None, None) { + match event.unwrap() { + HighlightEvent::HighlightStart(span) => { + highlights.push(span); + } + HighlightEvent::HighlightEnd => { + highlights.pop(); } + HighlightEvent::Source { start, end } => { + let style = match highlights.first() { + Some(span) => theme.get(&theme.scopes()[span.0]), + None => text_style, + }; - // if there's anything left, emit it too - if !slice.is_empty() { - let span = - Span::styled(slice.replace('\t', " "), style); - spans.push(span); + // TODO: replace tabs with indentation + + let mut slice = &text[start..end]; + // TODO: do we need to handle all unicode line endings + // here, or is just '\n' okay? + while let Some(end) = slice.find('\n') { + // emit span up to newline + let text = &slice[..end]; + let text = text.replace('\t', " "); // replace tabs + let span = Span::styled(text, style); + spans.push(span); + + // truncate slice to after newline + slice = &slice[end + 1..]; + + // make a new line + let spans = std::mem::take(&mut spans); + lines.push(Spans::from(spans)); + } + + // if there's anything left, emit it too + if !slice.is_empty() { + let span = Span::styled( + slice.replace('\t', " "), + style, + ); + spans.push(span); + } } } } + } else { + for line in text.lines() { + let span = Span::styled(line.to_string(), code_style); + lines.push(Spans::from(span)); + } } } else { for line in text.lines() { @@ -152,64 +172,60 @@ fn parse<'a>( lines.push(Spans::from(span)); } } + } else if let Some(Tag::Heading(_, _, _)) = tags.last() { + let mut span = to_span(text); + span.style = heading_style; + spans.push(span); } else { - for line in text.lines() { - let span = Span::styled(line.to_string(), code_style); - lines.push(Spans::from(span)); - } + let mut span = to_span(text); + span.style = text_style; + spans.push(span); } - } else if let Some(Tag::Heading(_)) = tags.last() { - let mut span = to_span(text); - span.style = heading_style; - spans.push(span); - } else { + } + Event::Code(text) | Event::Html(text) => { let mut span = to_span(text); - span.style = text_style; + span.style = code_style; spans.push(span); } + Event::SoftBreak | Event::HardBreak => { + // let spans = std::mem::replace(&mut spans, Vec::new()); + // lines.push(Spans::from(spans)); + spans.push(Span::raw(" ")); + } + Event::Rule => { + let mut span = Span::raw("---"); + span.style = code_style; + lines.push(Spans::from(span)); + lines.push(Spans::default()); + } + // TaskListMarker(bool) true if checked + _ => { + log::warn!("unhandled markdown event {:?}", event); + } } - Event::Code(text) | Event::Html(text) => { - let mut span = to_span(text); - span.style = code_style; - spans.push(span); - } - Event::SoftBreak | Event::HardBreak => { - // let spans = std::mem::replace(&mut spans, Vec::new()); - // lines.push(Spans::from(spans)); - spans.push(Span::raw(" ")); - } - Event::Rule => { - let mut span = Span::raw("---"); - span.style = code_style; - lines.push(Spans::from(span)); - lines.push(Spans::default()); - } - // TaskListMarker(bool) true if checked - _ => { - log::warn!("unhandled markdown event {:?}", event); - } + // build up a vec of Paragraph tui widgets } - // build up a vec of Paragraph tui widgets - } - if !spans.is_empty() { - lines.push(Spans::from(spans)); - } + if !spans.is_empty() { + lines.push(Spans::from(spans)); + } - // if last line is empty, remove it - if let Some(line) = lines.last() { - if line.0.is_empty() { - lines.pop(); + // if last line is empty, remove it + if let Some(line) = lines.last() { + if line.0.is_empty() { + lines.pop(); + } } - } - Text::from(lines) + Text::from(lines) + } } + impl Component for Markdown { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { use tui::widgets::{Paragraph, Widget, Wrap}; - let text = parse(&self.contents, Some(&cx.editor.theme), &self.config_loader); + let text = self.parse(Some(&cx.editor.theme)); let par = Paragraph::new(text) .wrap(Wrap { trim: false }) @@ -227,7 +243,8 @@ impl Component for Markdown { if padding >= viewport.1 || padding >= viewport.0 { return None; } - let contents = parse(&self.contents, None, &self.config_loader); + let contents = self.parse(None); + // TODO: account for tab width let max_text_width = (viewport.0 - padding).min(120); let mut text_width = 0; @@ -241,11 +258,6 @@ impl Component for Markdown { } else if content_width > text_width { text_width = content_width; } - - if height >= viewport.1 { - height = viewport.1; - break; - } } Some((text_width + padding, height)) diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index e891c1492..f9a0438c5 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -14,11 +14,18 @@ use helix_view::{graphics::Rect, Editor}; use tui::layout::Constraint; pub trait Item { - fn sort_text(&self) -> &str; - fn filter_text(&self) -> &str; - fn label(&self) -> &str; - fn row(&self) -> Row; + + fn sort_text(&self) -> &str { + self.label() + } + fn filter_text(&self) -> &str { + self.label() + } + + fn row(&self) -> Row { + Row::new(vec![Cell::from(self.label())]) + } } pub struct Menu { @@ -132,7 +139,17 @@ impl Menu { acc }); - let len = max_lens.iter().sum::() + n + 1; // +1: reserve some space for scrollbar + + let height = self.matches.len().min(10).min(viewport.1 as usize); + // do all the matches fit on a single screen? + let fits = self.matches.len() <= height; + + let mut len = max_lens.iter().sum::() + n; + + if !fits { + len += 1; // +1: reserve some space for scrollbar + } + let width = len.min(viewport.0 as usize); self.widths = max_lens @@ -140,8 +157,6 @@ impl Menu { .map(|len| Constraint::Length(len as u16)) .collect(); - let height = self.matches.len().min(10).min(viewport.1 as usize); - self.size = (width as u16, height as u16); // adjust scroll offsets if size changed @@ -190,7 +205,7 @@ impl Component for Menu { _ => return EventResult::Ignored, }; - let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { // remove the layer compositor.pop(); }))); @@ -202,7 +217,7 @@ impl Component for Menu { return close_fn; } // arrow up/ctrl-p/shift-tab prev completion choice (including updating the doc) - shift!(BackTab) | key!(Up) | ctrl!('p') | ctrl!('k') => { + shift!(Tab) | key!(Up) | ctrl!('p') | ctrl!('k') => { self.move_up(); (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); return EventResult::Consumed(None); @@ -297,12 +312,14 @@ impl Component for Menu { }, ); + let fits = len <= win_height; + for (i, _) in (scroll..(scroll + win_height).min(len)).enumerate() { let is_marked = i >= scroll_line && i < scroll_line + scroll_height; - if is_marked { - let cell = surface.get_mut(area.x + area.width - 2, area.y + i as u16); - cell.set_symbol("▐ "); + if !fits && is_marked { + let cell = &mut surface[(area.x + area.width - 2, area.y + i as u16)]; + cell.set_symbol("▐"); // cell.set_style(selected); // cell.set_style(if is_marked { selected } else { style }); } diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 3c203326c..49f7b2fa3 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -2,7 +2,7 @@ mod completion; pub(crate) mod editor; mod info; mod markdown; -mod menu; +pub mod menu; mod picker; mod popup; mod prompt; @@ -65,7 +65,7 @@ pub fn regex_prompt( return; } - let case_insensitive = if cx.editor.config.smart_case { + let case_insensitive = if cx.editor.config.search.smart_case { !input.chars().any(char::is_uppercase) } else { false @@ -174,7 +174,9 @@ pub mod completers { use crate::ui::prompt::Completion; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use fuzzy_matcher::FuzzyMatcher; + use helix_view::editor::Config; use helix_view::theme; + use once_cell::sync::Lazy; use std::borrow::Cow; use std::cmp::Reverse; @@ -186,6 +188,7 @@ pub mod completers { &helix_core::config_dir().join("themes"), )); names.push("default".into()); + names.push("base16_default".into()); let mut names: Vec<_> = names .into_iter() @@ -207,6 +210,31 @@ pub mod completers { names } + pub fn setting(input: &str) -> Vec { + static KEYS: Lazy> = Lazy::new(|| { + serde_json::to_value(Config::default()) + .unwrap() + .as_object() + .unwrap() + .keys() + .cloned() + .collect() + }); + + let matcher = Matcher::default(); + + let mut matches: Vec<_> = KEYS + .iter() + .filter_map(|name| matcher.fuzzy_match(name, input).map(|score| (name, score))) + .collect(); + + matches.sort_unstable_by_key(|(_file, score)| Reverse(*score)); + matches + .into_iter() + .map(|(name, _)| ((0..), name.into())) + .collect() + } + pub fn filename(input: &str) -> Vec { filename_impl(input, |entry| { let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir()); @@ -255,7 +283,7 @@ pub mod completers { let is_tilde = input.starts_with('~') && input.len() == 1; let path = helix_core::path::expand_tilde(Path::new(input)); - let (dir, file_name) = if input.ends_with('/') { + let (dir, file_name) = if input.ends_with(std::path::MAIN_SEPARATOR) { (path, None) } else { let file_name = path diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index eaca470e9..2c7db7f2b 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -46,7 +46,7 @@ pub struct FilePicker { } pub enum CachedPreview { - Document(Document), + Document(Box), Binary, LargeFile, NotFound, @@ -139,8 +139,8 @@ impl FilePicker { (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => CachedPreview::LargeFile, _ => { // TODO: enable syntax highlighting; blocked by async rendering - Document::open(path, None, Some(&editor.theme), None) - .map(CachedPreview::Document) + Document::open(path, None, None) + .map(|doc| CachedPreview::Document(Box::new(doc))) .unwrap_or(CachedPreview::NotFound) } }, @@ -159,6 +159,7 @@ impl Component for FilePicker { // |picker | | | // | | | | // +---------+ +---------+ + let render_preview = area.width > MIN_SCREEN_WIDTH_FOR_PREVIEW; let area = inner_rect(area); // -- Render the frame: @@ -220,13 +221,8 @@ impl Component for FilePicker { let offset = Position::new(first_line, 0); - let highlights = EditorView::doc_syntax_highlights( - doc, - offset, - area.height, - &cx.editor.theme, - &cx.editor.syn_loader, - ); + let highlights = + EditorView::doc_syntax_highlights(doc, offset, area.height, &cx.editor.theme); EditorView::render_text_highlights( doc, offset, @@ -397,6 +393,16 @@ fn inner_rect(area: Rect) -> Rect { } impl Component for Picker { + fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { + let max_width = 50.min(viewport.0); + let max_height = 10.min(viewport.1.saturating_sub(2)); // add some spacing in the viewport + + let height = (self.options.len() as u16 + 4) // add some spacing for input + padding + .min(max_height); + let width = max_width; + Some((width, height)) + } + fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { let key_event = match event { Event::Key(event) => event, @@ -404,13 +410,13 @@ impl Component for Picker { _ => return EventResult::Ignored, }; - let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { // remove the layer compositor.last_picker = compositor.pop(); }))); match key_event.into() { - shift!(BackTab) | key!(Up) | ctrl!('p') | ctrl!('k') => { + shift!(Tab) | key!(Up) | ctrl!('p') | ctrl!('k') => { self.move_up(); } key!(Tab) | key!(Down) | ctrl!('n') | ctrl!('j') => { @@ -492,10 +498,9 @@ impl Component for Picker { let sep_style = Style::default().fg(Color::Rgb(90, 89, 119)); let borders = BorderType::line_symbols(BorderType::Plain); for x in inner.left()..inner.right() { - surface - .get_mut(x, inner.y + 1) - .set_symbol(borders.horizontal) - .set_style(sep_style); + if let Some(cell) = surface.get_mut(x, inner.y + 1) { + cell.set_symbol(borders.horizontal).set_style(sep_style); + } } // -- Render the contents: @@ -505,7 +510,7 @@ impl Component for Picker { let selected = cx.editor.theme.get("ui.text.focus"); let rows = inner.height; - let offset = self.cursor / (rows as usize) * (rows as usize); + let offset = self.cursor - (self.cursor % std::cmp::max(1, rows as usize)); let files = self.matches.iter().skip(offset).map(|(index, _score)| { (index, self.options.get(*index).unwrap()) // get_unchecked @@ -513,7 +518,7 @@ impl Component for Picker { for (i, (_index, option)) in files.take(rows as usize).enumerate() { if i == (self.cursor - offset) { - surface.set_string(inner.x - 2, inner.y + i as u16, ">", selected); + surface.set_string(inner.x.saturating_sub(2), inner.y + i as u16, ">", selected); } surface.set_string_truncated( diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index 8f7921a11..4d319423a 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -6,7 +6,7 @@ use crossterm::event::Event; use tui::buffer::Buffer as Surface; use helix_core::Position; -use helix_view::graphics::Rect; +use helix_view::graphics::{Margin, Rect}; // TODO: share logic with Menu, it's essentially Popup(render_fn), but render fn needs to return // a width/height hint. maybe Popup(Box) @@ -14,17 +14,26 @@ use helix_view::graphics::Rect; pub struct Popup { contents: T, position: Option, + margin: Margin, size: (u16, u16), + child_size: (u16, u16), scroll: usize, + id: &'static str, } impl Popup { - pub fn new(contents: T) -> Self { + pub fn new(id: &'static str, contents: T) -> Self { Self { contents, position: None, + margin: Margin { + vertical: 0, + horizontal: 0, + }, size: (0, 0), + child_size: (0, 0), scroll: 0, + id, } } @@ -32,6 +41,11 @@ impl Popup { self.position = pos; } + pub fn margin(mut self, margin: Margin) -> Self { + self.margin = margin; + self + } + pub fn get_rel_position(&mut self, viewport: Rect, cx: &Context) -> (u16, u16) { let position = self .position @@ -68,6 +82,9 @@ impl Popup { pub fn scroll(&mut self, offset: usize, direction: bool) { if direction { self.scroll += offset; + + let max_offset = self.child_size.1.saturating_sub(self.size.1); + self.scroll = (self.scroll + offset).min(max_offset as usize); } else { self.scroll = self.scroll.saturating_sub(offset); } @@ -93,7 +110,7 @@ impl Component for Popup { _ => return EventResult::Ignored, }; - let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { // remove the layer compositor.pop(); }))); @@ -115,13 +132,26 @@ impl Component for Popup { // tab/enter/ctrl-k or whatever will confirm the selection/ ctrl-n/ctrl-p for scroll. } - fn required_size(&mut self, _viewport: (u16, u16)) -> Option<(u16, u16)> { + fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { + let max_width = 120.min(viewport.0); + let max_height = 26.min(viewport.1.saturating_sub(2)); // add some spacing in the viewport + + let inner = Rect::new(0, 0, max_width, max_height).inner(&self.margin); + let (width, height) = self .contents - .required_size((120, 26)) // max width, max height + .required_size((inner.width, inner.height)) .expect("Component needs required_size implemented in order to be embedded in a popup"); - self.size = (width, height); + self.child_size = (width, height); + self.size = ( + (width + self.margin.horizontal * 2).min(max_width), + (height + self.margin.vertical * 2).min(max_height), + ); + + // re-clamp scroll offset + let max_offset = self.child_size.1.saturating_sub(self.size.1); + self.scroll = self.scroll.min(max_offset as usize); Some(self.size) } @@ -141,6 +171,11 @@ impl Component for Popup { let background = cx.editor.theme.get("ui.popup"); surface.clear_with(area, background); - self.contents.render(area, surface, cx); + let inner = area.inner(&self.margin); + self.contents.render(inner, surface, cx); + } + + fn id(&self) -> Option<&'static str> { + Some(self.id) } } diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index e90b07727..4c4fef268 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -127,7 +127,7 @@ impl Prompt { let mut char_position = char_indices .iter() .position(|(idx, _)| *idx == self.cursor) - .unwrap_or_else(|| char_indices.len()); + .unwrap_or(char_indices.len()); for _ in 0..rep { // Skip any non-whitespace characters @@ -330,7 +330,7 @@ impl Prompt { .max(BASE_WIDTH); let cols = std::cmp::max(1, area.width / max_len); - let col_width = (area.width - (cols)) / cols; + let col_width = (area.width.saturating_sub(cols)) / cols; let height = ((self.completion.len() as u16 + cols - 1) / cols) .min(10) // at most 10 rows (or less) @@ -426,7 +426,7 @@ impl Component for Prompt { _ => return EventResult::Ignored, }; - let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { // remove the layer compositor.pop(); }))); @@ -473,7 +473,7 @@ impl Component for Prompt { } } key!(Enter) => { - if self.selection.is_some() && self.line.ends_with('/') { + if self.selection.is_some() && self.line.ends_with(std::path::MAIN_SEPARATOR) { self.completion = (self.completion_fn)(&self.line); self.exit_selection(); } else { @@ -505,7 +505,7 @@ impl Component for Prompt { self.change_completion_selection(CompletionDirection::Forward); (self.callback_fn)(cx, &self.line, PromptEvent::Update) } - shift!(BackTab) => { + shift!(Tab) => { self.change_completion_selection(CompletionDirection::Backward); (self.callback_fn)(cx, &self.line, PromptEvent::Update) } diff --git a/helix-term/src/ui/spinner.rs b/helix-term/src/ui/spinner.rs index e8a43b48d..68965469d 100644 --- a/helix-term/src/ui/spinner.rs +++ b/helix-term/src/ui/spinner.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, time::SystemTime}; +use std::{collections::HashMap, time::Instant}; #[derive(Default, Debug)] pub struct ProgressSpinners { @@ -25,7 +25,7 @@ impl Default for Spinner { pub struct Spinner { frames: Vec<&'static str>, count: usize, - start: Option, + start: Option, interval: u64, } @@ -50,14 +50,13 @@ impl Spinner { } pub fn start(&mut self) { - self.start = Some(SystemTime::now()); + self.start = Some(Instant::now()); } pub fn frame(&self) -> Option<&str> { let idx = (self .start - .map(|time| SystemTime::now().duration_since(time))? - .ok()? + .map(|time| Instant::now().duration_since(time))? .as_millis() / self.interval as u128) as usize % self.count; diff --git a/helix-tui/Cargo.toml b/helix-tui/Cargo.toml index 6df65d360..e4cfbe4cd 100644 --- a/helix-tui/Cargo.toml +++ b/helix-tui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-tui" -version = "0.5.0" +version = "0.6.0" authors = ["Blaž Hrastnik "] description = """ A library to build rich terminal user interfaces or dashboards @@ -18,8 +18,8 @@ default = ["crossterm"] [dependencies] bitflags = "1.3" cassowary = "0.3" -unicode-segmentation = "1.8" -crossterm = { version = "0.22", optional = true } +unicode-segmentation = "1.9" +crossterm = { version = "0.23", optional = true } serde = { version = "1", "optional" = true, features = ["derive"]} -helix-view = { version = "0.5", path = "../helix-view", features = ["term"] } -helix-core = { version = "0.5", path = "../helix-core" } +helix-view = { version = "0.6", path = "../helix-view", features = ["term"] } +helix-core = { version = "0.6", path = "../helix-core" } diff --git a/helix-tui/README.md b/helix-tui/README.md index 97b3d1d9e..5cc80aa65 100644 --- a/helix-tui/README.md +++ b/helix-tui/README.md @@ -2,5 +2,5 @@ This library is a fork of the great library [tui-rs](https://github.com/fdehau/tui-rs/). We've mainly relied on the double -buffer implementation and render diffing, side-stepping it's widget and +buffer implementation and render diffing, side-stepping its widget and layouting. diff --git a/helix-tui/src/backend/test.rs b/helix-tui/src/backend/test.rs index 3f56b49c8..52474148e 100644 --- a/helix-tui/src/backend/test.rs +++ b/helix-tui/src/backend/test.rs @@ -111,8 +111,7 @@ impl Backend for TestBackend { I: Iterator, { for (x, y, c) in content { - let cell = self.buffer.get_mut(x, y); - *cell = c.clone(); + self.buffer[(x, y)] = c.clone(); } Ok(()) } diff --git a/helix-tui/src/buffer.rs b/helix-tui/src/buffer.rs index f480bc2f3..f8673e436 100644 --- a/helix-tui/src/buffer.rs +++ b/helix-tui/src/buffer.rs @@ -90,19 +90,19 @@ impl Default for Cell { /// use helix_view::graphics::{Rect, Color, Style, Modifier}; /// /// let mut buf = Buffer::empty(Rect{x: 0, y: 0, width: 10, height: 5}); -/// buf.get_mut(0, 2).set_symbol("x"); -/// assert_eq!(buf.get(0, 2).symbol, "x"); +/// buf[(0, 2)].set_symbol("x"); +/// assert_eq!(buf[(0, 2)].symbol, "x"); /// buf.set_string(3, 0, "string", Style::default().fg(Color::Red).bg(Color::White)); -/// assert_eq!(buf.get(5, 0), &Cell{ +/// assert_eq!(buf[(5, 0)], Cell{ /// symbol: String::from("r"), /// fg: Color::Red, /// bg: Color::White, /// modifier: Modifier::empty() /// }); -/// buf.get_mut(5, 0).set_char('x'); -/// assert_eq!(buf.get(5, 0).symbol, "x"); +/// buf[(5, 0)].set_char('x'); +/// assert_eq!(buf[(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 { @@ -171,15 +162,38 @@ impl Buffer { } /// Returns a reference to Cell at the given coordinates - pub fn get(&self, x: u16, y: u16) -> &Cell { - let i = self.index_of(x, y); - &self.content[i] + pub fn get(&self, x: u16, y: u16) -> Option<&Cell> { + self.index_of_opt(x, y).map(|i| &self.content[i]) } /// Returns a mutable reference to Cell at the given coordinates - pub fn get_mut(&mut self, x: u16, y: u16) -> &mut Cell { - let i = self.index_of(x, y); - &mut self.content[i] + pub fn get_mut(&mut self, x: u16, y: u16) -> Option<&mut Cell> { + self.index_of_opt(x, y).map(|i| &mut self.content[i]) + } + + /// Tells whether the global (x, y) coordinates are inside the Buffer's area. + /// + /// Global coordinates are offset by the Buffer's area offset (`x`/`y`). + /// + /// # Examples + /// + /// ``` + /// # use helix_tui::buffer::Buffer; + /// # use helix_view::graphics::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// // Global coordinates inside the Buffer's area + /// assert!(buffer.in_bounds(209, 100)); + /// // Global coordinates outside the Buffer's area + /// assert!(!buffer.in_bounds(210, 100)); + /// ``` + /// + /// Global coordinates are offset by the Buffer's area offset (`x`/`y`). + pub fn in_bounds(&self, x: u16, y: u16) -> bool { + x >= self.area.left() + && x < self.area.right() + && y >= self.area.top() + && y < self.area.bottom() } /// Returns the index in the Vec for the given global (x, y) coordinates. @@ -193,7 +207,7 @@ impl Buffer { /// # use helix_view::graphics::Rect; /// let rect = Rect::new(200, 100, 10, 10); /// let buffer = Buffer::empty(rect); - /// // Global coordinates to the top corner of this buffer's area + /// // Global coordinates to the top corner of this Buffer's area /// assert_eq!(buffer.index_of(200, 100), 0); /// ``` /// @@ -202,10 +216,7 @@ impl Buffer { /// Panics when given an coordinate that is outside of this Buffer's area. pub fn index_of(&self, x: u16, y: u16) -> usize { debug_assert!( - x >= self.area.left() - && x < self.area.right() - && y >= self.area.top() - && y < self.area.bottom(), + self.in_bounds(x, y), "Trying to access position outside the buffer: x={}, y={}, area={:?}", x, y, @@ -214,6 +225,16 @@ impl Buffer { ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize } + /// Returns the index in the Vec for the given global (x, y) coordinates, + /// or `None` if the coordinates are outside the buffer's area. + fn index_of_opt(&self, x: u16, y: u16) -> Option { + if self.in_bounds(x, y) { + Some(self.index_of(x, y)) + } else { + None + } + } + /// Returns the (global) coordinates of a cell given its index /// /// Global coordinates are offset by the Buffer's area offset (`x`/`y`). @@ -287,6 +308,11 @@ impl Buffer { where S: AsRef, { + // prevent panic if out of range + if !self.in_bounds(x, y) || width == 0 { + return (x, y); + } + let mut index = self.index_of(x, y); let mut x_offset = x as usize; let width = if ellipsis { width - 1 } else { width }; @@ -381,7 +407,7 @@ impl Buffer { pub fn set_background(&mut self, area: Rect, color: Color) { for y in area.top()..area.bottom() { for x in area.left()..area.right() { - self.get_mut(x, y).set_bg(color); + self[(x, y)].set_bg(color); } } } @@ -389,7 +415,7 @@ impl Buffer { pub fn set_style(&mut self, area: Rect, style: Style) { for y in area.top()..area.bottom() { for x in area.left()..area.right() { - self.get_mut(x, y).set_style(style); + self[(x, y)].set_style(style); } } } @@ -417,7 +443,7 @@ impl Buffer { pub fn clear(&mut self, area: Rect) { for x in area.left()..area.right() { for y in area.top()..area.bottom() { - self.get_mut(x, y).reset(); + self[(x, y)].reset(); } } } @@ -426,7 +452,7 @@ impl Buffer { pub fn clear_with(&mut self, area: Rect, style: Style) { for x in area.left()..area.right() { for y in area.top()..area.bottom() { - let cell = self.get_mut(x, y); + let cell = &mut self[(x, y)]; cell.reset(); cell.set_style(style); } @@ -509,15 +535,32 @@ impl Buffer { updates.push((x, y, &next_buffer[i])); } - to_skip = current.symbol.width().saturating_sub(1); + let current_width = current.symbol.width(); + to_skip = current_width.saturating_sub(1); - let affected_width = std::cmp::max(current.symbol.width(), previous.symbol.width()); + let affected_width = std::cmp::max(current_width, previous.symbol.width()); invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); } updates } } +impl std::ops::Index<(u16, u16)> for Buffer { + type Output = Cell; + + fn index(&self, (x, y): (u16, u16)) -> &Self::Output { + let i = self.index_of(x, y); + &self.content[i] + } +} + +impl std::ops::IndexMut<(u16, u16)> for Buffer { + fn index_mut(&mut self, (x, y): (u16, u16)) -> &mut Self::Output { + let i = self.index_of(x, y); + &mut self.content[i] + } +} + #[cfg(test)] mod tests { use super::*; 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/block.rs b/helix-tui/src/widgets/block.rs index 648c2d7ee..26223c3eb 100644 --- a/helix-tui/src/widgets/block.rs +++ b/helix-tui/src/widgets/block.rs @@ -15,12 +15,12 @@ pub enum BorderType { } impl BorderType { - pub fn line_symbols(border_type: BorderType) -> line::Set { + pub fn line_symbols(border_type: Self) -> line::Set { match border_type { - BorderType::Plain => line::NORMAL, - BorderType::Rounded => line::ROUNDED, - BorderType::Double => line::DOUBLE, - BorderType::Thick => line::THICK, + Self::Plain => line::NORMAL, + Self::Rounded => line::ROUNDED, + Self::Double => line::DOUBLE, + Self::Thick => line::THICK, } } } @@ -140,14 +140,14 @@ impl<'a> Widget for Block<'a> { // Sides if self.borders.intersects(Borders::LEFT) { for y in area.top()..area.bottom() { - buf.get_mut(area.left(), y) + buf[(area.left(), y)] .set_symbol(symbols.vertical) .set_style(self.border_style); } } if self.borders.intersects(Borders::TOP) { for x in area.left()..area.right() { - buf.get_mut(x, area.top()) + buf[(x, area.top())] .set_symbol(symbols.horizontal) .set_style(self.border_style); } @@ -155,7 +155,7 @@ impl<'a> Widget for Block<'a> { if self.borders.intersects(Borders::RIGHT) { let x = area.right() - 1; for y in area.top()..area.bottom() { - buf.get_mut(x, y) + buf[(x, y)] .set_symbol(symbols.vertical) .set_style(self.border_style); } @@ -163,7 +163,7 @@ impl<'a> Widget for Block<'a> { if self.borders.intersects(Borders::BOTTOM) { let y = area.bottom() - 1; for x in area.left()..area.right() { - buf.get_mut(x, y) + buf[(x, y)] .set_symbol(symbols.horizontal) .set_style(self.border_style); } @@ -171,22 +171,22 @@ impl<'a> Widget for Block<'a> { // Corners if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) { - buf.get_mut(area.right() - 1, area.bottom() - 1) + buf[(area.right() - 1, area.bottom() - 1)] .set_symbol(symbols.bottom_right) .set_style(self.border_style); } if self.borders.contains(Borders::RIGHT | Borders::TOP) { - buf.get_mut(area.right() - 1, area.top()) + buf[(area.right() - 1, area.top())] .set_symbol(symbols.top_right) .set_style(self.border_style); } if self.borders.contains(Borders::LEFT | Borders::BOTTOM) { - buf.get_mut(area.left(), area.bottom() - 1) + buf[(area.left(), area.bottom() - 1)] .set_symbol(symbols.bottom_left) .set_style(self.border_style); } if self.borders.contains(Borders::LEFT | Borders::TOP) { - buf.get_mut(area.left(), area.top()) + buf[(area.left(), area.top())] .set_symbol(symbols.top_left) .set_style(self.border_style); } diff --git a/helix-tui/src/widgets/paragraph.rs b/helix-tui/src/widgets/paragraph.rs index fee35d250..4e8391621 100644 --- a/helix-tui/src/widgets/paragraph.rs +++ b/helix-tui/src/widgets/paragraph.rs @@ -166,7 +166,7 @@ impl<'a> Widget for Paragraph<'a> { Box::new(WordWrapper::new(&mut styled, text_area.width, trim)) } else { let mut line_composer = Box::new(LineTruncator::new(&mut styled, text_area.width)); - if let Alignment::Left = self.alignment { + if self.alignment == Alignment::Left { line_composer.set_horizontal_offset(self.scroll.1); } line_composer @@ -176,7 +176,7 @@ impl<'a> Widget for Paragraph<'a> { if y >= self.scroll.0 { let mut x = get_line_offset(current_line_width, text_area.width, self.alignment); for StyledGrapheme { symbol, style } in current_line { - buf.get_mut(text_area.left() + x, text_area.top() + y - self.scroll.0) + buf[(text_area.left() + x, text_area.top() + y - self.scroll.0)] .set_symbol(if symbol.is_empty() { // If the symbol is empty, the last char which rendered last time will // leave on the line. It's a quick fix. diff --git a/helix-tui/src/widgets/reflow.rs b/helix-tui/src/widgets/reflow.rs index 21847783b..33e52bb4e 100644 --- a/helix-tui/src/widgets/reflow.rs +++ b/helix-tui/src/widgets/reflow.rs @@ -404,8 +404,8 @@ mod test { let text = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点\ では、"; let (word_wrapper, word_wrapper_width) = - run_composer(Composer::WordWrapper { trim: true }, &text, width); - let (line_truncator, _) = run_composer(Composer::LineTruncator, &text, width); + run_composer(Composer::WordWrapper { trim: true }, text, width); + let (line_truncator, _) = run_composer(Composer::LineTruncator, text, width); assert_eq!(line_truncator, vec!["コンピュータ上で文字"]); let wrapped = vec![ "コンピュータ上で文字", 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/Cargo.toml b/helix-view/Cargo.toml index ffe6a111c..932c33216 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helix-view" -version = "0.5.0" +version = "0.6.0" authors = ["Blaž Hrastnik "] edition = "2021" license = "MPL-2.0" @@ -16,13 +16,13 @@ term = ["crossterm"] [dependencies] bitflags = "1.3" anyhow = "1" -helix-core = { version = "0.5", path = "../helix-core" } -helix-lsp = { version = "0.5", path = "../helix-lsp"} -helix-dap = { version = "0.5", path = "../helix-dap"} -crossterm = { version = "0.22", optional = true } +helix-core = { version = "0.6", path = "../helix-core" } +helix-lsp = { version = "0.6", path = "../helix-lsp"} +helix-dap = { version = "0.6", path = "../helix-dap"} +crossterm = { version = "0.23", optional = true } # Conversion traits -once_cell = "1.8" +once_cell = "1.9" url = "2" tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } @@ -31,7 +31,6 @@ futures-util = { version = "0.3", features = ["std", "async-await"], default-fea slotmap = "1" -encoding_rs = "0.8" chardetng = "0.1" serde = { version = "1.0", features = ["derive"] } @@ -41,7 +40,7 @@ log = "~0.4" which = "4.2" [target.'cfg(windows)'.dependencies] -clipboard-win = { version = "4.2", features = ["std"] } +clipboard-win = { version = "4.4", features = ["std"] } [dev-dependencies] helix-tui = { path = "../helix-tui" } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 76b19a07d..c0186ee53 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1,5 +1,6 @@ -use anyhow::{anyhow, Context, Error}; +use anyhow::{anyhow, bail, Context, Error}; use serde::de::{self, Deserialize, Deserializer}; +use serde::Serialize; use std::cell::Cell; use std::collections::HashMap; use std::fmt::Display; @@ -9,7 +10,8 @@ use std::str::FromStr; use std::sync::Arc; use helix_core::{ - history::History, + encoding, + history::{History, UndoKind}, indent::{auto_detect_indent_style, IndentStyle}, line_ending::auto_detect_line_ending, syntax::{self, LanguageConfiguration}, @@ -18,7 +20,7 @@ use helix_core::{ }; use helix_lsp::util::LspFormatting; -use crate::{DocumentId, Theme, ViewId}; +use crate::{DocumentId, ViewId}; /// 8kB of buffer space for encoding and decoding `Rope`s. const BUF_SIZE: usize = 8192; @@ -29,9 +31,9 @@ pub const SCRATCH_BUFFER_NAME: &str = "[scratch]"; #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum Mode { - Normal, - Select, - Insert, + Normal = 0, + Select = 1, + Insert = 2, } impl Display for Mode { @@ -52,7 +54,7 @@ impl FromStr for Mode { "normal" => Ok(Mode::Normal), "select" => Ok(Mode::Select), "insert" => Ok(Mode::Insert), - _ => Err(anyhow!("Invalid mode '{}'", s)), + _ => bail!("Invalid mode '{}'", s), } } } @@ -68,13 +70,22 @@ impl<'de> Deserialize<'de> for Mode { } } +impl Serialize for Mode { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(self) + } +} + pub struct Document { pub(crate) id: DocumentId, text: Rope, pub(crate) selections: HashMap, path: Option, - encoding: &'static encoding_rs::Encoding, + encoding: &'static encoding::Encoding, /// Current editing mode. pub mode: Mode, @@ -104,6 +115,7 @@ pub struct Document { last_saved_revision: usize, version: i32, // should be usize? + pub(crate) modified_since_accessed: bool, diagnostics: Vec, language_server: Option>, @@ -127,6 +139,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() @@ -141,8 +154,8 @@ impl fmt::Debug for Document { /// be used to override encoding auto-detection. pub fn from_reader( reader: &mut R, - encoding: Option<&'static encoding_rs::Encoding>, -) -> Result<(Rope, &'static encoding_rs::Encoding), Error> { + encoding: Option<&'static encoding::Encoding>, +) -> Result<(Rope, &'static encoding::Encoding), Error> { // These two buffers are 8192 bytes in size each and are used as // intermediaries during the decoding process. Text read into `buf` // from `reader` is decoded into `buf_out` as UTF-8. Once either @@ -210,11 +223,11 @@ pub fn from_reader( total_read += read; total_written += written; match result { - encoding_rs::CoderResult::InputEmpty => { + encoding::CoderResult::InputEmpty => { debug_assert_eq!(slice.len(), total_read); break; } - encoding_rs::CoderResult::OutputFull => { + encoding::CoderResult::OutputFull => { debug_assert!(slice.len() > total_read); builder.append(&buf_str[..total_written]); total_written = 0; @@ -249,7 +262,7 @@ pub fn from_reader( /// replacement characters may appear in the encoded text. pub async fn to_writer<'a, W: tokio::io::AsyncWriteExt + Unpin + ?Sized>( writer: &'a mut W, - encoding: &'static encoding_rs::Encoding, + encoding: &'static encoding::Encoding, rope: &'a Rope, ) -> Result<(), Error> { // Text inside a `Rope` is stored as non-contiguous blocks of data called @@ -284,12 +297,12 @@ pub async fn to_writer<'a, W: tokio::io::AsyncWriteExt + Unpin + ?Sized>( total_read += read; total_written += written; match result { - encoding_rs::CoderResult::InputEmpty => { + encoding::CoderResult::InputEmpty => { debug_assert_eq!(chunk.len(), total_read); debug_assert!(buf.len() >= total_written); break; } - encoding_rs::CoderResult::OutputFull => { + encoding::CoderResult::OutputFull => { debug_assert!(chunk.len() > total_read); writer.write_all(&buf[..total_written]).await?; total_written = 0; @@ -320,8 +333,8 @@ use helix_lsp::lsp; use url::Url; impl Document { - pub fn from(text: Rope, encoding: Option<&'static encoding_rs::Encoding>) -> Self { - let encoding = encoding.unwrap_or(encoding_rs::UTF_8); + pub fn from(text: Rope, encoding: Option<&'static encoding::Encoding>) -> Self { + let encoding = encoding.unwrap_or(encoding::UTF_8); let changes = ChangeSet::new(&text); let old_state = None; @@ -344,6 +357,7 @@ impl Document { history: Cell::new(History::default()), savepoint: None, last_saved_revision: 0, + modified_since_accessed: false, language_server: None, } } @@ -353,9 +367,8 @@ impl Document { /// overwritten with the `encoding` parameter. pub fn open( path: &Path, - encoding: Option<&'static encoding_rs::Encoding>, - theme: Option<&Theme>, - config_loader: Option<&syntax::Loader>, + encoding: Option<&'static encoding::Encoding>, + config_loader: Option>, ) -> Result { // Open the file if it exists, otherwise assume it is a new file (and thus empty). let (rope, encoding) = if path.exists() { @@ -363,7 +376,7 @@ impl Document { std::fs::File::open(path).context(format!("unable to open {:?}", path))?; from_reader(&mut file, encoding)? } else { - let encoding = encoding.unwrap_or(encoding_rs::UTF_8); + let encoding = encoding.unwrap_or(encoding::UTF_8); (Rope::from(DEFAULT_LINE_ENDING.as_str()), encoding) }; @@ -372,7 +385,7 @@ impl Document { // set the path and try detecting the language doc.set_path(Some(path))?; if let Some(loader) = config_loader { - doc.detect_language(theme, loader); + doc.detect_language(loader); } doc.detect_indent_and_line_ending(); @@ -383,7 +396,7 @@ impl Document { /// The same as [`format`], but only returns formatting changes if auto-formatting /// is configured. pub fn auto_format(&self) -> Option + 'static> { - if self.language_config().map(|c| c.auto_format) == Some(true) { + if self.language_config()?.auto_format { self.format() } else { None @@ -393,30 +406,27 @@ impl Document { /// If supported, returns the changes that should be applied to this document in order /// to format it nicely. pub fn format(&self) -> Option + 'static> { - if let Some(language_server) = self.language_server() { - let text = self.text.clone(); - let offset_encoding = language_server.offset_encoding(); - let request = language_server.text_document_formatting( - self.identifier(), - lsp::FormattingOptions::default(), - None, - )?; - - let fut = async move { - let edits = request.await.unwrap_or_else(|e| { - log::warn!("LSP formatting failed: {}", e); - Default::default() - }); - LspFormatting { - doc: text, - edits, - offset_encoding, - } - }; - Some(fut) - } else { - None - } + let language_server = self.language_server()?; + let text = self.text.clone(); + let offset_encoding = language_server.offset_encoding(); + let request = language_server.text_document_formatting( + self.identifier(), + lsp::FormattingOptions::default(), + None, + )?; + + let fut = async move { + let edits = request.await.unwrap_or_else(|e| { + log::warn!("LSP formatting failed: {}", e); + Default::default() + }); + LspFormatting { + doc: text, + edits, + offset_encoding, + } + }; + Some(fut) } pub fn save(&mut self) -> impl Future> { @@ -460,9 +470,7 @@ impl Document { if let Some(parent) = path.parent() { // TODO: display a prompt asking the user if the directories should be created if !parent.exists() { - return Err(Error::msg( - "can't save file, parent directory does not exist", - )); + bail!("can't save file, parent directory does not exist"); } } @@ -494,12 +502,12 @@ impl Document { } /// Detect the programming language based on the file type. - pub fn detect_language(&mut self, theme: Option<&Theme>, config_loader: &syntax::Loader) { + pub fn detect_language(&mut self, config_loader: Arc) { if let Some(path) = &self.path { let language_config = config_loader .language_config_for_file_name(path) .or_else(|| config_loader.language_config_for_shebang(self.text())); - self.set_language(theme, language_config); + self.set_language(language_config, Some(config_loader)); } } @@ -509,8 +517,7 @@ impl Document { /// line ending. pub fn detect_indent_and_line_ending(&mut self) { self.indent_style = auto_detect_indent_style(&self.text).unwrap_or_else(|| { - self.language - .as_ref() + self.language_config() .and_then(|config| config.indent.as_ref()) .map_or(DEFAULT_INDENT, |config| IndentStyle::from_str(&config.unit)) }); @@ -524,7 +531,7 @@ impl Document { // If there is no path or the path no longer exists. if path.is_none() { - return Err(anyhow!("can't find file to reload from")); + bail!("can't find file to reload from"); } let mut file = std::fs::File::open(path.unwrap())?; @@ -545,15 +552,13 @@ impl Document { /// Sets the [`Document`]'s encoding with the encoding correspondent to `label`. pub fn set_encoding(&mut self, label: &str) -> Result<(), Error> { - match encoding_rs::Encoding::for_label(label.as_bytes()) { - Some(encoding) => self.encoding = encoding, - None => return Err(anyhow::anyhow!("unknown encoding")), - } + self.encoding = encoding::Encoding::for_label(label.as_bytes()) + .ok_or_else(|| anyhow!("unknown encoding"))?; Ok(()) } /// Returns the [`Document`]'s current encoding. - pub fn encoding(&self) -> &'static encoding_rs::Encoding { + pub fn encoding(&self) -> &'static encoding::Encoding { self.encoding } @@ -573,15 +578,13 @@ impl Document { /// if it exists. pub fn set_language( &mut self, - theme: Option<&Theme>, language_config: Option>, + loader: Option>, ) { - if let Some(language_config) = language_config { - let scopes = theme.map(|theme| theme.scopes()).unwrap_or(&[]); - if let Some(highlight_config) = language_config.highlight_config(scopes) { - let syntax = Syntax::new(&self.text, highlight_config); + if let (Some(language_config), Some(loader)) = (language_config, loader) { + if let Some(highlight_config) = language_config.highlight_config(&loader.scopes()) { + let syntax = Syntax::new(&self.text, highlight_config, loader); self.syntax = Some(syntax); - // TODO: config.configure(scopes) is now delayed, is that ok? } self.language = Some(language_config); @@ -593,15 +596,10 @@ impl Document { /// Set the programming language for the file if you know the name (scope) but don't have the /// [`syntax::LanguageConfiguration`] for it. - pub fn set_language2( - &mut self, - scope: &str, - theme: Option<&Theme>, - config_loader: Arc, - ) { + pub fn set_language2(&mut self, scope: &str, config_loader: Arc) { let language_config = config_loader.language_config_for_scope(scope); - self.set_language(theme, language_config); + self.set_language(language_config, Some(config_loader)); } /// Set the LSP. @@ -639,6 +637,8 @@ impl Document { selection.clone().ensure_invariants(self.text.slice(..)), ); } + + self.modified_since_accessed = true; } if !transaction.changes().is_empty() { @@ -680,7 +680,7 @@ impl Document { if let Some(notify) = notify { tokio::spawn(notify); - } //.expect("failed to emit textDocument/didChange"); + } } } success @@ -708,11 +708,11 @@ impl Document { success } - /// Undo the last modification to the [`Document`]. Returns whether the undo was successful. - pub fn undo(&mut self, view_id: ViewId) -> bool { + fn undo_redo_impl(&mut self, view_id: ViewId, undo: bool) -> bool { let mut history = self.history.take(); - let success = if let Some(transaction) = history.undo() { - self.apply_impl(transaction, view_id) + let txn = if undo { history.undo() } else { history.redo() }; + let success = if let Some(txn) = txn { + self.apply_impl(txn, view_id) } else { false }; @@ -725,21 +725,14 @@ impl Document { success } + /// Undo the last modification to the [`Document`]. Returns whether the undo was successful. + pub fn undo(&mut self, view_id: ViewId) -> bool { + self.undo_redo_impl(view_id, true) + } + /// Redo the last modification to the [`Document`]. Returns whether the redo was sucessful. pub fn redo(&mut self, view_id: ViewId) -> bool { - let mut history = self.history.take(); - let success = if let Some(transaction) = history.redo() { - self.apply_impl(transaction, view_id) - } else { - false - }; - self.history.set(history); - - if success { - // reset changeset to fix len - self.changes = ChangeSet::new(self.text()); - } - success + self.undo_redo_impl(view_id, false) } pub fn savepoint(&mut self) { @@ -752,9 +745,12 @@ impl Document { } } - /// Undo modifications to the [`Document`] according to `uk`. - pub fn earlier(&mut self, view_id: ViewId, uk: helix_core::history::UndoKind) -> bool { - let txns = self.history.get_mut().earlier(uk); + fn earlier_later_impl(&mut self, view_id: ViewId, uk: UndoKind, earlier: bool) -> bool { + let txns = if earlier { + self.history.get_mut().earlier(uk) + } else { + self.history.get_mut().later(uk) + }; let mut success = false; for txn in txns { if self.apply_impl(&txn, view_id) { @@ -768,20 +764,14 @@ impl Document { success } + /// Undo modifications to the [`Document`] according to `uk`. + pub fn earlier(&mut self, view_id: ViewId, uk: UndoKind) -> bool { + self.earlier_later_impl(view_id, uk, true) + } + /// Redo modifications to the [`Document`] according to `uk`. - 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 { - if self.apply_impl(&txn, view_id) { - success = true; - } - } - if success { - // reset changeset to fix len - self.changes = ChangeSet::new(self.text()); - } - success + pub fn later(&mut self, view_id: ViewId, uk: UndoKind) -> bool { + self.earlier_later_impl(view_id, uk, false) } /// Commit pending changes to history @@ -837,6 +827,16 @@ impl Document { .map(|language| language.scope.as_str()) } + /// Language ID for the document. Either the `language-id` from the + /// `language-server` configuration, or the document language if no + /// `language-id` has been specified. + pub fn language_id(&self) -> Option<&str> { + self.language_config() + .and_then(|config| config.language_server.as_ref()) + .and_then(|lsp_config| lsp_config.language_id.as_deref()) + .or_else(|| Some(self.language()?.rsplit_once('.')?.1)) + } + /// Corresponding [`LanguageConfiguration`]. pub fn language_config(&self) -> Option<&LanguageConfiguration> { self.language.as_deref() @@ -847,18 +847,10 @@ impl Document { self.version } + /// Language server if it has been initialized. pub fn language_server(&self) -> Option<&helix_lsp::Client> { - let server = self.language_server.as_deref(); - let initialized = server - .map(|server| server.is_initialized()) - .unwrap_or(false); - - // only resolve language_server if it's initialized - if initialized { - server - } else { - None - } + let server = self.language_server.as_deref()?; + server.is_initialized().then(|| server) } #[inline] @@ -869,8 +861,7 @@ impl Document { /// Tab size in columns. pub fn tab_width(&self) -> usize { - self.language - .as_ref() + self.language_config() .and_then(|config| config.indent.as_ref()) .map_or(4, |config| config.tab_width) // fallback to 4 columns } @@ -883,6 +874,10 @@ impl Document { self.indent_style.as_str() } + pub fn changes(&self) -> &ChangeSet { + &self.changes + } + #[inline] /// File path on disk. pub fn path(&self) -> Option<&PathBuf> { @@ -891,7 +886,7 @@ impl Document { /// File path as a URL. pub fn url(&self) -> Option { - self.path().map(|path| Url::from_file_path(path).unwrap()) + Url::from_file_path(self.path()?).ok() } #[inline] @@ -914,10 +909,6 @@ impl Document { .map(helix_core::path::get_relative_path) } - // pub fn slice(&self, range: R) -> RopeSlice where R: RangeBounds { - // self.state.doc.slice - // } - // transact(Fn) ? // -- LSP methods @@ -938,7 +929,6 @@ impl Document { pub fn set_diagnostics(&mut self, diagnostics: Vec) { self.diagnostics = diagnostics; - // sort by range self.diagnostics .sort_unstable_by_key(|diagnostic| diagnostic.range); } @@ -1113,7 +1103,7 @@ mod test { macro_rules! test_decode { ($label:expr, $label_override:expr) => { - let encoding = encoding_rs::Encoding::for_label($label_override.as_bytes()).unwrap(); + let encoding = encoding::Encoding::for_label($label_override.as_bytes()).unwrap(); let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/encoding"); let path = base_path.join(format!("{}_in.txt", $label)); let ref_path = base_path.join(format!("{}_in_ref.txt", $label)); @@ -1132,7 +1122,7 @@ mod test { macro_rules! test_encode { ($label:expr, $label_override:expr) => { - let encoding = encoding_rs::Encoding::for_label($label_override.as_bytes()).unwrap(); + let encoding = encoding::Encoding::for_label($label_override.as_bytes()).unwrap(); let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/encoding"); let path = base_path.join(format!("{}_out.txt", $label)); let ref_path = base_path.join(format!("{}_out_ref.txt", $label)); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index c7b3baefb..2e6121bc4 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,7 +1,9 @@ use crate::{ clipboard::{get_clipboard_provider, ClipboardProvider}, - document::SCRATCH_BUFFER_NAME, + document::{Mode, SCRATCH_BUFFER_NAME}, graphics::{CursorKind, Rect}, + info::Info, + input::KeyEvent, theme::{self, Theme}, tree::{self, Tree}, Document, DocumentId, View, ViewId, @@ -22,7 +24,7 @@ use std::{ use tokio::time::{sleep, Duration, Instant, Sleep}; -use anyhow::{bail, Context, Error}; +use anyhow::{bail, Error}; pub use helix_core::diagnostic::Severity; pub use helix_core::register::Registers; @@ -30,7 +32,7 @@ use helix_core::syntax; use helix_core::{Position, Selection}; use helix_dap as dap; -use serde::Deserialize; +use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize}; fn deserialize_duration_millis<'de, D>(deserializer: D) -> Result where @@ -40,7 +42,7 @@ where Ok(Duration::from_millis(millis)) } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct FilePickerConfig { /// IgnoreOptions @@ -80,7 +82,7 @@ impl Default for FilePickerConfig { } } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct Config { /// Padding to keep between the edge of the screen and the cursor when scrolling. Defaults to 5. @@ -95,8 +97,6 @@ pub struct Config { pub line_number: LineNumber, /// Middle click paste support. Defaults to true. pub middle_click_paste: bool, - /// Smart case: Case insensitive searching unless pattern contains upper case characters. Defaults to true. - pub smart_case: bool, /// Automatic insertion of pairs to parentheses, brackets, etc. Defaults to true. pub auto_pairs: bool, /// Automatic auto-completion, automatically pop up without user trigger. Defaults to true. @@ -108,18 +108,101 @@ pub struct Config { /// Whether to display infoboxes. Defaults to true. pub auto_info: bool, pub file_picker: FilePickerConfig, + /// Shape for cursor in each mode + pub cursor_shape: CursorShapeConfig, + /// Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. Defaults to `false`. + pub true_color: bool, + /// Search configuration. + #[serde(default)] + pub search: SearchConfig, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +pub struct SearchConfig { + /// Smart case: Case insensitive searching unless pattern contains upper case characters. Defaults to true. + pub smart_case: bool, + /// Whether the search should wrap after depleting the matches. Default to true. + pub wrap_around: bool, +} + +// Cursor shape is read and used on every rendered frame and so needs +// to be fast. Therefore we avoid a hashmap and use an enum indexed array. +#[derive(Debug, Clone, PartialEq)] +pub struct CursorShapeConfig([CursorKind; 3]); + +impl CursorShapeConfig { + pub fn from_mode(&self, mode: Mode) -> CursorKind { + self.get(mode as usize).copied().unwrap_or_default() + } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] +impl<'de> Deserialize<'de> for CursorShapeConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let m = HashMap::::deserialize(deserializer)?; + let into_cursor = |mode: Mode| m.get(&mode).copied().unwrap_or_default(); + Ok(CursorShapeConfig([ + into_cursor(Mode::Normal), + into_cursor(Mode::Select), + into_cursor(Mode::Insert), + ])) + } +} + +impl Serialize for CursorShapeConfig { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(Some(self.len()))?; + let modes = [Mode::Normal, Mode::Select, Mode::Insert]; + for mode in modes { + map.serialize_entry(&mode, &self.from_mode(mode))?; + } + map.end() + } +} + +impl std::ops::Deref for CursorShapeConfig { + type Target = [CursorKind; 3]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Default for CursorShapeConfig { + fn default() -> Self { + Self([CursorKind::Block; 3]) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum LineNumber { /// Show absolute line number Absolute, - /// Show relative line number to the primary cursor + /// If focused and in normal/select mode, show relative line number to the primary cursor. + /// If unfocused or in insert mode, show absolute line number. Relative, } +impl std::str::FromStr for LineNumber { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "absolute" | "abs" => Ok(Self::Absolute), + "relative" | "rel" => Ok(Self::Relative), + _ => anyhow::bail!("Line number can only be `absolute` or `relative`."), + } + } +} + impl Default for Config { fn default() -> Self { Self { @@ -133,13 +216,24 @@ impl Default for Config { }, line_number: LineNumber::Absolute, middle_click_paste: true, - smart_case: true, auto_pairs: true, auto_completion: true, idle_timeout: Duration::from_millis(400), completion_trigger_len: 2, auto_info: true, file_picker: FilePickerConfig::default(), + cursor_shape: CursorShapeConfig::default(), + true_color: false, + search: SearchConfig::default(), + } + } +} + +impl Default for SearchConfig { + fn default() -> Self { + Self { + wrap_around: true, + smart_case: true, } } } @@ -177,6 +271,7 @@ pub struct Editor { pub count: Option, pub selected_register: Option, pub registers: Registers, + pub macro_recording: Option<(char, Vec)>, pub theme: Theme, pub language_servers: helix_lsp::Registry, @@ -190,6 +285,7 @@ pub struct Editor { pub theme_loader: Arc, pub status_msg: Option<(String, Severity)>, + pub autoinfo: Option, pub config: Config, @@ -225,6 +321,7 @@ impl Editor { documents: BTreeMap::new(), count: None, selected_register: None, + macro_recording: None, theme: theme_loader.default(), language_servers, debugger: None, @@ -235,6 +332,7 @@ impl Editor { registers: Registers::default(), clipboard_provider: get_clipboard_provider(), status_msg: None, + autoinfo: None, idle_timer: Box::pin(sleep(config.idle_timeout)), last_motion: None, config, @@ -275,31 +373,16 @@ impl Editor { } let scopes = theme.scopes(); - for config in self - .syn_loader - .language_configs_iter() - .filter(|cfg| cfg.is_highlight_initialized()) - { - config.reconfigure(scopes); - } + self.syn_loader.set_scopes(scopes.to_vec()); self.theme = theme; self._refresh(); } - pub fn set_theme_from_name(&mut self, theme: &str) -> anyhow::Result<()> { - let theme = self - .theme_loader - .load(theme.as_ref()) - .with_context(|| format!("failed setting theme `{}`", theme))?; - self.set_theme(theme); - 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); + doc.detect_language(self.syn_loader.clone()); Self::launch_language_server(&mut self.language_servers, doc) } @@ -323,11 +406,8 @@ impl Editor { 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(); + + let language_id = doc.language_id().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( @@ -394,7 +474,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`. @@ -403,7 +484,16 @@ 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(view.doc) + { + view.last_modified_docs = [Some(view.doc), view.last_modified_docs[0]]; + } + } } let view_id = view.id; @@ -471,7 +561,7 @@ impl Editor { let id = if let Some(id) = id { id } else { - let mut doc = Document::open(&path, None, Some(&self.theme), Some(&self.syn_loader))?; + let mut doc = Document::open(&path, None, Some(self.syn_loader.clone()))?; let _ = Self::launch_language_server(&mut self.language_servers, &mut doc); @@ -629,9 +719,10 @@ impl Editor { let inner = view.inner_area(); pos.col += inner.x as usize; pos.row += inner.y as usize; - (Some(pos), CursorKind::Hidden) + let cursorkind = self.config.cursor_shape.from_mode(doc.mode()); + (Some(pos), cursorkind) } else { - (None, CursorKind::Hidden) + (None, CursorKind::default()) } } diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs index 0bfca04aa..6d0a92928 100644 --- a/helix-view/src/graphics.rs +++ b/helix-view/src/graphics.rs @@ -1,10 +1,12 @@ use bitflags::bitflags; +use serde::{Deserialize, Serialize}; use std::{ cmp::{max, min}, str::FromStr, }; -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] /// UNSTABLE pub enum CursorKind { /// █ @@ -17,6 +19,12 @@ pub enum CursorKind { Hidden, } +impl Default for CursorKind { + fn default() -> Self { + Self::Block + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Margin { pub vertical: u16, @@ -25,7 +33,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 +41,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. @@ -334,7 +331,7 @@ impl FromStr for Modifier { /// ]; /// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1)); /// for style in &styles { -/// buffer.get_mut(0, 0).set_style(*style); +/// buffer[(0, 0)].set_style(*style); /// } /// assert_eq!( /// Style { @@ -343,7 +340,7 @@ impl FromStr for Modifier { /// add_modifier: Modifier::BOLD, /// sub_modifier: Modifier::empty(), /// }, -/// buffer.get(0, 0).style(), +/// buffer[(0, 0)].style(), /// ); /// ``` /// @@ -359,7 +356,7 @@ impl FromStr for Modifier { /// ]; /// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1)); /// for style in &styles { -/// buffer.get_mut(0, 0).set_style(*style); +/// buffer[(0, 0)].set_style(*style); /// } /// assert_eq!( /// Style { @@ -368,7 +365,7 @@ impl FromStr for Modifier { /// add_modifier: Modifier::empty(), /// sub_modifier: Modifier::empty(), /// }, -/// buffer.get(0, 0).style(), +/// buffer[(0, 0)].style(), /// ); /// ``` #[derive(Debug, Clone, Copy, PartialEq)] diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index e156b9e55..6a77c41fc 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -25,7 +25,8 @@ pub fn diagnostic<'doc>( Box::new(move |line: usize, _selected: bool, out: &mut String| { use helix_core::diagnostic::Severity; - if let Some(diagnostic) = diagnostics.iter().find(|d| d.line == line) { + if let Ok(index) = diagnostics.binary_search_by_key(&line, |d| d.line) { + let diagnostic = &diagnostics[index]; write!(out, "●").unwrap(); return Some(match diagnostic.severity { Some(Severity::Error) => error, @@ -60,29 +61,31 @@ pub fn line_number<'doc>( .char_to_line(doc.selection(view.id).primary().cursor(text)); let config = editor.config.line_number; + let mode = doc.mode; Box::new(move |line: usize, selected: bool, out: &mut String| { if line == last_line && !draw_last { write!(out, "{:>1$}", '~', width).unwrap(); Some(linenr) } else { - use crate::editor::LineNumber; - let line = match config { - LineNumber::Absolute => line + 1, - LineNumber::Relative => { - if current_line == line { - line + 1 - } else { - abs_diff(current_line, line) - } - } + use crate::{document::Mode, editor::LineNumber}; + + let relative = config == LineNumber::Relative + && mode != Mode::Insert + && is_focused + && current_line != line; + + let display_num = if relative { + abs_diff(current_line, line) + } else { + line + 1 }; let style = if selected && is_focused { linenr_select } else { linenr }; - write!(out, "{:>1$}", line, width).unwrap(); + write!(out, "{:>1$}", display_num, width).unwrap(); Some(style) } }) diff --git a/helix-view/src/info.rs b/helix-view/src/info.rs index b5a002fa4..5ad6a60c7 100644 --- a/helix-view/src/info.rs +++ b/helix-view/src/info.rs @@ -1,5 +1,5 @@ use crate::input::KeyEvent; -use helix_core::unicode::width::UnicodeWidthStr; +use helix_core::{register::Registers, unicode::width::UnicodeWidthStr}; use std::{collections::BTreeSet, fmt::Write}; #[derive(Debug)] @@ -16,33 +16,60 @@ pub struct Info { } impl Info { - pub fn new(title: &str, body: Vec<(&str, BTreeSet)>) -> Info { - let body = body - .into_iter() - .map(|(desc, events)| { - let events = events.iter().map(ToString::to_string).collect::>(); - (desc, events.join(", ")) - }) - .collect::>(); + pub fn new(title: &str, body: Vec<(String, String)>) -> Self { + if body.is_empty() { + return Self { + title: title.to_string(), + height: 1, + width: title.len() as u16, + text: "".to_string(), + }; + } - let keymaps_width = body.iter().map(|r| r.1.len()).max().unwrap(); + let item_width = body.iter().map(|(item, _)| item.width()).max().unwrap(); let mut text = String::new(); - for (desc, keyevents) in &body { - let _ = writeln!( - text, - "{:width$} {}", - keyevents, - desc, - width = keymaps_width - ); + for (item, desc) in &body { + let _ = writeln!(text, "{:width$} {}", item, desc, width = item_width); } - Info { + Self { title: title.to_string(), width: text.lines().map(|l| l.width()).max().unwrap() as u16, height: body.len() as u16, text, } } + + pub fn from_keymap(title: &str, body: Vec<(&str, BTreeSet)>) -> Self { + let body = body + .into_iter() + .map(|(desc, events)| { + let events = events.iter().map(ToString::to_string).collect::>(); + (events.join(", "), desc.to_string()) + }) + .collect(); + + Self::new(title, body) + } + + pub fn from_registers(registers: &Registers) -> Self { + let body = registers + .inner() + .iter() + .map(|(ch, reg)| { + let content = reg + .read() + .get(0) + .and_then(|s| s.lines().next()) + .map(String::from) + .unwrap_or_default(); + (ch.to_string(), content) + }) + .collect(); + + let mut infobox = Self::new("Registers", body); + infobox.width = 30; // copied content could be very long + infobox + } } diff --git a/helix-view/src/input.rs b/helix-view/src/input.rs index 580204ccc..14dadc3b9 100644 --- a/helix-view/src/input.rs +++ b/helix-view/src/input.rs @@ -36,7 +36,6 @@ pub(crate) mod keys { pub(crate) const PAGEUP: &str = "pageup"; pub(crate) const PAGEDOWN: &str = "pagedown"; pub(crate) const TAB: &str = "tab"; - pub(crate) const BACKTAB: &str = "backtab"; pub(crate) const DELETE: &str = "del"; pub(crate) const INSERT: &str = "ins"; pub(crate) const NULL: &str = "null"; @@ -82,7 +81,6 @@ impl fmt::Display for KeyEvent { KeyCode::PageUp => f.write_str(keys::PAGEUP)?, KeyCode::PageDown => f.write_str(keys::PAGEDOWN)?, KeyCode::Tab => f.write_str(keys::TAB)?, - KeyCode::BackTab => f.write_str(keys::BACKTAB)?, KeyCode::Delete => f.write_str(keys::DELETE)?, KeyCode::Insert => f.write_str(keys::INSERT)?, KeyCode::Null => f.write_str(keys::NULL)?, @@ -116,7 +114,6 @@ impl UnicodeWidthStr for KeyEvent { KeyCode::PageUp => keys::PAGEUP.len(), KeyCode::PageDown => keys::PAGEDOWN.len(), KeyCode::Tab => keys::TAB.len(), - KeyCode::BackTab => keys::BACKTAB.len(), KeyCode::Delete => keys::DELETE.len(), KeyCode::Insert => keys::INSERT.len(), KeyCode::Null => keys::NULL.len(), @@ -166,7 +163,6 @@ impl std::str::FromStr for KeyEvent { keys::PAGEUP => KeyCode::PageUp, keys::PAGEDOWN => KeyCode::PageDown, keys::TAB => KeyCode::Tab, - keys::BACKTAB => KeyCode::BackTab, keys::DELETE => KeyCode::Delete, keys::INSERT => KeyCode::Insert, keys::NULL => KeyCode::Null, @@ -220,14 +216,79 @@ impl<'de> Deserialize<'de> for KeyEvent { #[cfg(feature = "term")] impl From for KeyEvent { - fn from( - crossterm::event::KeyEvent { code, modifiers }: crossterm::event::KeyEvent, - ) -> KeyEvent { - KeyEvent { - code: code.into(), - modifiers: modifiers.into(), + fn from(crossterm::event::KeyEvent { code, modifiers }: crossterm::event::KeyEvent) -> Self { + if code == crossterm::event::KeyCode::BackTab { + // special case for BackTab -> Shift-Tab + let mut modifiers: KeyModifiers = modifiers.into(); + modifiers.insert(KeyModifiers::SHIFT); + Self { + code: KeyCode::Tab, + modifiers, + } + } else { + Self { + code: code.into(), + modifiers: modifiers.into(), + } + } + } +} + +#[cfg(feature = "term")] +impl From for crossterm::event::KeyEvent { + fn from(KeyEvent { code, modifiers }: KeyEvent) -> Self { + if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) { + // special case for Shift-Tab -> BackTab + let mut modifiers = modifiers; + modifiers.remove(KeyModifiers::SHIFT); + crossterm::event::KeyEvent { + code: crossterm::event::KeyCode::BackTab, + modifiers: modifiers.into(), + } + } else { + crossterm::event::KeyEvent { + code: code.into(), + modifiers: modifiers.into(), + } + } + } +} + +pub fn parse_macro(keys_str: &str) -> anyhow::Result> { + use anyhow::Context; + let mut keys_res: anyhow::Result<_> = Ok(Vec::new()); + let mut i = 0; + while let Ok(keys) = &mut keys_res { + if i >= keys_str.len() { + break; + } + if !keys_str.is_char_boundary(i) { + i += 1; + continue; + } + + let s = &keys_str[i..]; + let mut end_i = 1; + while !s.is_char_boundary(end_i) { + end_i += 1; + } + let c = &s[..end_i]; + if c == ">" { + keys_res = Err(anyhow!("Unmatched '>'")); + } else if c != "<" { + keys.push(c); + i += end_i; + } else { + match s.find('>').context("'>' expected") { + Ok(end_i) => { + keys.push(&s[1..end_i]); + i += end_i + 1; + } + Err(err) => keys_res = Err(err), + } } } + keys_res.and_then(|keys| keys.into_iter().map(str::parse).collect()) } #[cfg(test)] @@ -315,4 +376,120 @@ mod test { assert!(str::parse::("123").is_err()); assert!(str::parse::("S--").is_err()); } + + #[test] + fn parsing_valid_macros() { + assert_eq!( + parse_macro("xdo").ok(), + Some(vec![ + KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::NONE, + }, + ]), + ); + + assert_eq!( + parse_macro("vhxx").ok(), + Some(vec![ + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + }, + KeyEvent { + code: KeyCode::Char('v'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + }, + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::CONTROL, + }, + KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::ALT, + }, + ]) + ); + + assert_eq!( + parse_macro(":o foo.bar").ok(), + Some(vec![ + KeyEvent { + code: KeyCode::Char(':'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('.'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::NONE, + }, + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + }, + ]) + ); + } + + #[test] + fn parsing_invalid_macros_fails() { + assert!(parse_macro("abc123").is_err()); + assert!(parse_macro("wd").is_err()); + } } diff --git a/helix-view/src/keyboard.rs b/helix-view/src/keyboard.rs index 810aa0635..f17172091 100644 --- a/helix-view/src/keyboard.rs +++ b/helix-view/src/keyboard.rs @@ -79,8 +79,6 @@ pub enum KeyCode { PageDown, /// Tab key. Tab, - /// Shift + Tab key. - BackTab, /// Delete key. Delete, /// Insert key. @@ -116,7 +114,6 @@ impl From for crossterm::event::KeyCode { KeyCode::PageUp => CKeyCode::PageUp, KeyCode::PageDown => CKeyCode::PageDown, KeyCode::Tab => CKeyCode::Tab, - KeyCode::BackTab => CKeyCode::BackTab, KeyCode::Delete => CKeyCode::Delete, KeyCode::Insert => CKeyCode::Insert, KeyCode::F(f_number) => CKeyCode::F(f_number), @@ -144,7 +141,7 @@ impl From for KeyCode { CKeyCode::PageUp => KeyCode::PageUp, CKeyCode::PageDown => KeyCode::PageDown, CKeyCode::Tab => KeyCode::Tab, - CKeyCode::BackTab => KeyCode::BackTab, + CKeyCode::BackTab => unreachable!("BackTab should have been handled on KeyEvent level"), CKeyCode::Delete => KeyCode::Delete, CKeyCode::Insert => KeyCode::Insert, CKeyCode::F(f_number) => KeyCode::F(f_number), diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index 757316bde..00c1bbbd9 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -15,6 +15,10 @@ pub use crate::graphics::{Color, Modifier, Style}; pub static DEFAULT_THEME: Lazy = Lazy::new(|| { toml::from_slice(include_bytes!("../../theme.toml")).expect("Failed to parse default theme") }); +pub static BASE16_DEFAULT_THEME: Lazy = Lazy::new(|| { + toml::from_slice(include_bytes!("../../base16_theme.toml")) + .expect("Failed to parse base 16 default theme") +}); #[derive(Clone, Debug)] pub struct Loader { @@ -35,6 +39,9 @@ impl Loader { if name == "default" { return Ok(self.default()); } + if name == "base16_default" { + return Ok(self.base16_default()); + } let filename = format!("{}.toml", name); let user_path = self.user_dir.join(&filename); @@ -74,12 +81,20 @@ impl Loader { pub fn default(&self) -> Theme { DEFAULT_THEME.clone() } + + /// Returns the alternative 16-color default theme + pub fn base16_default(&self) -> Theme { + BASE16_DEFAULT_THEME.clone() + } } #[derive(Clone, Debug)] pub struct Theme { - scopes: Vec, + // UI styles are stored in a HashMap styles: HashMap, + // tree-sitter highlight styles are stored in a Vec to optimize lookups + scopes: Vec, + highlights: Vec