diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 93fcb98164df..21bb51b2ef6d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,9 @@ on: schedule: - cron: "00 01 * * *" +env: + MSRV: "1.76" + jobs: check: name: Check (msrv) @@ -16,8 +19,11 @@ jobs: steps: - name: Checkout sources uses: actions/checkout@v4 - - name: Install stable toolchain - uses: dtolnay/rust-toolchain@1.70 + + - name: Install MSRV toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.MSRV }} - uses: Swatinem/rust-cache@v2 with: @@ -37,8 +43,10 @@ jobs: - name: Checkout sources uses: actions/checkout@v4 - - name: Install stable toolchain - uses: dtolnay/rust-toolchain@1.70 + - name: Install MSRV toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.MSRV }} - uses: Swatinem/rust-cache@v2 with: @@ -69,9 +77,10 @@ jobs: - name: Checkout sources uses: actions/checkout@v4 - - name: Install stable toolchain - uses: dtolnay/rust-toolchain@1.70 + - name: Install MSRV toolchain + uses: dtolnay/rust-toolchain@master with: + toolchain: ${{ env.MSRV }} components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 @@ -97,8 +106,10 @@ jobs: - name: Checkout sources uses: actions/checkout@v4 - - name: Install stable toolchain - uses: dtolnay/rust-toolchain@1.70 + - name: Install MSRV toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.MSRV }} - uses: Swatinem/rust-cache@v2 with: @@ -107,6 +118,9 @@ jobs: - name: Validate queries run: cargo xtask query-check + - name: Validate themes + run: cargo xtask theme-check + - name: Generate docs run: cargo xtask docgen diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index b8be02e16633..9a25cbe457a5 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@v4 - name: Install nix - uses: cachix/install-nix-action@V27 + uses: cachix/install-nix-action@v30 - name: Authenticate with Cachix uses: cachix/cachix-action@v15 diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 39dc089825a1..d5ccb0be86aa 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -16,8 +16,8 @@ jobs: - name: Setup mdBook uses: peaceiris/actions-mdbook@v2 with: - mdbook-version: 'latest' - # mdbook-version: '0.4.8' + # mdbook-version: 'latest' + mdbook-version: '0.4.43' - run: mdbook build book diff --git a/.gitignore b/.gitignore index 6a6fc782a827..64a837dfddda 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ target helix-term/rustfmt.toml result runtime/grammars +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 7ec3332ef3a4..0d096f2cdf96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,9 +68,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.87" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10f00e1f6e58a40e807377c75c6a7f97bf9044fab57816f2414e6f5f4499d7b8" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" [[package]] name = "arc-swap" @@ -136,9 +136,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cc" -version = "1.1.18" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476" +checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" dependencies = [ "shlex", ] @@ -278,6 +278,17 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dunce" version = "1.0.5" @@ -292,9 +303,9 @@ checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "encoding_rs" -version = "0.8.34" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] @@ -346,6 +357,9 @@ name = "faster-hex" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183" +dependencies = [ + "serde", +] [[package]] name = "fastrand" @@ -355,9 +369,9 @@ checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "fern" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" +checksum = "69ff9c9d5fb3e6da8ac2f77ab76fe7e8087d512ce095200f8f29ac5b656cf6dc" dependencies = [ "log", ] @@ -369,7 +383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" dependencies = [ "libc", - "thiserror", + "thiserror 1.0.69", "winapi", ] @@ -412,15 +426,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -429,15 +443,15 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-task", @@ -465,9 +479,9 @@ checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" [[package]] name = "gix" -version = "0.66.0" +version = "0.68.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9048b8d1ae2104f045cb37e5c450fc49d5d8af22609386bfc739c11ba88995eb" +checksum = "b04c66359b5e17f92395abc433861df0edf48f39f3f590818d1d7217327dd6a1" dependencies = [ "gix-actor", "gix-attributes", @@ -508,28 +522,28 @@ dependencies = [ "gix-worktree", "once_cell", "smallvec", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-actor" -version = "0.32.0" +version = "0.33.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc19e312cd45c4a66cd003f909163dc2f8e1623e30a0c0c6df3776e89b308665" +checksum = "32b24171f514cef7bb4dfb72a0b06dacf609b33ba8ad2489d4c4559a03b7afb3" dependencies = [ "bstr", "gix-date", "gix-utils", "itoa", - "thiserror", + "thiserror 2.0.3", "winnow", ] [[package]] name = "gix-attributes" -version = "0.22.5" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebccbf25aa4a973dd352564a9000af69edca90623e8a16dad9cbc03713131311" +checksum = "ddf9bf852194c0edfe699a2d36422d2c1f28f73b7c6d446c3f0ccd3ba232cadc" dependencies = [ "bstr", "gix-glob", @@ -538,33 +552,33 @@ dependencies = [ "gix-trace", "kstring", "smallvec", - "thiserror", + "thiserror 2.0.3", "unicode-bom", ] [[package]] name = "gix-bitmap" -version = "0.2.11" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a371db66cbd4e13f0ed9dc4c0fea712d7276805fccc877f77e96374d317e87ae" +checksum = "d48b897b4bbc881aea994b4a5bbb340a04979d7be9089791304e04a9fbc66b53" dependencies = [ - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-chunk" -version = "0.4.8" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c8751169961ba7640b513c3b24af61aa962c967aaf04116734975cd5af0c52" +checksum = "c6ffbeb3a5c0b8b84c3fe4133a6f8c82fa962f4caefe8d0762eced025d3eb4f7" dependencies = [ - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-command" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff2e692b36bbcf09286c70803006ca3fd56551a311de450be317a0ab8ea92e7" +checksum = "6d7d6b8f3a64453fd7e8191eb80b351eb7ac0839b40a1237cd2c137d5079fe53" dependencies = [ "bstr", "gix-path", @@ -574,23 +588,23 @@ dependencies = [ [[package]] name = "gix-commitgraph" -version = "0.24.3" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133b06f67f565836ec0c473e2116a60fb74f80b6435e21d88013ac0e3c60fc78" +checksum = "a8da6591a7868fb2b6dabddea6b09988b0b05e0213f938dbaa11a03dd7a48d85" dependencies = [ "bstr", "gix-chunk", "gix-features", "gix-hash", "memmap2", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-config" -version = "0.40.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78e797487e6ca3552491de1131b4f72202f282fb33f198b1c34406d765b42bb0" +checksum = "6649b406ca1f99cb148959cf00468b231f07950f8ec438cc0903cda563606f19" dependencies = [ "bstr", "gix-config-value", @@ -602,41 +616,41 @@ dependencies = [ "memchr", "once_cell", "smallvec", - "thiserror", + "thiserror 2.0.3", "unicode-bom", "winnow", ] [[package]] name = "gix-config-value" -version = "0.14.8" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03f76169faa0dec598eac60f83d7fcdd739ec16596eca8fb144c88973dbe6f8c" +checksum = "49aaeef5d98390a3bcf9dbc6440b520b793d1bf3ed99317dc407b02be995b28e" dependencies = [ "bitflags", "bstr", "gix-path", "libc", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-date" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35c84b7af01e68daf7a6bb8bb909c1ff5edb3ce4326f1f43063a5a96d3c3c8a5" +checksum = "691142b1a34d18e8ed6e6114bc1a2736516c5ad60ef3aa9bd1b694886e3ca92d" dependencies = [ "bstr", "itoa", "jiff", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-diff" -version = "0.46.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c9afd80fff00f8b38b1c1928442feb4cd6d2232a6ed806b6b193151a3d336c" +checksum = "a327be31a392144b60ab0b1c863362c32a1c8f7effdfa2141d5d5b6b916ef3bf" dependencies = [ "bstr", "gix-command", @@ -647,16 +661,17 @@ dependencies = [ "gix-path", "gix-tempfile", "gix-trace", + "gix-traverse", "gix-worktree", "imara-diff", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-dir" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ed3a9076661359a1c5a27c12ad6c3ebe2dd96b8b3c0af6488ab7c128b7bdd98" +checksum = "acd6a0618958f9cce78a32724f8e06c4f4a57ca7080f645736d53676dc9b4db9" dependencies = [ "bstr", "gix-discover", @@ -669,14 +684,14 @@ dependencies = [ "gix-trace", "gix-utils", "gix-worktree", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-discover" -version = "0.35.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0577366b9567376bc26e815fd74451ebd0e6218814e242f8e5b7072c58d956d2" +checksum = "83bf6dfa4e266a4a9becb4d18fc801f92c3f7cc6c433dd86fdadbcf315ffb6ef" dependencies = [ "bstr", "dunce", @@ -685,14 +700,14 @@ dependencies = [ "gix-path", "gix-ref", "gix-sec", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-features" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7045ac9fe5f9c727f38799d002a7ed3583cd777e3322a7c4b43e3cf437dc69" +checksum = "7d85d673f2e022a340dba4713bed77ef2cf4cd737d2f3e0f159d45e0935fd81f" dependencies = [ "crc32fast", "flate2", @@ -703,15 +718,15 @@ dependencies = [ "once_cell", "prodash", "sha1_smol", - "thiserror", + "thiserror 2.0.3", "walkdir", ] [[package]] name = "gix-filter" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4121790ae140066e5b953becc72e7496278138d19239be2e63b5067b0843119e" +checksum = "5108cc58d58b27df10ac4de7f31b2eb96d588a33e5eba23739b865f5d8db7995" dependencies = [ "bstr", "encoding_rs", @@ -725,14 +740,14 @@ dependencies = [ "gix-trace", "gix-utils", "smallvec", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-fs" -version = "0.11.3" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bfe6249cfea6d0c0e0990d5226a4cb36f030444ba9e35e0639275db8f98575" +checksum = "34740384d8d763975858fa2c176b68652a6fcc09f616e24e3ce967b0d370e4d8" dependencies = [ "fastrand", "gix-features", @@ -741,9 +756,9 @@ dependencies = [ [[package]] name = "gix-glob" -version = "0.16.5" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74908b4bbc0a0a40852737e5d7889f676f081e340d5451a16e5b4c50d592f111" +checksum = "aaf69a6bec0a3581567484bf99a4003afcaf6c469fd4214352517ea355cf3435" dependencies = [ "bitflags", "bstr", @@ -753,19 +768,19 @@ dependencies = [ [[package]] name = "gix-hash" -version = "0.14.2" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d7df7366121b5018f947a04d37f034717e113dcf9ccd85c34b58e57a74d5e" +checksum = "0b5eccc17194ed0e67d49285e4853307e4147e95407f91c1c3e4a13ba9f4e4ce" dependencies = [ "faster-hex", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-hashtable" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddf80e16f3c19ac06ce415a38b8591993d3f73aede049cb561becb5b3a8e242" +checksum = "0ef65b256631078ef733bc5530c4e6b1c2e7d5c2830b75d4e9034ab3997d18fe" dependencies = [ "gix-hash", "hashbrown", @@ -774,9 +789,9 @@ dependencies = [ [[package]] name = "gix-ignore" -version = "0.11.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e447cd96598460f5906a0f6c75e950a39f98c2705fc755ad2f2020c9e937fab7" +checksum = "b6b1fb24d2a4af0aa7438e2771d60c14a80cf2c9bd55c29cf1712b841f05bb8a" dependencies = [ "bstr", "gix-glob", @@ -787,9 +802,9 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.35.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cd4203244444017682176e65fd0180be9298e58ed90bd4a8489a357795ed22d" +checksum = "270645fd20556b64c8ffa1540d921b281e6994413a0ca068596f97e9367a257a" dependencies = [ "bitflags", "bstr", @@ -810,64 +825,66 @@ dependencies = [ "memmap2", "rustix", "smallvec", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-lock" -version = "14.0.0" +version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bc7fe297f1f4614774989c00ec8b1add59571dc9b024b4c00acb7dedd4e19d" +checksum = "5102acdf4acae2644e38dbbd18cdfba9597a218f7d85f810fe5430207e03c2de" dependencies = [ "gix-tempfile", "gix-utils", - "thiserror", + "thiserror 1.0.69", ] [[package]] name = "gix-object" -version = "0.44.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f5b801834f1de7640731820c2df6ba88d95480dc4ab166a5882f8ff12b88efa" +checksum = "65d93e2bbfa83a307e47f45e45de7b6c04d7375a8bd5907b215f4bf45237d879" dependencies = [ "bstr", "gix-actor", "gix-date", "gix-features", "gix-hash", + "gix-hashtable", "gix-utils", "gix-validate", "itoa", "smallvec", - "thiserror", + "thiserror 2.0.3", "winnow", ] [[package]] name = "gix-odb" -version = "0.63.0" +version = "0.65.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3158068701c17df54f0ab2adda527f5a6aca38fd5fd80ceb7e3c0a2717ec747" +checksum = "93bed6e1b577c25a6bb8e6ecbf4df525f29a671ddf5f2221821a56a8dbeec4e3" dependencies = [ "arc-swap", "gix-date", "gix-features", "gix-fs", "gix-hash", + "gix-hashtable", "gix-object", "gix-pack", "gix-path", "gix-quote", "parking_lot", "tempfile", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-pack" -version = "0.53.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3223aa342eee21e1e0e403cad8ae9caf9edca55ef84c347738d10681676fd954" +checksum = "9b91fec04d359544fecbb8e85117ec746fbaa9046ebafcefb58cb74f20dc76d4" dependencies = [ "clru", "gix-chunk", @@ -878,39 +895,39 @@ dependencies = [ "gix-path", "memmap2", "smallvec", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-packetline-blocking" -version = "0.17.5" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9802304baa798dd6f5ff8008a2b6516d54b74a69ca2d3a2b9e2d6c3b5556b40" +checksum = "ce9004ce1bc00fd538b11c1ec8141a1558fb3af3d2b7ac1ac5c41881f9e42d2a" dependencies = [ "bstr", "faster-hex", "gix-trace", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-path" -version = "0.10.11" +version = "0.10.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebfc4febd088abdcbc9f1246896e57e37b7a34f6909840045a1767c6dafac7af" +checksum = "afc292ef1a51e340aeb0e720800338c805975724c1dfbd243185452efd8645b7" dependencies = [ "bstr", "gix-trace", "home", "once_cell", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-pathspec" -version = "0.7.7" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d23bf239532b4414d0e63b8ab3a65481881f7237ed9647bb10c1e3cc54c5ceb" +checksum = "4c472dfbe4a4e96fcf7efddcd4771c9037bb4fdea2faaabf2f4888210c75b81e" dependencies = [ "bitflags", "bstr", @@ -918,25 +935,25 @@ dependencies = [ "gix-config-value", "gix-glob", "gix-path", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-quote" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbff4f9b9ea3fa7a25a70ee62f545143abef624ac6aa5884344e70c8b0a1d9ff" +checksum = "64a1e282216ec2ab2816cd57e6ed88f8009e634aec47562883c05ac8a7009a63" dependencies = [ "bstr", "gix-utils", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-ref" -version = "0.47.0" +version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae0d8406ebf9aaa91f55a57f053c5a1ad1a39f60fdf0303142b7be7ea44311e5" +checksum = "1eae462723686272a58f49501015ef7c0d67c3e042c20049d8dd9c7eff92efde" dependencies = [ "gix-actor", "gix-features", @@ -949,43 +966,44 @@ dependencies = [ "gix-utils", "gix-validate", "memmap2", - "thiserror", + "thiserror 2.0.3", "winnow", ] [[package]] name = "gix-refspec" -version = "0.25.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebb005f82341ba67615ffdd9f7742c87787544441c88090878393d0682869ca6" +checksum = "00c056bb747868c7eb0aeb352c9f9181ab8ca3d0a2550f16470803500c6c413d" dependencies = [ "bstr", "gix-hash", "gix-revision", "gix-validate", "smallvec", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-revision" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4621b219ac0cdb9256883030c3d56a6c64a6deaa829a92da73b9a576825e1e" +checksum = "44488e0380847967bc3e3cacd8b22652e02ea1eb58afb60edd91847695cd2d8d" dependencies = [ "bstr", + "gix-commitgraph", "gix-date", "gix-hash", "gix-object", "gix-revwalk", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-revwalk" -version = "0.15.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b41e72544b93084ee682ef3d5b31b1ba4d8fa27a017482900e5e044d5b1b3984" +checksum = "510026fc32f456f8f067d8f37c34088b97a36b2229d88a6a5023ef179fcb109d" dependencies = [ "gix-commitgraph", "gix-date", @@ -993,14 +1011,14 @@ dependencies = [ "gix-hashtable", "gix-object", "smallvec", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-sec" -version = "0.10.8" +version = "0.10.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe4d52f30a737bbece5276fab5d3a8b276dc2650df963e293d0673be34e7a5f" +checksum = "a8b876ef997a955397809a2ec398d6a45b7a55b4918f2446344330f778d14fd6" dependencies = [ "bitflags", "gix-path", @@ -1010,9 +1028,9 @@ dependencies = [ [[package]] name = "gix-status" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f70d35ba639f0c16a6e4cca81aa374a05f07b23fa36ee8beb72c100d98b4ffea" +checksum = "201396192ee4c4dd9e8a84fed4b0d2b33d639fca815fb99b0f653dfeddf38585" dependencies = [ "bstr", "filetime", @@ -1028,14 +1046,14 @@ dependencies = [ "gix-pathspec", "gix-worktree", "portable-atomic", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-submodule" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529d0af78cc2f372b3218f15eb1e3d1635a21c8937c12e2dd0b6fc80c2ca874b" +checksum = "a2455f8c0fcb6ebe2a6e83c8f522d30615d763eb2ef7a23c7d929f9476e89f5c" dependencies = [ "bstr", "gix-config", @@ -1043,14 +1061,14 @@ dependencies = [ "gix-pathspec", "gix-refspec", "gix-url", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-tempfile" -version = "14.0.2" +version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046b4927969fa816a150a0cda2e62c80016fe11fb3c3184e4dddf4e542f108aa" +checksum = "2feb86ef094cc77a4a9a5afbfe5de626897351bbbd0de3cb9314baf3049adb82" dependencies = [ "dashmap", "gix-fs", @@ -1062,15 +1080,15 @@ dependencies = [ [[package]] name = "gix-trace" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cae0e8661c3ff92688ce1c8b8058b3efb312aba9492bbe93661a21705ab431b" +checksum = "04bdde120c29f1fc23a24d3e115aeeea3d60d8e65bab92cc5f9d90d9302eb952" [[package]] name = "gix-traverse" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030da39af94e4df35472e9318228f36530989327906f38e27807df305fccb780" +checksum = "3ff2ec9f779680f795363db1c563168b32b8d6728ec58564c628e85c92d29faf" dependencies = [ "bitflags", "gix-commitgraph", @@ -1080,28 +1098,27 @@ dependencies = [ "gix-object", "gix-revwalk", "smallvec", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-url" -version = "0.27.5" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd280c5e84fb22e128ed2a053a0daeacb6379469be6a85e3d518a0636e160c89" +checksum = "e09f97db3618fb8e473d7d97e77296b50aaee0ddcd6a867f07443e3e87391099" dependencies = [ "bstr", "gix-features", "gix-path", - "home", - "thiserror", + "thiserror 2.0.3", "url", ] [[package]] name = "gix-utils" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35192df7fd0fa112263bad8021e2df7167df4cc2a6e6d15892e1e55621d3d4dc" +checksum = "ba427e3e9599508ed98a6ddf8ed05493db114564e338e41f6a996d2e4790335f" dependencies = [ "bstr", "fastrand", @@ -1110,19 +1127,19 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f2badbb64e57b404593ee26b752c26991910fd0d81fe6f9a71c1a8309b6c86" +checksum = "cd520d09f9f585b34b32aba1d0b36ada89ab7fefb54a8ca3fe37fc482a750937" dependencies = [ "bstr", - "thiserror", + "thiserror 2.0.3", ] [[package]] name = "gix-worktree" -version = "0.36.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c312ad76a3f2ba8e865b360d5cb3aa04660971d16dec6dd0ce717938d903149a" +checksum = "756dbbe15188fa22540d5eab941f8f9cf511a5364d5aec34c88083c09f4bea13" dependencies = [ "bstr", "gix-attributes", @@ -1247,7 +1264,7 @@ dependencies = [ "log", "serde", "serde_json", - "thiserror", + "thiserror 2.0.3", "tokio", ] @@ -1303,7 +1320,7 @@ dependencies = [ "serde", "serde_json", "slotmap", - "thiserror", + "thiserror 2.0.3", "tokio", "tokio-stream", ] @@ -1330,6 +1347,8 @@ dependencies = [ "bitflags", "dunce", "etcetera", + "once_cell", + "regex-automata", "regex-cursor", "ropey", "rustix", @@ -1377,7 +1396,7 @@ dependencies = [ "smallvec", "tempfile", "termini", - "thiserror", + "thiserror 2.0.3", "tokio", "tokio-stream", "toml", @@ -1444,7 +1463,7 @@ dependencies = [ "serde_json", "slotmap", "tempfile", - "thiserror", + "thiserror 2.0.3", "tokio", "tokio-stream", "toml", @@ -1489,14 +1508,143 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "idna" -version = "0.5.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -1611,15 +1759,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.158" +version = "0.2.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "09d6582e104315a817dff97f75133544b2e094ee22447d2acf4a74e189ba06fc" [[package]] name = "libloading" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", "windows-targets 0.52.6", @@ -1642,6 +1790,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + [[package]] name = "lock_api" version = "0.4.12" @@ -1755,15 +1909,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "open" -version = "5.3.0" +version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a877bf6abd716642a53ef1b89fb498923a4afca5c754f9050b4d081c05c4b3" +checksum = "3ecd52f0b8d15c40ce4820aa251ed5de032e5d91fab27f7db2f40d42a8bdf69c" dependencies = [ "is-wsl", "libc", @@ -1834,15 +1988,19 @@ dependencies = [ [[package]] name = "prodash" -version = "28.0.0" +version = "29.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744a264d26b88a6a7e37cbad97953fa233b94d585236310bcbc88474b4092d79" +checksum = "a266d8d6020c61a437be704c5e618037588e1985c7dbb7bf8d265db84cffe325" +dependencies = [ + "log", + "parking_lot", +] [[package]] name = "pulldown-cmark" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666f0f59e259aea2d72e6012290c09877a780935cc3c18b1ceded41f3890d59c" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" dependencies = [ "bitflags", "memchr", @@ -1916,9 +2074,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.6" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -1928,9 +2086,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -1952,9 +2110,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "ropey" @@ -1974,9 +2132,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.36" +version = "0.38.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f55e80d50763938498dd5ebb18647174e0c76dc38c5505294bb224624f30f36" +checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" dependencies = [ "bitflags", "errno", @@ -2008,18 +2166,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.210" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", @@ -2028,9 +2186,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "itoa", "memchr", @@ -2169,6 +2327,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2183,20 +2347,31 @@ checksum = "e9557cb6521e8d009c51a8666f09356f4b817ba9ba0981a305bd86aee47bd35c" [[package]] name = "syn" -version = "2.0.77" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" -version = "3.12.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if", "fastrand", @@ -2227,18 +2402,38 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa" +dependencies = [ + "thiserror-impl 2.0.3", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568" dependencies = [ "proc-macro2", "quote", @@ -2254,6 +2449,16 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -2271,9 +2476,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.40.0" +version = "1.41.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" dependencies = [ "backtrace", "bytes", @@ -2362,12 +2567,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "unicode-bidi" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" - [[package]] name = "unicode-bom" version = "2.0.3" @@ -2376,9 +2575,9 @@ checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" [[package]] name = "unicode-general-category" -version = "0.6.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2281c8c1d221438e373249e065ca4989c4c36952c211ff21a0ee91c44a3869e7" +checksum = "24adfe8311434967077a6adff125729161e6e4934d76f6b7c55318ac5c9246d3" [[package]] name = "unicode-ident" @@ -2403,9 +2602,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" @@ -2415,9 +2614,9 @@ checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" [[package]] name = "url" -version = "2.5.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", @@ -2425,6 +2624,18 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" version = "0.9.5" @@ -2504,9 +2715,9 @@ checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] name = "which" -version = "6.0.3" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +checksum = "c9cad3279ade7346b96e38731a641d7343dd6a53d55083dd54eadfa5a1b38c6b" dependencies = [ "either", "home", @@ -2717,6 +2928,18 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + [[package]] name = "xtask" version = "24.7.0" @@ -2728,6 +2951,30 @@ dependencies = [ "toml", ] +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -2747,3 +2994,46 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 763992480176..753be4b462c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ package.helix-term.opt-level = 2 tree-sitter = { version = "0.22" } nucleo = "0.5.0" slotmap = "1.0.7" -thiserror = "1.0" +thiserror = "2.0" [workspace.package] version = "24.7.0" @@ -51,4 +51,4 @@ categories = ["editor"] repository = "https://github.com/helix-editor/helix" homepage = "https://helix-editor.com" license = "MPL-2.0" -rust-version = "1.70" +rust-version = "1.76" diff --git a/README.md b/README.md index 3b639214d8b6..11a909b2616f 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ All shortcuts/keymaps can be found [in the documentation on the website](https:/ - Built-in language server support - Smart, incremental syntax highlighting and code editing via tree-sitter -It's a terminal-based editor first, but I'd like to explore a custom renderer -(similar to Emacs) in wgpu or skulpin. +Although it's primarily a terminal-based editor, I am interested in exploring +a custom renderer (similar to Emacs) using wgpu or skulpin. Note: Only certain languages have indentation definitions at the moment. Check `runtime/queries//` for `indents.scm`. @@ -47,7 +47,7 @@ Note: Only certain languages have indentation definitions at the moment. Check [Installation documentation](https://docs.helix-editor.com/install.html). -[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg?exclude_unsupported=1)](https://repology.org/project/helix/versions) +[![Packaging status](https://repology.org/badge/vertical-allrepos/helix-editor.svg?exclude_unsupported=1)](https://repology.org/project/helix-editor/versions) # Contributing diff --git a/book/src/building-from-source.md b/book/src/building-from-source.md index 42ed57a27b68..539e9cf86681 100644 --- a/book/src/building-from-source.md +++ b/book/src/building-from-source.md @@ -117,7 +117,7 @@ to package the runtime into `/usr/lib/helix/runtime`. The rough steps a build script could follow are: 1. `export HELIX_DEFAULT_RUNTIME=/usr/lib/helix/runtime` -1. `cargo build --profile opt --locked --path helix-term` +1. `cargo build --profile opt --locked` 1. `cp -r runtime $BUILD_DIR/usr/lib/helix/` 1. `cp target/opt/hx $BUILD_DIR/usr/bin/hx` diff --git a/book/src/commands.md b/book/src/commands.md index 047a30a91c83..ee507276b51f 100644 --- a/book/src/commands.md +++ b/book/src/commands.md @@ -1,5 +1,16 @@ # Commands -Command mode can be activated by pressing `:`. The built-in commands are: +- [Typable commands](#typable-commands) +- [Static commands](#static-commands) + +## Typable commands + +Typable commands are used from command mode and may take arguments. Command mode can be activated by pressing `:`. The built-in typable commands are: {{#include ./generated/typable-cmd.md}} + +## Static Commands + +Static commands take no arguments and can be bound to keys. Static commands can also be executed from the command picker (`?`). The built-in static commands are: + +{{#include ./generated/static-cmd.md}} diff --git a/book/src/configuration.md b/book/src/configuration.md index 0cd12568bf94..317007efc7b4 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -27,8 +27,8 @@ hidden = false You can use a custom configuration file by specifying it with the `-c` or `--config` command line argument, for example `hx -c path/to/custom-config.toml`. -Additionally, you can reload the configuration file by sending the USR1 -signal to the Helix process on Unix operating systems, such as by using the command `pkill -USR1 hx`. +You can reload the config file by issuing the `:config-reload` command. Alternatively, on Unix operating systems, you can reload it by sending the USR1 +signal to the Helix process, such as by using the command `pkill -USR1 hx`. Finally, you can have a `config.toml` local to a project by putting it under a `.helix` directory in your repository. Its settings will be merged with the configuration directory `config.toml` and the built-in configuration. diff --git a/book/src/editor.md b/book/src/editor.md index 82d5f8461ef7..624bdff23d37 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -24,6 +24,7 @@ |--|--|---------| | `scrolloff` | Number of lines of padding around the edge of the screen when scrolling | `5` | | `mouse` | Enable mouse mode | `true` | +| `default-yank-register` | Default register used for yank/paste | `"` | | `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"]` | @@ -32,6 +33,7 @@ | `cursorcolumn` | Highlight all columns with a cursor | `false` | | `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` | | `auto-completion` | Enable automatic pop up of auto-completion | `true` | +| `path-completion` | Enable filepath completion. Show files and directories if an existing path at the cursor was recognized, either absolute or relative to the current opened document or current working directory (if the buffer is not yet saved). Defaults to true. | `true` | | `auto-format` | Enable automatic formatting on save | `true` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` | | `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant. | `250` | @@ -52,6 +54,30 @@ | `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid` | `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | `"abcdefghijklmnopqrstuvwxyz"` | `end-of-line-diagnostics` | Minimum severity of diagnostics to render at the end of the line. Set to `disable` to disable entirely. Refer to the setting about `inline-diagnostics` for more details | "disable" +| `clipboard-provider` | Which API to use for clipboard interaction. One of `pasteboard` (MacOS), `wayland`, `x-clip`, `x-sel`, `win-32-yank`, `termux`, `tmux`, `windows`, `termcode`, `none`, or a custom command set. | Platform and environment specific. | + +### `[editor.clipboard-provider]` Section + +Helix can be configured wither to use a builtin clipboard configuration or to use +a provided command. + +For instance, setting it to use OSC 52 termcodes, the configuration would be: +```toml +[editor] +clipboard-provider = "termcode" +``` + +Alternatively, Helix can be configured to use arbitary commands for clipboard integration: + +```toml +[editor.clipboard-provider.custom] +yank = { command = "cat", args = ["test.txt"] } +paste = { command = "tee", args = ["test.txt"] } +primary-yank = { command = "cat", args = ["test-primary.txt"] } # optional +primary-paste = { command = "tee", args = ["test-primary.txt"] } # optional +``` + +For custom commands the contents of the yank/paste is communicated over stdin/stdout. ### `[editor.statusline]` Section @@ -125,7 +151,7 @@ The following statusline elements can be configured: [^1]: By default, a progress spinner is shown in the statusline beside the file path. -[^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them! +[^2]: You may also have to activate them in the language server config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them! ### `[editor.cursor-shape]` Section @@ -428,7 +454,8 @@ fn main() { The new diagnostic rendering is not yet enabled by default. As soon as end of line or inline diagnostics are enabled the old diagnostics rendering is automatically disabled. The recommended default setting are: -``` +```toml +[editor] end-of-line-diagnostics = "hint" [editor.inline-diagnostics] cursor-line = "warning" # show warnings and errors on the cursorline inline diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index cb1c815f2150..0c3bf78e336d 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -1,8 +1,9 @@ -| Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default LSP | +| Language | Syntax Highlighting | Treesitter Textobjects | Auto Indent | Default language servers | | --- | --- | --- | --- | --- | | ada | ✓ | ✓ | | `ada_language_server` | | adl | ✓ | ✓ | ✓ | | | agda | ✓ | | | | +| amber | ✓ | | | | | astro | ✓ | | | | | awk | ✓ | ✓ | | `awk-language-server` | | bash | ✓ | ✓ | ✓ | `bash-language-server` | @@ -19,6 +20,7 @@ | cairo | ✓ | ✓ | ✓ | `cairo-language-server` | | capnp | ✓ | | ✓ | | | cel | ✓ | | | | +| circom | ✓ | | | `circom-lsp` | | clojure | ✓ | | | `clojure-lsp` | | cmake | ✓ | ✓ | ✓ | `cmake-language-server` | | comment | ✓ | | | | @@ -28,6 +30,7 @@ | crystal | ✓ | ✓ | | `crystalline` | | css | ✓ | | ✓ | `vscode-css-language-server` | | cue | ✓ | | | `cuelsp` | +| cylc | ✓ | ✓ | ✓ | | | d | ✓ | ✓ | ✓ | `serve-d` | | dart | ✓ | ✓ | ✓ | `dart` | | dbml | ✓ | | | | @@ -38,6 +41,7 @@ | dockerfile | ✓ | ✓ | | `docker-langserver` | | dot | ✓ | | | `dot-language-server` | | dtd | ✓ | | | | +| dune | ✓ | | | | | earthfile | ✓ | ✓ | ✓ | `earthlyls` | | edoc | ✓ | | | | | eex | ✓ | | | | @@ -48,7 +52,7 @@ | elvish | ✓ | | | `elvish` | | env | ✓ | ✓ | | | | erb | ✓ | | | | -| erlang | ✓ | ✓ | | `erlang_ls` | +| erlang | ✓ | ✓ | | `erlang_ls`, `elp` | | esdl | ✓ | | | | | fidl | ✓ | | | | | fish | ✓ | ✓ | ✓ | | @@ -67,13 +71,14 @@ | gjs | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` | | gleam | ✓ | ✓ | | `gleam` | | glimmer | ✓ | | | `ember-language-server` | -| glsl | ✓ | ✓ | ✓ | | +| glsl | ✓ | ✓ | ✓ | `glsl_analyzer` | | gn | ✓ | | | | | go | ✓ | ✓ | ✓ | `gopls`, `golangci-lint-langserver` | | godot-resource | ✓ | ✓ | | | | gomod | ✓ | | | `gopls` | | gotmpl | ✓ | | | `gopls` | | gowork | ✓ | | | `gopls` | +| gpr | ✓ | | | `ada_language_server` | | graphql | ✓ | ✓ | | `graphql-lsp` | | groovy | ✓ | | | | | gts | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` | @@ -86,9 +91,9 @@ | hocon | ✓ | ✓ | ✓ | | | hoon | ✓ | | | | | hosts | ✓ | | | | -| html | ✓ | | | `vscode-html-language-server` | +| html | ✓ | | | `vscode-html-language-server`, `superhtml` | | hurl | ✓ | ✓ | ✓ | | -| hyprlang | ✓ | | ✓ | | +| hyprlang | ✓ | | ✓ | `hyprls` | | idris | | | | `idris2-lsp` | | iex | ✓ | | | | | ini | ✓ | | | | @@ -129,10 +134,11 @@ | mermaid | ✓ | | | | | meson | ✓ | | ✓ | `mesonlsp` | | mint | | | | `mint` | -| mojo | ✓ | ✓ | ✓ | `mojo-lsp-server` | +| mojo | ✓ | ✓ | ✓ | `magic` | | move | ✓ | | | | | msbuild | ✓ | | ✓ | | | nasm | ✓ | ✓ | | | +| nestedtext | ✓ | ✓ | ✓ | | | nickel | ✓ | | ✓ | `nls` | | nim | ✓ | ✓ | ✓ | `nimlangserver` | | nix | ✓ | ✓ | | `nil`, `nixd` | @@ -163,8 +169,9 @@ | protobuf | ✓ | ✓ | ✓ | `bufls`, `pb` | | prql | ✓ | | | | | purescript | ✓ | ✓ | | `purescript-language-server` | -| python | ✓ | ✓ | ✓ | `pylsp` | +| python | ✓ | ✓ | ✓ | `ruff`, `jedi-language-server`, `pylsp` | | qml | ✓ | | ✓ | `qmlls` | +| quint | ✓ | | | `quint-language-server` | | r | ✓ | | | `R` | | racket | ✓ | | ✓ | `racket` | | regex | ✓ | | | | @@ -184,7 +191,9 @@ | smali | ✓ | | ✓ | | | smithy | ✓ | | | `cs` | | sml | ✓ | | | | +| snakemake | ✓ | | ✓ | `pylsp` | | solidity | ✓ | ✓ | | `solc` | +| spade | ✓ | | ✓ | `spade-language-server` | | spicedb | ✓ | | | | | sql | ✓ | ✓ | | | | sshclientconfig | ✓ | | | | @@ -199,7 +208,9 @@ | tact | ✓ | ✓ | ✓ | | | task | ✓ | | | | | tcl | ✓ | | ✓ | | +| teal | ✓ | | | | | templ | ✓ | | | `templ` | +| textproto | ✓ | ✓ | ✓ | | | tfvars | ✓ | | ✓ | `terraform-ls` | | thrift | ✓ | | | | | todotxt | ✓ | | | | @@ -211,10 +222,11 @@ | typespec | ✓ | ✓ | ✓ | `tsp-server` | | typst | ✓ | | | `tinymist`, `typst-lsp` | | ungrammar | ✓ | | | | -| unison | ✓ | | ✓ | | +| unison | ✓ | ✓ | ✓ | | | uxntal | ✓ | | | | | v | ✓ | ✓ | ✓ | `v-analyzer` | | vala | ✓ | ✓ | | `vala-language-server` | +| vento | ✓ | | | | | verilog | ✓ | ✓ | | `svlangserver` | | vhdl | ✓ | | | `vhdl_ls` | | vhs | ✓ | | | | diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md new file mode 100644 index 000000000000..4d838a10999d --- /dev/null +++ b/book/src/generated/static-cmd.md @@ -0,0 +1,294 @@ +| Name | Description | Default keybinds | +| --- | --- | --- | +| `no_op` | Do nothing | | +| `move_char_left` | Move left | normal: `` h ``, `` ``, insert: `` `` | +| `move_char_right` | Move right | normal: `` l ``, `` ``, insert: `` `` | +| `move_line_up` | Move up | normal: `` gk `` | +| `move_line_down` | Move down | normal: `` gj `` | +| `move_visual_line_up` | Move up | normal: `` k ``, `` ``, insert: `` `` | +| `move_visual_line_down` | Move down | normal: `` j ``, `` ``, insert: `` `` | +| `extend_char_left` | Extend left | select: `` h ``, `` `` | +| `extend_char_right` | Extend right | select: `` l ``, `` `` | +| `extend_line_up` | Extend up | select: `` gk `` | +| `extend_line_down` | Extend down | select: `` gj `` | +| `extend_visual_line_up` | Extend up | select: `` k ``, `` `` | +| `extend_visual_line_down` | Extend down | select: `` j ``, `` `` | +| `copy_selection_on_next_line` | Copy selection on next line | normal: `` C ``, select: `` C `` | +| `copy_selection_on_prev_line` | Copy selection on previous line | normal: `` ``, select: `` `` | +| `move_next_word_start` | Move to start of next word | normal: `` w `` | +| `move_prev_word_start` | Move to start of previous word | normal: `` b `` | +| `move_next_word_end` | Move to end of next word | normal: `` e `` | +| `move_prev_word_end` | Move to end of previous word | | +| `move_next_long_word_start` | Move to start of next long word | normal: `` W `` | +| `move_prev_long_word_start` | Move to start of previous long word | normal: `` B `` | +| `move_next_long_word_end` | Move to end of next long word | normal: `` E `` | +| `move_prev_long_word_end` | Move to end of previous long word | | +| `move_next_sub_word_start` | Move to start of next sub word | | +| `move_prev_sub_word_start` | Move to start of previous sub word | | +| `move_next_sub_word_end` | Move to end of next sub word | | +| `move_prev_sub_word_end` | Move to end of previous sub word | | +| `move_parent_node_end` | Move to end of the parent node | normal: `` `` | +| `move_parent_node_start` | Move to beginning of the parent node | normal: `` `` | +| `extend_next_word_start` | Extend to start of next word | select: `` w `` | +| `extend_prev_word_start` | Extend to start of previous word | select: `` b `` | +| `extend_next_word_end` | Extend to end of next word | select: `` e `` | +| `extend_prev_word_end` | Extend to end of previous word | | +| `extend_next_long_word_start` | Extend to start of next long word | select: `` W `` | +| `extend_prev_long_word_start` | Extend to start of previous long word | select: `` B `` | +| `extend_next_long_word_end` | Extend to end of next long word | select: `` E `` | +| `extend_prev_long_word_end` | Extend to end of prev long word | | +| `extend_next_sub_word_start` | Extend to start of next sub word | | +| `extend_prev_sub_word_start` | Extend to start of previous sub word | | +| `extend_next_sub_word_end` | Extend to end of next sub word | | +| `extend_prev_sub_word_end` | Extend to end of prev sub word | | +| `extend_parent_node_end` | Extend to end of the parent node | select: `` `` | +| `extend_parent_node_start` | Extend to beginning of the parent node | select: `` `` | +| `find_till_char` | Move till next occurrence of char | normal: `` t `` | +| `find_next_char` | Move to next occurrence of char | normal: `` f `` | +| `extend_till_char` | Extend till next occurrence of char | select: `` t `` | +| `extend_next_char` | Extend to next occurrence of char | select: `` f `` | +| `till_prev_char` | Move till previous occurrence of char | normal: `` T `` | +| `find_prev_char` | Move to previous occurrence of char | normal: `` F `` | +| `extend_till_prev_char` | Extend till previous occurrence of char | select: `` T `` | +| `extend_prev_char` | Extend to previous occurrence of char | select: `` F `` | +| `repeat_last_motion` | Repeat last motion | normal: `` ``, select: `` `` | +| `replace` | Replace with new char | normal: `` r ``, select: `` r `` | +| `switch_case` | Switch (toggle) case | normal: `` ~ ``, select: `` ~ `` | +| `switch_to_uppercase` | Switch to uppercase | normal: `` ``, select: `` `` | +| `switch_to_lowercase` | Switch to lowercase | normal: `` ` ``, select: `` ` `` | +| `page_up` | Move page up | normal: `` ``, `` Z ``, `` z ``, `` ``, `` Z ``, `` z ``, select: `` ``, `` Z ``, `` z ``, `` ``, `` Z ``, `` z ``, insert: `` `` | +| `page_down` | Move page down | normal: `` ``, `` Z ``, `` z ``, `` ``, `` Z ``, `` z ``, select: `` ``, `` Z ``, `` z ``, `` ``, `` Z ``, `` z ``, insert: `` `` | +| `half_page_up` | Move half page up | | +| `half_page_down` | Move half page down | | +| `page_cursor_up` | Move page and cursor up | | +| `page_cursor_down` | Move page and cursor down | | +| `page_cursor_half_up` | Move page and cursor half up | normal: `` ``, `` Z ``, `` z ``, `` Z ``, `` z ``, select: `` ``, `` Z ``, `` z ``, `` Z ``, `` z `` | +| `page_cursor_half_down` | Move page and cursor half down | normal: `` ``, `` Z ``, `` z ``, `` Z ``, `` z ``, select: `` ``, `` Z ``, `` z ``, `` Z ``, `` z `` | +| `select_all` | Select whole document | normal: `` % ``, select: `` % `` | +| `select_regex` | Select all regex matches inside selections | normal: `` s ``, select: `` s `` | +| `split_selection` | Split selections on regex matches | normal: `` S ``, select: `` S `` | +| `split_selection_on_newline` | Split selection on newlines | normal: `` ``, select: `` `` | +| `merge_selections` | Merge selections | normal: `` ``, select: `` `` | +| `merge_consecutive_selections` | Merge consecutive selections | normal: `` ``, select: `` `` | +| `search` | Search for regex pattern | normal: `` / ``, `` Z/ ``, `` z/ ``, select: `` / ``, `` Z/ ``, `` z/ `` | +| `rsearch` | Reverse search for regex pattern | normal: `` ? ``, `` Z? ``, `` z? ``, select: `` ? ``, `` Z? ``, `` z? `` | +| `search_next` | Select next search match | normal: `` n ``, `` Zn ``, `` zn ``, select: `` Zn ``, `` zn `` | +| `search_prev` | Select previous search match | normal: `` N ``, `` ZN ``, `` zN ``, select: `` ZN ``, `` zN `` | +| `extend_search_next` | Add next search match to selection | select: `` n `` | +| `extend_search_prev` | Add previous search match to selection | select: `` N `` | +| `search_selection` | Use current selection as search pattern | normal: `` ``, select: `` `` | +| `search_selection_detect_word_boundaries` | Use current selection as the search pattern, automatically wrapping with `\b` on word boundaries | normal: `` * ``, select: `` * `` | +| `make_search_word_bounded` | Modify current search to make it word bounded | | +| `global_search` | Global search in workspace folder | normal: `` / ``, select: `` / `` | +| `extend_line` | Select current line, if already selected, extend to another line based on the anchor | | +| `extend_line_below` | Select current line, if already selected, extend to next line | normal: `` x ``, select: `` x `` | +| `extend_line_above` | Select current line, if already selected, extend to previous line | | +| `select_line_above` | Select current line, if already selected, extend or shrink line above based on the anchor | | +| `select_line_below` | Select current line, if already selected, extend or shrink line below based on the anchor | | +| `extend_to_line_bounds` | Extend selection to line bounds | normal: `` X ``, select: `` X `` | +| `shrink_to_line_bounds` | Shrink selection to line bounds | normal: `` ``, select: `` `` | +| `delete_selection` | Delete selection | normal: `` d ``, select: `` d `` | +| `delete_selection_noyank` | Delete selection without yanking | normal: `` ``, select: `` `` | +| `change_selection` | Change selection | normal: `` c ``, select: `` c `` | +| `change_selection_noyank` | Change selection without yanking | normal: `` ``, select: `` `` | +| `collapse_selection` | Collapse selection into single cursor | normal: `` ; ``, select: `` ; `` | +| `flip_selections` | Flip selection cursor and anchor | normal: `` ``, select: `` `` | +| `ensure_selections_forward` | Ensure all selections face forward | normal: `` ``, select: `` `` | +| `insert_mode` | Insert before selection | normal: `` i ``, select: `` i `` | +| `append_mode` | Append after selection | normal: `` a ``, select: `` a `` | +| `command_mode` | Enter command mode | normal: `` : ``, select: `` : `` | +| `file_picker` | Open file picker | normal: `` f ``, select: `` f `` | +| `file_picker_in_current_buffer_directory` | Open file picker at current buffer's directory | | +| `file_picker_in_current_directory` | Open file picker at current working directory | normal: `` F ``, select: `` F `` | +| `code_action` | Perform code action | normal: `` a ``, select: `` a `` | +| `buffer_picker` | Open buffer picker | normal: `` b ``, select: `` b `` | +| `jumplist_picker` | Open jumplist picker | normal: `` j ``, select: `` j `` | +| `symbol_picker` | Open symbol picker | normal: `` s ``, select: `` s `` | +| `changed_file_picker` | Open changed file picker | normal: `` g ``, select: `` g `` | +| `select_references_to_symbol_under_cursor` | Select symbol references | normal: `` h ``, select: `` h `` | +| `workspace_symbol_picker` | Open workspace symbol picker | normal: `` S ``, select: `` S `` | +| `diagnostics_picker` | Open diagnostic picker | normal: `` d ``, select: `` d `` | +| `workspace_diagnostics_picker` | Open workspace diagnostic picker | normal: `` D ``, select: `` D `` | +| `last_picker` | Open last picker | normal: `` ' ``, select: `` ' `` | +| `insert_at_line_start` | Insert at start of line | normal: `` I ``, select: `` I `` | +| `insert_at_line_end` | Insert at end of line | normal: `` A ``, select: `` A `` | +| `open_below` | Open new line below selection | normal: `` o ``, select: `` o `` | +| `open_above` | Open new line above selection | normal: `` O ``, select: `` O `` | +| `normal_mode` | Enter normal mode | normal: `` ``, select: `` v ``, insert: `` `` | +| `select_mode` | Enter selection extend mode | normal: `` v `` | +| `exit_select_mode` | Exit selection mode | select: `` `` | +| `goto_definition` | Goto definition | normal: `` gd ``, select: `` gd `` | +| `goto_declaration` | Goto declaration | normal: `` gD ``, select: `` gD `` | +| `add_newline_above` | Add newline above | normal: `` [ ``, select: `` [ `` | +| `add_newline_below` | Add newline below | normal: `` ] ``, select: `` ] `` | +| `goto_type_definition` | Goto type definition | normal: `` gy ``, select: `` gy `` | +| `goto_implementation` | Goto implementation | normal: `` gi ``, select: `` gi `` | +| `goto_file_start` | Goto line number else file start | normal: `` gg ``, select: `` gg `` | +| `goto_file_end` | Goto file end | | +| `goto_file` | Goto files/URLs in selections | normal: `` gf ``, select: `` gf `` | +| `goto_file_hsplit` | Goto files in selections (hsplit) | normal: `` f ``, `` wf ``, select: `` f ``, `` wf `` | +| `goto_file_vsplit` | Goto files in selections (vsplit) | normal: `` F ``, `` wF ``, select: `` F ``, `` wF `` | +| `goto_reference` | Goto references | normal: `` gr ``, select: `` gr `` | +| `goto_window_top` | Goto window top | normal: `` gt ``, select: `` gt `` | +| `goto_window_center` | Goto window center | normal: `` gc ``, select: `` gc `` | +| `goto_window_bottom` | Goto window bottom | normal: `` gb ``, select: `` gb `` | +| `goto_last_accessed_file` | Goto last accessed file | normal: `` ga ``, select: `` ga `` | +| `goto_last_modified_file` | Goto last modified file | normal: `` gm ``, select: `` gm `` | +| `goto_last_modification` | Goto last modification | normal: `` g. ``, select: `` g. `` | +| `goto_line` | Goto line | normal: `` G ``, select: `` G `` | +| `goto_last_line` | Goto last line | normal: `` ge ``, select: `` ge `` | +| `goto_first_diag` | Goto first diagnostic | normal: `` [D ``, select: `` [D `` | +| `goto_last_diag` | Goto last diagnostic | normal: `` ]D ``, select: `` ]D `` | +| `goto_next_diag` | Goto next diagnostic | normal: `` ]d ``, select: `` ]d `` | +| `goto_prev_diag` | Goto previous diagnostic | normal: `` [d ``, select: `` [d `` | +| `goto_next_change` | Goto next change | normal: `` ]g ``, select: `` ]g `` | +| `goto_prev_change` | Goto previous change | normal: `` [g ``, select: `` [g `` | +| `goto_first_change` | Goto first change | normal: `` [G ``, select: `` [G `` | +| `goto_last_change` | Goto last change | normal: `` ]G ``, select: `` ]G `` | +| `goto_line_start` | Goto line start | normal: `` gh ``, `` ``, select: `` gh ``, insert: `` `` | +| `goto_line_end` | Goto line end | normal: `` gl ``, `` ``, select: `` gl `` | +| `goto_next_buffer` | Goto next buffer | normal: `` gn ``, select: `` gn `` | +| `goto_previous_buffer` | Goto previous buffer | normal: `` gp ``, select: `` gp `` | +| `goto_line_end_newline` | Goto newline at line end | insert: `` `` | +| `goto_first_nonwhitespace` | Goto first non-blank in line | normal: `` gs ``, select: `` gs `` | +| `trim_selections` | Trim whitespace from selections | normal: `` _ ``, select: `` _ `` | +| `extend_to_line_start` | Extend to line start | select: `` `` | +| `extend_to_first_nonwhitespace` | Extend to first non-blank in line | | +| `extend_to_line_end` | Extend to line end | select: `` `` | +| `extend_to_line_end_newline` | Extend to line end | | +| `signature_help` | Show signature help | | +| `smart_tab` | Insert tab if all cursors have all whitespace to their left; otherwise, run a separate command. | insert: `` `` | +| `insert_tab` | Insert tab char | insert: `` `` | +| `insert_newline` | Insert newline char | insert: `` ``, `` `` | +| `delete_char_backward` | Delete previous char | insert: `` ``, `` ``, `` `` | +| `delete_char_forward` | Delete next char | insert: `` ``, `` `` | +| `delete_word_backward` | Delete previous word | insert: `` ``, `` `` | +| `delete_word_forward` | Delete next word | insert: `` ``, `` `` | +| `kill_to_line_start` | Delete till start of line | insert: `` `` | +| `kill_to_line_end` | Delete till end of line | insert: `` `` | +| `undo` | Undo change | normal: `` u ``, select: `` u `` | +| `redo` | Redo change | normal: `` U ``, select: `` U `` | +| `earlier` | Move backward in history | normal: `` ``, select: `` `` | +| `later` | Move forward in history | normal: `` ``, select: `` `` | +| `commit_undo_checkpoint` | Commit changes to new checkpoint | insert: `` `` | +| `yank` | Yank selection | normal: `` y ``, select: `` y `` | +| `yank_to_clipboard` | Yank selections to clipboard | normal: `` y ``, select: `` y `` | +| `yank_to_primary_clipboard` | Yank selections to primary clipboard | | +| `yank_joined` | Join and yank selections | | +| `yank_joined_to_clipboard` | Join and yank selections to clipboard | | +| `yank_main_selection_to_clipboard` | Yank main selection to clipboard | normal: `` Y ``, select: `` Y `` | +| `yank_joined_to_primary_clipboard` | Join and yank selections to primary clipboard | | +| `yank_main_selection_to_primary_clipboard` | Yank main selection to primary clipboard | | +| `replace_with_yanked` | Replace with yanked text | normal: `` R ``, select: `` R `` | +| `replace_selections_with_clipboard` | Replace selections by clipboard content | normal: `` R ``, select: `` R `` | +| `replace_selections_with_primary_clipboard` | Replace selections by primary clipboard | | +| `paste_after` | Paste after selection | normal: `` p ``, select: `` p `` | +| `paste_before` | Paste before selection | normal: `` P ``, select: `` P `` | +| `paste_clipboard_after` | Paste clipboard after selections | normal: `` p ``, select: `` p `` | +| `paste_clipboard_before` | Paste clipboard before selections | normal: `` P ``, select: `` P `` | +| `paste_primary_clipboard_after` | Paste primary clipboard after selections | | +| `paste_primary_clipboard_before` | Paste primary clipboard before selections | | +| `indent` | Indent selection | normal: `` ``, select: `` `` | +| `unindent` | Unindent selection | normal: `` ``, select: `` `` | +| `format_selections` | Format selection | normal: `` = ``, select: `` = `` | +| `join_selections` | Join lines inside selection | normal: `` J ``, select: `` J `` | +| `join_selections_space` | Join lines inside selection and select spaces | normal: `` ``, select: `` `` | +| `keep_selections` | Keep selections matching regex | normal: `` K ``, select: `` K `` | +| `remove_selections` | Remove selections matching regex | normal: `` ``, select: `` `` | +| `align_selections` | Align selections in column | normal: `` & ``, select: `` & `` | +| `keep_primary_selection` | Keep primary selection | normal: `` , ``, select: `` , `` | +| `remove_primary_selection` | Remove primary selection | normal: `` ``, select: `` `` | +| `completion` | Invoke completion popup | insert: `` `` | +| `hover` | Show docs for item under cursor | normal: `` k ``, select: `` k `` | +| `toggle_comments` | Comment/uncomment selections | normal: `` ``, `` c ``, select: `` ``, `` c `` | +| `toggle_line_comments` | Line comment/uncomment selections | normal: `` ``, select: `` `` | +| `toggle_block_comments` | Block comment/uncomment selections | normal: `` C ``, select: `` C `` | +| `rotate_selections_forward` | Rotate selections forward | normal: `` ) ``, select: `` ) `` | +| `rotate_selections_backward` | Rotate selections backward | normal: `` ( ``, select: `` ( `` | +| `rotate_selection_contents_forward` | Rotate selection contents forward | normal: `` ``, select: `` `` | +| `rotate_selection_contents_backward` | Rotate selections contents backward | normal: `` ``, select: `` `` | +| `reverse_selection_contents` | Reverse selections contents | | +| `expand_selection` | Expand selection to parent syntax node | normal: `` ``, `` ``, select: `` ``, `` `` | +| `shrink_selection` | Shrink selection to previously expanded syntax node | normal: `` ``, `` ``, select: `` ``, `` `` | +| `select_next_sibling` | Select next sibling in the syntax tree | normal: `` ``, `` ``, select: `` ``, `` `` | +| `select_prev_sibling` | Select previous sibling the in syntax tree | normal: `` ``, `` ``, select: `` ``, `` `` | +| `select_all_siblings` | Select all siblings of the current node | normal: `` ``, select: `` `` | +| `select_all_children` | Select all children of the current node | normal: `` ``, `` ``, select: `` ``, `` `` | +| `jump_forward` | Jump forward on jumplist | normal: `` ``, `` ``, select: `` ``, `` `` | +| `jump_backward` | Jump backward on jumplist | normal: `` ``, select: `` `` | +| `save_selection` | Save current selection to jumplist | normal: `` ``, select: `` `` | +| `jump_view_right` | Jump to right split | normal: `` l ``, `` wl ``, `` ``, `` ``, `` w ``, `` w ``, select: `` l ``, `` wl ``, `` ``, `` ``, `` w ``, `` w `` | +| `jump_view_left` | Jump to left split | normal: `` h ``, `` wh ``, `` ``, `` ``, `` w ``, `` w ``, select: `` h ``, `` wh ``, `` ``, `` ``, `` w ``, `` w `` | +| `jump_view_up` | Jump to split above | normal: `` k ``, `` ``, `` wk ``, `` ``, `` w ``, `` w ``, select: `` k ``, `` ``, `` wk ``, `` ``, `` w ``, `` w `` | +| `jump_view_down` | Jump to split below | normal: `` j ``, `` wj ``, `` ``, `` ``, `` w ``, `` w ``, select: `` j ``, `` wj ``, `` ``, `` ``, `` w ``, `` w `` | +| `swap_view_right` | Swap with right split | normal: `` L ``, `` wL ``, select: `` L ``, `` wL `` | +| `swap_view_left` | Swap with left split | normal: `` H ``, `` wH ``, select: `` H ``, `` wH `` | +| `swap_view_up` | Swap with split above | normal: `` K ``, `` wK ``, select: `` K ``, `` wK `` | +| `swap_view_down` | Swap with split below | normal: `` J ``, `` wJ ``, select: `` J ``, `` wJ `` | +| `transpose_view` | Transpose splits | normal: `` t ``, `` wt ``, `` ``, `` w ``, select: `` t ``, `` wt ``, `` ``, `` w `` | +| `rotate_view` | Goto next window | normal: `` w ``, `` ww ``, `` ``, `` w ``, select: `` w ``, `` ww ``, `` ``, `` w `` | +| `rotate_view_reverse` | Goto previous window | | +| `hsplit` | Horizontal bottom split | normal: `` s ``, `` ws ``, `` ``, `` w ``, select: `` s ``, `` ws ``, `` ``, `` w `` | +| `hsplit_new` | Horizontal bottom split scratch buffer | normal: `` ns ``, `` wns ``, `` n ``, `` wn ``, select: `` ns ``, `` wns ``, `` n ``, `` wn `` | +| `vsplit` | Vertical right split | normal: `` v ``, `` wv ``, `` ``, `` w ``, select: `` v ``, `` wv ``, `` ``, `` w `` | +| `vsplit_new` | Vertical right split scratch buffer | normal: `` nv ``, `` wnv ``, `` n ``, `` wn ``, select: `` nv ``, `` wnv ``, `` n ``, `` wn `` | +| `wclose` | Close window | normal: `` q ``, `` wq ``, `` ``, `` w ``, select: `` q ``, `` wq ``, `` ``, `` w `` | +| `wonly` | Close windows except current | normal: `` o ``, `` wo ``, `` ``, `` w ``, select: `` o ``, `` wo ``, `` ``, `` w `` | +| `select_register` | Select register | normal: `` " ``, select: `` " `` | +| `insert_register` | Insert register | insert: `` `` | +| `align_view_middle` | Align view middle | normal: `` Zm ``, `` zm ``, select: `` Zm ``, `` zm `` | +| `align_view_top` | Align view top | normal: `` Zt ``, `` zt ``, select: `` Zt ``, `` zt `` | +| `align_view_center` | Align view center | normal: `` Zc ``, `` Zz ``, `` zc ``, `` zz ``, select: `` Zc ``, `` Zz ``, `` zc ``, `` zz `` | +| `align_view_bottom` | Align view bottom | normal: `` Zb ``, `` zb ``, select: `` Zb ``, `` zb `` | +| `scroll_up` | Scroll view up | normal: `` Zk ``, `` zk ``, `` Z ``, `` z ``, select: `` Zk ``, `` zk ``, `` Z ``, `` z `` | +| `scroll_down` | Scroll view down | normal: `` Zj ``, `` zj ``, `` Z ``, `` z ``, select: `` Zj ``, `` zj ``, `` Z ``, `` z `` | +| `match_brackets` | Goto matching bracket | normal: `` mm ``, select: `` mm `` | +| `surround_add` | Surround add | normal: `` ms ``, select: `` ms `` | +| `surround_replace` | Surround replace | normal: `` mr ``, select: `` mr `` | +| `surround_delete` | Surround delete | normal: `` md ``, select: `` md `` | +| `select_textobject_around` | Select around object | normal: `` ma ``, select: `` ma `` | +| `select_textobject_inner` | Select inside object | normal: `` mi ``, select: `` mi `` | +| `goto_next_function` | Goto next function | normal: `` ]f ``, select: `` ]f `` | +| `goto_prev_function` | Goto previous function | normal: `` [f ``, select: `` [f `` | +| `goto_next_class` | Goto next type definition | normal: `` ]t ``, select: `` ]t `` | +| `goto_prev_class` | Goto previous type definition | normal: `` [t ``, select: `` [t `` | +| `goto_next_parameter` | Goto next parameter | normal: `` ]a ``, select: `` ]a `` | +| `goto_prev_parameter` | Goto previous parameter | normal: `` [a ``, select: `` [a `` | +| `goto_next_comment` | Goto next comment | normal: `` ]c ``, select: `` ]c `` | +| `goto_prev_comment` | Goto previous comment | normal: `` [c ``, select: `` [c `` | +| `goto_next_test` | Goto next test | normal: `` ]T ``, select: `` ]T `` | +| `goto_prev_test` | Goto previous test | normal: `` [T ``, select: `` [T `` | +| `goto_next_entry` | Goto next pairing | normal: `` ]e ``, select: `` ]e `` | +| `goto_prev_entry` | Goto previous pairing | normal: `` [e ``, select: `` [e `` | +| `goto_next_paragraph` | Goto next paragraph | normal: `` ]p ``, select: `` ]p `` | +| `goto_prev_paragraph` | Goto previous paragraph | normal: `` [p ``, select: `` [p `` | +| `dap_launch` | Launch debug target | normal: `` Gl ``, select: `` Gl `` | +| `dap_restart` | Restart debugging session | normal: `` Gr ``, select: `` Gr `` | +| `dap_toggle_breakpoint` | Toggle breakpoint | normal: `` Gb ``, select: `` Gb `` | +| `dap_continue` | Continue program execution | normal: `` Gc ``, select: `` Gc `` | +| `dap_pause` | Pause program execution | normal: `` Gh ``, select: `` Gh `` | +| `dap_step_in` | Step in | normal: `` Gi ``, select: `` Gi `` | +| `dap_step_out` | Step out | normal: `` Go ``, select: `` Go `` | +| `dap_next` | Step to next | normal: `` Gn ``, select: `` Gn `` | +| `dap_variables` | List variables | normal: `` Gv ``, select: `` Gv `` | +| `dap_terminate` | End debug session | normal: `` Gt ``, select: `` Gt `` | +| `dap_edit_condition` | Edit breakpoint condition on current line | normal: `` G ``, select: `` G `` | +| `dap_edit_log` | Edit breakpoint log message on current line | normal: `` G ``, select: `` G `` | +| `dap_switch_thread` | Switch current thread | normal: `` Gst ``, select: `` Gst `` | +| `dap_switch_stack_frame` | Switch stack frame | normal: `` Gsf ``, select: `` Gsf `` | +| `dap_enable_exceptions` | Enable exception breakpoints | normal: `` Ge ``, select: `` Ge `` | +| `dap_disable_exceptions` | Disable exception breakpoints | normal: `` GE ``, select: `` GE `` | +| `shell_pipe` | Pipe selections through shell command | normal: `` \| ``, select: `` \| `` | +| `shell_pipe_to` | Pipe selections into shell command ignoring output | normal: `` ``, select: `` `` | +| `shell_insert_output` | Insert shell command output before selections | normal: `` ! ``, select: `` ! `` | +| `shell_append_output` | Append shell command output after selections | normal: `` ``, select: `` `` | +| `shell_keep_pipe` | Filter selections with shell predicate | normal: `` $ ``, select: `` $ `` | +| `suspend` | Suspend and return to shell | normal: `` ``, select: `` `` | +| `rename_symbol` | Rename symbol | normal: `` r ``, select: `` r `` | +| `increment` | Increment item under cursor | normal: `` ``, select: `` `` | +| `decrement` | Decrement item under cursor | normal: `` ``, select: `` `` | +| `record_macro` | Record macro | normal: `` Q ``, select: `` Q `` | +| `replay_macro` | Replay macro | normal: `` q ``, select: `` q `` | +| `command_palette` | Open command palette | normal: `` ? ``, select: `` ? `` | +| `goto_word` | Jump to a two-character label | normal: `` gw `` | +| `extend_to_word` | Extend to a two-character label | select: `` gw `` | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index fae78466952b..c798f425b3ad 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -18,7 +18,7 @@ | `:new`, `:n` | Create a new scratch buffer. | | `:goto-mark` | Go to the selection saved in a register. Register can be provided as argument or selected register else ^ will be used | | `:register-mark` | Save current selection into a register. Register can be provided as argument or selected register else ^ will be used | -| `:format`, `:fmt` | Format the file using the LSP formatter. | +| `:format`, `:fmt` | Format the file using an external formatter or language server. | | `:indent-style` | Set the indentation style for editing. ('t' for tabs or 1-16 for number of spaces.) | | `:line-ending` | Set the document's default line ending. Options: crlf, lf. | | `:earlier`, `:ear` | Jump back to an earlier point in edit history. Accepts a number of steps or a time span. | @@ -74,7 +74,7 @@ | `:sort` | Sort ranges in selection. | | `:rsort` | Sort ranges in selection in reverse order. | | `:reflow` | Hard-wrap the current selection of lines to a given width. | -| `:tree-sitter-subtree`, `:ts-subtree` | Display tree sitter subtree under cursor, primarily for debugging queries. | +| `:tree-sitter-subtree`, `:ts-subtree` | Display the smallest tree-sitter subtree that spans the primary selection, primarily for debugging queries. | | `:config-reload` | Refresh user config. | | `:config-open` | Open the user config.toml file. | | `:config-open-workspace` | Open the workspace config.toml file. | diff --git a/book/src/keymap.md b/book/src/keymap.md index e7ae6ae4779e..2797eaee2908 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -112,39 +112,43 @@ Normal mode is the default mode when you launch helix. You can return to it from ### Selection manipulation -| Key | Description | Command | -| ----- | ----------- | ------- | -| `s` | Select all regex matches inside selections | `select_regex` | -| `S` | Split selection into sub selections on regex matches | `split_selection` | -| `Alt-s` | Split selection on newlines | `split_selection_on_newline` | -| `Alt-minus` | Merge selections | `merge_selections` | -| `Alt-_` | Merge consecutive selections | `merge_consecutive_selections` | -| `&` | Align selection in columns | `align_selections` | -| `_` | Trim whitespace from the selection | `trim_selections` | -| `;` | Collapse selection onto a single cursor | `collapse_selection` | -| `Alt-;` | Flip selection cursor and anchor | `flip_selections` | -| `Alt-:` | Ensures the selection is in forward direction | `ensure_selections_forward` | -| `,` | Keep only the primary selection | `keep_primary_selection` | -| `Alt-,` | Remove the primary selection | `remove_primary_selection` | -| `C` | Copy selection onto the next line (Add cursor below) | `copy_selection_on_next_line` | -| `Alt-C` | Copy selection onto the previous line (Add cursor above) | `copy_selection_on_prev_line` | -| `(` | Rotate main selection backward | `rotate_selections_backward` | -| `)` | Rotate main selection forward | `rotate_selections_forward` | -| `Alt-(` | Rotate selection contents backward | `rotate_selection_contents_backward` | -| `Alt-)` | Rotate selection contents forward | `rotate_selection_contents_forward` | -| `%` | Select entire file | `select_all` | -| `x` | Select current line, if already selected, extend to next line | `extend_line_below` | -| `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` | -| `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` | -| `J` | Join lines inside selection | `join_selections` | -| `Alt-J` | Join lines inside selection and select the inserted space | `join_selections_space` | -| `K` | Keep selections matching the regex | `keep_selections` | -| `Alt-K` | Remove selections matching the regex | `remove_selections` | -| `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` | -| `Alt-o`, `Alt-up` | Expand selection to parent syntax node (**TS**) | `expand_selection` | -| `Alt-i`, `Alt-down` | Shrink syntax tree object selection (**TS**) | `shrink_selection` | -| `Alt-p`, `Alt-left` | Select previous sibling node in syntax tree (**TS**) | `select_prev_sibling` | -| `Alt-n`, `Alt-right` | Select next sibling node in syntax tree (**TS**) | `select_next_sibling` | +| Key | Description | Command | +| ----- | ----------- | ------- | +| `s` | Select all regex matches inside selections | `select_regex` | +| `S` | Split selection into sub selections on regex matches | `split_selection` | +| `Alt-s` | Split selection on newlines | `split_selection_on_newline` | +| `Alt-minus` | Merge selections | `merge_selections` | +| `Alt-_` | Merge consecutive selections | `merge_consecutive_selections` | +| `&` | Align selection in columns | `align_selections` | +| `_` | Trim whitespace from the selection | `trim_selections` | +| `;` | Collapse selection onto a single cursor | `collapse_selection` | +| `Alt-;` | Flip selection cursor and anchor | `flip_selections` | +| `Alt-:` | Ensures the selection is in forward direction | `ensure_selections_forward` | +| `,` | Keep only the primary selection | `keep_primary_selection` | +| `Alt-,` | Remove the primary selection | `remove_primary_selection` | +| `C` | Copy selection onto the next line (Add cursor below) | `copy_selection_on_next_line` | +| `Alt-C` | Copy selection onto the previous line (Add cursor above) | `copy_selection_on_prev_line` | +| `(` | Rotate main selection backward | `rotate_selections_backward` | +| `)` | Rotate main selection forward | `rotate_selections_forward` | +| `Alt-(` | Rotate selection contents backward | `rotate_selection_contents_backward` | +| `Alt-)` | Rotate selection contents forward | `rotate_selection_contents_forward` | +| `%` | Select entire file | `select_all` | +| `x` | Select current line, if already selected, extend to next line | `extend_line_below` | +| `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` | +| `Alt-x` | Shrink selection to line bounds (line-wise selection) | `shrink_to_line_bounds` | +| `J` | Join lines inside selection | `join_selections` | +| `Alt-J` | Join lines inside selection and select the inserted space | `join_selections_space` | +| `K` | Keep selections matching the regex | `keep_selections` | +| `Alt-K` | Remove selections matching the regex | `remove_selections` | +| `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` | +| `Alt-o`, `Alt-up` | Expand selection to parent syntax node (**TS**) | `expand_selection` | +| `Alt-i`, `Alt-down` | Shrink syntax tree object selection (**TS**) | `shrink_selection` | +| `Alt-p`, `Alt-left` | Select previous sibling node in syntax tree (**TS**) | `select_prev_sibling` | +| `Alt-n`, `Alt-right` | Select next sibling node in syntax tree (**TS**) | `select_next_sibling` | +| `Alt-a` | Select all sibling nodes in syntax tree (**TS**) | `select_all_siblings` | +| `Alt-I`, `Alt-Shift-down`| Select all children nodes in syntax tree (**TS**) | `select_all_children` | +| `Alt-e` | Move to end of parent node in syntax tree (**TS**) | `move_parent_node_end` | +| `Alt-b` | Move to start of parent node in syntax tree (**TS**) | `move_parent_node_start` | ### Search @@ -156,7 +160,8 @@ Search commands all operate on the `/` register by default. To use a different r | `?` | Search for previous pattern | `rsearch` | | `n` | Select next search match | `search_next` | | `N` | Select previous search match | `search_prev` | -| `*` | Use current selection as the search pattern | `search_selection` | +| `*` | Use current selection as the search pattern, automatically wrapping with `\b` on word boundaries | `search_selection_detect_word_boundaries` | +| `Alt-*` | Use current selection as the search pattern | `search_selection` | ### Minor modes @@ -278,7 +283,7 @@ This layer is a kludge of mappings, mostly pickers. | Key | Description | Command | | ----- | ----------- | ------- | -| `f` | Open file picker | `file_picker` | +| `f` | Open file picker at LSP workspace root | `file_picker` | | `F` | Open file picker at current working directory | `file_picker_in_current_directory` | | `b` | Open buffer picker | `buffer_picker` | | `j` | Open jumplist picker | `jumplist_picker` | diff --git a/book/src/languages.md b/book/src/languages.md index fe105cced820..a70b4789158b 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -13,7 +13,7 @@ There are three possible locations for a `languages.toml` file: 2. In your [configuration directory](./configuration.md). This overrides values from the built-in language configuration. For example, to disable - auto-LSP-formatting in Rust: + auto-formatting for Rust: ```toml # in /helix/languages.toml @@ -69,6 +69,7 @@ These configuration keys are available: | `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout | | `soft-wrap` | [editor.softwrap](./configuration.md#editorsoft-wrap-section) | `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` | +| `path-completion` | Overrides the `editor.path-completion` config key for the language. | | `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. | | `persistent-diagnostic-sources` | An array of LSP diagnostic sources assumed unchanged when the language server resends the same set of diagnostics. Helix can track the position for these diagnostics internally instead. Useful for diagnostics that are recomputed on save. @@ -127,7 +128,7 @@ These are the available options for a language server. | ---- | ----------- | | `command` | The name or path of the language server binary to execute. Binaries must be in `$PATH` | | `args` | A list of arguments to pass to the language server binary | -| `config` | LSP initialization options | +| `config` | Language server initialization options | | `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` | | `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` | | `required-root-patterns` | A list of `glob` patterns to look for in the working directory. The language server is started if at least one of them is found. | diff --git a/book/src/package-managers.md b/book/src/package-managers.md index 3cfd31003ed2..a08baccd1313 100644 --- a/book/src/package-managers.md +++ b/book/src/package-managers.md @@ -17,7 +17,7 @@ - [Chocolatey](#chocolatey) - [MSYS2](#msys2) -[![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions) +[![Packaging status](https://repology.org/badge/vertical-allrepos/helix-editor.svg)](https://repology.org/project/helix-editor/versions) ## Linux @@ -101,7 +101,15 @@ Download the official Helix AppImage from the [latest releases](https://github.c chmod +x helix-*.AppImage # change permission for executable mode ./helix-*.AppImage # run helix ``` - + +You can optionally [add the `.desktop` file](./building-from-source.md#configure-the-desktop-shortcut). Helix must be installed in `PATH` with the name `hx`. For example: +```sh +mkdir -p "$HOME/.local/bin" +mv helix-*.AppImage "$HOME/.local/bin/hx" +``` + +and make sure `~/.local/bin` is in your `PATH`. + ## macOS ### Homebrew Core diff --git a/book/src/pickers.md b/book/src/pickers.md index 4149e560b941..67a195a65e31 100644 --- a/book/src/pickers.md +++ b/book/src/pickers.md @@ -4,7 +4,7 @@ Helix has a variety of pickers, which are interactive windows used to select var ### Filtering Picker Results -Most pickers perform fuzzy matching using [fzf syntax](https://github.com/junegunn/fzf?tab=readme-ov-file#search-syntax). Two exceptions are the global search picker, which uses regex, and the workspace symbol picker, which passes search terms to the LSP. Note that OR operations (`|`) are not currently supported. +Most pickers perform fuzzy matching using [fzf syntax](https://github.com/junegunn/fzf?tab=readme-ov-file#search-syntax). Two exceptions are the global search picker, which uses regex, and the workspace symbol picker, which passes search terms to the language server. Note that OR operations (`|`) are not currently supported. If a picker shows multiple columns, you may apply the filter to a specific column by prefixing the column name with `%`. Column names can be shortened to any prefix, so `%p`, `%pa` or `%pat` all mean the same as `%path`. For example, a query of `helix %p .toml !lang` in the global search picker searches for the term "helix" within files with paths ending in ".toml" but not including "lang". diff --git a/book/src/remapping.md b/book/src/remapping.md index e3efdf16f851..41e20f84ba0c 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -4,10 +4,31 @@ Helix currently supports one-way key remapping through a simple TOML configurati file. (More powerful solutions such as rebinding via commands will be available in the future). +There are three kinds of commands that can be used in keymaps: + +* Static commands: commands like `move_char_right` which are usually bound to + keys and used for movement and editing. A list of static commands is + available in the [Keymap](./keymap.html) documentation and in the source code + in [`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. +* Typable commands: commands that can be executed from command mode (`:`), for + example `:write!`. See the [Commands](./commands.html) documentation for a + list of available typeable commands or the `TypableCommandList` declaration in + the source code at [`helix-term/src/commands/typed.rs`](https://github.com/helix-editor/helix/blob/master/helix-term/src/commands/typed.rs). +* Macros: sequences of keys that are executed in order. These keybindings + start with `@` and then list any number of keys to be executed. For example + `@miw` can be used to select the surrounding word. For now, macro keybindings + are not allowed in keybinding sequences due to limitations in the way that + command sequences are executed. Modifier keys (e.g. Alt+o) can be used + like `""`, e.g. `"@miw"` + To remap keys, create a `config.toml` file in your `helix` configuration directory (default `~/.config/helix` on Linux systems) with a structure like this: +> 💡 To set a modifier + key as a keymap, type `A-X = ...` or `C-X = ...` for Alt + X or Ctrl + X. Combine with Shift using a dash, e.g. `C-S-esc`. +> Within macros, wrap them in `<>`, e.g. `` and `` to distinguish from the `A` or `C` keys. + ```toml # At most one section each of 'keys.normal', 'keys.insert' and 'keys.select' [keys.normal] @@ -18,6 +39,7 @@ w = "move_line_up" # Maps the 'w' key move_line_up "C-S-esc" = "extend_line" # Maps Ctrl-Shift-Escape to extend_line g = { a = "code_action" } # Maps `ga` to show possible code actions "ret" = ["open_below", "normal_mode"] # Maps the enter key to open_below then re-enter normal mode +"A-x" = "@x" # Maps Alt-x to a macro selecting the whole line and deleting it without yanking it [keys.insert] "A-x" = "normal_mode" # Maps Alt-X to enter normal mode @@ -74,21 +96,3 @@ Ctrl, Shift and Alt modifiers are encoded respectively with the prefixes | Escape | `"esc"` | Keys can be disabled by binding them to the `no_op` command. - -## Commands - -There are three kinds of commands that can be used in keymaps: - -* Static commands: commands like `move_char_right` which are usually bound to - keys and used for movement and editing. A list of static commands is - available in the [Keymap](./keymap.html) documentation and in the source code - in [`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`. -* Typable commands: commands that can be executed from command mode (`:`), for - example `:write!`. See the [Commands](./commands.html) documentation for a - list of available typeable commands. -* Macros: sequences of keys that are executed in order. These keybindings - start with `@` and then list any number of keys to be executed. For example - `@miw` can be used to select the surrounding word. For now, macro keybindings - are not allowed in keybinding sequences due to limitations in the way that - command sequences are executed. diff --git a/book/src/themes.md b/book/src/themes.md index 1bc2627dd8cd..40e12330e425 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -283,7 +283,6 @@ These scopes are used for theming the editor interface: | `ui.debug.active` | Indicator for the line at which debugging execution is paused at, found in the gutter | | `ui.gutter` | Gutter | | `ui.gutter.selected` | Gutter for the line the cursor is on | -| `ui.highlight.frameline` | Line at which debugging execution is paused at | | `ui.linenr` | Line numbers | | `ui.linenr.selected` | Line number for the line the cursor is on | | `ui.statusline` | Statusline | @@ -297,7 +296,7 @@ These scopes are used for theming the editor interface: | `ui.bufferline.background` | Style for bufferline background | | `ui.popup` | Documentation popups (e.g. Space + k) | | `ui.popup.info` | Prompt for multiple key options | -| `ui.picker.header` | Header row area in pickers with multiple columns | +| `ui.picker.header` | Header row area in pickers with multiple columns | | `ui.picker.header.column` | Column names in pickers with multiple columns | | `ui.picker.header.column.active` | The column name in pickers with multiple columns where the cursor is entering into. | | `ui.window` | Borderlines separating splits | @@ -310,8 +309,8 @@ These scopes are used for theming the editor interface: | `ui.virtual.whitespace` | Visible whitespace characters | | `ui.virtual.indent-guide` | Vertical indent width guides | | `ui.virtual.inlay-hint` | Default style for inlay hints of all kinds | -| `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (LSPs are not required to set a kind) | -| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (LSPs are not required to set a kind) | +| `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (language servers are not required to set a kind) | +| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (language servers are not required to set a kind) | | `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) | | `ui.virtual.jump-label` | Style for virtual jump labels | | `ui.menu` | Code and command completion menus | @@ -320,6 +319,7 @@ These scopes are used for theming the editor interface: | `ui.selection` | For selections in the editing area | | `ui.selection.primary` | | | `ui.highlight` | Highlighted lines in the picker preview | +| `ui.highlight.frameline` | Line at which debugging execution is paused at | | `ui.cursorline.primary` | The line of the primary cursor ([if cursorline is enabled][editor-section]) | | `ui.cursorline.secondary` | The lines of any other cursors ([if cursorline is enabled][editor-section]) | | `ui.cursorcolumn.primary` | The column of the primary cursor ([if cursorcolumn is enabled][editor-section]) | diff --git a/book/src/usage.md b/book/src/usage.md index 859cb670959d..a22a18492389 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -7,3 +7,27 @@ can be accessed via the command `hx --tutor` or `:tutor`. > 💡 Currently, not all functionality is fully documented, please refer to the > [key mappings](./keymap.md) list. +## Modes + +Helix is a modal editor, meaning it has different modes for different tasks. The main modes are: + +* [Normal mode](./keymap.md#normal-mode): For navigation and editing commands. This is the default mode. +* [Insert mode](./keymap.md#insert-mode): For typing text directly into the document. Access by typing `i` in normal mode. +* [Select/extend mode](./keymap.md#select--extend-mode): For making selections and performing operations on them. Access by typing `v` in normal mode. + +## Buffers + +Buffers are in-memory representations of files. You can have multiple buffers open at once. Use [pickers](./pickers.md) or commands like `:buffer-next` and `:buffer-previous` to open buffers or switch between them. + +## Selection-first editing + +Inspired by [Kakoune](http://kakoune.org/), Helix follows the `selection → action` model. This means that whatever you are going to act on (a word, a paragraph, a line, etc.) is selected first and the action itself (delete, change, yank, etc.) comes second. A cursor is simply a single width selection. + +## Multiple selections + +Also inspired by Kakoune, multiple selections are a core mode of interaction in Helix. For example, the standard way of replacing multiple instance of a word is to first select all instances (so there is one selection per instance) and then use the change action (`c`) to edit them all at the same time. + +## Motions + +Motions are commands that move the cursor or modify selections. They're used for navigation and text manipulation. Examples include `w` to move to the next word, or `f` to find a character. See the [Movement](./keymap.md#movement) section of the keymap for more motions. + diff --git a/book/theme/index.hbs b/book/theme/index.hbs index 0a0bc55019cb..0a46ac64915a 100644 --- a/book/theme/index.hbs +++ b/book/theme/index.hbs @@ -1,5 +1,5 @@ - + @@ -52,15 +52,17 @@ {{/if}} - - -
+ - + + + + +
- - -
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 86caff717a63..df33d6e52c25 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -59,10 +59,16 @@ the default value (e.g. to `10240` from `256`) by running `ulimit -n 10240`. ## Minimum Stable Rust Version (MSRV) Policy -Helix follows the MSRV of Firefox. -The current MSRV and future changes to the MSRV are listed in the [Firefox documentation]. +Helix keeps an intentionally low MSRV for the sake of easy building and packaging +downstream. We follow [Firefox's MSRV policy]. Once Firefox's MSRV increases we +may bump ours as well, but be sure to check that popular distributions like Ubuntu +package the new MSRV version. When increasing the MSRV, update these three places: -[Firefox documentation]: https://firefox-source-docs.mozilla.org/writing-rust-code/update-policy.html +* the `workspace.package.rust-version` key in `Cargo.toml` in the repository root +* the `env.MSRV` key at the top of `.github/workflows/build.yml` +* the `toolchain.channel` key in `rust-toolchain.toml` + +[Firefox's MSRV policy]: https://firefox-source-docs.mozilla.org/writing-rust-code/update-policy.html [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 diff --git a/flake.lock b/flake.lock index 48fb4a59fae3..9d114d1018b0 100644 --- a/flake.lock +++ b/flake.lock @@ -1,17 +1,12 @@ { "nodes": { "crane": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, "locked": { - "lastModified": 1709610799, - "narHash": "sha256-5jfLQx0U9hXbi2skYMGodDJkIgffrjIOgMRjZqms2QE=", + "lastModified": 1727974419, + "narHash": "sha256-WD0//20h+2/yPGkO88d2nYbb23WMWYvnRyDQ9Dx4UHg=", "owner": "ipetkov", "repo": "crane", - "rev": "81c393c776d5379c030607866afef6406ca1be57", + "rev": "37e4f9f0976cb9281cd3f0c70081e5e0ecaee93f", "type": "github" }, "original": { @@ -25,11 +20,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1709126324, - "narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=", + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", "owner": "numtide", "repo": "flake-utils", - "rev": "d465f4819400de7c8d874d50b982301f28a84605", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", "type": "github" }, "original": { @@ -40,11 +35,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1709479366, - "narHash": "sha256-n6F0n8UV6lnTZbYPl1A9q1BS0p4hduAv1mGAP17CVd0=", + "lastModified": 1728018373, + "narHash": "sha256-NOiTvBbRLIOe5F6RbHaAh6++BNjsb149fGZd1T4+KBg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "b8697e57f10292a6165a20f03d2f42920dfaf973", + "rev": "bc947f541ae55e999ffdb4013441347d83b00feb", "type": "github" }, "original": { @@ -64,19 +59,16 @@ }, "rust-overlay": { "inputs": { - "flake-utils": [ - "flake-utils" - ], "nixpkgs": [ "nixpkgs" ] }, "locked": { - "lastModified": 1709604635, - "narHash": "sha256-le4fwmWmjGRYWwkho0Gr7mnnZndOOe4XGbLw68OvF40=", + "lastModified": 1728268235, + "narHash": "sha256-lJMFnMO4maJuNO6PQ5fZesrTmglze3UFTTBuKGwR1Nw=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "e86c0fb5d3a22a5f30d7f64ecad88643fe26449d", + "rev": "25685cc2c7054efc31351c172ae77b21814f2d42", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index c7e4fdce5f9e..2f3be94ee241 100644 --- a/flake.nix +++ b/flake.nix @@ -6,15 +6,9 @@ flake-utils.url = "github:numtide/flake-utils"; rust-overlay = { url = "github:oxalica/rust-overlay"; - inputs = { - nixpkgs.follows = "nixpkgs"; - flake-utils.follows = "flake-utils"; - }; - }; - crane = { - url = "github:ipetkov/crane"; inputs.nixpkgs.follows = "nixpkgs"; }; + crane.url = "github:ipetkov/crane"; }; outputs = { @@ -114,7 +108,7 @@ if pkgs.stdenv.isLinux then pkgs.stdenv else pkgs.clangStdenv; - rustFlagsEnv = pkgs.lib.optionalString stdenv.isLinux "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment"; + rustFlagsEnv = pkgs.lib.optionalString stdenv.isLinux "-C link-arg=-fuse-ld=lld -C target-cpu=native -Clink-arg=-Wl,--no-rosegment --cfg tokio_unstable"; rustToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; craneLibMSRV = (crane.mkLib pkgs).overrideToolchain rustToolchain; craneLibStable = (crane.mkLib pkgs).overrideToolchain pkgs.pkgsBuildHost.rust-bin.stable.latest.default; diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 63f44566b200..aad56a5f92ea 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -23,24 +23,24 @@ helix-parsec = { path = "../helix-parsec" } ropey = { version = "1.6.1", default-features = false, features = ["simd"] } smallvec = "1.13" smartstring = "1.0.1" -unicode-segmentation = "1.11" +unicode-segmentation = "1.12" # unicode-width is changing width definitions # that both break our logic and disagree with common # width definitions in terminals, we need to replace it. # For now lets lock the version to avoid rendering glitches # when installing without `--locked` unicode-width = "=0.1.12" -unicode-general-category = "0.6" +unicode-general-category = "1.0" slotmap.workspace = true tree-sitter.workspace = true -once_cell = "1.19" +once_cell = "1.20" arc-swap = "1" regex = "1" bitflags = "2.6" ahash = "0.8.11" hashbrown = { version = "0.14.5", features = ["raw"] } dunce = "1.0" -url = "2.5.0" +url = "2.5.4" log = "0.4" serde = { version = "1.0", features = ["derive"] } diff --git a/helix-core/src/comment.rs b/helix-core/src/comment.rs index 536b710abf0f..d63d05fa9360 100644 --- a/helix-core/src/comment.rs +++ b/helix-core/src/comment.rs @@ -9,6 +9,24 @@ use crate::{ use helix_stdx::rope::RopeSliceExt; use std::borrow::Cow; +pub const DEFAULT_COMMENT_TOKEN: &str = "#"; + +/// Returns the longest matching comment token of the given line (if it exists). +pub fn get_comment_token<'a, S: AsRef>( + text: RopeSlice, + tokens: &'a [S], + line_num: usize, +) -> Option<&'a str> { + let line = text.line(line_num); + let start = line.first_non_whitespace_char()?; + + tokens + .iter() + .map(AsRef::as_ref) + .filter(|token| line.slice(start..).starts_with(token)) + .max_by_key(|token| token.len()) +} + /// Given text, a comment token, and a set of line indices, returns the following: /// - Whether the given lines should be considered commented /// - If any of the lines are uncommented, all lines are considered as such. @@ -28,21 +46,20 @@ fn find_line_comment( let mut min = usize::MAX; // minimum col for first_non_whitespace_char let mut margin = 1; let token_len = token.chars().count(); + for line in lines { let line_slice = text.line(line); if let Some(pos) = line_slice.first_non_whitespace_char() { let len = line_slice.len_chars(); - if pos < min { - min = pos; - } + min = std::cmp::min(min, pos); // line can be shorter than pos + token len let fragment = Cow::from(line_slice.slice(pos..std::cmp::min(pos + token.len(), len))); + // as soon as one of the non-blank lines doesn't have a comment, the whole block is + // considered uncommented. if fragment != token { - // as soon as one of the non-blank lines doesn't have a comment, the whole block is - // considered uncommented. commented = false; } @@ -56,6 +73,7 @@ fn find_line_comment( to_change.push(line); } } + (commented, to_change, min, margin) } @@ -63,7 +81,7 @@ fn find_line_comment( pub fn toggle_line_comments(doc: &Rope, selection: &Selection, token: Option<&str>) -> Transaction { let text = doc.slice(..); - let token = token.unwrap_or("//"); + let token = token.unwrap_or(DEFAULT_COMMENT_TOKEN); let comment = Tendril::from(format!("{} ", token)); let mut lines: Vec = Vec::with_capacity(selection.len()); @@ -129,10 +147,7 @@ pub fn find_block_comments( let mut only_whitespace = true; let mut comment_changes = Vec::with_capacity(selection.len()); let default_tokens = tokens.first().cloned().unwrap_or_default(); - // TODO: check if this can be removed on MSRV bump - #[allow(clippy::redundant_clone)] let mut start_token = default_tokens.start.clone(); - #[allow(clippy::redundant_clone)] let mut end_token = default_tokens.end.clone(); let mut tokens = tokens.to_vec(); @@ -317,56 +332,87 @@ pub fn split_lines_of_selection(text: RopeSlice, selection: &Selection) -> Selec mod test { use super::*; - #[test] - fn test_find_line_comment() { - // four lines, two space indented, except for line 1 which is blank. - let mut doc = Rope::from(" 1\n\n 2\n 3"); - // select whole document - let mut selection = Selection::single(0, doc.len_chars() - 1); + mod find_line_comment { + use super::*; - let text = doc.slice(..); + #[test] + fn not_commented() { + // four lines, two space indented, except for line 1 which is blank. + let doc = Rope::from(" 1\n\n 2\n 3"); - let res = find_line_comment("//", text, 0..3); - // (commented = true, to_change = [line 0, line 2], min = col 2, margin = 0) - assert_eq!(res, (false, vec![0, 2], 2, 0)); + let text = doc.slice(..); - // comment - let transaction = toggle_line_comments(&doc, &selection, None); - transaction.apply(&mut doc); - selection = selection.map(transaction.changes()); + let res = find_line_comment("//", text, 0..3); + // (commented = false, to_change = [line 0, line 2], min = col 2, margin = 0) + assert_eq!(res, (false, vec![0, 2], 2, 0)); + } - assert_eq!(doc, " // 1\n\n // 2\n // 3"); + #[test] + fn is_commented() { + // three lines where the second line is empty. + let doc = Rope::from("// hello\n\n// there"); - // uncomment - let transaction = toggle_line_comments(&doc, &selection, None); - transaction.apply(&mut doc); - selection = selection.map(transaction.changes()); - assert_eq!(doc, " 1\n\n 2\n 3"); - assert!(selection.len() == 1); // to ignore the selection unused warning + let res = find_line_comment("//", doc.slice(..), 0..3); - // 0 margin comments - doc = Rope::from(" //1\n\n //2\n //3"); - // reset the selection. - selection = Selection::single(0, doc.len_chars() - 1); + // (commented = true, to_change = [line 0, line 2], min = col 0, margin = 1) + assert_eq!(res, (true, vec![0, 2], 0, 1)); + } + } - let transaction = toggle_line_comments(&doc, &selection, None); - transaction.apply(&mut doc); - selection = selection.map(transaction.changes()); - assert_eq!(doc, " 1\n\n 2\n 3"); - assert!(selection.len() == 1); // to ignore the selection unused warning + // TODO: account for uncommenting with uneven comment indentation + mod toggle_line_comment { + use super::*; - // 0 margin comments, with no space - doc = Rope::from("//"); - // reset the selection. - selection = Selection::single(0, doc.len_chars() - 1); + #[test] + fn comment() { + // four lines, two space indented, except for line 1 which is blank. + let mut doc = Rope::from(" 1\n\n 2\n 3"); + // select whole document + let selection = Selection::single(0, doc.len_chars() - 1); - let transaction = toggle_line_comments(&doc, &selection, None); - transaction.apply(&mut doc); - selection = selection.map(transaction.changes()); - assert_eq!(doc, ""); - assert!(selection.len() == 1); // to ignore the selection unused warning + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + + assert_eq!(doc, " # 1\n\n # 2\n # 3"); + } - // TODO: account for uncommenting with uneven comment indentation + #[test] + fn uncomment() { + let mut doc = Rope::from(" # 1\n\n # 2\n # 3"); + let mut selection = Selection::single(0, doc.len_chars() - 1); + + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + selection = selection.map(transaction.changes()); + + assert_eq!(doc, " 1\n\n 2\n 3"); + assert!(selection.len() == 1); // to ignore the selection unused warning + } + + #[test] + fn uncomment_0_margin_comments() { + let mut doc = Rope::from(" #1\n\n #2\n #3"); + let mut selection = Selection::single(0, doc.len_chars() - 1); + + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + selection = selection.map(transaction.changes()); + + assert_eq!(doc, " 1\n\n 2\n 3"); + assert!(selection.len() == 1); // to ignore the selection unused warning + } + + #[test] + fn uncomment_0_margin_comments_with_no_space() { + let mut doc = Rope::from("#"); + let mut selection = Selection::single(0, doc.len_chars() - 1); + + let transaction = toggle_line_comments(&doc, &selection, None); + transaction.apply(&mut doc); + selection = selection.map(transaction.changes()); + assert_eq!(doc, ""); + assert!(selection.len() == 1); // to ignore the selection unused warning + } } #[test] @@ -413,4 +459,32 @@ mod test { transaction.apply(&mut doc); assert_eq!(doc, ""); } + + /// Test, if `get_comment_tokens` works, even if the content of the file includes chars, whose + /// byte size unequal the amount of chars + #[test] + fn test_get_comment_with_char_boundaries() { + let rope = Rope::from("··"); + let tokens = ["//", "///"]; + + assert_eq!( + super::get_comment_token(rope.slice(..), tokens.as_slice(), 0), + None + ); + } + + /// Test for `get_comment_token`. + /// + /// Assuming the comment tokens are stored as `["///", "//"]`, `get_comment_token` should still + /// return `///` instead of `//` if the user is in a doc-comment section. + #[test] + fn test_use_longest_comment() { + let text = Rope::from(" /// amogus"); + let tokens = ["///", "//"]; + + assert_eq!( + super::get_comment_token(text.slice(..), tokens.as_slice(), 0), + Some("///") + ); + } } diff --git a/helix-core/src/completion.rs b/helix-core/src/completion.rs new file mode 100644 index 000000000000..0bd111eb4767 --- /dev/null +++ b/helix-core/src/completion.rs @@ -0,0 +1,12 @@ +use std::borrow::Cow; + +use crate::Transaction; + +#[derive(Debug, PartialEq, Clone)] +pub struct CompletionItem { + pub transaction: Transaction, + pub label: Cow<'static, str>, + pub kind: Cow<'static, str>, + /// Containing Markdown + pub documentation: String, +} diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index 91f11e620343..4ca85d315cc1 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -346,7 +346,7 @@ pub struct RopeGraphemes<'a> { cursor: GraphemeCursor, } -impl<'a> fmt::Debug for RopeGraphemes<'a> { +impl fmt::Debug for RopeGraphemes<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RopeGraphemes") .field("text", &self.text) @@ -358,7 +358,7 @@ impl<'a> fmt::Debug for RopeGraphemes<'a> { } } -impl<'a> RopeGraphemes<'a> { +impl RopeGraphemes<'_> { #[must_use] pub fn new(slice: RopeSlice) -> RopeGraphemes { let mut chunks = slice.chunks(); @@ -423,7 +423,7 @@ pub struct RevRopeGraphemes<'a> { cursor: GraphemeCursor, } -impl<'a> fmt::Debug for RevRopeGraphemes<'a> { +impl fmt::Debug for RevRopeGraphemes<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RevRopeGraphemes") .field("text", &self.text) @@ -435,7 +435,7 @@ impl<'a> fmt::Debug for RevRopeGraphemes<'a> { } } -impl<'a> RevRopeGraphemes<'a> { +impl RevRopeGraphemes<'_> { #[must_use] pub fn new(slice: RopeSlice) -> RevRopeGraphemes { let (mut chunks, mut cur_chunk_start, _, _) = slice.chunks_at_byte(slice.len_bytes()); @@ -542,7 +542,7 @@ impl<'a> From<&'a str> for GraphemeStr<'a> { } } -impl<'a> From for GraphemeStr<'a> { +impl From for GraphemeStr<'_> { fn from(g: String) -> Self { let len = g.len(); let ptr = Box::into_raw(g.into_bytes().into_boxed_slice()) as *mut u8; diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index ae26c13e0799..3faae53ec131 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -386,7 +386,7 @@ enum IndentCaptureType<'a> { Align(RopeSlice<'a>), } -impl<'a> IndentCaptureType<'a> { +impl IndentCaptureType<'_> { fn default_scope(&self) -> IndentScope { match self { IndentCaptureType::Indent | IndentCaptureType::IndentAlways => IndentScope::Tail, diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 9165560d0aa5..413c2da77ae0 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -3,6 +3,7 @@ pub use encoding_rs as encoding; pub mod auto_pairs; pub mod chars; pub mod comment; +pub mod completion; pub mod config; pub mod diagnostic; pub mod diff; @@ -63,6 +64,7 @@ pub use selection::{Range, Selection}; pub use smallvec::{smallvec, SmallVec}; pub use syntax::Syntax; +pub use completion::CompletionItem; pub use diagnostic::Diagnostic; pub use line_ending::{LineEnding, NATIVE_LINE_ENDING}; diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index aeb658b47182..b1cf87759264 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -689,7 +689,7 @@ impl Selection { pub fn fragments<'a>( &'a self, text: RopeSlice<'a>, - ) -> impl DoubleEndedIterator> + ExactSizeIterator> + 'a + ) -> impl DoubleEndedIterator> + ExactSizeIterator> { self.ranges.iter().map(move |range| range.fragment(text)) } @@ -773,7 +773,7 @@ pub struct LineRangeIter<'a> { text: RopeSlice<'a>, } -impl<'a> Iterator for LineRangeIter<'a> { +impl Iterator for LineRangeIter<'_> { type Item = (usize, usize); fn next(&mut self) -> Option { diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 7be512f52e2c..6ddf433cb7c6 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -125,6 +125,9 @@ pub struct LanguageConfiguration { #[serde(skip_serializing_if = "Option::is_none")] pub formatter: Option, + /// If set, overrides `editor.path-completion`. + pub path_completion: Option, + #[serde(default)] pub diagnostic_severity: Severity, @@ -616,7 +619,7 @@ pub enum CapturedNode<'a> { Grouped(Vec>), } -impl<'a> CapturedNode<'a> { +impl CapturedNode<'_> { pub fn start_byte(&self) -> usize { match self { Self::Single(n) => n.start_byte(), @@ -1849,7 +1852,7 @@ struct HighlightIterLayer<'a> { depth: u32, } -impl<'a> fmt::Debug for HighlightIterLayer<'a> { +impl fmt::Debug for HighlightIterLayer<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("HighlightIterLayer").finish() } @@ -2106,7 +2109,7 @@ impl HighlightConfiguration { } } -impl<'a> HighlightIterLayer<'a> { +impl HighlightIterLayer<'_> { // 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. @@ -2244,7 +2247,7 @@ fn intersect_ranges( result } -impl<'a> HighlightIter<'a> { +impl HighlightIter<'_> { fn emit_event( &mut self, offset: usize, @@ -2299,7 +2302,7 @@ impl<'a> HighlightIter<'a> { } } -impl<'a> Iterator for HighlightIter<'a> { +impl Iterator for HighlightIter<'_> { type Item = Result; fn next(&mut self) -> Option { @@ -2692,6 +2695,8 @@ fn pretty_print_tree_impl( } write!(fmt, "({}", node.kind())?; + } else { + write!(fmt, " \"{}\"", node.kind())?; } // Handle children. @@ -2950,7 +2955,7 @@ mod test { #[test] fn test_pretty_print() { let source = r#"// Hello"#; - assert_pretty_print("rust", source, "(line_comment)", 0, source.len()); + assert_pretty_print("rust", source, "(line_comment \"//\")", 0, source.len()); // A large tree should be indented with fields: let source = r#"fn main() { @@ -2960,16 +2965,16 @@ mod test { "rust", source, concat!( - "(function_item\n", + "(function_item \"fn\"\n", " name: (identifier)\n", - " parameters: (parameters)\n", - " body: (block\n", + " parameters: (parameters \"(\" \")\")\n", + " body: (block \"{\"\n", " (expression_statement\n", " (macro_invocation\n", - " macro: (identifier)\n", - " (token_tree\n", - " (string_literal\n", - " (string_content)))))))", + " macro: (identifier) \"!\"\n", + " (token_tree \"(\"\n", + " (string_literal \"\"\"\n", + " (string_content) \"\"\") \")\")) \";\") \"}\"))", ), 0, source.len(), @@ -2981,7 +2986,7 @@ mod test { // Error nodes are printed as errors: let source = r#"}{"#; - assert_pretty_print("rust", source, "(ERROR)", 0, source.len()); + assert_pretty_print("rust", source, "(ERROR \"}\" \"{\")", 0, source.len()); // Fields broken under unnamed nodes are determined correctly. // In the following source, `object` belongs to the `singleton_method` @@ -2996,11 +3001,11 @@ mod test { "ruby", source, concat!( - "(singleton_method\n", - " object: (self)\n", + "(singleton_method \"def\"\n", + " object: (self) \".\"\n", " name: (identifier)\n", " body: (body_statement\n", - " (true)))" + " (true)) \"end\")" ), 0, source.len(), diff --git a/helix-core/src/syntax/tree_cursor.rs b/helix-core/src/syntax/tree_cursor.rs index bec4a1c6c773..d82ea74dbfff 100644 --- a/helix-core/src/syntax/tree_cursor.rs +++ b/helix-core/src/syntax/tree_cursor.rs @@ -217,7 +217,7 @@ impl<'a> TreeCursor<'a> { /// Returns an iterator over the children of the node the TreeCursor is on /// at the time this is called. - pub fn children(&'a mut self) -> ChildIter { + pub fn children(&'a mut self) -> ChildIter<'a> { let parent = self.node(); ChildIter { @@ -229,7 +229,7 @@ impl<'a> TreeCursor<'a> { /// Returns an iterator over the named children of the node the TreeCursor is on /// at the time this is called. - pub fn named_children(&'a mut self) -> ChildIter { + pub fn named_children(&'a mut self) -> ChildIter<'a> { let parent = self.node(); ChildIter { diff --git a/helix-core/src/text_annotations.rs b/helix-core/src/text_annotations.rs index ff28a8dd236e..9704c3d6b892 100644 --- a/helix-core/src/text_annotations.rs +++ b/helix-core/src/text_annotations.rs @@ -211,7 +211,7 @@ impl Layer<'_, A, M> { } impl<'a, A, M> From<(&'a [A], M)> for Layer<'a, A, M> { - fn from((annotations, metadata): (&'a [A], M)) -> Layer { + fn from((annotations, metadata): (&'a [A], M)) -> Layer<'a, A, M> { Layer { annotations, current_index: Cell::new(0), diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index c5c94b750529..450b47365ecb 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -769,7 +769,7 @@ impl<'a> ChangeIterator<'a> { } } -impl<'a> Iterator for ChangeIterator<'a> { +impl Iterator for ChangeIterator<'_> { type Item = Change; fn next(&mut self) -> Option { diff --git a/helix-core/src/uri.rs b/helix-core/src/uri.rs index 4e03c58b16df..cbe0fadda67d 100644 --- a/helix-core/src/uri.rs +++ b/helix-core/src/uri.rs @@ -1,12 +1,18 @@ -use std::path::{Path, PathBuf}; +use std::{ + fmt, + path::{Path, PathBuf}, + sync::Arc, +}; /// A generic pointer to a file location. /// /// Currently this type only supports paths to local files. +/// +/// Cloning this type is cheap: the internal representation uses an Arc. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[non_exhaustive] pub enum Uri { - File(PathBuf), + File(Arc), } impl Uri { @@ -23,26 +29,18 @@ impl Uri { Self::File(path) => Some(path), } } - - pub fn as_path_buf(self) -> Option { - match self { - Self::File(path) => Some(path), - } - } } impl From for Uri { fn from(path: PathBuf) -> Self { - Self::File(path) + Self::File(path.into()) } } -impl TryFrom for PathBuf { - type Error = (); - - fn try_from(uri: Uri) -> Result { - match uri { - Uri::File(path) => Ok(path), +impl fmt::Display for Uri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::File(path) => write!(f, "{}", path.display()), } } } @@ -59,11 +57,16 @@ pub enum UrlConversionErrorKind { UnableToConvert, } -impl std::fmt::Display for UrlConversionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for UrlConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.kind { UrlConversionErrorKind::UnsupportedScheme => { - write!(f, "unsupported scheme in URL: {}", self.source.scheme()) + write!( + f, + "unsupported scheme '{}' in URL {}", + self.source.scheme(), + self.source + ) } UrlConversionErrorKind::UnableToConvert => { write!(f, "unable to convert URL to file path: {}", self.source) @@ -77,7 +80,7 @@ impl std::error::Error for UrlConversionError {} fn convert_url_to_uri(url: &url::Url) -> Result { if url.scheme() == "file" { url.to_file_path() - .map(|path| Uri::File(helix_stdx::path::normalize(path))) + .map(|path| Uri::File(helix_stdx::path::normalize(path).into())) .map_err(|_| UrlConversionErrorKind::UnableToConvert) } else { Err(UrlConversionErrorKind::UnsupportedScheme) diff --git a/helix-core/src/wrap.rs b/helix-core/src/wrap.rs index f32d6f4bc11d..337b389aef63 100644 --- a/helix-core/src/wrap.rs +++ b/helix-core/src/wrap.rs @@ -4,6 +4,8 @@ use textwrap::{Options, WordSplitter::NoHyphenation}; /// Given a slice of text, return the text re-wrapped to fit it /// within the given width. pub fn reflow_hard_wrap(text: &str, text_width: usize) -> SmartString { - let options = Options::new(text_width).word_splitter(NoHyphenation); + let options = Options::new(text_width) + .word_splitter(NoHyphenation) + .word_separator(textwrap::WordSeparator::AsciiSpace); textwrap::refill(text, options).into() } diff --git a/helix-dap/Cargo.toml b/helix-dap/Cargo.toml index c37340cc6037..d67932afbaa9 100644 --- a/helix-dap/Cargo.toml +++ b/helix-dap/Cargo.toml @@ -24,4 +24,4 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std thiserror.workspace = true [dev-dependencies] -fern = "0.6" +fern = "0.7" diff --git a/helix-event/Cargo.toml b/helix-event/Cargo.toml index e7c877355ef6..ee4038e69835 100644 --- a/helix-event/Cargo.toml +++ b/helix-event/Cargo.toml @@ -19,11 +19,11 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "p # setup new events on initialization, hardware-lock-elision hugely benefits this case # as it essentially makes the lock entirely free as long as there is no writes parking_lot = { version = "0.12", features = ["hardware-lock-elision"] } -once_cell = "1.18" +once_cell = "1.20" anyhow = "1" log = "0.4" -futures-executor = "0.3.28" +futures-executor = "0.3.31" [features] integration_test = [] diff --git a/helix-event/src/cancel.rs b/helix-event/src/cancel.rs index f027be80e8de..2029c9456b97 100644 --- a/helix-event/src/cancel.rs +++ b/helix-event/src/cancel.rs @@ -1,15 +1,18 @@ +use std::borrow::Borrow; use std::future::Future; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering::Relaxed; +use std::sync::Arc; -pub use oneshot::channel as cancelation; -use tokio::sync::oneshot; +use tokio::sync::Notify; -pub type CancelTx = oneshot::Sender<()>; -pub type CancelRx = oneshot::Receiver<()>; - -pub async fn cancelable_future(future: impl Future, cancel: CancelRx) -> Option { +pub async fn cancelable_future( + future: impl Future, + cancel: impl Borrow, +) -> Option { tokio::select! { biased; - _ = cancel => { + _ = cancel.borrow().canceled() => { None } res = future => { @@ -17,3 +20,268 @@ pub async fn cancelable_future(future: impl Future, cancel: Cance } } } + +#[derive(Default, Debug)] +struct Shared { + state: AtomicU64, + // `Notify` has some features that we don't really need here because it + // supports waking single tasks (`notify_one`) and does its own (more + // complicated) state tracking, we could reimplement the waiter linked list + // with modest effort and reduce memory consumption by one word/8 bytes and + // reduce code complexity/number of atomic operations. + // + // I don't think that's worth the complexity (unsafe code). + // + // if we only cared about async code then we could also only use a notify + // (without the generation count), this would be equivalent (or maybe more + // correct if we want to allow cloning the TX) but it would be extremly slow + // to frequently check for cancelation from sync code + notify: Notify, +} + +impl Shared { + fn generation(&self) -> u32 { + self.state.load(Relaxed) as u32 + } + + fn num_running(&self) -> u32 { + (self.state.load(Relaxed) >> 32) as u32 + } + + /// Increments the generation count and sets `num_running` + /// to the provided value, this operation is not with + /// regard to the generation counter (doesn't use `fetch_add`) + /// so the calling code must ensure it cannot execute concurrently + /// to maintain correctness (but not safety) + fn inc_generation(&self, num_running: u32) -> (u32, u32) { + let state = self.state.load(Relaxed); + let generation = state as u32; + let prev_running = (state >> 32) as u32; + // no need to create a new generation if the refcount is zero (fastpath) + if prev_running == 0 && num_running == 0 { + return (generation, 0); + } + let new_generation = generation.saturating_add(1); + self.state.store( + new_generation as u64 | ((num_running as u64) << 32), + Relaxed, + ); + self.notify.notify_waiters(); + (new_generation, prev_running) + } + + fn inc_running(&self, generation: u32) { + let mut state = self.state.load(Relaxed); + loop { + let current_generation = state as u32; + if current_generation != generation { + break; + } + let off = 1 << 32; + let res = self.state.compare_exchange_weak( + state, + state.saturating_add(off), + Relaxed, + Relaxed, + ); + match res { + Ok(_) => break, + Err(new_state) => state = new_state, + } + } + } + + fn dec_running(&self, generation: u32) { + let mut state = self.state.load(Relaxed); + loop { + let current_generation = state as u32; + if current_generation != generation { + break; + } + let num_running = (state >> 32) as u32; + // running can't be zero here, that would mean we miscounted somewhere + assert_ne!(num_running, 0); + let off = 1 << 32; + let res = self + .state + .compare_exchange_weak(state, state - off, Relaxed, Relaxed); + match res { + Ok(_) => break, + Err(new_state) => state = new_state, + } + } + } +} + +// This intentionally doesn't implement `Clone` and requires a mutable reference +// for cancelation to avoid races (in inc_generation). + +/// A task controller allows managing a single subtask enabling the controller +/// to cancel the subtask and to check whether it is still running. +/// +/// For efficiency reasons the controller can be reused/restarted, +/// in that case the previous task is automatically canceled. +/// +/// If the controller is dropped, the subtasks are automatically canceled. +#[derive(Default, Debug)] +pub struct TaskController { + shared: Arc, +} + +impl TaskController { + pub fn new() -> Self { + TaskController::default() + } + /// Cancels the active task (handle). + /// + /// Returns whether any tasks were still running before the cancelation. + pub fn cancel(&mut self) -> bool { + self.shared.inc_generation(0).1 != 0 + } + + /// Checks whether there are any task handles + /// that haven't been dropped (or canceled) yet. + pub fn is_running(&self) -> bool { + self.shared.num_running() != 0 + } + + /// Starts a new task and cancels the previous task (handles). + pub fn restart(&mut self) -> TaskHandle { + TaskHandle { + generation: self.shared.inc_generation(1).0, + shared: self.shared.clone(), + } + } +} + +impl Drop for TaskController { + fn drop(&mut self) { + self.cancel(); + } +} + +/// A handle that is used to link a task with a task controller. +/// +/// It can be used to cancel async futures very efficiently but can also be checked for +/// cancelation very quickly (single atomic read) in blocking code. +/// The handle can be cheaply cloned (reference counted). +/// +/// The TaskController can check whether a task is "running" by inspecting the +/// refcount of the (current) tasks handles. Therefore, if that information +/// is important, ensure that the handle is not dropped until the task fully +/// completes. +pub struct TaskHandle { + shared: Arc, + generation: u32, +} + +impl Clone for TaskHandle { + fn clone(&self) -> Self { + self.shared.inc_running(self.generation); + TaskHandle { + shared: self.shared.clone(), + generation: self.generation, + } + } +} + +impl Drop for TaskHandle { + fn drop(&mut self) { + self.shared.dec_running(self.generation); + } +} + +impl TaskHandle { + /// Waits until [`TaskController::cancel`] is called for the corresponding + /// [`TaskController`]. Immediately returns if `cancel` was already called since + pub async fn canceled(&self) { + let notified = self.shared.notify.notified(); + if !self.is_canceled() { + notified.await + } + } + + pub fn is_canceled(&self) -> bool { + self.generation != self.shared.generation() + } +} + +#[cfg(test)] +mod tests { + use std::future::poll_fn; + + use futures_executor::block_on; + use tokio::task::yield_now; + + use crate::{cancelable_future, TaskController}; + + #[test] + fn immediate_cancel() { + let mut controller = TaskController::new(); + let handle = controller.restart(); + controller.cancel(); + assert!(handle.is_canceled()); + controller.restart(); + assert!(handle.is_canceled()); + + let res = block_on(cancelable_future( + poll_fn(|_cx| std::task::Poll::Ready(())), + handle, + )); + assert!(res.is_none()); + } + + #[test] + fn running_count() { + let mut controller = TaskController::new(); + let handle = controller.restart(); + assert!(controller.is_running()); + assert!(!handle.is_canceled()); + drop(handle); + assert!(!controller.is_running()); + assert!(!controller.cancel()); + let handle = controller.restart(); + assert!(!handle.is_canceled()); + assert!(controller.is_running()); + let handle2 = handle.clone(); + assert!(!handle.is_canceled()); + assert!(controller.is_running()); + drop(handle2); + assert!(!handle.is_canceled()); + assert!(controller.is_running()); + assert!(controller.cancel()); + assert!(handle.is_canceled()); + assert!(!controller.is_running()); + } + + #[test] + fn no_cancel() { + let mut controller = TaskController::new(); + let handle = controller.restart(); + assert!(!handle.is_canceled()); + + let res = block_on(cancelable_future( + poll_fn(|_cx| std::task::Poll::Ready(())), + handle, + )); + assert!(res.is_some()); + } + + #[test] + fn delayed_cancel() { + let mut controller = TaskController::new(); + let handle = controller.restart(); + + let mut hit = false; + let res = block_on(cancelable_future( + async { + controller.cancel(); + hit = true; + yield_now().await; + }, + handle, + )); + assert!(res.is_none()); + assert!(hit); + } +} diff --git a/helix-event/src/lib.rs b/helix-event/src/lib.rs index de018a79ddca..8aa6b52fa2f9 100644 --- a/helix-event/src/lib.rs +++ b/helix-event/src/lib.rs @@ -32,7 +32,7 @@ //! to helix-view in the future if we manage to detach the compositor from its rendering backend. use anyhow::Result; -pub use cancel::{cancelable_future, cancelation, CancelRx, CancelTx}; +pub use cancel::{cancelable_future, TaskController, TaskHandle}; pub use debounce::{send_blocking, AsyncHook}; pub use redraw::{ lock_frame, redraw_requested, request_redraw, start_frame, RenderLockGuard, RequestRedrawOnDrop, diff --git a/helix-loader/Cargo.toml b/helix-loader/Cargo.toml index f74829f30787..b87a9184aa31 100644 --- a/helix-loader/Cargo.toml +++ b/helix-loader/Cargo.toml @@ -22,7 +22,7 @@ serde = { version = "1.0", features = ["derive"] } toml = "0.8" etcetera = "0.8" tree-sitter.workspace = true -once_cell = "1.19" +once_cell = "1.20" log = "0.4" # TODO: these two should be on !wasm32 only @@ -30,7 +30,7 @@ log = "0.4" # cloning/compiling tree-sitter grammars cc = { version = "1" } threadpool = { version = "1.0" } -tempfile = "3.12.0" +tempfile = "3.14.0" dunce = "1.0.5" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index f36c76c4fec4..0e7c134d013e 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -225,7 +225,7 @@ pub fn merge_toml_values(left: toml::Value, right: toml::Value, merge_depth: usi /// Used as a ceiling dir for LSP root resolution, the filepicker and potentially as a future filewatching root /// /// This function starts searching the FS upward from the CWD -/// and returns the first directory that contains either `.git`, `.svn` or `.helix`. +/// and returns the first directory that contains either `.git`, `.svn`, `.jj` or `.helix`. /// If no workspace was found returns (CWD, true). /// Otherwise (workspace, false) is returned pub fn find_workspace() -> (PathBuf, bool) { @@ -233,6 +233,7 @@ pub fn find_workspace() -> (PathBuf, bool) { for ancestor in current_dir.ancestors() { if ancestor.join(".git").exists() || ancestor.join(".svn").exists() + || ancestor.join(".jj").exists() || ancestor.join(".helix").exists() { return (ancestor.to_owned(), false); diff --git a/helix-lsp-types/Cargo.toml b/helix-lsp-types/Cargo.toml index 1ecb3d810cad..0460c10199a5 100644 --- a/helix-lsp-types/Cargo.toml +++ b/helix-lsp-types/Cargo.toml @@ -22,10 +22,10 @@ license = "MIT" [dependencies] bitflags = "2.6.0" -serde = { version = "1.0.209", features = ["derive"] } -serde_json = "1.0.127" +serde = { version = "1.0.215", features = ["derive"] } +serde_json = "1.0.133" serde_repr = "0.1" -url = {version = "2.0.0", features = ["serde"]} +url = {version = "2.5.4", features = ["serde"]} [features] default = [] diff --git a/helix-lsp-types/src/completion.rs b/helix-lsp-types/src/completion.rs index 2555228a7c22..7c006bdb62ad 100644 --- a/helix-lsp-types/src/completion.rs +++ b/helix-lsp-types/src/completion.rs @@ -497,7 +497,6 @@ pub struct CompletionItem { /// insertText is ignored. /// /// Most editors support two different operation when accepting a completion item. One is to insert a - /// completion text and the other is to replace an existing text with a completion text. Since this can /// usually not predetermined by a server it can report both ranges. Clients need to signal support for /// `InsertReplaceEdits` via the `textDocument.completion.insertReplaceSupport` client capability diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index 1522ca340534..5f22ede7b7bc 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -26,7 +26,7 @@ globset = "0.4.15" log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tokio = { version = "1.40", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } +tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } tokio-stream = "0.1.15" parking_lot = "0.12.3" arc-swap = "1" diff --git a/helix-lsp/src/jsonrpc.rs b/helix-lsp/src/jsonrpc.rs index f415dde0be4b..9ff57cde941f 100644 --- a/helix-lsp/src/jsonrpc.rs +++ b/helix-lsp/src/jsonrpc.rs @@ -137,7 +137,7 @@ impl Serialize for Version { struct VersionVisitor; -impl<'v> Visitor<'v> for VersionVisitor { +impl Visitor<'_> for VersionVisitor { type Value = Version; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { diff --git a/helix-parsec/src/lib.rs b/helix-parsec/src/lib.rs index 846d02d614fd..0ec44436fdbf 100644 --- a/helix-parsec/src/lib.rs +++ b/helix-parsec/src/lib.rs @@ -43,7 +43,7 @@ pub trait Parser<'a> { #[doc(hidden)] impl<'a, F, T> Parser<'a> for F where - F: Fn(&'a str) -> ParseResult, + F: Fn(&'a str) -> ParseResult<'a, T>, { type Output = T; diff --git a/helix-stdx/Cargo.toml b/helix-stdx/Cargo.toml index 1c0d06ab1249..84313b5b0e80 100644 --- a/helix-stdx/Cargo.toml +++ b/helix-stdx/Cargo.toml @@ -15,9 +15,11 @@ homepage.workspace = true dunce = "1.0" etcetera = "0.8" ropey = { version = "1.6.1", default-features = false } -which = "6.0" +which = "7.0" regex-cursor = "0.1.4" bitflags = "2.6" +once_cell = "1.19" +regex-automata = "0.4.9" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] } @@ -26,4 +28,4 @@ windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Securit rustix = { version = "0.38", features = ["fs"] } [dev-dependencies] -tempfile = "3.12" +tempfile = "3.14" diff --git a/helix-stdx/src/env.rs b/helix-stdx/src/env.rs index 51450d225870..6e14c7a875ae 100644 --- a/helix-stdx/src/env.rs +++ b/helix-stdx/src/env.rs @@ -1,9 +1,13 @@ use std::{ - ffi::OsStr, + borrow::Cow, + ffi::{OsStr, OsString}, path::{Path, PathBuf}, sync::RwLock, }; +use once_cell::sync::Lazy; + +// We keep the CWD as a static so that we can access it in places where we don't have access to the Editor static CWD: RwLock> = RwLock::new(None); // Get the current working directory. @@ -33,12 +37,12 @@ pub fn current_working_dir() -> PathBuf { cwd } -pub fn set_current_working_dir(path: impl AsRef) -> std::io::Result<()> { +pub fn set_current_working_dir(path: impl AsRef) -> std::io::Result> { let path = crate::path::canonicalize(path); std::env::set_current_dir(&path)?; let mut cwd = CWD.write().unwrap(); - *cwd = Some(path); - Ok(()) + + Ok(cwd.replace(path)) } pub fn env_var_is_set(env_var_name: &str) -> bool { @@ -59,6 +63,93 @@ pub fn which>( }) } +fn find_brace_end(src: &[u8]) -> Option { + use regex_automata::meta::Regex; + + static REGEX: Lazy = Lazy::new(|| Regex::builder().build("[{}]").unwrap()); + let mut depth = 0; + for mat in REGEX.find_iter(src) { + let pos = mat.start(); + match src[pos] { + b'{' => depth += 1, + b'}' if depth == 0 => return Some(pos), + b'}' => depth -= 1, + _ => unreachable!(), + } + } + None +} + +fn expand_impl(src: &OsStr, mut resolve: impl FnMut(&OsStr) -> Option) -> Cow { + use regex_automata::meta::Regex; + + static REGEX: Lazy = Lazy::new(|| { + Regex::builder() + .build_many(&[ + r"\$\{([^\}:]+):-", + r"\$\{([^\}:]+):=", + r"\$\{([^\}-]+)-", + r"\$\{([^\}=]+)=", + r"\$\{([^\}]+)", + r"\$(\w+)", + ]) + .unwrap() + }); + + let bytes = src.as_encoded_bytes(); + let mut res = Vec::with_capacity(bytes.len()); + let mut pos = 0; + for captures in REGEX.captures_iter(bytes) { + let mat = captures.get_match().unwrap(); + let pattern_id = mat.pattern().as_usize(); + let mut range = mat.range(); + let var = &bytes[captures.get_group(1).unwrap().range()]; + let default = if pattern_id != 5 { + let Some(bracket_pos) = find_brace_end(&bytes[range.end..]) else { + break; + }; + let default = &bytes[range.end..range.end + bracket_pos]; + range.end += bracket_pos + 1; + default + } else { + &[] + }; + // safety: this is a codepoint aligned substring of an osstr (always valid) + let var = unsafe { OsStr::from_encoded_bytes_unchecked(var) }; + let expansion = resolve(var); + let expansion = match &expansion { + Some(val) => { + if val.is_empty() && pattern_id < 2 { + default + } else { + val.as_encoded_bytes() + } + } + None => default, + }; + res.extend_from_slice(&bytes[pos..range.start]); + pos = range.end; + res.extend_from_slice(expansion); + } + if pos == 0 { + src.into() + } else { + res.extend_from_slice(&bytes[pos..]); + // safety: this is a composition of valid osstr (and codepoint aligned slices which are also valid) + unsafe { OsString::from_encoded_bytes_unchecked(res) }.into() + } +} + +/// performs substitution of enviorment variables. Supports the following (POSIX) syntax: +/// +/// * `$`, `${}` +/// * `${:-}`, `${-}` +/// * `${:=}`, `${=default}` +/// +pub fn expand + ?Sized>(src: &S) -> Cow { + expand_impl(src.as_ref(), |var| std::env::var_os(var)) +} + #[derive(Debug)] pub struct ExecutableNotFoundError { command: String, @@ -75,7 +166,9 @@ impl std::error::Error for ExecutableNotFoundError {} #[cfg(test)] mod tests { - use super::{current_working_dir, set_current_working_dir}; + use std::ffi::{OsStr, OsString}; + + use super::{current_working_dir, expand_impl, set_current_working_dir}; #[test] fn current_dir_is_set() { @@ -88,4 +181,34 @@ mod tests { let cwd = current_working_dir(); assert_eq!(cwd, new_path); } + + macro_rules! assert_env_expand { + ($env: expr, $lhs: expr, $rhs: expr) => { + assert_eq!(&*expand_impl($lhs.as_ref(), $env), OsStr::new($rhs)); + }; + } + + /// paths that should work on all platforms + #[test] + fn test_env_expand() { + let env = |var: &OsStr| -> Option { + match var.to_str().unwrap() { + "FOO" => Some("foo".into()), + "EMPTY" => Some("".into()), + _ => None, + } + }; + assert_env_expand!(env, "pass_trough", "pass_trough"); + assert_env_expand!(env, "$FOO", "foo"); + assert_env_expand!(env, "bar/$FOO/baz", "bar/foo/baz"); + assert_env_expand!(env, "bar/${FOO}/baz", "bar/foo/baz"); + assert_env_expand!(env, "baz/${BAR:-bar}/foo", "baz/bar/foo"); + assert_env_expand!(env, "baz/${BAR:=bar}/foo", "baz/bar/foo"); + assert_env_expand!(env, "baz/${BAR-bar}/foo", "baz/bar/foo"); + assert_env_expand!(env, "baz/${BAR=bar}/foo", "baz/bar/foo"); + assert_env_expand!(env, "baz/${EMPTY:-bar}/foo", "baz/bar/foo"); + assert_env_expand!(env, "baz/${EMPTY:=bar}/foo", "baz/bar/foo"); + assert_env_expand!(env, "baz/${EMPTY-bar}/foo", "baz//foo"); + assert_env_expand!(env, "baz/${EMPTY=bar}/foo", "baz//foo"); + } } diff --git a/helix-stdx/src/path.rs b/helix-stdx/src/path.rs index 968596a703fc..72b233cca90c 100644 --- a/helix-stdx/src/path.rs +++ b/helix-stdx/src/path.rs @@ -1,8 +1,12 @@ pub use etcetera::home_dir; +use once_cell::sync::Lazy; +use regex_cursor::{engines::meta::Regex, Input}; +use ropey::RopeSlice; use std::{ borrow::Cow, ffi::OsString, + ops::Range, path::{Component, Path, PathBuf, MAIN_SEPARATOR_STR}, }; @@ -51,7 +55,7 @@ where /// Normalize a path without resolving symlinks. // Strategy: start from the first component and move up. Cannonicalize previous path, -// join component, cannonicalize new path, strip prefix and join to the final result. +// join component, canonicalize new path, strip prefix and join to the final result. pub fn normalize(path: impl AsRef) -> PathBuf { let mut components = path.as_ref().components().peekable(); let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { @@ -201,6 +205,96 @@ pub fn get_truncated_path(path: impl AsRef) -> PathBuf { ret } +fn path_component_regex(windows: bool) -> String { + // TODO: support backslash path escape on windows (when using git bash for example) + let space_escape = if windows { r"[\^`]\s" } else { r"[\\]\s" }; + // partially baesd on what's allowed in an url but with some care to avoid + // false positivies (like any kind of brackets or quotes) + r"[\w@.\-+#$%?!,;~&]|".to_owned() + space_escape +} + +/// Regex for delimited environment captures like `${HOME}`. +fn braced_env_regex(windows: bool) -> String { + r"\$\{(?:".to_owned() + &path_component_regex(windows) + r"|[/:=])+\}" +} + +fn compile_path_regex( + prefix: &str, + postfix: &str, + match_single_file: bool, + windows: bool, +) -> Regex { + let first_component = format!( + "(?:{}|(?:{}))", + braced_env_regex(windows), + path_component_regex(windows) + ); + // For all components except the first we allow an equals so that `foo=/ + // bar/baz` does not include foo. This is primarily intended for url queries + // (where an equals is never in the first component) + let component = format!("(?:{first_component}|=)"); + let sep = if windows { r"[/\\]" } else { "/" }; + let url_prefix = r"[\w+\-.]+://??"; + let path_prefix = if windows { + // single slash handles most windows prefixes (like\\server\...) but `\ + // \?\C:\..` (and C:\) needs special handling, since we don't allow : in path + // components (so that colon separated paths and : work) + r"\\\\\?\\\w:|\w:|\\|" + } else { + "" + }; + let path_start = format!("(?:{first_component}+|~|{path_prefix}{url_prefix})"); + let optional = if match_single_file { + format!("|{path_start}") + } else { + String::new() + }; + let path_regex = format!( + "{prefix}(?:{path_start}?(?:(?:{sep}{component}+)+{sep}?|{sep}){optional}){postfix}" + ); + Regex::new(&path_regex).unwrap() +} + +/// If `src` ends with a path then this function returns the part of the slice. +pub fn get_path_suffix(src: RopeSlice<'_>, match_single_file: bool) -> Option> { + let regex = if match_single_file { + static REGEX: Lazy = Lazy::new(|| compile_path_regex("", "$", true, cfg!(windows))); + &*REGEX + } else { + static REGEX: Lazy = Lazy::new(|| compile_path_regex("", "$", false, cfg!(windows))); + &*REGEX + }; + + regex + .find(Input::new(src)) + .map(|mat| src.byte_slice(mat.range())) +} + +/// Returns an iterator of the **byte** ranges in src that contain a path. +pub fn find_paths( + src: RopeSlice<'_>, + match_single_file: bool, +) -> impl Iterator> + '_ { + let regex = if match_single_file { + static REGEX: Lazy = Lazy::new(|| compile_path_regex("", "", true, cfg!(windows))); + &*REGEX + } else { + static REGEX: Lazy = Lazy::new(|| compile_path_regex("", "", false, cfg!(windows))); + &*REGEX + }; + regex.find_iter(Input::new(src)).map(|mat| mat.range()) +} + +/// Performs substitution of `~` and environment variables, see [`env::expand`](crate::env::expand) and [`expand_tilde`] +pub fn expand + ?Sized>(path: &T) -> Cow<'_, Path> { + let path = path.as_ref(); + let path = expand_tilde(path); + match crate::env::expand(&*path) { + Cow::Borrowed(_) => path, + Cow::Owned(path) => PathBuf::from(path).into(), + } +} + #[cfg(test)] mod tests { use std::{ @@ -208,7 +302,10 @@ mod tests { path::{Component, Path}, }; - use crate::path; + use regex_cursor::Input; + use ropey::RopeSlice; + + use crate::path::{self, compile_path_regex}; #[test] fn expand_tilde() { @@ -228,4 +325,127 @@ mod tests { assert_ne!(component_count, 0); } } + + macro_rules! assert_match { + ($regex: expr, $haystack: expr) => { + let haystack = Input::new(RopeSlice::from($haystack)); + assert!( + $regex.is_match(haystack), + "regex should match {}", + $haystack + ); + }; + } + macro_rules! assert_no_match { + ($regex: expr, $haystack: expr) => { + let haystack = Input::new(RopeSlice::from($haystack)); + assert!( + !$regex.is_match(haystack), + "regex should not match {}", + $haystack + ); + }; + } + + macro_rules! assert_matches { + ($regex: expr, $haystack: expr, [$($matches: expr),*]) => { + let src = $haystack; + let matches: Vec<_> = $regex + .find_iter(Input::new(RopeSlice::from(src))) + .map(|it| &src[it.range()]) + .collect(); + assert_eq!(matches, vec![$($matches),*]); + }; + } + + /// Linux-only path + #[test] + fn path_regex_unix() { + // due to ambiguity with the `\` path separator we can't support space escapes `\ ` on windows + let regex = compile_path_regex("^", "$", false, false); + assert_match!(regex, "${FOO}/hello\\ world"); + assert_match!(regex, "${FOO}/\\ "); + } + + /// Windows-only paths + #[test] + fn path_regex_windows() { + let regex = compile_path_regex("^", "$", false, true); + assert_match!(regex, "${FOO}/hello^ world"); + assert_match!(regex, "${FOO}/hello` world"); + assert_match!(regex, "${FOO}/^ "); + assert_match!(regex, "${FOO}/` "); + assert_match!(regex, r"foo\bar"); + assert_match!(regex, r"foo\bar"); + assert_match!(regex, r"..\bar"); + assert_match!(regex, r"..\"); + assert_match!(regex, r"C:\"); + assert_match!(regex, r"\\?\C:\foo"); + assert_match!(regex, r"\\server\foo"); + } + + /// Paths that should work on all platforms + #[test] + fn path_regex() { + for windows in [false, true] { + let regex = compile_path_regex("^", "$", false, windows); + assert_no_match!(regex, "foo"); + assert_no_match!(regex, ""); + assert_match!(regex, "https://github.com/notifications/query=foo"); + assert_match!(regex, "file:///foo/bar"); + assert_match!(regex, "foo/bar"); + assert_match!(regex, "$HOME/foo"); + assert_match!(regex, "${FOO:-bar}/baz"); + assert_match!(regex, "foo/bar_"); + assert_match!(regex, "/home/bar"); + assert_match!(regex, "foo/"); + assert_match!(regex, "./"); + assert_match!(regex, "../"); + assert_match!(regex, "../.."); + assert_match!(regex, "./foo"); + assert_match!(regex, "./foo.rs"); + assert_match!(regex, "/"); + assert_match!(regex, "~/"); + assert_match!(regex, "~/foo"); + assert_match!(regex, "~/foo"); + assert_match!(regex, "~/foo/../baz"); + assert_match!(regex, "${HOME}/foo"); + assert_match!(regex, "$HOME/foo"); + assert_match!(regex, "/$FOO"); + assert_match!(regex, "/${FOO}"); + assert_match!(regex, "/${FOO}/${BAR}"); + assert_match!(regex, "/${FOO}/${BAR}/foo"); + assert_match!(regex, "/${FOO}/${BAR}"); + assert_match!(regex, "${FOO}/hello_$WORLD"); + assert_match!(regex, "${FOO}/hello_${WORLD}"); + let regex = compile_path_regex("", "", false, windows); + assert_no_match!(regex, ""); + assert_matches!( + regex, + r#"${FOO}/hello_${WORLD} ${FOO}/hello_${WORLD} foo("./bar", "/home/foo")""#, + [ + "${FOO}/hello_${WORLD}", + "${FOO}/hello_${WORLD}", + "./bar", + "/home/foo" + ] + ); + assert_matches!( + regex, + r#"--> helix-stdx/src/path.rs:427:13"#, + ["helix-stdx/src/path.rs"] + ); + assert_matches!( + regex, + r#"PATH=/foo/bar:/bar/baz:${foo:-/foo}/bar:${PATH}"#, + ["/foo/bar", "/bar/baz", "${foo:-/foo}/bar"] + ); + let regex = compile_path_regex("^", "$", true, windows); + assert_no_match!(regex, ""); + assert_match!(regex, "foo"); + assert_match!(regex, "foo/"); + assert_match!(regex, "$FOO"); + assert_match!(regex, "${BAR}"); + } + } } diff --git a/helix-stdx/src/rope.rs b/helix-stdx/src/rope.rs index 2695555e39d6..f7e31924abea 100644 --- a/helix-stdx/src/rope.rs +++ b/helix-stdx/src/rope.rs @@ -51,7 +51,7 @@ impl<'a> RopeSliceExt<'a> for RopeSlice<'a> { if len < text.len() { return false; } - self.get_byte_slice(..len - text.len()) + self.get_byte_slice(..text.len()) .map_or(false, |start| start == text) } @@ -137,4 +137,14 @@ mod tests { } } } + + #[test] + fn starts_with() { + assert!(RopeSlice::from("asdf").starts_with("a")); + } + + #[test] + fn ends_with() { + assert!(RopeSlice::from("asdf").ends_with("f")); + } } diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 12724ba4cf87..c0afbbc82d71 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -34,7 +34,7 @@ helix-loader = { path = "../helix-loader" } helix-parsec = { path = "../helix-parsec" } anyhow = "1" -once_cell = "1.19" +once_cell = "1.20" tokio = { version = "1", features = [ "rt", @@ -61,7 +61,7 @@ arc-swap = { version = "1.7.1" } termini = "1" # Logging -fern = "0.6" +fern = "0.7" chrono = { version = "0.4", default-features = false, features = ["clock"] } log = "0.4" @@ -72,11 +72,11 @@ ignore = "0.4" pulldown-cmark = { version = "0.12", default-features = false } # file type detection content_inspector = "0.2.4" -thiserror = "1.0" +thiserror.workspace = true # opening URLs -open = "5.3.0" -url = "2.5.2" +open = "5.3.1" +url = "2.5.4" # config toml = "0.8" @@ -90,7 +90,7 @@ grep-searcher = "0.1.14" [target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } -libc = "0.2.158" +libc = "0.2.167" [target.'cfg(target_os = "macos")'.dependencies] crossterm = { version = "0.28", features = [ @@ -105,5 +105,5 @@ helix-loader = { path = "../helix-loader" } [dev-dependencies] smallvec = "1.13" indoc = "2.0.5" -tempfile = "3.12.0" +tempfile = "3.14.0" same-file = "1.0.1" diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index bd6b5a870e3d..a567815fcaa6 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -846,7 +846,15 @@ impl Application { } } Notification::ShowMessage(params) => { - log::warn!("unhandled window/showMessage: {:?}", params); + if self.config.load().editor.lsp.display_messages { + match params.typ { + lsp::MessageType::ERROR => self.editor.set_error(params.message), + lsp::MessageType::WARNING => { + self.editor.set_warning(params.message) + } + _ => self.editor.set_status(params.message), + } + } } Notification::LogMessage(params) => { log::info!("window/logMessage: {:?}", params); @@ -930,7 +938,7 @@ impl Application { self.lsp_progress.update(server_id, token, work); } - if self.config.load().editor.lsp.display_messages { + if self.config.load().editor.lsp.display_progress_messages { self.editor.set_status(status); } } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index aa21e4d68d7c..c9417f97a26e 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -7,7 +7,7 @@ use futures_util::FutureExt; use helix_event::status; use helix_parsec::{seq, take_until, Parser}; use helix_stdx::{ - path::expand_tilde, + path::{self, find_paths}, rope::{self, RopeSliceExt}, }; use helix_vcs::{FileChange, Hunk}; @@ -23,8 +23,8 @@ use helix_core::{ encoding, find_workspace, graphemes::{self, next_grapheme_boundary, RevRopeGraphemes}, history::UndoKind, - increment, indent, - indent::IndentStyle, + increment, + indent::{self, IndentStyle}, line_ending::{get_line_ending_of_str, line_end_char_index}, match_brackets, movement::{self, move_vertically_visual, Direction}, @@ -101,7 +101,7 @@ pub struct Context<'a> { pub jobs: &'a mut Jobs, } -impl<'a> Context<'a> { +impl Context<'_> { /// Push a new component onto the compositor. pub fn push_layer(&mut self, component: Box) { self.callback @@ -356,6 +356,7 @@ impl MappableCommand { extend_search_next, "Add next search match to selection", extend_search_prev, "Add previous search match to selection", search_selection, "Use current selection as search pattern", + search_selection_detect_word_boundaries, "Use current selection as the search pattern, automatically wrapping with `\\b` on word boundaries", make_search_word_bounded, "Modify current search to make it word bounded", global_search, "Global search in workspace folder", extend_line, "Select current line, if already selected, extend to another line based on the anchor", @@ -1275,53 +1276,30 @@ fn goto_file_impl(cx: &mut Context, action: Action) { .unwrap_or_default(); let paths: Vec<_> = if selections.len() == 1 && primary.len() == 1 { - // Secial case: if there is only one one-width selection, try to detect the - // path under the cursor. - let is_valid_path_char = |c: &char| { - #[cfg(target_os = "windows")] - let valid_chars = &[ - '@', '/', '\\', '.', '-', '_', '+', '#', '$', '%', '{', '}', '[', ']', ':', '!', - '~', '=', - ]; - #[cfg(not(target_os = "windows"))] - let valid_chars = &['@', '/', '.', '-', '_', '+', '#', '$', '%', '~', '=', ':']; - - valid_chars.contains(c) || c.is_alphabetic() || c.is_numeric() - }; - - let cursor_pos = primary.cursor(text.slice(..)); - let pre_cursor_pos = cursor_pos.saturating_sub(1); - let post_cursor_pos = cursor_pos + 1; - let start_pos = if is_valid_path_char(&text.char(cursor_pos)) { - cursor_pos - } else if is_valid_path_char(&text.char(pre_cursor_pos)) { - pre_cursor_pos - } else { - post_cursor_pos - }; - - let prefix_len = text - .chars_at(start_pos) - .reversed() - .take_while(is_valid_path_char) - .count(); - - let postfix_len = text - .chars_at(start_pos) - .take_while(is_valid_path_char) - .count(); - - let path: String = text - .slice((start_pos - prefix_len)..(start_pos + postfix_len)) - .into(); - log::debug!("goto_file auto-detected path: {}", path); - - vec![path] + let mut pos = primary.cursor(text.slice(..)); + pos = text.char_to_byte(pos); + let search_start = text + .line_to_byte(text.byte_to_line(pos)) + .max(pos.saturating_sub(1000)); + let search_end = text + .line_to_byte(text.byte_to_line(pos) + 1) + .min(pos + 1000); + let search_range = text.slice(search_start..search_end); + // we also allow paths that are next to the cursor (can be ambigous but + // rarely so in practice) so that gf on quoted/braced path works (not sure about this + // but apparently that is how gf has worked historically in helix) + let path = find_paths(search_range, true) + .take_while(|range| search_start + range.start <= pos + 1) + .find(|range| pos <= search_start + range.end) + .map(|range| Cow::from(search_range.byte_slice(range))); + log::debug!("goto_file auto-detected path: {path:?}"); + let path = path.unwrap_or_else(|| primary.fragment(text.slice(..))); + vec![path.into_owned()] } else { // Otherwise use each selection, trimmed. selections .fragments(text.slice(..)) - .map(|sel| sel.trim().to_string()) + .map(|sel| sel.trim().to_owned()) .filter(|sel| !sel.is_empty()) .collect() }; @@ -1332,7 +1310,7 @@ fn goto_file_impl(cx: &mut Context, action: Action) { continue; } - let path = expand_tilde(Cow::from(PathBuf::from(sel))); + let path = path::expand(&sel); let path = &rel_path.join(path); if path.is_dir() { let picker = ui::file_picker(path.into(), &cx.editor.config()); @@ -2268,14 +2246,53 @@ fn extend_search_prev(cx: &mut Context) { } fn search_selection(cx: &mut Context) { + search_selection_impl(cx, false) +} + +fn search_selection_detect_word_boundaries(cx: &mut Context) { + search_selection_impl(cx, true) +} + +fn search_selection_impl(cx: &mut Context, detect_word_boundaries: bool) { + fn is_at_word_start(text: RopeSlice, index: usize) -> bool { + let ch = text.char(index); + if index == 0 { + return char_is_word(ch); + } + let prev_ch = text.char(index - 1); + + !char_is_word(prev_ch) && char_is_word(ch) + } + + fn is_at_word_end(text: RopeSlice, index: usize) -> bool { + if index == 0 || index == text.len_chars() { + return false; + } + let ch = text.char(index); + let prev_ch = text.char(index - 1); + + char_is_word(prev_ch) && !char_is_word(ch) + } + let register = cx.register.unwrap_or('/'); let (view, doc) = current!(cx.editor); - let contents = doc.text().slice(..); + let text = doc.text().slice(..); let regex = doc .selection(view.id) .iter() - .map(|selection| regex::escape(&selection.fragment(contents))) + .map(|selection| { + let add_boundary_prefix = + detect_word_boundaries && is_at_word_start(text, selection.from()); + let add_boundary_suffix = + detect_word_boundaries && is_at_word_end(text, selection.to()); + + let prefix = if add_boundary_prefix { "\\b" } else { "" }; + let suffix = if add_boundary_suffix { "\\b" } else { "" }; + + let word = regex::escape(&selection.fragment(text)); + format!("{}{}{}", prefix, word, suffix) + }) .collect::>() // Collect into hashset to deduplicate identical regexes .into_iter() .collect::>() @@ -2738,7 +2755,9 @@ fn delete_selection_impl(cx: &mut Context, op: Operation, yank: YankAction) { // yank the selection let text = doc.text().slice(..); let values: Vec = selection.fragments(text).map(Cow::into_owned).collect(); - let reg_name = cx.register.unwrap_or('"'); + let reg_name = cx + .register + .unwrap_or_else(|| cx.editor.config.load().default_yank_register); if let Err(err) = cx.editor.registers.write(reg_name, values) { cx.editor.set_error(err.to_string()); return; @@ -3470,31 +3489,51 @@ fn open(cx: &mut Context, open: Open) { ) }; - let indent = indent::indent_for_newline( - doc.language_config(), - doc.syntax(), - &doc.config.load().indent_heuristic, - &doc.indent_style, - doc.tab_width(), - text, - line_num, - line_end_index, - cursor_line, - ); + let continue_comment_token = doc + .language_config() + .and_then(|config| config.comment_tokens.as_ref()) + .and_then(|tokens| comment::get_comment_token(text, tokens, cursor_line)); + + let line = text.line(cursor_line); + let indent = match line.first_non_whitespace_char() { + Some(pos) if continue_comment_token.is_some() => line.slice(..pos).to_string(), + _ => indent::indent_for_newline( + doc.language_config(), + doc.syntax(), + &doc.config.load().indent_heuristic, + &doc.indent_style, + doc.tab_width(), + text, + line_num, + line_end_index, + cursor_line, + ), + }; let indent_len = indent.len(); let mut text = String::with_capacity(1 + indent_len); text.push_str(doc.line_ending.as_str()); text.push_str(&indent); + + if let Some(token) = continue_comment_token { + text.push_str(token); + text.push(' '); + } + let text = text.repeat(count); // calculate new selection ranges let pos = offs + line_end_index + line_end_offset_width; + let comment_len = continue_comment_token + .map(|token| token.len() + 1) // `+ 1` for the extra space added + .unwrap_or_default(); for i in 0..count { // pos -> beginning of reference line, - // + (i * (1+indent_len)) -> beginning of i'th line from pos - // + indent_len -> -> indent for i'th line - ranges.push(Range::point(pos + (i * (1 + indent_len)) + indent_len)); + // + (i * (1+indent_len + comment_len)) -> beginning of i'th line from pos (possibly including comment token) + // + indent_len + comment_len -> -> indent for i'th line + ranges.push(Range::point( + pos + (i * (1 + indent_len + comment_len)) + indent_len + comment_len, + )); } offs += text.chars().count(); @@ -3925,33 +3964,35 @@ pub mod insert { let curr = contents.get_char(pos).unwrap_or(' '); let current_line = text.char_to_line(pos); - let line_is_only_whitespace = text - .line(current_line) - .chars() - .all(|char| char.is_ascii_whitespace()); + let line_start = text.line_to_char(current_line); let mut new_text = String::new(); - // If the current line is all whitespace, insert a line ending at the beginning of - // the current line. This makes the current line empty and the new line contain the - // indentation of the old line. - let (from, to, local_offs) = if line_is_only_whitespace { - let line_start = text.line_to_char(current_line); - new_text.push_str(doc.line_ending.as_str()); + let continue_comment_token = doc + .language_config() + .and_then(|config| config.comment_tokens.as_ref()) + .and_then(|tokens| comment::get_comment_token(text, tokens, current_line)); - (line_start, line_start, new_text.chars().count()) - } else { - let indent = indent::indent_for_newline( - doc.language_config(), - doc.syntax(), - &doc.config.load().indent_heuristic, - &doc.indent_style, - doc.tab_width(), - text, - current_line, - pos, - current_line, - ); + let (from, to, local_offs) = if let Some(idx) = + text.slice(line_start..pos).last_non_whitespace_char() + { + let first_trailing_whitespace_char = (line_start + idx + 1).min(pos); + let line = text.line(current_line); + + let indent = match line.first_non_whitespace_char() { + Some(pos) if continue_comment_token.is_some() => line.slice(..pos).to_string(), + _ => indent::indent_for_newline( + doc.language_config(), + doc.syntax(), + &doc.config.load().indent_heuristic, + &doc.indent_style, + doc.tab_width(), + text, + current_line, + pos, + current_line, + ), + }; // If we are between pairs (such as brackets), we want to // insert an additional line which is indented one level @@ -3961,36 +4002,61 @@ pub mod insert { .and_then(|pairs| pairs.get(prev)) .map_or(false, |pair| pair.open == prev && pair.close == curr); - let local_offs = if on_auto_pair { + let local_offs = if let Some(token) = continue_comment_token { + new_text.push_str(doc.line_ending.as_str()); + new_text.push_str(&indent); + new_text.push_str(token); + new_text.push(' '); + new_text.chars().count() + } else if on_auto_pair { + // line where the cursor will be let inner_indent = indent.clone() + doc.indent_style.as_str(); new_text.reserve_exact(2 + indent.len() + inner_indent.len()); new_text.push_str(doc.line_ending.as_str()); new_text.push_str(&inner_indent); + + // line where the matching pair will be let local_offs = new_text.chars().count(); new_text.push_str(doc.line_ending.as_str()); new_text.push_str(&indent); + local_offs } else { new_text.reserve_exact(1 + indent.len()); new_text.push_str(doc.line_ending.as_str()); new_text.push_str(&indent); + new_text.chars().count() }; - (pos, pos, local_offs) + ( + first_trailing_whitespace_char, + pos, + // Note that `first_trailing_whitespace_char` is at least `pos` so the + // unsigned subtraction (`pos - first_trailing_whitespace_char`) cannot + // underflow. + local_offs as isize - (pos - first_trailing_whitespace_char) as isize, + ) + } else { + // If the current line is all whitespace, insert a line ending at the beginning of + // the current line. This makes the current line empty and the new line contain the + // indentation of the old line. + new_text.push_str(doc.line_ending.as_str()); + + (line_start, line_start, new_text.chars().count() as isize) }; let new_range = if range.cursor(text) > range.anchor { // when appending, extend the range by local_offs Range::new( range.anchor + global_offs, - range.head + local_offs + global_offs, + (range.head as isize + local_offs) as usize + global_offs, ) } else { // when inserting, slide the range by local_offs Range::new( - range.anchor + local_offs + global_offs, - range.head + local_offs + global_offs, + (range.anchor as isize + local_offs) as usize + global_offs, + (range.head as isize + local_offs) as usize + global_offs, ) }; @@ -4183,7 +4249,11 @@ fn commit_undo_checkpoint(cx: &mut Context) { // Yank / Paste fn yank(cx: &mut Context) { - yank_impl(cx.editor, cx.register.unwrap_or('"')); + yank_impl( + cx.editor, + cx.register + .unwrap_or(cx.editor.config().default_yank_register), + ); exit_select_mode(cx); } @@ -4244,7 +4314,12 @@ fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { fn yank_joined(cx: &mut Context) { let separator = doc!(cx.editor).line_ending.as_str(); - yank_joined_impl(cx.editor, separator, cx.register.unwrap_or('"')); + yank_joined_impl( + cx.editor, + separator, + cx.register + .unwrap_or(cx.editor.config().default_yank_register), + ); exit_select_mode(cx); } @@ -4301,6 +4376,10 @@ fn paste_impl( return; } + if mode == Mode::Insert { + doc.append_changes_to_history(view); + } + let repeat = std::iter::repeat( // `values` is asserted to have at least one entry above. values @@ -4400,7 +4479,12 @@ fn paste_primary_clipboard_before(cx: &mut Context) { } fn replace_with_yanked(cx: &mut Context) { - replace_with_yanked_impl(cx.editor, cx.register.unwrap_or('"'), cx.count()); + replace_with_yanked_impl( + cx.editor, + cx.register + .unwrap_or(cx.editor.config().default_yank_register), + cx.count(), + ); exit_select_mode(cx); } @@ -4463,7 +4547,8 @@ fn paste(editor: &mut Editor, register: char, pos: Paste, count: usize) { fn paste_after(cx: &mut Context) { paste( cx.editor, - cx.register.unwrap_or('"'), + cx.register + .unwrap_or(cx.editor.config().default_yank_register), Paste::After, cx.count(), ); @@ -4473,7 +4558,8 @@ fn paste_after(cx: &mut Context) { fn paste_before(cx: &mut Context) { paste( cx.editor, - cx.register.unwrap_or('"'), + cx.register + .unwrap_or(cx.editor.config().default_yank_register), Paste::Before, cx.count(), ); @@ -4629,6 +4715,14 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) { let text = doc.text(); let slice = text.slice(..); + let comment_tokens = doc + .language_config() + .and_then(|config| config.comment_tokens.as_deref()) + .unwrap_or(&[]); + // Sort by length to handle Rust's /// vs // + let mut comment_tokens: Vec<&str> = comment_tokens.iter().map(|x| x.as_str()).collect(); + comment_tokens.sort_unstable_by_key(|x| std::cmp::Reverse(x.len())); + let mut changes = Vec::new(); for selection in doc.selection(view.id) { @@ -4640,10 +4734,31 @@ fn join_selections_impl(cx: &mut Context, select_space: bool) { changes.reserve(lines.len()); + let first_line_idx = slice.line_to_char(start); + let first_line_idx = skip_while(slice, first_line_idx, |ch| matches!(ch, ' ' | 't')) + .unwrap_or(first_line_idx); + let first_line = slice.slice(first_line_idx..); + let mut current_comment_token = comment_tokens + .iter() + .find(|token| first_line.starts_with(token)); + for line in lines { let start = line_end_char_index(&slice, line); let mut end = text.line_to_char(line + 1); end = skip_while(slice, end, |ch| matches!(ch, ' ' | '\t')).unwrap_or(end); + let slice_from_end = slice.slice(end..); + if let Some(token) = comment_tokens + .iter() + .find(|token| slice_from_end.starts_with(token)) + { + if Some(token) == current_comment_token { + end += token.chars().count(); + end = skip_while(slice, end, |ch| matches!(ch, ' ' | '\t')).unwrap_or(end); + } else { + // update current token, but don't delete this one. + current_comment_token = Some(token); + } + } let separator = if end == line_end_char_index(&slice, line + 1) { // the joining line contains only space-characters => don't include a whitespace when joining @@ -5298,7 +5413,8 @@ fn insert_register(cx: &mut Context) { cx.register = Some(ch); paste( cx.editor, - cx.register.unwrap_or('"'), + cx.register + .unwrap_or(cx.editor.config().default_yank_register), Paste::Cursor, cx.count(), ); diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 93ac2a849f79..fcc0333e8cd8 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -34,7 +34,7 @@ use crate::{ use std::{ cmp::Ordering, collections::{BTreeMap, HashSet}, - fmt::{Display, Write}, + fmt::Display, future::Future, path::Path, }; @@ -61,10 +61,31 @@ macro_rules! language_server_with_feature { }}; } +/// A wrapper around `lsp::Location` that swaps out the LSP URI for `helix_core::Uri`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Location { + uri: Uri, + range: lsp::Range, +} + +fn lsp_location_to_location(location: lsp::Location) -> Option { + let uri = match location.uri.try_into() { + Ok(uri) => uri, + Err(err) => { + log::warn!("discarding invalid or unsupported URI: {err}"); + return None; + } + }; + Some(Location { + uri, + range: location.range, + }) +} + struct SymbolInformationItem { + location: Location, symbol: lsp::SymbolInformation, offset_encoding: OffsetEncoding, - uri: Uri, } struct DiagnosticStyles { @@ -75,35 +96,35 @@ struct DiagnosticStyles { } struct PickerDiagnostic { - uri: Uri, + location: Location, diag: lsp::Diagnostic, offset_encoding: OffsetEncoding, } -fn uri_to_file_location<'a>(uri: &'a Uri, range: &lsp::Range) -> Option> { - let path = uri.as_path()?; - let line = Some((range.start.line as usize, range.end.line as usize)); +fn location_to_file_location(location: &Location) -> Option { + let path = location.uri.as_path()?; + let line = Some(( + location.range.start.line as usize, + location.range.end.line as usize, + )); Some((path.into(), line)) } fn jump_to_location( editor: &mut Editor, - location: &lsp::Location, + location: &Location, offset_encoding: OffsetEncoding, action: Action, ) { let (view, doc) = current!(editor); push_jump(view, doc); - let path = match location.uri.to_file_path() { - Ok(path) => path, - Err(_) => { - let err = format!("unable to convert URI to filepath: {}", location.uri); - editor.set_error(err); - return; - } + let Some(path) = location.uri.as_path() else { + let err = format!("unable to convert URI to filepath: {:?}", location.uri); + editor.set_error(err); + return; }; - jump_to_position(editor, &path, location.range, offset_encoding, action); + jump_to_position(editor, path, location.range, offset_encoding, action); } fn jump_to_position( @@ -196,7 +217,10 @@ fn diag_picker( for (diag, ls) in diags { if let Some(ls) = cx.editor.language_server_by_id(ls) { flat_diag.push(PickerDiagnostic { - uri: uri.clone(), + location: Location { + uri: uri.clone(), + range: diag.range, + }, diag, offset_encoding: ls.offset_encoding(), }); @@ -243,7 +267,7 @@ fn diag_picker( // between message code and message 2, ui::PickerColumn::new("path", |item: &PickerDiagnostic, _| { - if let Some(path) = item.uri.as_path() { + if let Some(path) = item.location.uri.as_path() { path::get_truncated_path(path) .to_string_lossy() .to_string() @@ -261,26 +285,14 @@ fn diag_picker( primary_column, flat_diag, styles, - move |cx, - PickerDiagnostic { - uri, - diag, - offset_encoding, - }, - action| { - let Some(path) = uri.as_path() else { - return; - }; - jump_to_position(cx.editor, path, diag.range, *offset_encoding, action); + move |cx, diag, action| { + jump_to_location(cx.editor, &diag.location, diag.offset_encoding, action); let (view, doc) = current!(cx.editor); view.diagnostics_handler .immediately_show_diagnostic(doc, view.id); }, ) - .with_preview(move |_editor, PickerDiagnostic { uri, diag, .. }| { - let line = Some((diag.range.start.line as usize, diag.range.end.line as usize)); - Some((uri.as_path()?.into(), line)) - }) + .with_preview(move |_editor, diag| location_to_file_location(&diag.location)) .truncate_start(false) } @@ -303,7 +315,10 @@ pub fn symbol_picker(cx: &mut Context) { container_name: None, }, offset_encoding, - uri: uri.clone(), + location: Location { + uri: uri.clone(), + range: symbol.selection_range, + }, }); for child in symbol.children.into_iter().flatten() { nested_to_flat(list, file, uri, child, offset_encoding); @@ -337,7 +352,10 @@ pub fn symbol_picker(cx: &mut Context) { lsp::DocumentSymbolResponse::Flat(symbols) => symbols .into_iter() .map(|symbol| SymbolInformationItem { - uri: doc_uri.clone(), + location: Location { + uri: doc_uri.clone(), + range: symbol.location.range, + }, symbol, offset_encoding, }) @@ -392,17 +410,10 @@ pub fn symbol_picker(cx: &mut Context) { symbols, (), move |cx, item, action| { - jump_to_location( - cx.editor, - &item.symbol.location, - item.offset_encoding, - action, - ); + jump_to_location(cx.editor, &item.location, item.offset_encoding, action); }, ) - .with_preview(move |_editor, item| { - uri_to_file_location(&item.uri, &item.symbol.location.range) - }) + .with_preview(move |_editor, item| location_to_file_location(&item.location)) .truncate_start(false); compositor.push(Box::new(overlaid(picker))) @@ -453,8 +464,11 @@ pub fn workspace_symbol_picker(cx: &mut Context) { } }; Some(SymbolInformationItem { + location: Location { + uri, + range: symbol.location.range, + }, symbol, - uri, offset_encoding, }) }) @@ -490,7 +504,7 @@ pub fn workspace_symbol_picker(cx: &mut Context) { }) .without_filtering(), ui::PickerColumn::new("path", |item: &SymbolInformationItem, _| { - if let Some(path) = item.uri.as_path() { + if let Some(path) = item.location.uri.as_path() { path::get_relative_path(path) .to_string_lossy() .to_string() @@ -507,15 +521,10 @@ pub fn workspace_symbol_picker(cx: &mut Context) { [], (), move |cx, item, action| { - jump_to_location( - cx.editor, - &item.symbol.location, - item.offset_encoding, - action, - ); + jump_to_location(cx.editor, &item.location, item.offset_encoding, action); }, ) - .with_preview(|_editor, item| uri_to_file_location(&item.uri, &item.symbol.location.range)) + .with_preview(|_editor, item| location_to_file_location(&item.location)) .with_dynamic_query(get_symbols, None) .truncate_start(false); @@ -847,7 +856,7 @@ impl Display for ApplyEditErrorKind { fn goto_impl( editor: &mut Editor, compositor: &mut Compositor, - locations: Vec, + locations: Vec, offset_encoding: OffsetEncoding, ) { let cwdir = helix_stdx::env::current_working_dir(); @@ -860,80 +869,41 @@ fn goto_impl( _locations => { let columns = [ui::PickerColumn::new( "location", - |item: &lsp::Location, cwdir: &std::path::PathBuf| { - // The preallocation here will overallocate a few characters since it will account for the - // URL's scheme, which is not used most of the time since that scheme will be "file://". - // Those extra chars will be used to avoid allocating when writing the line number (in the - // common case where it has 5 digits or less, which should be enough for a cast majority - // of usages). - let mut res = String::with_capacity(item.uri.as_str().len()); - - if item.uri.scheme() == "file" { - // With the preallocation above and UTF-8 paths already, this closure will do one (1) - // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`. - if let Ok(path) = item.uri.to_file_path() { - // We don't convert to a `helix_core::Uri` here because we've already checked the scheme. - // This path won't be normalized but it's only used for display. - res.push_str( - &path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy(), - ); - } + |item: &Location, cwdir: &std::path::PathBuf| { + let path = if let Some(path) = item.uri.as_path() { + path.strip_prefix(cwdir).unwrap_or(path).to_string_lossy() } else { - // Never allocates since we declared the string with this capacity already. - res.push_str(item.uri.as_str()); - } + item.uri.to_string().into() + }; - // Most commonly, this will not allocate, especially on Unix systems where the root prefix - // is a simple `/` and not `C:\` (with whatever drive letter) - write!(&mut res, ":{}", item.range.start.line + 1) - .expect("Will only failed if allocating fail"); - res.into() + format!("{path}:{}", item.range.start.line + 1).into() }, )]; let picker = Picker::new(columns, 0, locations, cwdir, move |cx, location, action| { jump_to_location(cx.editor, location, offset_encoding, action) }) - .with_preview(move |_editor, location| { - use crate::ui::picker::PathOrId; - - let lines = Some(( - location.range.start.line as usize, - location.range.end.line as usize, - )); - - // TODO: we should avoid allocating by doing the Uri conversion ahead of time. - // - // To do this, introduce a `Location` type in `helix-core` that reuses the core - // `Uri` type instead of the LSP `Url` type and replaces the LSP `Range` type. - // Refactor the callers of `goto_impl` to pass iterators that translate the - // LSP location type to the custom one in core, or have them collect and pass - // `Vec`s. Replace the `uri_to_file_location` function with - // `location_to_file_location` that takes only `&helix_core::Location` as - // parameters. - // - // By doing this we can also eliminate the duplicated URI info in the - // `SymbolInformationItem` type and introduce a custom Symbol type in `helix-core` - // which will be reused in the future for tree-sitter based symbol pickers. - let path = Uri::try_from(&location.uri).ok()?.as_path_buf()?; - #[allow(deprecated)] - Some((PathOrId::from_path_buf(path), lines)) - }); + .with_preview(move |_editor, location| location_to_file_location(location)); compositor.push(Box::new(overlaid(picker))); } } } -fn to_locations(definitions: Option) -> Vec { +fn to_locations(definitions: Option) -> Vec { match definitions { - Some(lsp::GotoDefinitionResponse::Scalar(location)) => vec![location], - Some(lsp::GotoDefinitionResponse::Array(locations)) => locations, + Some(lsp::GotoDefinitionResponse::Scalar(location)) => { + lsp_location_to_location(location).into_iter().collect() + } + Some(lsp::GotoDefinitionResponse::Array(locations)) => locations + .into_iter() + .flat_map(lsp_location_to_location) + .collect(), Some(lsp::GotoDefinitionResponse::Link(locations)) => locations .into_iter() - .map(|location_link| lsp::Location { - uri: location_link.target_uri, - range: location_link.target_range, + .map(|location_link| { + lsp::Location::new(location_link.target_uri, location_link.target_range) }) + .flat_map(lsp_location_to_location) .collect(), None => Vec::new(), } @@ -1018,7 +988,11 @@ pub fn goto_reference(cx: &mut Context) { cx.callback( future, move |editor, compositor, response: Option>| { - let items = response.unwrap_or_default(); + let items: Vec = response + .into_iter() + .flatten() + .flat_map(lsp_location_to_location) + .collect(); if items.is_empty() { editor.set_error("No references found."); } else { diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 41404e2d843b..f9183ea4016f 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -9,6 +9,7 @@ use super::*; use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; use helix_core::{line_ending, shellwords::Shellwords}; +use helix_stdx::path::home_dir; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::editor::{CloseError, ConfigEvent}; use serde_json::Value; @@ -615,13 +616,15 @@ fn format( } let (view, doc) = current!(cx.editor); - if let Some(format) = doc.format() { - let callback = make_format_callback(doc.id(), doc.version(), view.id, format, None); - cx.jobs.callback(callback); - } + let format = doc.format().context( + "A formatter isn't available, and no language server provides formatting capabilities", + )?; + let callback = make_format_callback(doc.id(), doc.version(), view.id, format, None); + cx.jobs.callback(callback); Ok(()) } + fn set_indent_style( cx: &mut compositor::Context, args: &[Cow], @@ -1233,7 +1236,7 @@ fn show_clipboard_provider( } cx.editor - .set_status(cx.editor.registers.clipboard_provider_name().to_string()); + .set_status(cx.editor.registers.clipboard_provider_name()); Ok(()) } @@ -1246,18 +1249,23 @@ fn change_current_directory( return Ok(()); } - let dir = args - .first() - .context("target directory not provided")? - .as_ref(); - let dir = helix_stdx::path::expand_tilde(Path::new(dir)); + let dir = match args.first().map(AsRef::as_ref) { + Some("-") => cx + .editor + .last_cwd + .clone() + .ok_or(anyhow!("No previous working directory"))?, + Some(input_path) => helix_stdx::path::expand_tilde(Path::new(input_path)).to_path_buf(), + None => home_dir()?, + }; - helix_stdx::env::set_current_working_dir(dir)?; + cx.editor.last_cwd = helix_stdx::env::set_current_working_dir(dir)?; cx.editor.set_status(format!( "Current working directory is now {}", helix_stdx::env::current_working_dir().display() )); + Ok(()) } @@ -2809,7 +2817,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "format", aliases: &["fmt"], - doc: "Format the file using the LSP formatter.", + doc: "Format the file using an external formatter or language server.", fun: format, signature: CommandSignature::none(), }, @@ -3205,7 +3213,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "tree-sitter-subtree", aliases: &["ts-subtree"], - doc: "Display tree sitter subtree under cursor, primarily for debugging queries.", + doc: "Display the smallest tree-sitter subtree that spans the primary selection, primarily for debugging queries.", fun: tree_sitter_subtree, signature: CommandSignature::none(), }, diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 3dcb5f2bfcbc..a57fd6177c47 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -27,7 +27,7 @@ pub struct Context<'a> { pub jobs: &'a mut Jobs, } -impl<'a> Context<'a> { +impl Context<'_> { /// Waits on all pending jobs, and then tries to flush all pending write /// operations for all documents. pub fn block_try_flush_writes(&mut self) -> anyhow::Result<()> { diff --git a/helix-term/src/handlers/completion.rs b/helix-term/src/handlers/completion.rs index 68956c85f504..f3223487c6ca 100644 --- a/helix-term/src/handlers/completion.rs +++ b/helix-term/src/handlers/completion.rs @@ -4,20 +4,20 @@ use std::time::Duration; use arc_swap::ArcSwap; use futures_util::stream::FuturesUnordered; +use futures_util::FutureExt; use helix_core::chars::char_is_word; use helix_core::syntax::LanguageServerFeature; -use helix_event::{ - cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx, -}; +use helix_event::{cancelable_future, register_hook, send_blocking, TaskController, TaskHandle}; use helix_lsp::lsp; use helix_lsp::util::pos_to_lsp_pos; use helix_stdx::rope::RopeSliceExt; use helix_view::document::{Mode, SavePoint}; use helix_view::handlers::lsp::CompletionEvent; use helix_view::{DocumentId, Editor, ViewId}; +use path::path_completion; use tokio::sync::mpsc::Sender; use tokio::time::Instant; -use tokio_stream::StreamExt; +use tokio_stream::StreamExt as _; use crate::commands; use crate::compositor::Compositor; @@ -27,10 +27,13 @@ use crate::job::{dispatch, dispatch_blocking}; use crate::keymap::MappableCommand; use crate::ui::editor::InsertEvent; use crate::ui::lsp::SignatureHelp; -use crate::ui::{self, CompletionItem, Popup}; +use crate::ui::{self, Popup}; use super::Handlers; +pub use item::{CompletionItem, LspCompletionItem}; pub use resolve::ResolveHandler; +mod item; +mod path; mod resolve; #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -53,12 +56,8 @@ pub(super) struct CompletionHandler { /// currently active trigger which will cause a /// completion request after the timeout trigger: Option, - /// A handle for currently active completion request. - /// This can be used to determine whether the current - /// request is still active (and new triggers should be - /// ignored) and can also be used to abort the current - /// request (by dropping the handle) - request: Option, + in_flight: Option, + task_controller: TaskController, config: Arc>, } @@ -66,8 +65,9 @@ impl CompletionHandler { pub fn new(config: Arc>) -> CompletionHandler { Self { config, - request: None, + task_controller: TaskController::new(), trigger: None, + in_flight: None, } } } @@ -80,6 +80,9 @@ impl helix_event::AsyncHook for CompletionHandler { event: Self::Event, _old_timeout: Option, ) -> Option { + if self.in_flight.is_some() && !self.task_controller.is_running() { + self.in_flight = None; + } match event { CompletionEvent::AutoTrigger { cursor: trigger_pos, @@ -90,7 +93,7 @@ impl helix_event::AsyncHook for CompletionHandler { // but people may create weird keymaps/use the mouse so lets be extra careful if self .trigger - .as_ref() + .or(self.in_flight) .map_or(true, |trigger| trigger.doc != doc || trigger.view != view) { self.trigger = Some(Trigger { @@ -103,7 +106,7 @@ impl helix_event::AsyncHook for CompletionHandler { } CompletionEvent::TriggerChar { cursor, doc, view } => { // immediately request completions and drop all auto completion requests - self.request = None; + self.task_controller.cancel(); self.trigger = Some(Trigger { pos: cursor, view, @@ -113,7 +116,6 @@ impl helix_event::AsyncHook for CompletionHandler { } CompletionEvent::ManualTrigger { cursor, doc, view } => { // immediately request completions and drop all auto completion requests - self.request = None; self.trigger = Some(Trigger { pos: cursor, view, @@ -126,21 +128,21 @@ impl helix_event::AsyncHook for CompletionHandler { } CompletionEvent::Cancel => { self.trigger = None; - self.request = None; + self.task_controller.cancel(); } CompletionEvent::DeleteText { cursor } => { // if we deleted the original trigger, abort the completion - if matches!(self.trigger, Some(Trigger{ pos, .. }) if cursor < pos) { + if matches!(self.trigger.or(self.in_flight), Some(Trigger{ pos, .. }) if cursor < pos) + { self.trigger = None; - self.request = None; + self.task_controller.cancel(); } } } self.trigger.map(|trigger| { // if the current request was closed forget about it // otherwise immediately restart the completion request - let cancel = self.request.take().map_or(false, |req| !req.is_closed()); - let timeout = if trigger.kind == TriggerKind::Auto && !cancel { + let timeout = if trigger.kind == TriggerKind::Auto { self.config.load().editor.completion_timeout } else { // we want almost instant completions for trigger chars @@ -155,17 +157,17 @@ impl helix_event::AsyncHook for CompletionHandler { fn finish_debounce(&mut self) { let trigger = self.trigger.take().expect("debounce always has a trigger"); - let (tx, rx) = cancelation(); - self.request = Some(tx); + self.in_flight = Some(trigger); + let handle = self.task_controller.restart(); dispatch_blocking(move |editor, compositor| { - request_completion(trigger, rx, editor, compositor) + request_completion(trigger, handle, editor, compositor) }); } } fn request_completion( mut trigger: Trigger, - cancel: CancelRx, + handle: TaskHandle, editor: &mut Editor, compositor: &mut Compositor, ) { @@ -251,15 +253,19 @@ fn request_completion( None => Vec::new(), } .into_iter() - .map(|item| CompletionItem { - item, - provider: language_server_id, - resolved: false, + .map(|item| { + CompletionItem::Lsp(LspCompletionItem { + item, + provider: language_server_id, + resolved: false, + }) }) .collect(); anyhow::Ok(items) } + .boxed() }) + .chain(path_completion(cursor, text.clone(), doc, handle.clone())) .collect(); let future = async move { @@ -280,12 +286,13 @@ fn request_completion( let ui = compositor.find::().unwrap(); ui.last_insert.1.push(InsertEvent::RequestCompletion); tokio::spawn(async move { - let items = cancelable_future(future, cancel).await.unwrap_or_default(); - if items.is_empty() { + let items = cancelable_future(future, &handle).await; + let Some(items) = items.filter(|items| !items.is_empty()) else { return; - } + }; dispatch(move |editor, compositor| { - show_completion(editor, compositor, items, trigger, savepoint) + show_completion(editor, compositor, items, trigger, savepoint); + drop(handle) }) .await }); @@ -346,7 +353,17 @@ pub fn trigger_auto_completion( .. }) if triggers.iter().any(|trigger| text.ends_with(trigger))) }); - if is_trigger_char { + + let cursor_char = text + .get_bytes_at(text.len_bytes()) + .and_then(|t| t.reversed().next()); + + #[cfg(windows)] + let is_path_completion_trigger = matches!(cursor_char, Some(b'/' | b'\\')); + #[cfg(not(windows))] + let is_path_completion_trigger = matches!(cursor_char, Some(b'/')); + + if is_trigger_char || (is_path_completion_trigger && doc.path_completion_enabled()) { send_blocking( tx, CompletionEvent::TriggerChar { diff --git a/helix-term/src/handlers/completion/item.rs b/helix-term/src/handlers/completion/item.rs new file mode 100644 index 000000000000..bcd35cd5411e --- /dev/null +++ b/helix-term/src/handlers/completion/item.rs @@ -0,0 +1,41 @@ +use helix_lsp::{lsp, LanguageServerId}; + +#[derive(Debug, PartialEq, Clone)] +pub struct LspCompletionItem { + pub item: lsp::CompletionItem, + pub provider: LanguageServerId, + pub resolved: bool, +} + +#[derive(Debug, PartialEq, Clone)] +pub enum CompletionItem { + Lsp(LspCompletionItem), + Other(helix_core::CompletionItem), +} + +impl PartialEq for LspCompletionItem { + fn eq(&self, other: &CompletionItem) -> bool { + match other { + CompletionItem::Lsp(other) => self == other, + _ => false, + } + } +} + +impl PartialEq for helix_core::CompletionItem { + fn eq(&self, other: &CompletionItem) -> bool { + match other { + CompletionItem::Other(other) => self == other, + _ => false, + } + } +} + +impl CompletionItem { + pub fn preselect(&self) -> bool { + match self { + CompletionItem::Lsp(LspCompletionItem { item, .. }) => item.preselect.unwrap_or(false), + CompletionItem::Other(_) => false, + } + } +} diff --git a/helix-term/src/handlers/completion/path.rs b/helix-term/src/handlers/completion/path.rs new file mode 100644 index 000000000000..e92be51cfa78 --- /dev/null +++ b/helix-term/src/handlers/completion/path.rs @@ -0,0 +1,189 @@ +use std::{ + borrow::Cow, + fs, + path::{Path, PathBuf}, + str::FromStr as _, +}; + +use futures_util::{future::BoxFuture, FutureExt as _}; +use helix_core as core; +use helix_core::Transaction; +use helix_event::TaskHandle; +use helix_stdx::path::{self, canonicalize, fold_home_dir, get_path_suffix}; +use helix_view::Document; +use url::Url; + +use super::item::CompletionItem; + +pub(crate) fn path_completion( + cursor: usize, + text: core::Rope, + doc: &Document, + handle: TaskHandle, +) -> Option>>> { + if !doc.path_completion_enabled() { + return None; + } + + let cur_line = text.char_to_line(cursor); + let start = text.line_to_char(cur_line).max(cursor.saturating_sub(1000)); + let line_until_cursor = text.slice(start..cursor); + + let (dir_path, typed_file_name) = + get_path_suffix(line_until_cursor, false).and_then(|matched_path| { + let matched_path = Cow::from(matched_path); + let path: Cow<_> = if matched_path.starts_with("file://") { + Url::from_str(&matched_path) + .ok() + .and_then(|url| url.to_file_path().ok())? + .into() + } else { + Path::new(&*matched_path).into() + }; + let path = path::expand(&path); + let parent_dir = doc.path().and_then(|dp| dp.parent()); + let path = match parent_dir { + Some(parent_dir) if path.is_relative() => parent_dir.join(&path), + _ => path.into_owned(), + }; + #[cfg(windows)] + let ends_with_slash = matches!(matched_path.as_bytes().last(), Some(b'/' | b'\\')); + #[cfg(not(windows))] + let ends_with_slash = matches!(matched_path.as_bytes().last(), Some(b'/')); + + if ends_with_slash { + Some((PathBuf::from(path.as_path()), None)) + } else { + path.parent().map(|parent_path| { + ( + PathBuf::from(parent_path), + path.file_name().and_then(|f| f.to_str().map(String::from)), + ) + }) + } + })?; + + if handle.is_canceled() { + return None; + } + + let future = tokio::task::spawn_blocking(move || { + let Ok(read_dir) = std::fs::read_dir(&dir_path) else { + return Vec::new(); + }; + + read_dir + .filter_map(Result::ok) + .filter_map(|dir_entry| { + dir_entry + .metadata() + .ok() + .and_then(|md| Some((dir_entry.file_name().into_string().ok()?, md))) + }) + .map_while(|(file_name, md)| { + if handle.is_canceled() { + return None; + } + + let kind = path_kind(&md); + let documentation = path_documentation(&md, &dir_path.join(&file_name), kind); + + let edit_diff = typed_file_name + .as_ref() + .map(|f| f.len()) + .unwrap_or_default(); + + let transaction = Transaction::change( + &text, + std::iter::once((cursor - edit_diff, cursor, Some((&file_name).into()))), + ); + + Some(CompletionItem::Other(core::CompletionItem { + kind: Cow::Borrowed(kind), + label: file_name.into(), + transaction, + documentation, + })) + }) + .collect::>() + }); + + Some(async move { Ok(future.await?) }.boxed()) +} + +#[cfg(unix)] +fn path_documentation(md: &fs::Metadata, full_path: &Path, kind: &str) -> String { + let full_path = fold_home_dir(canonicalize(full_path)); + let full_path_name = full_path.to_string_lossy(); + + use std::os::unix::prelude::PermissionsExt; + let mode = md.permissions().mode(); + + let perms = [ + (libc::S_IRUSR, 'r'), + (libc::S_IWUSR, 'w'), + (libc::S_IXUSR, 'x'), + (libc::S_IRGRP, 'r'), + (libc::S_IWGRP, 'w'), + (libc::S_IXGRP, 'x'), + (libc::S_IROTH, 'r'), + (libc::S_IWOTH, 'w'), + (libc::S_IXOTH, 'x'), + ] + .into_iter() + .fold(String::with_capacity(9), |mut acc, (p, s)| { + // This cast is necessary on some platforms such as macos as `mode_t` is u16 there + #[allow(clippy::unnecessary_cast)] + acc.push(if mode & (p as u32) > 0 { s } else { '-' }); + acc + }); + + // TODO it would be great to be able to individually color the documentation, + // but this will likely require a custom doc implementation (i.e. not `lsp::Documentation`) + // and/or different rendering in completion.rs + format!( + "type: `{kind}`\n\ + permissions: `[{perms}]`\n\ + full path: `{full_path_name}`", + ) +} + +#[cfg(not(unix))] +fn path_documentation(_md: &fs::Metadata, full_path: &Path, kind: &str) -> String { + let full_path = fold_home_dir(canonicalize(full_path)); + let full_path_name = full_path.to_string_lossy(); + format!("type: `{kind}`\nfull path: `{full_path_name}`",) +} + +#[cfg(unix)] +fn path_kind(md: &fs::Metadata) -> &'static str { + if md.is_symlink() { + "link" + } else if md.is_dir() { + "folder" + } else { + use std::os::unix::fs::FileTypeExt; + if md.file_type().is_block_device() { + "block" + } else if md.file_type().is_socket() { + "socket" + } else if md.file_type().is_char_device() { + "char_device" + } else if md.file_type().is_fifo() { + "fifo" + } else { + "file" + } + } +} + +#[cfg(not(unix))] +fn path_kind(md: &fs::Metadata) -> &'static str { + if md.is_symlink() { + "link" + } else if md.is_dir() { + "folder" + } else { + "file" + } +} diff --git a/helix-term/src/handlers/completion/resolve.rs b/helix-term/src/handlers/completion/resolve.rs index 0b2c90672f51..802d6f51d81c 100644 --- a/helix-term/src/handlers/completion/resolve.rs +++ b/helix-term/src/handlers/completion/resolve.rs @@ -4,9 +4,10 @@ use helix_lsp::lsp; use tokio::sync::mpsc::Sender; use tokio::time::{Duration, Instant}; -use helix_event::{send_blocking, AsyncHook, CancelRx}; +use helix_event::{send_blocking, AsyncHook, TaskController, TaskHandle}; use helix_view::Editor; +use super::LspCompletionItem; use crate::handlers::completion::CompletionItem; use crate::job; @@ -22,7 +23,7 @@ use crate::job; /// > 'completionItem/resolve' request is sent with the selected completion item as a parameter. /// > The returned completion item should have the documentation property filled in. pub struct ResolveHandler { - last_request: Option>, + last_request: Option>, resolver: Sender, } @@ -30,15 +31,11 @@ impl ResolveHandler { pub fn new() -> ResolveHandler { ResolveHandler { last_request: None, - resolver: ResolveTimeout { - next_request: None, - in_flight: None, - } - .spawn(), + resolver: ResolveTimeout::default().spawn(), } } - pub fn ensure_item_resolved(&mut self, editor: &mut Editor, item: &mut CompletionItem) { + pub fn ensure_item_resolved(&mut self, editor: &mut Editor, item: &mut LspCompletionItem) { if item.resolved { return; } @@ -93,14 +90,15 @@ impl ResolveHandler { } struct ResolveRequest { - item: Arc, + item: Arc, ls: Arc, } #[derive(Default)] struct ResolveTimeout { next_request: Option, - in_flight: Option<(helix_event::CancelTx, Arc)>, + in_flight: Option>, + task_controller: TaskController, } impl AsyncHook for ResolveTimeout { @@ -120,7 +118,7 @@ impl AsyncHook for ResolveTimeout { } else if self .in_flight .as_ref() - .is_some_and(|(_, old_request)| old_request.item == request.item.item) + .is_some_and(|old_request| old_request.item == request.item.item) { self.next_request = None; None @@ -134,14 +132,14 @@ impl AsyncHook for ResolveTimeout { let Some(request) = self.next_request.take() else { return; }; - let (tx, rx) = helix_event::cancelation(); - self.in_flight = Some((tx, request.item.clone())); - tokio::spawn(request.execute(rx)); + let token = self.task_controller.restart(); + self.in_flight = Some(request.item.clone()); + tokio::spawn(request.execute(token)); } } impl ResolveRequest { - async fn execute(self, cancel: CancelRx) { + async fn execute(self, cancel: TaskHandle) { let future = self.ls.resolve_completion_item(&self.item.item); let Some(resolved_item) = helix_event::cancelable_future(future, cancel).await else { return; @@ -152,8 +150,8 @@ impl ResolveRequest { .unwrap() .completion { - let resolved_item = match resolved_item { - Ok(item) => CompletionItem { + let resolved_item = CompletionItem::Lsp(match resolved_item { + Ok(item) => LspCompletionItem { item, resolved: true, ..*self.item @@ -166,8 +164,8 @@ impl ResolveRequest { item.resolved = true; item } - }; - completion.replace_item(&self.item, resolved_item); + }); + completion.replace_item(&*self.item, resolved_item); }; }) .await diff --git a/helix-term/src/handlers/signature_help.rs b/helix-term/src/handlers/signature_help.rs index aaa97b9a058d..e4f7e935a7cd 100644 --- a/helix-term/src/handlers/signature_help.rs +++ b/helix-term/src/handlers/signature_help.rs @@ -2,9 +2,7 @@ use std::sync::Arc; use std::time::Duration; use helix_core::syntax::LanguageServerFeature; -use helix_event::{ - cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx, -}; +use helix_event::{cancelable_future, register_hook, send_blocking, TaskController, TaskHandle}; use helix_lsp::lsp::{self, SignatureInformation}; use helix_stdx::rope::RopeSliceExt; use helix_view::document::Mode; @@ -22,11 +20,11 @@ use crate::ui::lsp::{Signature, SignatureHelp}; use crate::ui::Popup; use crate::{job, ui}; -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] enum State { Open, Closed, - Pending { request: CancelTx }, + Pending, } /// debounce timeout in ms, value taken from VSCode @@ -37,6 +35,7 @@ const TIMEOUT: u64 = 120; pub(super) struct SignatureHelpHandler { trigger: Option, state: State, + task_controller: TaskController, } impl SignatureHelpHandler { @@ -44,6 +43,7 @@ impl SignatureHelpHandler { SignatureHelpHandler { trigger: None, state: State::Closed, + task_controller: TaskController::new(), } } } @@ -76,12 +76,11 @@ impl helix_event::AsyncHook for SignatureHelpHandler { } SignatureHelpEvent::RequestComplete { open } => { // don't cancel rerequest that was already triggered - if let State::Pending { request } = &self.state { - if !request.is_closed() { - return timeout; - } + if self.state == State::Pending && self.task_controller.is_running() { + return timeout; } self.state = if open { State::Open } else { State::Closed }; + self.task_controller.cancel(); return timeout; } @@ -94,16 +93,16 @@ impl helix_event::AsyncHook for SignatureHelpHandler { fn finish_debounce(&mut self) { let invocation = self.trigger.take().unwrap(); - let (tx, rx) = cancelation(); - self.state = State::Pending { request: tx }; - job::dispatch_blocking(move |editor, _| request_signature_help(editor, invocation, rx)) + self.state = State::Pending; + let handle = self.task_controller.restart(); + job::dispatch_blocking(move |editor, _| request_signature_help(editor, invocation, handle)) } } pub fn request_signature_help( editor: &mut Editor, invoked: SignatureHelpInvoked, - cancel: CancelRx, + cancel: TaskHandle, ) { let (view, doc) = current!(editor); diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs index 0bbb5735ca69..54789b8997a6 100644 --- a/helix-term/src/health.rs +++ b/helix-term/src/health.rs @@ -1,10 +1,10 @@ +use crate::config::{Config, ConfigLoadError}; use crossterm::{ style::{Color, Print, Stylize}, tty::IsTty, }; use helix_core::config::{default_lang_config, user_lang_config}; use helix_loader::grammar::load_runtime_file; -use helix_view::clipboard::get_clipboard_provider; use std::io::Write; #[derive(Copy, Clone)] @@ -53,7 +53,6 @@ pub fn general() -> std::io::Result<()> { let lang_file = helix_loader::lang_config_file(); let log_file = helix_loader::log_file(); let rt_dirs = helix_loader::runtime_dirs(); - let clipboard_provider = get_clipboard_provider(); if config_file.exists() { writeln!(stdout, "Config file: {}", config_file.display())?; @@ -92,7 +91,6 @@ pub fn general() -> std::io::Result<()> { writeln!(stdout, "{}", msg.yellow())?; } } - writeln!(stdout, "Clipboard provider: {}", clipboard_provider.name())?; Ok(()) } @@ -101,8 +99,19 @@ pub fn clipboard() -> std::io::Result<()> { let stdout = std::io::stdout(); let mut stdout = stdout.lock(); - let board = get_clipboard_provider(); - match board.name().as_ref() { + let config = match Config::load_default() { + Ok(config) => config, + Err(ConfigLoadError::Error(err)) if err.kind() == std::io::ErrorKind::NotFound => { + Config::default() + } + Err(err) => { + writeln!(stdout, "{}", "Configuration file malformed".red())?; + writeln!(stdout, "{}", err)?; + return Ok(()); + } + }; + + match config.editor.clipboard_provider.name().as_ref() { "none" => { writeln!( stdout, @@ -145,7 +154,7 @@ pub fn languages_all() -> std::io::Result<()> { } }; - let mut headings = vec!["Language", "LSP", "DAP", "Formatter"]; + let mut headings = vec!["Language", "Language servers", "Debug adapter", "Formatter"]; for feat in TsFeature::all() { headings.push(feat.short_title()) diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index b9656a64dce6..a4ccddd279c9 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -140,7 +140,8 @@ pub fn default() -> HashMap { "?" => rsearch, "n" => search_next, "N" => search_prev, - "*" => search_selection, + "*" => search_selection_detect_word_boundaries, + "A-*" => search_selection, "u" => undo, "U" => redo, diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 14397bb5c4c7..cb0af6fc638a 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,6 +1,9 @@ use crate::{ compositor::{Component, Context, Event, EventResult}, - handlers::{completion::ResolveHandler, trigger_auto_completion}, + handlers::{ + completion::{CompletionItem, LspCompletionItem, ResolveHandler}, + trigger_auto_completion, + }, }; use helix_view::{ document::SavePoint, @@ -13,12 +16,12 @@ use tui::{buffer::Buffer as Surface, text::Span}; use std::{borrow::Cow, sync::Arc}; -use helix_core::{chars, Change, Transaction}; +use helix_core::{self as core, chars, Change, Transaction}; use helix_view::{graphics::Rect, Document, Editor}; use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent}; -use helix_lsp::{lsp, util, LanguageServerId, OffsetEncoding}; +use helix_lsp::{lsp, util, OffsetEncoding}; impl menu::Item for CompletionItem { type Data = (); @@ -28,30 +31,35 @@ impl menu::Item for CompletionItem { #[inline] fn filter_text(&self, _data: &Self::Data) -> Cow { - self.item - .filter_text - .as_ref() - .unwrap_or(&self.item.label) - .as_str() - .into() + match self { + CompletionItem::Lsp(LspCompletionItem { item, .. }) => item + .filter_text + .as_ref() + .unwrap_or(&item.label) + .as_str() + .into(), + CompletionItem::Other(core::CompletionItem { label, .. }) => label.clone(), + } } fn format(&self, _data: &Self::Data) -> menu::Row { - let deprecated = self.item.deprecated.unwrap_or_default() - || self.item.tags.as_ref().map_or(false, |tags| { - tags.contains(&lsp::CompletionItemTag::DEPRECATED) - }); + let deprecated = match self { + CompletionItem::Lsp(LspCompletionItem { item, .. }) => { + item.deprecated.unwrap_or_default() + || item.tags.as_ref().map_or(false, |tags| { + tags.contains(&lsp::CompletionItemTag::DEPRECATED) + }) + } + CompletionItem::Other(_) => false, + }; - menu::Row::new(vec![ - menu::Cell::from(Span::styled( - self.item.label.as_str(), - if deprecated { - Style::default().add_modifier(Modifier::CROSSED_OUT) - } else { - Style::default() - }, - )), - menu::Cell::from(match self.item.kind { + let label = match self { + CompletionItem::Lsp(LspCompletionItem { item, .. }) => item.label.as_str(), + CompletionItem::Other(core::CompletionItem { label, .. }) => label, + }; + + let kind = match self { + CompletionItem::Lsp(LspCompletionItem { item, .. }) => match item.kind { Some(lsp::CompletionItemKind::TEXT) => "text", Some(lsp::CompletionItemKind::METHOD) => "method", Some(lsp::CompletionItemKind::FUNCTION) => "function", @@ -82,18 +90,24 @@ impl menu::Item for CompletionItem { "" } None => "", - }), + }, + CompletionItem::Other(core::CompletionItem { kind, .. }) => kind, + }; + + menu::Row::new([ + menu::Cell::from(Span::styled( + label, + if deprecated { + Style::default().add_modifier(Modifier::CROSSED_OUT) + } else { + Style::default() + }, + )), + menu::Cell::from(kind), ]) } } -#[derive(Debug, PartialEq, Default, Clone)] -pub struct CompletionItem { - pub item: lsp::CompletionItem, - pub provider: LanguageServerId, - pub resolved: bool, -} - /// Wraps a Menu. pub struct Completion { popup: Popup>, @@ -115,11 +129,11 @@ impl Completion { let preview_completion_insert = editor.config().preview_completion_insert; let replace_mode = editor.config().completion_replace; // Sort completion items according to their preselect status (given by the LSP server) - items.sort_by_key(|item| !item.item.preselect.unwrap_or(false)); + items.sort_by_key(|item| !item.preselect()); // Then create the menu let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| { - fn item_to_transaction( + fn lsp_item_to_transaction( doc: &Document, view_id: ViewId, item: &lsp::CompletionItem, @@ -257,16 +271,23 @@ impl Completion { // always present here let item = item.unwrap(); - let transaction = item_to_transaction( - doc, - view.id, - &item.item, - language_server!(item).offset_encoding(), - trigger_offset, - true, - replace_mode, - ); - doc.apply_temporary(&transaction, view.id); + match item { + CompletionItem::Lsp(item) => doc.apply_temporary( + &lsp_item_to_transaction( + doc, + view.id, + &item.item, + language_server!(item).offset_encoding(), + trigger_offset, + true, + replace_mode, + ), + view.id, + ), + CompletionItem::Other(core::CompletionItem { transaction, .. }) => { + doc.apply_temporary(transaction, view.id) + } + }; } PromptEvent::Update => {} PromptEvent::Validate => { @@ -275,32 +296,46 @@ impl Completion { { doc.restore(view, &savepoint, false); } - // always present here - let mut item = item.unwrap().clone(); - - let language_server = language_server!(item); - let offset_encoding = language_server.offset_encoding(); - if !item.resolved { - if let Some(resolved) = - Self::resolve_completion_item(language_server, item.item.clone()) - { - item.item = resolved; - } - }; // if more text was entered, remove it doc.restore(view, &savepoint, true); // save an undo checkpoint before the completion doc.append_changes_to_history(view); - let transaction = item_to_transaction( - doc, - view.id, - &item.item, - offset_encoding, - trigger_offset, - false, - replace_mode, - ); + + // item always present here + let (transaction, additional_edits) = match item.unwrap().clone() { + CompletionItem::Lsp(mut item) => { + let language_server = language_server!(item); + + // resolve item if not yet resolved + if !item.resolved { + if let Some(resolved_item) = Self::resolve_completion_item( + language_server, + item.item.clone(), + ) { + item.item = resolved_item; + } + }; + + let encoding = language_server.offset_encoding(); + let transaction = lsp_item_to_transaction( + doc, + view.id, + &item.item, + encoding, + trigger_offset, + false, + replace_mode, + ); + let add_edits = item.item.additional_text_edits; + + (transaction, add_edits.map(|edits| (edits, encoding))) + } + CompletionItem::Other(core::CompletionItem { transaction, .. }) => { + (transaction, None) + } + }; + doc.apply(&transaction, view.id); editor.last_completion = Some(CompleteAction::Applied { @@ -309,7 +344,7 @@ impl Completion { }); // TODO: add additional _edits to completion_changes? - if let Some(additional_edits) = item.item.additional_text_edits { + if let Some((additional_edits, offset_encoding)) = additional_edits { if !additional_edits.is_empty() { let transaction = util::generate_transaction_from_edits( doc.text(), @@ -414,7 +449,11 @@ impl Completion { self.popup.contents().is_empty() } - pub fn replace_item(&mut self, old_item: &CompletionItem, new_item: CompletionItem) { + pub fn replace_item( + &mut self, + old_item: &impl PartialEq, + new_item: CompletionItem, + ) { self.popup.contents_mut().replace_option(old_item, new_item); } @@ -440,7 +479,7 @@ impl Component for Completion { Some(option) => option, None => return, }; - if !option.resolved { + if let CompletionItem::Lsp(option) = option { self.resolve_handler.ensure_item_resolved(cx.editor, option); } // need to render: @@ -465,27 +504,32 @@ impl Component for Completion { Markdown::new(md, cx.editor.syn_loader.clone()) }; - let mut markdown_doc = match &option.item.documentation { - Some(lsp::Documentation::String(contents)) - | Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { - kind: lsp::MarkupKind::PlainText, - value: contents, - })) => { - // TODO: convert to wrapped text - markdowned(language, option.item.detail.as_deref(), Some(contents)) - } - Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { - kind: lsp::MarkupKind::Markdown, - value: contents, - })) => { - // TODO: set language based on doc scope - markdowned(language, option.item.detail.as_deref(), Some(contents)) - } - None if option.item.detail.is_some() => { - // TODO: set language based on doc scope - markdowned(language, option.item.detail.as_deref(), None) + let mut markdown_doc = match option { + CompletionItem::Lsp(option) => match &option.item.documentation { + Some(lsp::Documentation::String(contents)) + | Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { + kind: lsp::MarkupKind::PlainText, + value: contents, + })) => { + // TODO: convert to wrapped text + markdowned(language, option.item.detail.as_deref(), Some(contents)) + } + Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: contents, + })) => { + // TODO: set language based on doc scope + markdowned(language, option.item.detail.as_deref(), Some(contents)) + } + None if option.item.detail.is_some() => { + // TODO: set language based on doc scope + markdowned(language, option.item.detail.as_deref(), None) + } + None => return, + }, + CompletionItem::Other(option) => { + markdowned(language, None, Some(&option.documentation)) } - None => return, }; let popup_area = self.popup.area(area, cx.editor); diff --git a/helix-term/src/ui/document.rs b/helix-term/src/ui/document.rs index 79145ba04773..d1a74e7e2bc4 100644 --- a/helix-term/src/ui/document.rs +++ b/helix-term/src/ui/document.rs @@ -433,7 +433,7 @@ impl<'a> TextRenderer<'a> { Grapheme::Newline => &self.newline, }; - let in_bounds = self.column_in_bounds(position.col + width - 1); + let in_bounds = self.column_in_bounds(position.col, width); if in_bounds { self.surface.set_string( @@ -452,7 +452,6 @@ impl<'a> TextRenderer<'a> { ); self.surface.set_style(rect, style); } - if *is_in_indent_area && !is_whitespace { *last_indent_level = position.col; *is_in_indent_area = false; @@ -461,8 +460,8 @@ impl<'a> TextRenderer<'a> { width } - pub fn column_in_bounds(&self, colum: usize) -> bool { - self.offset.col <= colum && colum < self.viewport.width as usize + self.offset.col + pub fn column_in_bounds(&self, colum: usize, width: usize) -> bool { + self.offset.col <= colum && colum + width <= self.offset.col + self.viewport.width as usize } /// Overlay indentation guides ontop of a rendered line @@ -523,8 +522,6 @@ impl<'a> TextRenderer<'a> { self.surface.set_style(area, style); } - /// Sets the style of an area **within the text viewport* this accounts - /// both for the renderers vertical offset and its viewport #[allow(clippy::too_many_arguments)] pub fn set_string_truncated( &mut self, diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index f7541fe25750..5179be4f4e1c 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -2,13 +2,14 @@ use crate::{ commands::{self, OnKeyCallback}, compositor::{Component, Context, Event, EventResult}, events::{OnModeSwitch, PostCommand}, + handlers::completion::CompletionItem, key, keymap::{KeymapResult, Keymaps}, ui::{ document::{render_document, LinePos, TextRenderer}, statusline, text_decorations::{self, Decoration, DecorationManager, InlineDiagnostics}, - Completion, CompletionItem, ProgressSpinners, + Completion, ProgressSpinners, }, }; diff --git a/helix-term/src/ui/lsp.rs b/helix-term/src/ui/lsp.rs index b6491085b2ed..4886263055c2 100644 --- a/helix-term/src/ui/lsp.rs +++ b/helix-term/src/ui/lsp.rs @@ -96,7 +96,10 @@ impl Component for SignatureHelp { fn render(&mut self, area: Rect, surface: &mut Buffer, cx: &mut Context) { let margin = Margin::horizontal(1); - let signature = &self.signatures[self.active_signature]; + let signature = self + .signatures + .get(self.active_signature) + .unwrap_or_else(|| &self.signatures[0]); let active_param_span = signature.active_param_range.map(|(start, end)| { vec![( @@ -108,9 +111,13 @@ impl Component for SignatureHelp { )] }); - let sig = &self.signatures[self.active_signature]; + let signature = self + .signatures + .get(self.active_signature) + .unwrap_or_else(|| &self.signatures[0]); + let sig_text = crate::ui::markdown::highlighted_code_block( - sig.signature.as_str(), + signature.signature.as_str(), &self.language, Some(&cx.editor.theme), Arc::clone(&self.config_loader), @@ -130,7 +137,7 @@ impl Component for SignatureHelp { let sig_text_para = Paragraph::new(&sig_text).wrap(Wrap { trim: false }); sig_text_para.render(sig_text_area, surface); - if sig.signature_doc.is_none() { + if signature.signature_doc.is_none() { return; } @@ -142,7 +149,7 @@ impl Component for SignatureHelp { } } - let sig_doc = match &sig.signature_doc { + let sig_doc = match &signature.signature_doc { None => return, Some(doc) => Markdown::new(doc.clone(), Arc::clone(&self.config_loader)), }; @@ -160,12 +167,15 @@ impl Component for SignatureHelp { const PADDING: u16 = 2; const SEPARATOR_HEIGHT: u16 = 1; - let sig = &self.signatures[self.active_signature]; + let signature = self + .signatures + .get(self.active_signature) + .unwrap_or_else(|| &self.signatures[0]); let max_text_width = viewport.0.saturating_sub(PADDING).clamp(10, 120); let signature_text = crate::ui::markdown::highlighted_code_block( - sig.signature.as_str(), + signature.signature.as_str(), &self.language, None, Arc::clone(&self.config_loader), @@ -174,7 +184,7 @@ impl Component for SignatureHelp { let (sig_width, sig_height) = crate::ui::text::required_size(&signature_text, max_text_width); - let (width, height) = match sig.signature_doc { + let (width, height) = match signature.signature_doc { Some(ref doc) => { let doc_md = Markdown::new(doc.clone(), Arc::clone(&self.config_loader)); let doc_text = doc_md.parse(None); diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index c120d0b25cf3..612832ce1221 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -228,7 +228,7 @@ impl Menu { } impl Menu { - pub fn replace_option(&mut self, old_option: &T, new_option: T) { + pub fn replace_option(&mut self, old_option: &impl PartialEq, new_option: T) { for option in &mut self.options { if old_option == option { *option = new_option; @@ -346,10 +346,6 @@ impl Component for Menu { let win_height = area.height as usize; - const fn div_ceil(a: usize, b: usize) -> usize { - (a + b - 1) / b - } - let rows = options .iter() .map(|option| option.format(&self.editor_data)); @@ -390,7 +386,7 @@ impl Component for Menu { let scroll_style = theme.get("ui.menu.scroll"); if !fits { - let scroll_height = div_ceil(win_height.pow(2), len).min(win_height); + let scroll_height = win_height.pow(2).div_ceil(len).min(win_height); let scroll_line = (win_height - scroll_height) * scroll / std::cmp::max(1, len.saturating_sub(win_height)); diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 6a3e198c1051..ab9b5392bace 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -17,7 +17,7 @@ mod text_decorations; use crate::compositor::Compositor; use crate::filter_picker_entry; use crate::job::{self, Callback}; -pub use completion::{Completion, CompletionItem}; +pub use completion::Completion; pub use editor::EditorView; use helix_stdx::rope; pub use markdown::Markdown; diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 82fe9689164d..df8d52ebd2f8 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -32,7 +32,7 @@ use std::{ borrow::Cow, collections::HashMap, io::Read, - path::{Path, PathBuf}, + path::Path, sync::{ atomic::{self, AtomicUsize}, Arc, @@ -63,30 +63,16 @@ pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024; #[derive(PartialEq, Eq, Hash)] pub enum PathOrId<'a> { Id(DocumentId), - // See [PathOrId::from_path_buf]: this will eventually become `Path(&Path)`. - Path(Cow<'a, Path>), -} - -impl<'a> PathOrId<'a> { - /// Creates a [PathOrId] from a PathBuf - /// - /// # Deprecated - /// The owned version of PathOrId will be removed in a future refactor - /// and replaced with `&'a Path`. See the caller of this function for - /// more details on its removal. - #[deprecated] - pub fn from_path_buf(path_buf: PathBuf) -> Self { - Self::Path(Cow::Owned(path_buf)) - } + Path(&'a Path), } impl<'a> From<&'a Path> for PathOrId<'a> { fn from(path: &'a Path) -> Self { - Self::Path(Cow::Borrowed(path)) + Self::Path(path) } } -impl<'a> From for PathOrId<'a> { +impl From for PathOrId<'_> { fn from(v: DocumentId) -> Self { Self::Id(v) } @@ -581,7 +567,6 @@ impl Picker { match path_or_id { PathOrId::Path(path) => { - let path = path.as_ref(); if let Some(doc) = editor.document_by_path(path) { return Some((Preview::EditorDocument(doc), range)); } diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index 2cefaf61b58f..db77492db8d6 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -344,12 +344,8 @@ impl Component for Popup { let fits = len <= win_height; let scroll_style = cx.editor.theme.get("ui.menu.scroll"); - const fn div_ceil(a: usize, b: usize) -> usize { - (a + b - 1) / b - } - if !fits { - let scroll_height = div_ceil(win_height.pow(2), len).min(win_height); + let scroll_height = win_height.pow(2).div_ceil(len).min(win_height); let scroll_line = (win_height - scroll_height) * scroll / std::cmp::max(1, len.saturating_sub(win_height)); diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 6ba2fcb9e251..1e443ce7ff0e 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -415,7 +415,8 @@ impl Prompt { let cols = std::cmp::max(1, area.width / max_len); let col_width = (area.width.saturating_sub(cols)) / cols; - let height = ((self.completion.len() as u16 + cols - 1) / cols) + let height = (self.completion.len() as u16) + .div_ceil(cols) .min(10) // at most 10 rows (or less) .min(area.height.saturating_sub(1)); diff --git a/helix-term/src/ui/text_decorations.rs b/helix-term/src/ui/text_decorations.rs index 630af5817661..931ea431178c 100644 --- a/helix-term/src/ui/text_decorations.rs +++ b/helix-term/src/ui/text_decorations.rs @@ -164,7 +164,7 @@ impl Decoration for Cursor<'_> { renderer: &mut TextRenderer, grapheme: &FormattedGrapheme, ) -> usize { - if renderer.column_in_bounds(grapheme.visual_pos.col) + if renderer.column_in_bounds(grapheme.visual_pos.col, grapheme.width()) && renderer.offset.row < grapheme.visual_pos.row { let position = grapheme.visual_pos - renderer.offset; diff --git a/helix-term/src/ui/text_decorations/diagnostics.rs b/helix-term/src/ui/text_decorations/diagnostics.rs index 2d9e8370089d..fb82bcf54c23 100644 --- a/helix-term/src/ui/text_decorations/diagnostics.rs +++ b/helix-term/src/ui/text_decorations/diagnostics.rs @@ -98,20 +98,29 @@ impl Renderer<'_, '_> { fn draw_eol_diagnostic(&mut self, diag: &Diagnostic, row: u16, col: usize) -> u16 { let style = self.styles.severity_style(diag.severity()); let width = self.renderer.viewport.width; - if !self.renderer.column_in_bounds(col + 1) { - return 0; + let start_col = (col - self.renderer.offset.col) as u16; + let mut end_col = start_col; + let mut draw_col = (col + 1) as u16; + + for line in diag.message.lines() { + if !self.renderer.column_in_bounds(draw_col as usize, 1) { + break; + } + + (end_col, _) = self.renderer.set_string_truncated( + self.renderer.viewport.x + draw_col, + row, + line, + width.saturating_sub(draw_col) as usize, + |_| style, + true, + false, + ); + + draw_col = end_col - self.renderer.viewport.x + 2; // double space between lines } - let col = (col - self.renderer.offset.col) as u16; - let (new_col, _) = self.renderer.set_string_truncated( - self.renderer.viewport.x + col + 1, - row, - &diag.message, - width.saturating_sub(col + 1) as usize, - |_| style, - true, - false, - ); - new_col - col + + end_col - start_col } fn draw_diagnostic(&mut self, diag: &Diagnostic, col: u16, next_severity: Option) { diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index 9f196827faf3..32badaa415fe 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -2,6 +2,7 @@ use helix_term::application::Application; use super::*; +mod insert; mod movement; mod write; @@ -632,6 +633,41 @@ async fn test_join_selections_space() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn test_join_selections_comment() -> anyhow::Result<()> { + test(( + indoc! {"\ + /// #[a|]#bc + /// def + "}, + ":lang rustJ", + indoc! {"\ + /// #[a|]#bc def + "}, + )) + .await?; + + // Only join if the comment token matches the previous line. + test(( + indoc! {"\ + #[| // a + // b + /// c + /// d + e + /// f + // g]# + "}, + ":lang rustJ", + indoc! {"\ + #[| // a b /// c d e f // g]# + "}, + )) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn test_read_file() -> anyhow::Result<()> { let mut file = tempfile::NamedTempFile::new()?; diff --git a/helix-term/tests/test/commands/insert.rs b/helix-term/tests/test/commands/insert.rs new file mode 100644 index 000000000000..ac2d179aebdb --- /dev/null +++ b/helix-term/tests/test/commands/insert.rs @@ -0,0 +1,121 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread")] +async fn insert_newline_trim_trailing_whitespace() -> anyhow::Result<()> { + // Trailing whitespace is trimmed. + test(( + indoc! {"\ + hello·······#[| + ]#world + "} + .replace('·', " "), + "i", + indoc! {"\ + hello + #[| + ]#world + "} + .replace('·', " "), + )) + .await?; + + // Whitespace that would become trailing is trimmed too. + test(( + indoc! {"\ + hello········#[|w]#orld + "} + .replace('·', " "), + "i", + indoc! {"\ + hello + #[|w]#orld + "} + .replace('·', " "), + )) + .await?; + + // Only whitespace before the cursor is trimmed. + test(( + indoc! {"\ + hello········#[|·]#····world + "} + .replace('·', " "), + "i", + indoc! {"\ + hello + #[|·]#····world + "} + .replace('·', " "), + )) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_newline_continue_line_comment() -> anyhow::Result<()> { + // `insert_newline` continues a single line comment + test(( + indoc! {"\ + // Hello world!#[| + ]# + "}, + ":lang rusti", + indoc! {"\ + // Hello world! + // #[| + ]# + "}, + )) + .await?; + + // The comment is not continued if the cursor is before the comment token. (Note that we + // are entering insert-mode with `I`.) + test(( + indoc! {"\ + // Hello world!#[| + ]# + "}, + ":lang rustI", + indoc! {"\ + \n#[/|]#/ Hello world! + "}, + )) + .await?; + + // `insert_newline` again clears the whitespace on the first continued comment and continues + // the comment again. + test(( + indoc! {"\ + // Hello world! + // #[| + ]# + "}, + ":lang rusti", + indoc! {"\ + // Hello world! + // + // #[| + ]# + "}, + )) + .await?; + + // Line comment continuation and trailing whitespace is also trimmed when using + // `insert_newline` in the middle of a comment. + test(( + indoc! {"\ + //·hello····#[|·]#····world + "} + .replace('·', " "), + ":lang rusti", + indoc! {"\ + //·hello + //·#[|·]#····world + "} + .replace('·', " "), + )) + .await?; + + Ok(()) +} diff --git a/helix-term/tests/test/languages/yaml.rs b/helix-term/tests/test/languages/yaml.rs index 10e1861d475b..1d95964005fe 100644 --- a/helix-term/tests/test/languages/yaml.rs +++ b/helix-term/tests/test/languages/yaml.rs @@ -795,7 +795,7 @@ async fn auto_indent() -> anyhow::Result<()> { "##}, "i", indoc! {"\ - foo: + foo: #[|b]#ar "}, ), diff --git a/helix-tui/Cargo.toml b/helix-tui/Cargo.toml index ac56c72421f3..96f008a01bf9 100644 --- a/helix-tui/Cargo.toml +++ b/helix-tui/Cargo.toml @@ -20,9 +20,9 @@ helix-core = { path = "../helix-core" } bitflags = "2.6" cassowary = "0.3" -unicode-segmentation = "1.11" +unicode-segmentation = "1.12" crossterm = { version = "0.28", optional = true } termini = "1.0" serde = { version = "1", "optional" = true, features = ["derive"]} -once_cell = "1.19" +once_cell = "1.20" log = "~0.4" diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs index a5e8a68af8fc..c4313e15fc12 100644 --- a/helix-tui/src/text.rs +++ b/helix-tui/src/text.rs @@ -212,7 +212,7 @@ impl<'a> From> for Span<'a> { #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Spans<'a>(pub Vec>); -impl<'a> Spans<'a> { +impl Spans<'_> { /// Returns the width of the underlying string. /// /// ## Examples diff --git a/helix-tui/src/widgets/block.rs b/helix-tui/src/widgets/block.rs index 8b8141ea931b..ee7aa7573a9e 100644 --- a/helix-tui/src/widgets/block.rs +++ b/helix-tui/src/widgets/block.rs @@ -123,7 +123,7 @@ impl<'a> Block<'a> { } } -impl<'a> Widget for Block<'a> { +impl Widget for Block<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if area.area() == 0 { return; diff --git a/helix-tui/src/widgets/paragraph.rs b/helix-tui/src/widgets/paragraph.rs index 79beb0516228..73153a077e03 100644 --- a/helix-tui/src/widgets/paragraph.rs +++ b/helix-tui/src/widgets/paragraph.rs @@ -129,7 +129,7 @@ impl<'a> Paragraph<'a> { } } -impl<'a> Widget for Paragraph<'a> { +impl Widget for Paragraph<'_> { fn render(mut self, area: Rect, buf: &mut Buffer) { buf.set_style(area, self.style); let text_area = match self.block.take() { diff --git a/helix-tui/src/widgets/reflow.rs b/helix-tui/src/widgets/reflow.rs index 67c4db443892..ff102eb1954f 100644 --- a/helix-tui/src/widgets/reflow.rs +++ b/helix-tui/src/widgets/reflow.rs @@ -39,7 +39,7 @@ impl<'a, 'b> WordWrapper<'a, 'b> { } } -impl<'a, 'b> LineComposer<'a> for WordWrapper<'a, 'b> { +impl<'a> LineComposer<'a> for WordWrapper<'a, '_> { fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> { if self.max_line_width == 0 { return None; @@ -152,7 +152,7 @@ impl<'a, 'b> LineTruncator<'a, 'b> { } } -impl<'a, 'b> LineComposer<'a> for LineTruncator<'a, 'b> { +impl<'a> LineComposer<'a> for LineTruncator<'a, '_> { fn next_line(&mut self) -> Option<(&[StyledGrapheme<'a>], u16)> { if self.max_line_width == 0 { return None; diff --git a/helix-tui/src/widgets/table.rs b/helix-tui/src/widgets/table.rs index 3564871deec3..9c67a376fdea 100644 --- a/helix-tui/src/widgets/table.rs +++ b/helix-tui/src/widgets/table.rs @@ -34,7 +34,7 @@ pub struct Cell<'a> { style: Style, } -impl<'a> Cell<'a> { +impl Cell<'_> { /// Set the `Style` of this cell. pub fn style(mut self, style: Style) -> Self { self.style = style; @@ -351,7 +351,7 @@ impl TableState { } // impl<'a> StatefulWidget for Table<'a> { -impl<'a> Table<'a> { +impl Table<'_> { // type State = TableState; pub fn render_table( @@ -486,7 +486,7 @@ fn render_cell(buf: &mut Buffer, cell: &Cell, area: Rect, truncate: bool) { } } -impl<'a> Widget for Table<'a> { +impl Widget for Table<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let mut state = TableState::default(); Table::render_table(self, area, buf, &mut state, false); diff --git a/helix-vcs/Cargo.toml b/helix-vcs/Cargo.toml index 245fdb8dc11d..8d3f55a66eb4 100644 --- a/helix-vcs/Cargo.toml +++ b/helix-vcs/Cargo.toml @@ -19,7 +19,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync", "p parking_lot = "0.12" arc-swap = { version = "1.7.1" } -gix = { version = "0.66.0", features = ["attributes", "status"], default-features = false, optional = true } +gix = { version = "0.68.0", features = ["attributes", "status"], default-features = false, optional = true } imara-diff = "0.1.7" anyhow = "1" @@ -29,4 +29,4 @@ log = "0.4" git = ["gix"] [dev-dependencies] -tempfile = "3.12" +tempfile = "3.14" diff --git a/helix-vcs/src/git.rs b/helix-vcs/src/git.rs index 78e582436ba9..189f6e22bea5 100644 --- a/helix-vcs/src/git.rs +++ b/helix-vcs/src/git.rs @@ -22,18 +22,24 @@ use crate::FileChange; #[cfg(test)] mod test; +#[inline] +fn get_repo_dir(file: &Path) -> Result<&Path> { + file.parent().context("file has no parent directory") +} + pub fn get_diff_base(file: &Path) -> Result> { debug_assert!(!file.exists() || file.is_file()); debug_assert!(file.is_absolute()); + let file = gix::path::realpath(file).context("resolve symlinks")?; // TODO cache repository lookup - let repo_dir = file.parent().context("file has no parent directory")?; + let repo_dir = get_repo_dir(&file)?; let repo = open_repo(repo_dir) .context("failed to open git repo")? .to_thread_local(); let head = repo.head_commit()?; - let file_oid = find_file_in_commit(&repo, &head, file)?; + let file_oid = find_file_in_commit(&repo, &head, &file)?; let file_object = repo.find_object(file_oid)?; let data = file_object.detach().data; @@ -56,7 +62,9 @@ pub fn get_diff_base(file: &Path) -> Result> { pub fn get_current_head_name(file: &Path) -> Result>>> { debug_assert!(!file.exists() || file.is_file()); debug_assert!(file.is_absolute()); - let repo_dir = file.parent().context("file has no parent directory")?; + let file = gix::path::realpath(file).context("resolve symlinks")?; + + let repo_dir = get_repo_dir(&file)?; let repo = open_repo(repo_dir) .context("failed to open git repo")? .to_thread_local(); @@ -190,7 +198,7 @@ fn find_file_in_commit(repo: &Repository, commit: &Commit, file: &Path) -> Resul let rel_path = file.strip_prefix(repo_dir)?; let tree = commit.tree()?; let tree_entry = tree - .lookup_entry_by_path(rel_path, &mut Vec::new())? + .lookup_entry_by_path(rel_path)? .context("file is untracked")?; match tree_entry.mode().kind() { // not a file, everything is new, do not show diff diff --git a/helix-vcs/src/git/test.rs b/helix-vcs/src/git/test.rs index 95ff10b232c9..164040f50cd7 100644 --- a/helix-vcs/src/git/test.rs +++ b/helix-vcs/src/git/test.rs @@ -98,9 +98,13 @@ fn directory() { assert!(git::get_diff_base(&dir).is_err()); } -/// Test that `get_file_head` does not return content for a symlink. -/// This is important to correctly cover cases where a symlink is removed and replaced by a file. -/// If the contents of the symlink object were returned a diff between a path and the actual file would be produced (bad ui). +/// Test that `get_diff_base` resolves symlinks so that the same diff base is +/// used as the target file. +/// +/// This is important to correctly cover cases where a symlink is removed and +/// replaced by a file. If the contents of the symlink object were returned +/// a diff between a literal file path and the actual file content would be +/// produced (bad ui). #[cfg(any(unix, windows))] #[test] fn symlink() { @@ -108,14 +112,41 @@ fn symlink() { use std::os::unix::fs::symlink; #[cfg(not(unix))] use std::os::windows::fs::symlink_file as symlink; + let temp_git = empty_git_repo(); let file = temp_git.path().join("file.txt"); - let contents = b"foo".as_slice(); - File::create(&file).unwrap().write_all(contents).unwrap(); + let contents = Vec::from(b"foo"); + File::create(&file).unwrap().write_all(&contents).unwrap(); let file_link = temp_git.path().join("file_link.txt"); + symlink("file.txt", &file_link).unwrap(); + create_commit(temp_git.path(), true); + + assert_eq!(git::get_diff_base(&file_link).unwrap(), contents); + assert_eq!(git::get_diff_base(&file).unwrap(), contents); +} + +/// Test that `get_diff_base` returns content when the file is a symlink to +/// another file that is in a git repo, but the symlink itself is not. +#[cfg(any(unix, windows))] +#[test] +fn symlink_to_git_repo() { + #[cfg(unix)] + use std::os::unix::fs::symlink; + #[cfg(not(unix))] + use std::os::windows::fs::symlink_file as symlink; + + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let temp_git = empty_git_repo(); + let file = temp_git.path().join("file.txt"); + let contents = Vec::from(b"foo"); + File::create(&file).unwrap().write_all(&contents).unwrap(); create_commit(temp_git.path(), true); - assert!(git::get_diff_base(&file_link).is_err()); - assert_eq!(git::get_diff_base(&file).unwrap(), Vec::from(contents)); + + let file_link = temp_dir.path().join("file_link.txt"); + symlink(&file, &file_link).unwrap(); + + assert_eq!(git::get_diff_base(&file_link).unwrap(), contents); + assert_eq!(git::get_diff_base(&file).unwrap(), contents); } diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index ddfa9f7e4e9b..6f71fa05204f 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -28,11 +28,11 @@ bitflags = "2.6" anyhow = "1" crossterm = { version = "0.28", optional = true } -tempfile = "3.12" +tempfile = "3.14" # Conversion traits -once_cell = "1.19" -url = "2.5.2" +once_cell = "1.20" +url = "2.5.4" arc-swap = { version = "1.7.1" } diff --git a/helix-view/src/clipboard.rs b/helix-view/src/clipboard.rs index 379accc7e41a..5e16461e0d88 100644 --- a/helix-view/src/clipboard.rs +++ b/helix-view/src/clipboard.rs @@ -1,356 +1,224 @@ // Implementation reference: https://github.com/neovim/neovim/blob/f2906a4669a2eef6d7bf86a29648793d63c98949/runtime/autoload/provider/clipboard.vim#L68-L152 -use anyhow::Result; +use serde::{Deserialize, Serialize}; use std::borrow::Cow; +use thiserror::Error; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy)] pub enum ClipboardType { Clipboard, Selection, } -pub trait ClipboardProvider: std::fmt::Debug { - fn name(&self) -> Cow; - fn get_contents(&self, clipboard_type: ClipboardType) -> Result; - fn set_contents(&mut self, contents: String, clipboard_type: ClipboardType) -> Result<()>; +#[derive(Debug, Error)] +pub enum ClipboardError { + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error("could not convert terminal output to UTF-8: {0}")] + FromUtf8Error(#[from] std::string::FromUtf8Error), + #[cfg(windows)] + #[error("Windows API error: {0}")] + WinAPI(#[from] clipboard_win::ErrorCode), + #[error("clipboard provider command failed")] + CommandFailed, + #[error("failed to write to clipboard provider's stdin")] + StdinWriteFailed, + #[error("clipboard provider did not return any contents")] + MissingStdout, + #[error("This clipboard provider does not support reading")] + ReadingNotSupported, } -#[cfg(not(windows))] -macro_rules! command_provider { - (paste => $get_prg:literal $( , $get_arg:literal )* ; copy => $set_prg:literal $( , $set_arg:literal )* ; ) => {{ - log::debug!( - "Using {} to interact with the system clipboard", - if $set_prg != $get_prg { format!("{}+{}", $set_prg, $get_prg)} else { $set_prg.to_string() } - ); - Box::new(provider::command::Provider { - get_cmd: provider::command::Config { - prg: $get_prg, - args: &[ $( $get_arg ),* ], - }, - set_cmd: provider::command::Config { - prg: $set_prg, - args: &[ $( $set_arg ),* ], - }, - get_primary_cmd: None, - set_primary_cmd: None, - }) - }}; - - (paste => $get_prg:literal $( , $get_arg:literal )* ; - copy => $set_prg:literal $( , $set_arg:literal )* ; - primary_paste => $pr_get_prg:literal $( , $pr_get_arg:literal )* ; - primary_copy => $pr_set_prg:literal $( , $pr_set_arg:literal )* ; - ) => {{ - log::debug!( - "Using {} to interact with the system and selection (primary) clipboard", - if $set_prg != $get_prg { format!("{}+{}", $set_prg, $get_prg)} else { $set_prg.to_string() } - ); - Box::new(provider::command::Provider { - get_cmd: provider::command::Config { - prg: $get_prg, - args: &[ $( $get_arg ),* ], - }, - set_cmd: provider::command::Config { - prg: $set_prg, - args: &[ $( $set_arg ),* ], - }, - get_primary_cmd: Some(provider::command::Config { - prg: $pr_get_prg, - args: &[ $( $pr_get_arg ),* ], - }), - set_primary_cmd: Some(provider::command::Config { - prg: $pr_set_prg, - args: &[ $( $pr_set_arg ),* ], - }), - }) - }}; -} - -#[cfg(windows)] -pub fn get_clipboard_provider() -> Box { - Box::::default() -} - -#[cfg(target_os = "macos")] -pub fn get_clipboard_provider() -> Box { - use helix_stdx::env::{binary_exists, env_var_is_set}; - - if env_var_is_set("TMUX") && binary_exists("tmux") { - command_provider! { - paste => "tmux", "save-buffer", "-"; - copy => "tmux", "load-buffer", "-w", "-"; - } - } else if binary_exists("pbcopy") && binary_exists("pbpaste") { - command_provider! { - paste => "pbpaste"; - copy => "pbcopy"; - } - } else { - Box::new(provider::FallbackProvider::new()) - } -} +type Result = std::result::Result; +#[cfg(not(target_arch = "wasm32"))] +pub use external::ClipboardProvider; #[cfg(target_arch = "wasm32")] -pub fn get_clipboard_provider() -> Box { - // TODO: - Box::new(provider::FallbackProvider::new()) -} +pub use noop::ClipboardProvider; -#[cfg(not(any(windows, target_arch = "wasm32", target_os = "macos")))] -pub fn get_clipboard_provider() -> Box { - use helix_stdx::env::{binary_exists, env_var_is_set}; - use provider::command::is_exit_success; - // TODO: support for user-defined provider, probably when we have plugin support by setting a - // variable? - - if env_var_is_set("WAYLAND_DISPLAY") && binary_exists("wl-copy") && binary_exists("wl-paste") { - command_provider! { - paste => "wl-paste", "--no-newline"; - copy => "wl-copy", "--type", "text/plain"; - primary_paste => "wl-paste", "-p", "--no-newline"; - primary_copy => "wl-copy", "-p", "--type", "text/plain"; - } - } else if env_var_is_set("DISPLAY") && binary_exists("xclip") { - command_provider! { - paste => "xclip", "-o", "-selection", "clipboard"; - copy => "xclip", "-i", "-selection", "clipboard"; - primary_paste => "xclip", "-o"; - primary_copy => "xclip", "-i"; - } - } else if env_var_is_set("DISPLAY") - && binary_exists("xsel") - && is_exit_success("xsel", &["-o", "-b"]) - { - // FIXME: check performance of is_exit_success - command_provider! { - paste => "xsel", "-o", "-b"; - copy => "xsel", "-i", "-b"; - primary_paste => "xsel", "-o"; - primary_copy => "xsel", "-i"; - } - } else if binary_exists("win32yank.exe") { - command_provider! { - paste => "win32yank.exe", "-o", "--lf"; - copy => "win32yank.exe", "-i", "--crlf"; - } - } else if binary_exists("termux-clipboard-set") && binary_exists("termux-clipboard-get") { - command_provider! { - paste => "termux-clipboard-get"; - copy => "termux-clipboard-set"; - } - } else if env_var_is_set("TMUX") && binary_exists("tmux") { - command_provider! { - paste => "tmux", "save-buffer", "-"; - copy => "tmux", "load-buffer", "-w", "-"; - } - } else { - Box::new(provider::FallbackProvider::new()) - } -} +// Clipboard not supported for wasm +#[cfg(target_arch = "wasm32")] +mod noop { + use super::*; -#[cfg(not(target_os = "windows"))] -pub mod provider { - use super::{ClipboardProvider, ClipboardType}; - use anyhow::Result; - use std::borrow::Cow; + #[derive(Debug, Clone)] + pub enum ClipboardProvider {} - #[cfg(feature = "term")] - mod osc52 { - use {super::ClipboardType, crate::base64}; + impl ClipboardProvider { + pub fn detect() -> Self { + Self + } - #[derive(Debug)] - pub struct SetClipboardCommand { - encoded_content: String, - clipboard_type: ClipboardType, + pub fn name(&self) -> Cow { + "none".into() } - impl SetClipboardCommand { - pub fn new(content: &str, clipboard_type: ClipboardType) -> Self { - Self { - encoded_content: base64::encode(content.as_bytes()), - clipboard_type, - } - } + pub fn get_contents(&self, _clipboard_type: ClipboardType) -> Result { + Err(ClipboardError::ReadingNotSupported) } - impl crossterm::Command for SetClipboardCommand { - fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { - let kind = match &self.clipboard_type { - ClipboardType::Clipboard => "c", - ClipboardType::Selection => "p", - }; - // Send an OSC 52 set command: https://terminalguide.namepad.de/seq/osc-52/ - write!(f, "\x1b]52;{};{}\x1b\\", kind, &self.encoded_content) - } + pub fn set_contents(&self, _content: &str, _clipboard_type: ClipboardType) -> Result<()> { + Ok(()) } } +} - #[derive(Debug)] - pub struct FallbackProvider { - buf: String, - primary_buf: String, - } +#[cfg(not(target_arch = "wasm32"))] +mod external { + use super::*; - impl FallbackProvider { - pub fn new() -> Self { - #[cfg(feature = "term")] - log::debug!( - "No native clipboard provider found. Yanking by OSC 52 and pasting will be internal to Helix" - ); - #[cfg(not(feature = "term"))] - log::warn!( - "No native clipboard provider found! Yanking and pasting will be internal to Helix" - ); - Self { - buf: String::new(), - primary_buf: String::new(), - } - } + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + pub struct Command { + command: Cow<'static, str>, + #[serde(default)] + args: Cow<'static, [Cow<'static, str>]>, } - impl Default for FallbackProvider { - fn default() -> Self { - Self::new() - } + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + #[serde(rename_all = "kebab-case")] + pub struct CommandProvider { + yank: Command, + paste: Command, + yank_primary: Option, + paste_primary: Option, } - impl ClipboardProvider for FallbackProvider { + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + #[serde(rename_all = "kebab-case")] + #[allow(clippy::large_enum_variant)] + pub enum ClipboardProvider { + Pasteboard, + Wayland, + XClip, + XSel, + Win32Yank, + Tmux, + #[cfg(windows)] + Windows, + Termux, #[cfg(feature = "term")] - fn name(&self) -> Cow { - Cow::Borrowed("termcode") - } - - #[cfg(not(feature = "term"))] - fn name(&self) -> Cow { - Cow::Borrowed("none") - } + Termcode, + Custom(CommandProvider), + None, + } - fn get_contents(&self, clipboard_type: ClipboardType) -> Result { - // This is the same noop if term is enabled or not. - // We don't use the get side of OSC 52 as it isn't often enabled, it's a security hole, - // and it would require this to be async to listen for the response - let value = match clipboard_type { - ClipboardType::Clipboard => self.buf.clone(), - ClipboardType::Selection => self.primary_buf.clone(), - }; + impl Default for ClipboardProvider { + #[cfg(windows)] + fn default() -> Self { + use helix_stdx::env::binary_exists; - Ok(value) + if binary_exists("win32yank.exe") { + Self::Win32Yank + } else { + Self::Windows + } } - fn set_contents(&mut self, content: String, clipboard_type: ClipboardType) -> Result<()> { - #[cfg(feature = "term")] - crossterm::execute!( - std::io::stdout(), - osc52::SetClipboardCommand::new(&content, clipboard_type) - )?; - // Set our internal variables to use in get_content regardless of using OSC 52 - match clipboard_type { - ClipboardType::Clipboard => self.buf = content, - ClipboardType::Selection => self.primary_buf = content, + #[cfg(target_os = "macos")] + fn default() -> Self { + use helix_stdx::env::{binary_exists, env_var_is_set}; + + if env_var_is_set("TMUX") && binary_exists("tmux") { + Self::Tmux + } else if binary_exists("pbcopy") && binary_exists("pbpaste") { + Self::Pasteboard + } else if cfg!(feature = "term") { + Self::Termcode + } else { + Self::None } - Ok(()) } - } - - #[cfg(not(target_arch = "wasm32"))] - pub mod command { - use super::*; - use anyhow::{bail, Context as _}; #[cfg(not(any(windows, target_os = "macos")))] - pub fn is_exit_success(program: &str, args: &[&str]) -> bool { - std::process::Command::new(program) - .args(args) - .output() - .ok() - .and_then(|out| out.status.success().then_some(())) - .is_some() - } + fn default() -> Self { + use helix_stdx::env::{binary_exists, env_var_is_set}; + + fn is_exit_success(program: &str, args: &[&str]) -> bool { + std::process::Command::new(program) + .args(args) + .output() + .ok() + .and_then(|out| out.status.success().then_some(())) + .is_some() + } - #[derive(Debug)] - pub struct Config { - pub prg: &'static str, - pub args: &'static [&'static str], + if env_var_is_set("WAYLAND_DISPLAY") + && binary_exists("wl-copy") + && binary_exists("wl-paste") + { + Self::Wayland + } else if env_var_is_set("DISPLAY") && binary_exists("xclip") { + Self::XClip + } else if env_var_is_set("DISPLAY") + && binary_exists("xsel") + // FIXME: check performance of is_exit_success + && is_exit_success("xsel", &["-o", "-b"]) + { + Self::XSel + } else if binary_exists("termux-clipboard-set") && binary_exists("termux-clipboard-get") + { + Self::Termux + } else if env_var_is_set("TMUX") && binary_exists("tmux") { + Self::Tmux + } else if binary_exists("win32yank.exe") { + Self::Win32Yank + } else if cfg!(feature = "term") { + Self::Termcode + } else { + Self::None + } } + } - impl Config { - fn execute(&self, input: Option<&str>, pipe_output: bool) -> Result> { - use std::io::Write; - use std::process::{Command, Stdio}; - - let stdin = input.map(|_| Stdio::piped()).unwrap_or_else(Stdio::null); - let stdout = pipe_output.then(Stdio::piped).unwrap_or_else(Stdio::null); - - let mut command: Command = Command::new(self.prg); - - let mut command_mut: &mut Command = command - .args(self.args) - .stdin(stdin) - .stdout(stdout) - .stderr(Stdio::null()); - - // Fix for https://github.com/helix-editor/helix/issues/5424 - if cfg!(unix) { - use std::os::unix::process::CommandExt; - - unsafe { - command_mut = command_mut.pre_exec(|| match libc::setsid() { - -1 => Err(std::io::Error::last_os_error()), - _ => Ok(()), - }); - } - } - - let mut child = command_mut.spawn()?; - - if let Some(input) = input { - let mut stdin = child.stdin.take().context("stdin is missing")?; - stdin - .write_all(input.as_bytes()) - .context("couldn't write in stdin")?; - } - - // TODO: add timer? - let output = child.wait_with_output()?; - - if !output.status.success() { - bail!("clipboard provider {} failed", self.prg); - } - - if pipe_output { - Ok(Some(String::from_utf8(output.stdout)?)) + impl ClipboardProvider { + pub fn name(&self) -> Cow<'_, str> { + fn builtin_name<'a>( + name: &'static str, + provider: &'static CommandProvider, + ) -> Cow<'a, str> { + if provider.yank.command != provider.paste.command { + Cow::Owned(format!( + "{} ({}+{})", + name, provider.yank.command, provider.paste.command + )) } else { - Ok(None) + Cow::Owned(format!("{} ({})", name, provider.yank.command)) } } - } - - #[derive(Debug)] - pub struct Provider { - pub get_cmd: Config, - pub set_cmd: Config, - pub get_primary_cmd: Option, - pub set_primary_cmd: Option, - } - impl ClipboardProvider for Provider { - fn name(&self) -> Cow { - if self.get_cmd.prg != self.set_cmd.prg { - Cow::Owned(format!("{}+{}", self.get_cmd.prg, self.set_cmd.prg)) - } else { - Cow::Borrowed(self.get_cmd.prg) - } + match self { + // These names should match the config option names from Serde + Self::Pasteboard => builtin_name("pasteboard", &PASTEBOARD), + Self::Wayland => builtin_name("wayland", &WL_CLIPBOARD), + Self::XClip => builtin_name("x-clip", &XCLIP), + Self::XSel => builtin_name("x-sel", &XSEL), + Self::Win32Yank => builtin_name("win-32-yank", &WIN32), + Self::Tmux => builtin_name("tmux", &TMUX), + Self::Termux => builtin_name("termux", &TERMUX), + #[cfg(windows)] + Self::Windows => "windows".into(), + #[cfg(feature = "term")] + Self::Termcode => "termcode".into(), + Self::Custom(command_provider) => Cow::Owned(format!( + "custom ({}+{})", + command_provider.yank.command, command_provider.paste.command + )), + Self::None => "none".into(), } + } - fn get_contents(&self, clipboard_type: ClipboardType) -> Result { + pub fn get_contents(&self, clipboard_type: &ClipboardType) -> Result { + fn yank_from_builtin( + provider: CommandProvider, + clipboard_type: &ClipboardType, + ) -> Result { match clipboard_type { - ClipboardType::Clipboard => Ok(self - .get_cmd - .execute(None, true)? - .context("output is missing")?), + ClipboardType::Clipboard => execute_command(&provider.yank, None, true)? + .ok_or(ClipboardError::MissingStdout), ClipboardType::Selection => { - if let Some(cmd) = &self.get_primary_cmd { - return cmd.execute(None, true)?.context("output is missing"); + if let Some(cmd) = provider.yank_primary.as_ref() { + return execute_command(cmd, None, true)? + .ok_or(ClipboardError::MissingStdout); } Ok(String::new()) @@ -358,56 +226,274 @@ pub mod provider { } } - fn set_contents(&mut self, value: String, clipboard_type: ClipboardType) -> Result<()> { + match self { + Self::Pasteboard => yank_from_builtin(PASTEBOARD, clipboard_type), + Self::Wayland => yank_from_builtin(WL_CLIPBOARD, clipboard_type), + Self::XClip => yank_from_builtin(XCLIP, clipboard_type), + Self::XSel => yank_from_builtin(XSEL, clipboard_type), + Self::Win32Yank => yank_from_builtin(WIN32, clipboard_type), + Self::Tmux => yank_from_builtin(TMUX, clipboard_type), + Self::Termux => yank_from_builtin(TERMUX, clipboard_type), + #[cfg(target_os = "windows")] + Self::Windows => match clipboard_type { + ClipboardType::Clipboard => { + let contents = + clipboard_win::get_clipboard(clipboard_win::formats::Unicode)?; + Ok(contents) + } + ClipboardType::Selection => Ok(String::new()), + }, + #[cfg(feature = "term")] + Self::Termcode => Err(ClipboardError::ReadingNotSupported), + Self::Custom(command_provider) => { + execute_command(&command_provider.yank, None, true)? + .ok_or(ClipboardError::MissingStdout) + } + Self::None => Err(ClipboardError::ReadingNotSupported), + } + } + + pub fn set_contents(&self, content: &str, clipboard_type: ClipboardType) -> Result<()> { + fn paste_to_builtin( + provider: CommandProvider, + content: &str, + clipboard_type: ClipboardType, + ) -> Result<()> { let cmd = match clipboard_type { - ClipboardType::Clipboard => &self.set_cmd, + ClipboardType::Clipboard => &provider.paste, ClipboardType::Selection => { - if let Some(cmd) = &self.set_primary_cmd { + if let Some(cmd) = provider.paste_primary.as_ref() { cmd } else { return Ok(()); } } }; - cmd.execute(Some(&value), false).map(|_| ()) + + execute_command(cmd, Some(content), false).map(|_| ()) + } + + match self { + Self::Pasteboard => paste_to_builtin(PASTEBOARD, content, clipboard_type), + Self::Wayland => paste_to_builtin(WL_CLIPBOARD, content, clipboard_type), + Self::XClip => paste_to_builtin(XCLIP, content, clipboard_type), + Self::XSel => paste_to_builtin(XSEL, content, clipboard_type), + Self::Win32Yank => paste_to_builtin(WIN32, content, clipboard_type), + Self::Tmux => paste_to_builtin(TMUX, content, clipboard_type), + Self::Termux => paste_to_builtin(TERMUX, content, clipboard_type), + #[cfg(target_os = "windows")] + Self::Windows => match clipboard_type { + ClipboardType::Clipboard => { + clipboard_win::set_clipboard(clipboard_win::formats::Unicode, content)?; + Ok(()) + } + ClipboardType::Selection => Ok(()), + }, + #[cfg(feature = "term")] + Self::Termcode => { + crossterm::queue!( + std::io::stdout(), + osc52::SetClipboardCommand::new(content, clipboard_type) + )?; + Ok(()) + } + Self::Custom(command_provider) => match clipboard_type { + ClipboardType::Clipboard => { + execute_command(&command_provider.paste, Some(content), false).map(|_| ()) + } + ClipboardType::Selection => { + if let Some(cmd) = &command_provider.paste_primary { + execute_command(cmd, Some(content), false).map(|_| ()) + } else { + Ok(()) + } + } + }, + Self::None => Ok(()), } } } -} -#[cfg(target_os = "windows")] -mod provider { - use super::{ClipboardProvider, ClipboardType}; - use anyhow::Result; - use std::borrow::Cow; + macro_rules! command_provider { + ($name:ident, + yank => $yank_cmd:literal $( , $yank_arg:literal )* ; + paste => $paste_cmd:literal $( , $paste_arg:literal )* ; ) => { + const $name: CommandProvider = CommandProvider { + yank: Command { + command: Cow::Borrowed($yank_cmd), + args: Cow::Borrowed(&[ $( Cow::Borrowed($yank_arg) ),* ]) + }, + paste: Command { + command: Cow::Borrowed($paste_cmd), + args: Cow::Borrowed(&[ $( Cow::Borrowed($paste_arg) ),* ]) + }, + yank_primary: None, + paste_primary: None, + }; + }; + ($name:ident, + yank => $yank_cmd:literal $( , $yank_arg:literal )* ; + paste => $paste_cmd:literal $( , $paste_arg:literal )* ; + yank_primary => $yank_primary_cmd:literal $( , $yank_primary_arg:literal )* ; + paste_primary => $paste_primary_cmd:literal $( , $paste_primary_arg:literal )* ; ) => { + const $name: CommandProvider = CommandProvider { + yank: Command { + command: Cow::Borrowed($yank_cmd), + args: Cow::Borrowed(&[ $( Cow::Borrowed($yank_arg) ),* ]) + }, + paste: Command { + command: Cow::Borrowed($paste_cmd), + args: Cow::Borrowed(&[ $( Cow::Borrowed($paste_arg) ),* ]) + }, + yank_primary: Some(Command { + command: Cow::Borrowed($yank_primary_cmd), + args: Cow::Borrowed(&[ $( Cow::Borrowed($yank_primary_arg) ),* ]) + }), + paste_primary: Some(Command { + command: Cow::Borrowed($paste_primary_cmd), + args: Cow::Borrowed(&[ $( Cow::Borrowed($paste_primary_arg) ),* ]) + }), + }; + }; + } - #[derive(Default, Debug)] - pub struct WindowsProvider; + command_provider! { + TMUX, + yank => "tmux", "save-buffer", "-"; + paste => "tmux", "load-buffer", "-w", "-"; + } + command_provider! { + PASTEBOARD, + yank => "pbpaste"; + paste => "pbcopy"; + } + command_provider! { + WL_CLIPBOARD, + yank => "wl-paste", "--no-newline"; + paste => "wl-copy", "--type", "text/plain"; + yank_primary => "wl-paste", "-p", "--no-newline"; + paste_primary => "wl-copy", "-p", "--type", "text/plain"; + } + command_provider! { + XCLIP, + yank => "xclip", "-o", "-selection", "clipboard"; + paste => "xclip", "-i", "-selection", "clipboard"; + yank_primary => "xclip", "-o"; + paste_primary => "xclip", "-i"; + } + command_provider! { + XSEL, + yank => "xsel", "-o", "-b"; + paste => "xsel", "-i", "-b"; + yank_primary => "xsel", "-o"; + paste_primary => "xsel", "-i"; + } + command_provider! { + WIN32, + yank => "win32yank.exe", "-o", "--lf"; + paste => "win32yank.exe", "-i", "--crlf"; + } + command_provider! { + TERMUX, + yank => "termux-clipboard-get"; + paste => "termux-clipboard-set"; + } - impl ClipboardProvider for WindowsProvider { - fn name(&self) -> Cow { - log::debug!("Using clipboard-win to interact with the system clipboard"); - Cow::Borrowed("clipboard-win") + #[cfg(feature = "term")] + mod osc52 { + use {super::ClipboardType, crate::base64}; + + pub struct SetClipboardCommand { + encoded_content: String, + clipboard_type: ClipboardType, } - fn get_contents(&self, clipboard_type: ClipboardType) -> Result { - match clipboard_type { - ClipboardType::Clipboard => { - let contents = clipboard_win::get_clipboard(clipboard_win::formats::Unicode)?; - Ok(contents) + impl SetClipboardCommand { + pub fn new(content: &str, clipboard_type: ClipboardType) -> Self { + Self { + encoded_content: base64::encode(content.as_bytes()), + clipboard_type, } - ClipboardType::Selection => Ok(String::new()), } } - fn set_contents(&mut self, contents: String, clipboard_type: ClipboardType) -> Result<()> { - match clipboard_type { - ClipboardType::Clipboard => { - clipboard_win::set_clipboard(clipboard_win::formats::Unicode, contents)?; - } - ClipboardType::Selection => {} - }; - Ok(()) + impl crossterm::Command for SetClipboardCommand { + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + let kind = match &self.clipboard_type { + ClipboardType::Clipboard => "c", + ClipboardType::Selection => "p", + }; + // Send an OSC 52 set command: https://terminalguide.namepad.de/seq/osc-52/ + write!(f, "\x1b]52;{};{}\x1b\\", kind, &self.encoded_content) + } + #[cfg(windows)] + fn execute_winapi(&self) -> std::result::Result<(), std::io::Error> { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "OSC clipboard codes not supported by winapi.", + )) + } + } + } + + fn execute_command( + cmd: &Command, + input: Option<&str>, + pipe_output: bool, + ) -> Result> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let stdin = input.map(|_| Stdio::piped()).unwrap_or_else(Stdio::null); + let stdout = pipe_output.then(Stdio::piped).unwrap_or_else(Stdio::null); + + let mut command: Command = Command::new(cmd.command.as_ref()); + + #[allow(unused_mut)] + let mut command_mut: &mut Command = command + .args(cmd.args.iter().map(AsRef::as_ref)) + .stdin(stdin) + .stdout(stdout) + .stderr(Stdio::null()); + + // Fix for https://github.com/helix-editor/helix/issues/5424 + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + + unsafe { + command_mut = command_mut.pre_exec(|| match libc::setsid() { + -1 => Err(std::io::Error::last_os_error()), + _ => Ok(()), + }); + } + } + + let mut child = command_mut.spawn()?; + + if let Some(input) = input { + let mut stdin = child.stdin.take().ok_or(ClipboardError::StdinWriteFailed)?; + stdin + .write_all(input.as_bytes()) + .map_err(|_| ClipboardError::StdinWriteFailed)?; + } + + // TODO: add timer? + let output = child.wait_with_output()?; + + if !output.status.success() { + log::error!( + "clipboard provider {} failed with stderr: \"{}\"", + cmd.command, + String::from_utf8_lossy(&output.stderr) + ); + return Err(ClipboardError::CommandFailed); + } + + if pipe_output { + Ok(Some(String::from_utf8(output.stdout)?)) + } else { + Ok(None) } } } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 91ec27874853..fa089cdafeab 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1713,6 +1713,12 @@ impl Document { self.version } + pub fn path_completion_enabled(&self) -> bool { + self.language_config() + .and_then(|lang_config| lang_config.path_completion) + .unwrap_or_else(|| self.config.load().path_completion) + } + /// maintains the order as configured in the language_servers TOML array pub fn language_servers(&self) -> impl Iterator { self.language_config().into_iter().flat_map(move |config| { diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 1708b3b4e053..aa9a11533bbb 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,5 +1,6 @@ use crate::{ annotations::diagnostics::{DiagnosticFilter, InlineDiagnosticsConfig}, + clipboard::ClipboardProvider, document::{ DocumentOpenError, DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint, }, @@ -267,8 +268,15 @@ pub struct Config { pub auto_pairs: AutoPairConfig, /// Automatic auto-completion, automatically pop up without user trigger. Defaults to true. pub auto_completion: bool, + /// Enable filepath completion. + /// Show files and directories if an existing path at the cursor was recognized, + /// either absolute or relative to the current opened document or current working directory (if the buffer is not yet saved). + /// Defaults to true. + pub path_completion: bool, /// Automatic formatting on save. Defaults to true. pub auto_format: bool, + /// Default register used for yank/paste. Defaults to '"' + pub default_yank_register: char, /// Automatic save on focus lost and/or after delay. /// Time delay in milliseconds since last edit after which auto save timer triggers. /// Time delay defaults to false with 3000ms delay. Focus lost defaults to false. @@ -345,6 +353,8 @@ pub struct Config { /// Display diagnostic below the line they occur. pub inline_diagnostics: InlineDiagnosticsConfig, pub end_of_line_diagnostics: DiagnosticFilter, + // Set to override the default clipboard provider + pub clipboard_provider: ClipboardProvider, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] @@ -421,7 +431,9 @@ pub fn get_terminal_provider() -> Option { pub struct LspConfig { /// Enables LSP pub enable: bool, - /// Display LSP progress messages below statusline + /// Display LSP messagess from $/progress below statusline + pub display_progress_messages: bool, + /// Display LSP messages from window/showMessage below statusline pub display_messages: bool, /// Enable automatic pop up of signature help (parameter hints) pub auto_signature_help: bool, @@ -439,7 +451,8 @@ impl Default for LspConfig { fn default() -> Self { Self { enable: true, - display_messages: false, + display_progress_messages: false, + display_messages: true, auto_signature_help: true, display_signature_help_docs: true, display_inlay_hints: false, @@ -944,7 +957,9 @@ impl Default for Config { middle_click_paste: true, auto_pairs: AutoPairConfig::default(), auto_completion: true, + path_completion: true, auto_format: true, + default_yank_register: '"', auto_save: AutoSave::default(), idle_timeout: Duration::from_millis(250), completion_timeout: Duration::from_millis(250), @@ -979,6 +994,7 @@ impl Default for Config { jump_label_alphabet: ('a'..='z').collect(), inline_diagnostics: InlineDiagnosticsConfig::default(), end_of_line_diagnostics: DiagnosticFilter::Disable, + clipboard_provider: ClipboardProvider::default(), } } } @@ -1057,6 +1073,7 @@ pub struct Editor { redraw_timer: Pin>, last_motion: Option, pub last_completion: Option, + pub last_cwd: Option, pub exit_code: i32, @@ -1180,13 +1197,17 @@ impl Editor { theme_loader, last_theme: None, last_selection: None, - registers: Registers::default(), + registers: Registers::new(Box::new(arc_swap::access::Map::new( + Arc::clone(&config), + |config: &Config| &config.clipboard_provider, + ))), status_msg: None, autoinfo: None, idle_timer: Box::pin(sleep(conf.idle_timeout)), redraw_timer: Box::pin(sleep(Duration::MAX)), last_motion: None, last_completion: None, + last_cwd: None, config, auto_pairs, exit_code: 0, @@ -1271,6 +1292,13 @@ impl Editor { self.status_msg = Some((error, Severity::Error)); } + #[inline] + pub fn set_warning>>(&mut self, warning: T) { + let warning = warning.into(); + log::warn!("editor warning: {}", warning); + self.status_msg = Some((warning, Severity::Warning)); + } + #[inline] pub fn get_status(&self) -> Option<(&Cow<'static, str>, &Severity)> { self.status_msg.as_ref().map(|(status, sev)| (status, sev)) diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index 6aff2e50c58d..1fd2289db5d8 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -243,7 +243,7 @@ impl Editor { match op { ResourceOp::Create(op) => { let uri = Uri::try_from(&op.uri)?; - let path = uri.as_path_buf().expect("URIs are valid paths"); + let path = uri.as_path().expect("URIs are valid paths"); let ignore_if_exists = op.options.as_ref().map_or(false, |options| { !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) }); @@ -255,13 +255,15 @@ impl Editor { } } - fs::write(&path, [])?; - self.language_servers.file_event_handler.file_changed(path); + fs::write(path, [])?; + self.language_servers + .file_event_handler + .file_changed(path.to_path_buf()); } } ResourceOp::Delete(op) => { let uri = Uri::try_from(&op.uri)?; - let path = uri.as_path_buf().expect("URIs are valid paths"); + let path = uri.as_path().expect("URIs are valid paths"); if path.is_dir() { let recursive = op .options @@ -270,11 +272,13 @@ impl Editor { .unwrap_or(false); if recursive { - fs::remove_dir_all(&path)? + fs::remove_dir_all(path)? } else { - fs::remove_dir(&path)? + fs::remove_dir(path)? } - self.language_servers.file_event_handler.file_changed(path); + self.language_servers + .file_event_handler + .file_changed(path.to_path_buf()); } else if path.is_file() { fs::remove_file(path)?; } diff --git a/helix-view/src/register.rs b/helix-view/src/register.rs index 3a2e1b7cc39a..d286a85ccafe 100644 --- a/helix-view/src/register.rs +++ b/helix-view/src/register.rs @@ -1,10 +1,11 @@ use std::{borrow::Cow, collections::HashMap, iter}; use anyhow::Result; +use arc_swap::access::DynAccess; use helix_core::NATIVE_LINE_ENDING; use crate::{ - clipboard::{get_clipboard_provider, ClipboardProvider, ClipboardType}, + clipboard::{ClipboardError, ClipboardProvider, ClipboardType}, Editor, }; @@ -20,28 +21,25 @@ use crate::{ /// * Document path (`%`): filename of the current buffer /// * System clipboard (`*`) /// * Primary clipboard (`+`) -#[derive(Debug)] pub struct Registers { /// The mapping of register to values. /// Values are stored in reverse order when inserted with `Registers::write`. /// The order is reversed again in `Registers::read`. This allows us to /// efficiently prepend new values in `Registers::push`. inner: HashMap>, - clipboard_provider: Box, + clipboard_provider: Box>, pub last_search_register: char, } -impl Default for Registers { - fn default() -> Self { +impl Registers { + pub fn new(clipboard_provider: Box>) -> Self { Self { inner: Default::default(), - clipboard_provider: get_clipboard_provider(), + clipboard_provider, last_search_register: '/', } } -} -impl Registers { pub fn read<'a>(&'a self, name: char, editor: &'a Editor) -> Option> { match name { '_' => Some(RegisterValues::new(iter::empty())), @@ -64,7 +62,7 @@ impl Registers { Some(RegisterValues::new(iter::once(path))) } '*' | '+' => Some(read_from_clipboard( - self.clipboard_provider.as_ref(), + &self.clipboard_provider.load(), self.inner.get(&name), match name { '+' => ClipboardType::Clipboard, @@ -84,8 +82,8 @@ impl Registers { '_' => Ok(()), '#' | '.' | '%' => Err(anyhow::anyhow!("Register {name} does not support writing")), '*' | '+' => { - self.clipboard_provider.set_contents( - values.join(NATIVE_LINE_ENDING.as_str()), + self.clipboard_provider.load().set_contents( + &values.join(NATIVE_LINE_ENDING.as_str()), match name { '+' => ClipboardType::Clipboard, '*' => ClipboardType::Selection, @@ -114,7 +112,10 @@ impl Registers { '*' => ClipboardType::Selection, _ => unreachable!(), }; - let contents = self.clipboard_provider.get_contents(clipboard_type)?; + let contents = self + .clipboard_provider + .load() + .get_contents(&clipboard_type)?; let saved_values = self.inner.entry(name).or_default(); if !contents_are_saved(saved_values, &contents) { @@ -127,7 +128,8 @@ impl Registers { } value.push_str(&contents); self.clipboard_provider - .set_contents(value, clipboard_type)?; + .load() + .set_contents(&value, clipboard_type)?; Ok(()) } @@ -198,7 +200,8 @@ impl Registers { fn clear_clipboard(&mut self, clipboard_type: ClipboardType) { if let Err(err) = self .clipboard_provider - .set_contents("".into(), clipboard_type) + .load() + .set_contents("", clipboard_type) { log::error!( "Failed to clear {} clipboard: {err}", @@ -210,17 +213,17 @@ impl Registers { } } - pub fn clipboard_provider_name(&self) -> Cow { - self.clipboard_provider.name() + pub fn clipboard_provider_name(&self) -> String { + self.clipboard_provider.load().name().into_owned() } } fn read_from_clipboard<'a>( - provider: &dyn ClipboardProvider, + provider: &ClipboardProvider, saved_values: Option<&'a Vec>, clipboard_type: ClipboardType, ) -> RegisterValues<'a> { - match provider.get_contents(clipboard_type) { + match provider.get_contents(&clipboard_type) { Ok(contents) => { // If we're pasting the same values that we just yanked, re-use // the saved values. This allows pasting multiple selections @@ -235,6 +238,10 @@ fn read_from_clipboard<'a>( RegisterValues::new(iter::once(contents.into())) } } + Err(ClipboardError::ReadingNotSupported) => match saved_values { + Some(values) => RegisterValues::new(values.iter().map(Cow::from).rev()), + None => RegisterValues::new(iter::empty()), + }, Err(err) => { log::error!( "Failed to read {} clipboard: {err}", @@ -304,13 +311,13 @@ impl<'a> Iterator for RegisterValues<'a> { } } -impl<'a> DoubleEndedIterator for RegisterValues<'a> { +impl DoubleEndedIterator for RegisterValues<'_> { fn next_back(&mut self) -> Option { self.iter.next_back() } } -impl<'a> ExactSizeIterator for RegisterValues<'a> { +impl ExactSizeIterator for RegisterValues<'_> { fn len(&self) -> usize { self.iter.len() } diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index 4acc56648aa0..fca47413c1e1 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -53,20 +53,34 @@ impl Loader { /// Loads a theme searching directories in priority order. pub fn load(&self, name: &str) -> Result { + let (theme, warnings) = self.load_with_warnings(name)?; + + for warning in warnings { + warn!("Theme '{}': {}", name, warning); + } + + Ok(theme) + } + + /// Loads a theme searching directories in priority order, returning any warnings + pub fn load_with_warnings(&self, name: &str) -> Result<(Theme, Vec)> { if name == "default" { - return Ok(self.default()); + return Ok((self.default(), Vec::new())); } if name == "base16_default" { - return Ok(self.base16_default()); + return Ok((self.base16_default(), Vec::new())); } let mut visited_paths = HashSet::new(); - let theme = self.load_theme(name, &mut visited_paths).map(Theme::from)?; + let (theme, warnings) = self + .load_theme(name, &mut visited_paths) + .map(Theme::from_toml)?; - Ok(Theme { + let theme = Theme { name: name.into(), ..theme - }) + }; + Ok((theme, warnings)) } /// Recursively load a theme, merging with any inherited parent themes. @@ -87,10 +101,7 @@ impl Loader { let theme_toml = if let Some(parent_theme_name) = inherits { let parent_theme_name = parent_theme_name.as_str().ok_or_else(|| { - anyhow!( - "Theme: expected 'inherits' to be a string: {}", - parent_theme_name - ) + anyhow!("Expected 'inherits' to be a string: {}", parent_theme_name) })?; let parent_theme_toml = match parent_theme_name { @@ -181,9 +192,9 @@ impl Loader { }) .ok_or_else(|| { if cycle_found { - anyhow!("Theme: cycle found in inheriting: {}", name) + anyhow!("Cycle found in inheriting: {}", name) } else { - anyhow!("Theme: file not found for: {}", name) + anyhow!("File not found for: {}", name) } }) } @@ -220,19 +231,11 @@ pub struct Theme { impl From for Theme { fn from(value: Value) -> Self { - if let Value::Table(table) = value { - let (styles, scopes, highlights) = build_theme_values(table); - - Self { - styles, - scopes, - highlights, - ..Default::default() - } - } else { - warn!("Expected theme TOML value to be a table, found {:?}", value); - Default::default() + let (theme, warnings) = Theme::from_toml(value); + for warning in warnings { + warn!("{}", warning); } + theme } } @@ -242,31 +245,29 @@ impl<'de> Deserialize<'de> for Theme { D: Deserializer<'de>, { let values = Map::::deserialize(deserializer)?; - - let (styles, scopes, highlights) = build_theme_values(values); - - Ok(Self { - styles, - scopes, - highlights, - ..Default::default() - }) + let (theme, warnings) = Theme::from_keys(values); + for warning in warnings { + warn!("{}", warning); + } + Ok(theme) } } fn build_theme_values( mut values: Map, -) -> (HashMap, Vec, Vec