diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b483e3af..d1c9bc03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,17 +61,17 @@ jobs: build: [x86_64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc include: - build: x86_64-linux - os: ubuntu-latest + os: ubuntu-22.04 rust: stable target: x86_64-unknown-linux-gnu cross: false - build: aarch64-linux - os: ubuntu-latest + os: ubuntu-22.04 rust: stable target: aarch64-unknown-linux-gnu cross: true # - build: riscv64-linux - # os: ubuntu-latest + # os: ubuntu-22.04 # rust: stable # target: riscv64gc-unknown-linux-gnu # cross: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7a1388..3658cd78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ Packaging: As always, a big thank you to all of the contributors! This release saw changes from 171 contributors. +Breaking changes: + +* The `editor.lsp.display-messages` key now controls messages sent with the LSP `window/showMessage` notification rather than progress messages. If you want to enable progress messages you should now enable the `editor.lsp.display-progress-messages` key instead. ([#5535](https://github.com/helix-editor/helix/pull/5535)) + Features: * Big refactor for `Picker`s ([#9647](https://github.com/helix-editor/helix/pull/9647), [#11209](https://github.com/helix-editor/helix/pull/11209), [#11216](https://github.com/helix-editor/helix/pull/11216), [#11211](https://github.com/helix-editor/helix/pull/11211), [#11343](https://github.com/helix-editor/helix/pull/11343), [#11406](https://github.com/helix-editor/helix/pull/11406)) diff --git a/Cargo.lock b/Cargo.lock index 7c421ffb..484021ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be" [[package]] name = "bstr" @@ -136,9 +136,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cc" -version = "1.2.6" +version = "1.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d6dbb628b8f8555f86d0323c2eb39e3ec81901f4b83e091db8a6a76d316a333" +checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b" dependencies = [ "shlex", ] @@ -530,7 +530,7 @@ dependencies = [ "gix-worktree", "once_cell", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -543,7 +543,7 @@ dependencies = [ "gix-date", "gix-utils", "itoa", - "thiserror 2.0.9", + "thiserror 2.0.11", "winnow", ] @@ -560,7 +560,7 @@ dependencies = [ "gix-trace", "kstring", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", "unicode-bom", ] @@ -570,7 +570,7 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d48b897b4bbc881aea994b4a5bbb340a04979d7be9089791304e04a9fbc66b53" dependencies = [ - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -579,7 +579,7 @@ version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6ffbeb3a5c0b8b84c3fe4133a6f8c82fa962f4caefe8d0762eced025d3eb4f7" dependencies = [ - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -605,7 +605,7 @@ dependencies = [ "gix-features", "gix-hash", "memmap2", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -624,7 +624,7 @@ dependencies = [ "memchr", "once_cell", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", "unicode-bom", "winnow", ] @@ -639,7 +639,7 @@ dependencies = [ "bstr", "gix-path", "libc", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -651,7 +651,7 @@ dependencies = [ "bstr", "itoa", "jiff", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -672,7 +672,7 @@ dependencies = [ "gix-traverse", "gix-worktree", "imara-diff", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -692,7 +692,7 @@ dependencies = [ "gix-trace", "gix-utils", "gix-worktree", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -708,7 +708,7 @@ dependencies = [ "gix-path", "gix-ref", "gix-sec", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -726,7 +726,7 @@ dependencies = [ "once_cell", "prodash", "sha1_smol", - "thiserror 2.0.9", + "thiserror 2.0.11", "walkdir", ] @@ -748,7 +748,7 @@ dependencies = [ "gix-trace", "gix-utils", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -781,7 +781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5eccc17194ed0e67d49285e4853307e4147e95407f91c1c3e4a13ba9f4e4ce" dependencies = [ "faster-hex", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -833,7 +833,7 @@ dependencies = [ "memmap2", "rustix", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -864,7 +864,7 @@ dependencies = [ "gix-validate", "itoa", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", "winnow", ] @@ -886,7 +886,7 @@ dependencies = [ "gix-quote", "parking_lot", "tempfile", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -904,7 +904,7 @@ dependencies = [ "gix-path", "memmap2", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -916,7 +916,7 @@ dependencies = [ "bstr", "faster-hex", "gix-trace", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -928,7 +928,7 @@ dependencies = [ "bstr", "faster-hex", "gix-trace", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -941,7 +941,7 @@ dependencies = [ "gix-trace", "home", "once_cell", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -956,7 +956,7 @@ dependencies = [ "gix-config-value", "gix-glob", "gix-path", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -974,7 +974,7 @@ dependencies = [ "gix-transport", "gix-utils", "maybe-async", - "thiserror 2.0.9", + "thiserror 2.0.11", "winnow", ] @@ -986,7 +986,7 @@ checksum = "64a1e282216ec2ab2816cd57e6ed88f8009e634aec47562883c05ac8a7009a63" dependencies = [ "bstr", "gix-utils", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1006,7 +1006,7 @@ dependencies = [ "gix-utils", "gix-validate", "memmap2", - "thiserror 2.0.9", + "thiserror 2.0.11", "winnow", ] @@ -1021,7 +1021,7 @@ dependencies = [ "gix-revision", "gix-validate", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1036,7 +1036,7 @@ dependencies = [ "gix-hash", "gix-object", "gix-revwalk", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1051,7 +1051,7 @@ dependencies = [ "gix-hashtable", "gix-object", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1075,7 +1075,7 @@ dependencies = [ "bstr", "gix-hash", "gix-lock", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1098,7 +1098,7 @@ dependencies = [ "gix-pathspec", "gix-worktree", "portable-atomic", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1113,7 +1113,7 @@ dependencies = [ "gix-pathspec", "gix-refspec", "gix-url", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1149,7 +1149,7 @@ dependencies = [ "gix-quote", "gix-sec", "gix-url", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1166,7 +1166,7 @@ dependencies = [ "gix-object", "gix-revwalk", "smallvec", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1179,7 +1179,7 @@ dependencies = [ "gix-features", "gix-path", "percent-encoding", - "thiserror 2.0.9", + "thiserror 2.0.11", "url", ] @@ -1201,7 +1201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd520d09f9f585b34b32aba1d0b36ada89ab7fefb54a8ca3fe37fc482a750937" dependencies = [ "bstr", - "thiserror 2.0.9", + "thiserror 2.0.11", ] [[package]] @@ -1335,7 +1335,7 @@ dependencies = [ "log", "serde", "serde_json", - "thiserror 2.0.9", + "thiserror 2.0.11", "tokio", ] @@ -1391,7 +1391,7 @@ dependencies = [ "serde", "serde_json", "slotmap", - "thiserror 2.0.9", + "thiserror 2.0.11", "tokio", "tokio-stream", ] @@ -1466,7 +1466,7 @@ dependencies = [ "smallvec", "tempfile", "termini", - "thiserror 2.0.9", + "thiserror 2.0.11", "tokio", "tokio-stream", "toml", @@ -1536,7 +1536,7 @@ dependencies = [ "slotmap", "smartstring", "tempfile", - "thiserror 2.0.9", + "thiserror 2.0.11", "tokio", "tokio-stream", "toml", @@ -1999,9 +1999,9 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "open" -version = "5.3.1" +version = "5.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ecd52f0b8d15c40ce4820aa251ed5de032e5d91fab27f7db2f40d42a8bdf69c" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" dependencies = [ "is-wsl", "libc", @@ -2216,9 +2216,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.42" +version = "0.38.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" +checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" dependencies = [ "bitflags", "errno", @@ -2270,9 +2270,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.134" +version = "1.0.135" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" +checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" dependencies = [ "itoa", "memchr", @@ -2454,12 +2454,13 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.14.0" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" dependencies = [ "cfg-if", "fastrand", + "getrandom", "once_cell", "rustix", "windows-sys 0.59.0", @@ -2496,11 +2497,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.9" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" dependencies = [ - "thiserror-impl 2.0.9", + "thiserror-impl 2.0.11", ] [[package]] @@ -2516,9 +2517,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.9" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" dependencies = [ "proc-macro2", "quote", @@ -2561,9 +2562,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.42.0" +version = "1.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" +checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" dependencies = [ "backtrace", "bytes", @@ -2579,9 +2580,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index ddf02c4e..0ae0b41e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,8 @@ tree-sitter = { version = "0.22" } nucleo = "0.5.0" slotmap = "1.0.7" thiserror = "2.0" +tempfile = "3.15.0" +bitflags = "2.7" [workspace.package] version = "25.1.0" diff --git a/book/src/editor.md b/book/src/editor.md index 6d34c35a..69d4de96 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -145,7 +145,8 @@ The following statusline elements can be configured: | Key | Description | Default | | --- | ----------- | ------- | | `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` | -| `display-messages` | Display LSP progress messages below statusline[^1] | `false` | +| `display-messages` | Display LSP `window/showMessage` messages below statusline[^1] | `true` | +| `display-progress-messages` | Display LSP progress messages below statusline[^1] | `false` | | `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` | | `display-inlay-hints` | Display inlay hints[^2] | `false` | | `display-color-swatches` | Shows color swatches next to colors | `true` | diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index 45f69a54..376080b5 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -23,6 +23,7 @@ | circom | ✓ | | | `circom-lsp` | | clojure | ✓ | | | `clojure-lsp` | | cmake | ✓ | ✓ | ✓ | `cmake-language-server` | +| codeql | ✓ | ✓ | | `codeql` | | comment | ✓ | | | | | common-lisp | ✓ | | ✓ | `cl-lsp` | | cpon | ✓ | | ✓ | | @@ -80,6 +81,7 @@ | gowork | ✓ | | | `gopls` | | gpr | ✓ | | | `ada_language_server` | | graphql | ✓ | ✓ | | `graphql-lsp` | +| gren | ✓ | ✓ | | | | groovy | ✓ | | | | | gts | ✓ | ✓ | ✓ | `typescript-language-server`, `vscode-eslint-language-server`, `ember-language-server` | | hare | ✓ | | | | diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index d245ec13..b8821c41 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -36,7 +36,7 @@ tree-sitter.workspace = true once_cell = "1.20" arc-swap = "1" regex = "1" -bitflags = "2.6" +bitflags.workspace = true ahash = "0.8.11" hashbrown = { version = "0.14.5", features = ["raw"] } dunce = "1.0" diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs index edfd9ad1..9d873c36 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -1,358 +1,6 @@ -use smartstring::{LazyCompact, SmartString}; use std::borrow::Cow; -/// A utility for parsing shell-like command lines. -/// -/// The `Shellwords` struct takes an input string and allows extracting the command and its arguments. -/// -/// # Features -/// -/// - Parses command and arguments from input strings. -/// - Supports single, double, and backtick quoted arguments. -/// - Respects backslash escaping in arguments. -/// -/// # Examples -/// -/// Basic usage: -/// -/// ``` -/// # use helix_core::shellwords::Shellwords; -/// let shellwords = Shellwords::from(":o helix-core/src/shellwords.rs"); -/// assert_eq!(":o", shellwords.command()); -/// assert_eq!("helix-core/src/shellwords.rs", shellwords.args().next().unwrap()); -/// ``` -/// -/// Empty command: -/// -/// ``` -/// # use helix_core::shellwords::Shellwords; -/// let shellwords = Shellwords::from(" "); -/// assert!(shellwords.command().is_empty()); -/// ``` -/// -/// # Iterator -/// -/// The `args` method returns a non-allocating iterator, `Args`, over the arguments of the input. -/// -/// ``` -/// # use helix_core::shellwords::Shellwords; -/// let shellwords = Shellwords::from(":o a b c"); -/// let mut args = shellwords.args(); -/// assert_eq!(Some("a"), args.next()); -/// assert_eq!(Some("b"), args.next()); -/// assert_eq!(Some("c"), args.next()); -/// assert_eq!(None, args.next()); -/// ``` -#[derive(Clone, Copy)] -pub struct Shellwords<'a> { - input: &'a str, -} - -impl<'a> From<&'a str> for Shellwords<'a> { - #[inline] - fn from(input: &'a str) -> Self { - Self { input } - } -} - -impl<'a> From<&'a String> for Shellwords<'a> { - #[inline] - fn from(input: &'a String) -> Self { - Self { input } - } -} - -impl<'a> From<&'a Cow<'a, str>> for Shellwords<'a> { - #[inline] - fn from(input: &'a Cow) -> Self { - Self { input } - } -} - -impl<'a> Shellwords<'a> { - #[inline] - #[must_use] - pub fn command(&self) -> &str { - self.input - .split_once(' ') - .map_or(self.input, |(command, _)| command) - } - - #[inline] - #[must_use] - pub fn args(&self) -> Args<'a> { - let args = self.input.split_once(' ').map_or("", |(_, args)| args); - Args::parse(args) - } - - #[inline] - pub fn input(&self) -> &str { - self.input - } - - /// Checks that the input ends with a whitespace character which is not escaped. - /// - /// # Examples - /// - /// ```rust - /// # use helix_core::shellwords::Shellwords; - /// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true); - /// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true); - /// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true); - /// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false); - /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), true); - /// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false); - /// ``` - #[inline] - pub fn ends_with_whitespace(&self) -> bool { - self.input.ends_with(' ') - } -} - -/// An iterator over an input string which yields arguments. -/// -/// Splits on whitespace, but respects quoted substrings (using double quotes, single quotes, or backticks). -#[derive(Debug, Clone)] -pub struct Args<'a> { - input: &'a str, - idx: usize, - start: usize, -} - -impl<'a> Args<'a> { - #[inline] - fn parse(input: &'a str) -> Self { - Self { - input, - idx: 0, - start: 0, - } - } - - #[inline] - pub fn is_empty(&self) -> bool { - self.input.is_empty() - } - - /// Returns the args exactly as input. - /// - /// # Examples - /// ``` - /// # use helix_core::shellwords::Args; - /// let args = Args::from(r#"sed -n "s/test t/not /p""#); - /// assert_eq!(r#"sed -n "s/test t/not /p""#, args.raw()); - /// - /// let args = Args::from(r#"cat "file name with space.txt""#); - /// assert_eq!(r#"cat "file name with space.txt""#, args.raw()); - /// ``` - #[inline] - pub fn raw(&self) -> &str { - self.input - } - - /// Returns the remainder of the args exactly as input. - /// - /// # Examples - /// ``` - /// # use helix_core::shellwords::Args; - /// let mut args = Args::from(r#"sed -n "s/test t/not /p""#); - /// assert_eq!("sed", args.next().unwrap()); - /// assert_eq!(r#"-n "s/test t/not /p""#, args.rest()); - /// ``` - /// - /// Never calling `next` and using `rest` is functionally equivalent to calling `raw`. - #[inline] - pub fn rest(&self) -> &str { - &self.input[self.idx..] - } - - /// Returns a reference to the `next()` value without advancing the iterator. - /// - /// Unlike `std::iter::Peakable::peek` this does not return a double reference, `&&str` - /// but a normal `&str`. - #[inline] - #[must_use] - pub fn peek(&self) -> Option<&str> { - self.clone().next() - } - - /// Returns the total number of arguments given in a command. - /// - /// This count is aware of all parsing rules for `Args`. - #[must_use] - pub fn arg_count(&self) -> usize { - Self { - input: self.input, - idx: 0, - start: 0, - } - .fold(0, |acc, _| acc + 1) - } - - /// Convenient function to return an empty `Args`. - /// - /// When used in any iteration, it will always return `None`. - #[inline(always)] - pub const fn empty() -> Self { - Self { - input: "", - idx: 0, - start: 0, - } - } -} - -impl<'a> Iterator for Args<'a> { - type Item = &'a str; - - #[inline] - #[allow(clippy::too_many_lines)] - fn next(&mut self) -> Option { - // The parser loop is split into three main blocks to handle different types of input processing: - // - // 1. Quote block: - // - Detects an unescaped quote character, either starting an in-quote scan or, if already in-quote, - // locating the closing quote to return the quoted argument. - // - Handles cases where mismatched quotes are ignored and when quotes appear as the last character. - // - // 2. Whitespace block: - // - Handles arguments separated by whitespace (space or tab), respecting quotes so quoted phrases - // remain grouped together. - // - Splits arguments by whitespace when outside of a quoted context and updates boundaries accordingly. - // - // 3. Catch-all block: - // - Handles any other character, updating the `is_escaped` status if a backslash is encountered, - // advancing the loop to the next character. - - let bytes = self.input.as_bytes(); - let mut in_quotes = false; - let mut quote = b'\0'; - let mut is_escaped = false; - - while self.idx < bytes.len() { - match bytes[self.idx] { - b'"' | b'\'' | b'`' if !is_escaped => { - if in_quotes { - // Found the proper closing quote, so can return the arg and advance the state along. - if bytes[self.idx] == quote { - let arg = Some(&self.input[self.start..self.idx]); - self.idx += 1; - self.start = self.idx; - return arg; - } - // If quote does not match the type of the opening quote, then do nothing and advance. - self.idx += 1; - } else if self.idx == bytes.len() - 1 { - // Special case for when a quote is the last input in args. - // e.g: :read "file with space.txt"" - // This preserves the quote as an arg: - // - `file with space` - // - `"` - let arg = Some(&self.input[self.idx..]); - self.idx = bytes.len(); - self.start = bytes.len(); - return arg; - } else { - // Found opening quote. - in_quotes = true; - // Kind of quote that was found. - quote = bytes[self.idx]; - - if self.start < self.idx { - // When part of the input ends in a quote, `one two" three`, this properly returns the `two` - // before advancing to the quoted arg for the next iteration: - // - `one` <- previous arg - // - `two` <- this step - // - ` three` <- next arg - let arg = Some(&self.input[self.start..self.idx]); - self.idx += 1; - self.start = self.idx; - return arg; - } - - // Advance after quote. - self.idx += 1; - // Exclude quote from arg output. - self.start = self.idx; - } - } - b' ' | b'\t' if !in_quotes => { - // Found a true whitespace separator that wasn't inside quotes. - - // Check if there is anything to return or if its just advancing over whitespace. - // `start` will only be less than `idx` when there is something to return. - if self.start < self.idx { - let arg = Some(&self.input[self.start..self.idx]); - self.idx += 1; - self.start = self.idx; - return arg; - } - - // Advance beyond the whitespace. - self.idx += 1; - - // This is where `start` will be set to the start of an arg boundary, either encountering a word - // boundary or a quote boundary. If it finds a quote, then it will be advanced again in that part - // of the code. Either way, all that remains for the check above will be to return a full arg. - self.start = self.idx; - } - _ => { - // If previous loop didn't find any backslash and was already escaped it will change to false - // as the backslash chain was broken. - // - // If the previous loop had no backslash escape, and found one this iteration, then its the start - // of an escape chain. - is_escaped = match (is_escaped, bytes[self.idx]) { - (false, b'\\') => true, // Set `is_escaped` if the current byte is a backslash - _ => false, //Reset `is_escaped` if it was true, otherwise keep `is_escaped` as false - }; - - // Advance to next `char`. - self.idx += 1; - } - } - } - - // Fallback that catches when the loop would have exited but failed to return the arg between start and the end. - if self.start < bytes.len() { - let arg = Some(&self.input[self.start..]); - self.start = bytes.len(); - return arg; - } - - // All args have been parsed. - None - } - - fn count(self) -> usize - where - Self: Sized, - { - panic!("use `arg_count` instead to get the number of arguments."); - } -} - -impl<'a> From<&'a String> for Args<'a> { - fn from(args: &'a String) -> Self { - Args::parse(args) - } -} - -impl<'a> From<&'a str> for Args<'a> { - fn from(args: &'a str) -> Self { - Args::parse(args) - } -} - -impl<'a> From<&'a Cow<'_, str>> for Args<'a> { - fn from(args: &'a Cow) -> Self { - Args::parse(args) - } -} - /// Auto escape for shellwords usage. -#[inline] -#[must_use] pub fn escape(input: Cow) -> Cow { if !input.chars().any(|x| x.is_ascii_whitespace()) { input @@ -365,141 +13,186 @@ pub fn escape(input: Cow) -> Cow { buf })) } else { - Cow::Owned(format!("\"{input}\"")) + Cow::Owned(format!("\"{}\"", input)) } } -/// Unescapes a string, converting escape sequences into their literal characters. -/// -/// This function handles the following escape sequences: -/// - `\\n` is converted to `\n` (newline) -/// - `\\t` is converted to `\t` (tab) -/// - `\\u{...}` is converted to the corresponding Unicode character -/// -/// Other escape sequences, such as `\\` followed by any character not listed above, will remain unchanged. -/// -/// If input is invalid, for example if there is invalid unicode, \u{999999999}, it will return the input as is. -/// -/// # Examples -/// -/// Basic usage: -/// -/// ``` -/// # use helix_core::shellwords::unescape; -/// let unescaped = unescape("hello\\nworld"); -/// assert_eq!("hello\nworld", unescaped); -/// ``` -/// -/// Unescaping tabs: -/// -/// ``` -/// # use helix_core::shellwords::unescape; -/// let unescaped = unescape("hello\\tworld"); -/// assert_eq!("hello\tworld", unescaped); -/// ``` -/// -/// Unescaping Unicode characters: -/// -/// ``` -/// # use helix_core::shellwords::unescape; -/// let unescaped = unescape("hello\\u{1f929}world"); -/// assert_eq!("hello\u{1f929}world", unescaped); -/// assert_eq!("hello🤩world", unescaped); -/// ``` -/// -/// Handling backslashes: -/// -/// ``` -/// # use helix_core::shellwords::unescape; -/// let unescaped = unescape(r"hello\\world"); -/// assert_eq!(r"hello\\world", unescaped); -/// -/// let unescaped = unescape(r"hello\\\\world"); -/// assert_eq!(r"hello\\\\world", unescaped); -/// ``` -/// -/// # Note -/// -/// This function is opinionated, with a clear purpose of handling user input, not a general or generic unescaping utility, and does not unescape sequences like `\\'` or `\\\"`, leaving them as is. -#[inline] -#[must_use] -pub fn unescape(input: &str) -> Cow<'_, str> { - enum State { - Normal, - Escaped, - Unicode, - } +enum State { + OnWhitespace, + Unquoted, + UnquotedEscaped, + Quoted, + QuoteEscaped, + Dquoted, + DquoteEscaped, +} - let mut unescaped = String::new(); - let mut state = State::Normal; - let mut is_escaped = false; - // NOTE: Max unicode code point is U+10FFFF for a maximum of 6 chars - let mut unicode = SmartString::::new_const(); +pub struct Shellwords<'a> { + state: State, + /// Shellwords where whitespace and escapes has been resolved. + words: Vec>, + /// The parts of the input that are divided into shellwords. This can be + /// used to retrieve the original text for a given word by looking up the + /// same index in the Vec as the word in `words`. + parts: Vec<&'a str>, +} - for (idx, ch) in input.char_indices() { - match state { - State::Normal => match ch { - '\\' => { - if !is_escaped { - // PERF: As not every separator will be escaped, we use `String::new` as that has no initial - // allocation. If an escape is found, then we reserve capacity thats the len of the separator, - // as the new unescaped string will be at least that long. - unescaped.reserve(input.len()); - if idx > 0 { - // First time finding an escape, so all prior chars can be added to the new unescaped - // version if its not the very first char found. - unescaped.push_str(&input[0..idx]); +impl<'a> From<&'a str> for Shellwords<'a> { + fn from(input: &'a str) -> Self { + use State::*; + + let mut state = Unquoted; + let mut words = Vec::new(); + let mut parts = Vec::new(); + let mut escaped = String::with_capacity(input.len()); + + let mut part_start = 0; + let mut unescaped_start = 0; + let mut end = 0; + + for (i, c) in input.char_indices() { + state = match state { + OnWhitespace => match c { + '"' => { + end = i; + Dquoted + } + '\'' => { + end = i; + Quoted + } + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + UnquotedEscaped + } else { + OnWhitespace } } - state = State::Escaped; - is_escaped = true; - } - _ => { - if is_escaped { - unescaped.push(ch); + c if c.is_ascii_whitespace() => { + end = i; + OnWhitespace } - } - }, - State::Escaped => { - match ch { - 'n' => unescaped.push('\n'), - 't' => unescaped.push('\t'), - 'u' => { - state = State::Unicode; - continue; + _ => Unquoted, + }, + Unquoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + UnquotedEscaped + } else { + Unquoted + } } - // Uncomment if you want to handle '\\' to '\' - // '\\' => unescaped.push('\\'), - _ => { - unescaped.push('\\'); - unescaped.push(ch); + c if c.is_ascii_whitespace() => { + end = i; + OnWhitespace } - } - state = State::Normal; + _ => Unquoted, + }, + UnquotedEscaped => Unquoted, + Quoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + QuoteEscaped + } else { + Quoted + } + } + '\'' => { + end = i; + OnWhitespace + } + _ => Quoted, + }, + QuoteEscaped => Quoted, + Dquoted => match c { + '\\' => { + if cfg!(unix) { + escaped.push_str(&input[unescaped_start..i]); + unescaped_start = i + 1; + DquoteEscaped + } else { + Dquoted + } + } + '"' => { + end = i; + OnWhitespace + } + _ => Dquoted, + }, + DquoteEscaped => Dquoted, + }; + + let c_len = c.len_utf8(); + if i == input.len() - c_len && end == 0 { + end = i + c_len; } - State::Unicode => match ch { - '{' => continue, - '}' => { - let Ok(digit) = u32::from_str_radix(&unicode, 16) else { - return input.into(); - }; - let Some(point) = char::from_u32(digit) else { - return input.into(); - }; - unescaped.push(point); - // Might be more unicode to unescape so clear for reuse. - unicode.clear(); - state = State::Normal; + + if end > 0 { + let esc_trim = escaped.trim(); + let inp = &input[unescaped_start..end]; + + if !(esc_trim.is_empty() && inp.trim().is_empty()) { + if esc_trim.is_empty() { + words.push(inp.into()); + parts.push(inp); + } else { + words.push([escaped, inp.into()].concat().into()); + parts.push(&input[part_start..end]); + escaped = "".to_string(); + } } - _ => unicode.push(ch), - }, + unescaped_start = i + 1; + part_start = i + 1; + end = 0; + } + } + + debug_assert!(words.len() == parts.len()); + + Self { + state, + words, + parts, } } +} - if is_escaped { - unescaped.into() - } else { - input.into() +impl<'a> Shellwords<'a> { + /// Checks that the input ends with a whitespace character which is not escaped. + /// + /// # Examples + /// + /// ```rust + /// use helix_core::shellwords::Shellwords; + /// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true); + /// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true); + /// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true); + /// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false); + /// #[cfg(unix)] + /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false); + /// #[cfg(unix)] + /// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false); + /// ``` + pub fn ends_with_whitespace(&self) -> bool { + matches!(self.state, State::OnWhitespace) + } + + /// Returns the list of shellwords calculated from the input string. + pub fn words(&self) -> &[Cow<'a, str>] { + &self.words + } + + /// Returns a list of strings which correspond to [`Self::words`] but represent the original + /// text in the input string - including escape characters - without separating whitespace. + pub fn parts(&self) -> &[&'a str] { + &self.parts } } @@ -508,202 +201,114 @@ mod test { use super::*; #[test] - fn base() { + #[cfg(windows)] + fn test_normal() { let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#; let shellwords = Shellwords::from(input); - let args = vec![ - "single_word", - "twó", - "wörds", - r"\three\", - r#"\"with\"#, - r"escaping\\", - ]; - - assert_eq!(":o", shellwords.command()); - assert_eq!(args, shellwords.args().collect::>()); - } - - #[test] - fn should_have_empty_args() { - let shellwords = Shellwords::from(":quit"); - assert!( - shellwords.args().is_empty(), - "args: `{}`", - shellwords.args().next().unwrap() - ); - assert!(shellwords.args().next().is_none()); - } - - #[test] - fn should_return_empty_command() { - let shellwords = Shellwords::from(" "); - assert!(shellwords.command().is_empty()); - } - - #[test] - fn should_support_unicode_args() { - assert_eq!( - Shellwords::from(":sh echo 𒀀").args().collect::>(), - &["echo", "𒀀"] - ); - assert_eq!( - Shellwords::from(":sh echo 𒀀 hello world𒀀") - .args() - .collect::>(), - &["echo", "𒀀", "hello", "world𒀀"] - ); - } - - #[test] - fn should_preserve_quote_if_last_argument() { - let sh = Shellwords::from(r#":read "file with space.txt"""#); - let mut args = sh.args(); - assert_eq!("file with space.txt", args.next().unwrap()); - assert_eq!(r#"""#, args.next().unwrap()); - } - - #[test] - fn should_return_rest_of_non_closed_quote_as_one_argument() { - let sh = Shellwords::from(r":rename 'should be one \'argument"); - assert_eq!(r"should be one \'argument", sh.args().next().unwrap()); - } - - #[test] - fn should_respect_escaped_quote_in_what_looks_like_non_closed_arg() { - let sh = Shellwords::from(r":rename 'should be one \\'argument"); - let mut args = sh.args(); - assert_eq!(r"should be one \\", args.next().unwrap()); - assert_eq!(r"argument", args.next().unwrap()); - } - - #[test] - fn should_split_args() { - assert_eq!(Shellwords::from(":o a").args().collect::>(), &["a"]); - assert_eq!( - Shellwords::from(":o a\\ ").args().collect::>(), - &["a\\"] - ); - } - - #[test] - fn should_parse_args_even_with_leading_whitespace() { - // Three spaces - assert_eq!( - Shellwords::from(":o a").args().collect::>(), - &["a"] - ); - } - - #[test] - fn should_peek_next_arg_and_not_consume() { - let mut args = Shellwords::from(":o a").args(); - - assert_eq!(Some("a"), args.peek()); - assert_eq!(Some("a"), args.next()); - assert_eq!(None, args.next()); - } - - #[test] - fn should_parse_single_quotes_while_respecting_escapes() { - let quoted = - r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#; - let shellwords = Shellwords::from(quoted); - let result = shellwords.args().collect::>(); + let result = shellwords.words().to_vec(); let expected = vec![ - "single_word", - "twó wörds", - "", - " ", - r#"\three\' \"with\ escaping\\"#, - "quote incomplete", + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó"), + Cow::from("wörds"), + Cow::from("\\three\\"), + Cow::from("\\"), + Cow::from("with\\ escaping\\\\"), ]; + // TODO test is_owned and is_borrowed, once they get stabilized. assert_eq!(expected, result); } - #[test] - fn should_parse_double_quotes_while_respecting_escapes() { - let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#; - let shellwords = Shellwords::from(dquoted); - let result = shellwords.args().collect::>(); - let expected = vec![ - "single_word", - "twó wörds", - "", - " ", - r#"\three\' \"with\ escaping\\"#, - "dquote incomplete", - ]; - assert_eq!(expected, result); - } - - #[test] - fn should_respect_escapes_with_mixed_quotes() { - let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#; - let shellwords = Shellwords::from(dquoted); - let result = shellwords.args().collect::>(); - let expected = vec![ - "single_word", - "twó wörds", - r#"\three\' \"with\ escaping\\"#, - "no space before", - "and after", - "$#%^@", - "%^&(%^", - r")(*&^%", - r"a\\\\\b", - // Last ' is important, as if the user input an accidental quote at the end, this should be checked in - // commands where there should only be one input and return an error rather than silently succeed. - "'", - ]; - assert_eq!(expected, result); - } - - #[test] - fn should_return_rest() { - let input = r#":set statusline.center ["file-type","file-encoding"]"#; - let shellwords = Shellwords::from(input); - let mut args = shellwords.args(); - assert_eq!(":set", shellwords.command()); - assert_eq!(Some("statusline.center"), args.next()); - assert_eq!(r#"["file-type","file-encoding"]"#, args.rest()); - } - - #[test] - fn should_return_no_args() { - let mut args = Args::parse(""); - assert!(args.next().is_none()); - assert!(args.is_empty()); - assert!(args.arg_count() == 0); - } - - #[test] - fn should_leave_escaped_quotes() { - let input = r#"\" \` \' \"with \'with \`with"#; - let result = Args::parse(input).collect::>(); - assert_eq!(r#"\""#, result[0]); - assert_eq!(r"\`", result[1]); - assert_eq!(r"\'", result[2]); - assert_eq!(r#"\"with"#, result[3]); - assert_eq!(r"\'with", result[4]); - assert_eq!(r"\`with", result[5]); - } - - #[test] - fn should_leave_literal_newline_alone() { - let result = Args::parse(r"\n").collect::>(); - assert_eq!(r"\n", result[0]); - } - - #[test] - fn should_leave_literal_unicode_alone() { - let result = Args::parse(r"\u{C}").collect::>(); - assert_eq!(r"\u{C}", result[0]); - } - #[test] #[cfg(unix)] - fn should_escape_unix() { + fn test_normal() { + let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#; + let shellwords = Shellwords::from(input); + let result = shellwords.words().to_vec(); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó"), + Cow::from("wörds"), + Cow::from(r#"three "with escaping\"#), + ]; + // TODO test is_owned and is_borrowed, once they get stabilized. + assert_eq!(expected, result); + } + + #[test] + #[cfg(unix)] + fn test_quoted() { + let quoted = + r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#; + let shellwords = Shellwords::from(quoted); + let result = shellwords.words().to_vec(); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó wörds"), + Cow::from(r#"three' "with escaping\"#), + Cow::from("quote incomplete"), + ]; + assert_eq!(expected, result); + } + + #[test] + #[cfg(unix)] + fn test_dquoted() { + let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#; + let shellwords = Shellwords::from(dquoted); + let result = shellwords.words().to_vec(); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó wörds"), + Cow::from(r#"three' "with escaping\"#), + Cow::from("dquote incomplete"), + ]; + assert_eq!(expected, result); + } + + #[test] + #[cfg(unix)] + fn test_mixed() { + let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#; + let shellwords = Shellwords::from(dquoted); + let result = shellwords.words().to_vec(); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó wörds"), + Cow::from("three' \"with escaping\\"), + Cow::from("no space before"), + Cow::from("and after"), + Cow::from("$#%^@"), + Cow::from("%^&(%^"), + Cow::from(")(*&^%"), + Cow::from(r#"a\\b"#), + //last ' just changes to quoted but since we dont have anything after it, it should be ignored + ]; + assert_eq!(expected, result); + } + + #[test] + fn test_lists() { + let input = + r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "quotes"]'"#; + let shellwords = Shellwords::from(input); + let result = shellwords.words().to_vec(); + let expected = vec![ + Cow::from(":set"), + Cow::from("statusline.center"), + Cow::from(r#"["file-type","file-encoding"]"#), + Cow::from(r#"["list", "in", "quotes"]"#), + ]; + assert_eq!(expected, result); + } + + #[test] + #[cfg(unix)] + fn test_escaping_unix() { assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar")); assert_eq!(escape("foo bar".into()), Cow::Borrowed("foo\\ bar")); assert_eq!(escape("foo\tbar".into()), Cow::Borrowed("foo\\\tbar")); @@ -711,79 +316,35 @@ mod test { #[test] #[cfg(windows)] - fn should_escape_windows() { + fn test_escaping_windows() { assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar")); assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\"")); } #[test] - fn should_unescape_newline() { - let unescaped = unescape("hello\\nworld"); - assert_eq!("hello\nworld", unescaped); + #[cfg(unix)] + fn test_parts() { + assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]); + assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\ "]); } #[test] - fn should_unescape_tab() { - let unescaped = unescape("hello\\tworld"); - assert_eq!("hello\tworld", unescaped); + #[cfg(windows)] + fn test_parts() { + assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]); + assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\"]); } #[test] - fn should_unescape_unicode() { - let unescaped = unescape("hello\\u{1f929}world"); - assert_eq!("hello\u{1f929}world", unescaped, "char: 🤩 "); - assert_eq!("hello🤩world", unescaped); - } - - #[test] - fn should_return_original_input_due_to_bad_unicode() { - let unescaped = unescape("hello\\u{999999999}world"); - assert_eq!("hello\\u{999999999}world", unescaped); - } - - #[test] - fn should_not_unescape_slash() { - let unescaped = unescape(r"hello\\world"); - assert_eq!(r"hello\\world", unescaped); - - let unescaped = unescape(r"hello\\\\world"); - assert_eq!(r"hello\\\\world", unescaped); - } - - #[test] - fn should_not_unescape_slash_single_quote() { - let unescaped = unescape("\\'"); - assert_eq!(r"\'", unescaped); - } - - #[test] - fn should_not_unescape_slash_double_quote() { - let unescaped = unescape("\\\""); - assert_eq!(r#"\""#, unescaped); - } - - #[test] - fn should_not_change_anything() { - let unescaped = unescape("'"); - assert_eq!("'", unescaped); - let unescaped = unescape(r#"""#); - assert_eq!(r#"""#, unescaped); - } - - #[test] - fn should_only_unescape_newline_not_slash_single_quote() { - let unescaped = unescape("\\n\'"); - assert_eq!("\n'", unescaped); - let unescaped = unescape("\\n\\'"); - assert_eq!("\n\\'", unescaped); - } - - #[test] - fn should_unescape_args() { - // 1f929: 🤩 - let args = Args::parse(r#"'hello\u{1f929} world' '["hello", "\u{1f929}", "world"]'"#) - .collect::>(); - assert_eq!("hello\u{1f929} world", unescape(args[0])); - assert_eq!(r#"["hello", "🤩", "world"]"#, unescape(args[1])); + fn test_multibyte_at_end() { + assert_eq!(Shellwords::from("𒀀").parts(), &["𒀀"]); + assert_eq!( + Shellwords::from(":sh echo 𒀀").parts(), + &[":sh", "echo", "𒀀"] + ); + assert_eq!( + Shellwords::from(":sh echo 𒀀 hello world𒀀").parts(), + &[":sh", "echo", "𒀀", "hello", "world𒀀"] + ); } } diff --git a/helix-loader/Cargo.toml b/helix-loader/Cargo.toml index b87a9184..d97bf9d1 100644 --- a/helix-loader/Cargo.toml +++ b/helix-loader/Cargo.toml @@ -30,7 +30,7 @@ log = "0.4" # cloning/compiling tree-sitter grammars cc = { version = "1" } threadpool = { version = "1.0" } -tempfile = "3.14.0" +tempfile.workspace = true dunce = "1.0.5" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/helix-loader/build.rs b/helix-loader/build.rs index cfa3a9ad..22f2fa8f 100644 --- a/helix-loader/build.rs +++ b/helix-loader/build.rs @@ -6,14 +6,6 @@ const MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR"); const MINOR: &str = env!("CARGO_PKG_VERSION_MINOR"); const PATCH: &str = env!("CARGO_PKG_VERSION_PATCH"); -fn get_calver() -> String { - if PATCH == "0" { - format!("{MAJOR}.{MINOR}") - } else { - format!("{MAJOR}.{MINOR}.{PATCH}") - } -} - fn main() { let git_hash = Command::new("git") .args(["rev-parse", "HEAD"]) @@ -23,7 +15,17 @@ fn main() { .and_then(|x| String::from_utf8(x.stdout).ok()) .or_else(|| option_env!("HELIX_NIX_BUILD_REV").map(|s| s.to_string())); - let calver = get_calver(); + let minor = if MINOR.len() == 1 { + // Print single-digit months in '0M' format + format!("0{MINOR}") + } else { + MINOR.to_string() + }; + let calver = if PATCH == "0" { + format!("{MAJOR}.{minor}") + } else { + format!("{MAJOR}.{minor}.{PATCH}") + }; let version: Cow<_> = match &git_hash { Some(git_hash) => format!("{} ({})", calver, &git_hash[..8]).into(), None => calver.into(), diff --git a/helix-lsp-types/Cargo.toml b/helix-lsp-types/Cargo.toml index fa3f3aba..9f9900fe 100644 --- a/helix-lsp-types/Cargo.toml +++ b/helix-lsp-types/Cargo.toml @@ -21,9 +21,9 @@ keywords = ["language", "server", "lsp", "vscode", "lsif"] license = "MIT" [dependencies] -bitflags = "2.6.0" +bitflags.workspace = true serde = { version = "1.0.217", features = ["derive"] } -serde_json = "1.0.134" +serde_json = "1.0.135" serde_repr = "0.1" url = {version = "2.5.4", features = ["serde"]} diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index c68097ee..12da18e9 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.42", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } +tokio = { version = "1.43", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } tokio-stream = "0.1.17" parking_lot = "0.12.3" arc-swap = "1" diff --git a/helix-stdx/Cargo.toml b/helix-stdx/Cargo.toml index 84313b5b..d575a28f 100644 --- a/helix-stdx/Cargo.toml +++ b/helix-stdx/Cargo.toml @@ -17,7 +17,7 @@ etcetera = "0.8" ropey = { version = "1.6.1", default-features = false } which = "7.0" regex-cursor = "0.1.4" -bitflags = "2.6" +bitflags.workspace = true once_cell = "1.19" regex-automata = "0.4.9" @@ -28,4 +28,4 @@ windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Securit rustix = { version = "0.38", features = ["fs"] } [dev-dependencies] -tempfile = "3.14" +tempfile.workspace = true diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index e8c2db24..83d6ccc9 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -59,7 +59,7 @@ content_inspector = "0.2.4" thiserror.workspace = true # opening URLs -open = "5.3.1" +open = "5.3.2" url = "2.5.4" # config @@ -85,5 +85,5 @@ helix-loader = { path = "../helix-loader" } [dev-dependencies] smallvec = "1.13" indoc = "2.0.5" -tempfile = "3.14.0" +tempfile.workspace = true same-file = "1.0.1" diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 3db06d80..b2f814c3 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -30,9 +30,7 @@ use helix_core::{ object, pos_at_coords, regex::{self, Regex}, search::{self, CharMatcher}, - selection, - shellwords::{self, Args}, - surround, + selection, shellwords, surround, syntax::{BlockCommentToken, LanguageServerFeature}, text_annotations::{Overlay, TextAnnotations}, textobject, @@ -209,7 +207,7 @@ use helix_view::{align_view, Align}; pub enum MappableCommand { Typable { name: String, - args: String, + args: Vec, doc: String, }, Static { @@ -244,17 +242,15 @@ impl MappableCommand { pub fn execute(&self, cx: &mut Context) { match &self { Self::Typable { name, args, doc: _ } => { + let args: Vec> = args.iter().map(Cow::from).collect(); if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { let mut cx = compositor::Context { editor: cx.editor, jobs: cx.jobs, scroll: None, }; - - if let Err(err) = - (command.fun)(&mut cx, Args::from(args), PromptEvent::Validate) - { - cx.editor.set_error(format!("{err}")); + if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { + cx.editor.set_error(format!("{}", e)); } } } @@ -628,15 +624,21 @@ impl std::str::FromStr for MappableCommand { fn from_str(s: &str) -> Result { if let Some(suffix) = s.strip_prefix(':') { - let (name, args) = suffix.split_once(' ').unwrap_or((suffix, "")); + let mut typable_command = suffix.split(' ').map(|arg| arg.trim()); + let name = typable_command + .next() + .ok_or_else(|| anyhow!("Expected typable command name"))?; + let args = typable_command + .map(|s| s.to_owned()) + .collect::>(); typed::TYPABLE_COMMAND_MAP .get(name) .map(|cmd| MappableCommand::Typable { name: cmd.name.to_owned(), doc: format!(":{} {:?}", cmd.name, args), - args: args.to_string(), + args, }) - .ok_or_else(|| anyhow!("No TypableCommand named '{}'", name)) + .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s)) } else if let Some(suffix) = s.strip_prefix('@') { helix_view::input::parse_macro(suffix).map(|keys| Self::Macro { name: s.to_string(), @@ -3101,12 +3103,11 @@ fn buffer_picker(cx: &mut Context) { }) .with_preview(|editor, meta| { let doc = &editor.documents.get(&meta.id)?; - let &view_id = doc.selections().keys().next()?; - let line = doc - .selection(view_id) - .primary() - .cursor_line(doc.text().slice(..)); - Some((meta.id.into(), Some((line, line)))) + let lines = doc.selections().values().next().map(|selection| { + let cursor_line = selection.primary().cursor_line(doc.text().slice(..)); + (cursor_line, cursor_line) + }); + Some((meta.id.into(), lines)) }); cx.push_layer(Box::new(overlaid(picker))); } @@ -3311,7 +3312,7 @@ pub fn command_palette(cx: &mut Context) { .iter() .map(|cmd| MappableCommand::Typable { name: cmd.name.to_owned(), - args: String::new(), + args: Vec::new(), doc: cmd.doc.to_owned(), }), ); @@ -3532,6 +3533,7 @@ fn open(cx: &mut Context, open: Open) { let text = doc.text().slice(..); let contents = doc.text(); let selection = doc.selection(view.id); + let mut offs = 0; let mut ranges = SmallVec::with_capacity(selection.len()); @@ -3608,7 +3610,7 @@ fn open(cx: &mut Context, open: Open) { let text = text.repeat(count); // calculate new selection ranges - let pos = above_next_line_end_index + above_next_line_end_width; + let pos = offs + above_next_line_end_index + above_next_line_end_width; let comment_len = continue_comment_token .map(|token| token.len() + 1) // `+ 1` for the extra space added .unwrap_or_default(); @@ -3621,6 +3623,9 @@ fn open(cx: &mut Context, open: Open) { )); } + // update the offset for the next range + offs += text.chars().count(); + ( above_next_line_end_index, above_next_line_end_index, @@ -4385,19 +4390,13 @@ fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { let (view, doc) = current!(editor); let text = doc.text().slice(..); - let separator = if separator.is_empty() { - doc.line_ending.as_str() - } else { - separator - }; - let selection = doc.selection(view.id); let selections = selection.len(); let joined = selection .fragments(text) .fold(String::new(), |mut acc, fragment| { if !acc.is_empty() { - acc.push_str(&shellwords::unescape(separator)); + acc.push_str(separator); } acc.push_str(&fragment); acc @@ -4844,7 +4843,7 @@ 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')) + 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 diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index a35fa23a..83dd936c 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -109,7 +109,6 @@ fn dap_callback( jobs.callback(callback); } -// TODO: transition to `shellwords::Args` instead of `Option>>` pub fn dap_start_impl( cx: &mut compositor::Context, name: Option<&str>, @@ -313,7 +312,6 @@ pub fn dap_restart(cx: &mut Context) { ); } -// TODO: transition to `shellwords::Args` instead of `Vec` fn debug_parameter_prompt( completions: Vec, config_name: String, diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index fd5f9792..237c5ed4 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -13,7 +13,6 @@ 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; -use shellwords::Args; use ui::completers::{self, Completer}; #[derive(Clone)] @@ -22,17 +21,17 @@ pub struct TypableCommand { pub aliases: &'static [&'static str], pub doc: &'static str, // params, flags, helper, completer - pub fun: fn(&mut compositor::Context, Args, PromptEvent) -> anyhow::Result<()>, + pub fun: fn(&mut compositor::Context, &[Cow], PromptEvent) -> anyhow::Result<()>, /// What completion methods, if any, does this command have? pub signature: CommandSignature, } impl TypableCommand { fn completer_for_argument_number(&self, n: usize) -> &Completer { - self.signature - .positional_args - .get(n) - .unwrap_or(&self.signature.var_args) + match self.signature.positional_args.get(n) { + Some(completer) => completer, + _ => &self.signature.var_args, + } } } @@ -68,7 +67,7 @@ impl CommandSignature { } } -fn quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { log::debug!("quitting..."); if event != PromptEvent::Validate { @@ -79,7 +78,7 @@ fn quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow: // last view and we have unsaved changes if cx.editor.tree.views().count() == 1 { - buffers_remaining_impl(cx.editor)?; + buffers_remaining_impl(cx.editor)? } cx.block_try_flush_writes()?; @@ -88,7 +87,11 @@ fn quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow: Ok(()) } -fn force_quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn force_quit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -101,13 +104,12 @@ fn force_quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> a Ok(()) } -fn open(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(!args.is_empty(), ":open needs at least one argument"); - + ensure!(!args.is_empty(), "wrong argument count"); for arg in args { let (path, pos) = args::parse_file(arg); let path = helix_stdx::path::expand_tilde(path); @@ -173,7 +175,7 @@ fn buffer_close_by_ids_impl( Ok(()) } -fn buffer_gather_paths_impl(editor: &mut Editor, args: Args) -> Vec { +fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow]) -> Vec { // No arguments implies current document if args.is_empty() { let doc_id = view!(editor).doc; @@ -184,7 +186,7 @@ fn buffer_gather_paths_impl(editor: &mut Editor, args: Args) -> Vec let mut document_ids = vec![]; for arg in args { let doc_id = editor.documents().find_map(|doc| { - let arg_path = Some(Path::new(arg)); + let arg_path = Some(Path::new(arg.as_ref())); if doc.path().map(|p| p.as_path()) == arg_path || doc.relative_path() == arg_path { Some(doc.id()) } else { @@ -210,7 +212,7 @@ fn buffer_gather_paths_impl(editor: &mut Editor, args: Args) -> Vec fn buffer_close( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -223,7 +225,7 @@ fn buffer_close( fn force_buffer_close( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -245,7 +247,7 @@ fn buffer_gather_others_impl(editor: &mut Editor) -> Vec { fn buffer_close_others( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -258,7 +260,7 @@ fn buffer_close_others( fn force_buffer_close_others( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -275,7 +277,7 @@ fn buffer_gather_all_impl(editor: &mut Editor) -> Vec { fn buffer_close_all( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -288,7 +290,7 @@ fn buffer_close_all( fn force_buffer_close_all( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -301,7 +303,7 @@ fn force_buffer_close_all( fn buffer_next( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -314,7 +316,7 @@ fn buffer_next( fn buffer_previous( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -325,10 +327,15 @@ fn buffer_previous( Ok(()) } -fn write_impl(cx: &mut compositor::Context, path: Option<&str>, force: bool) -> anyhow::Result<()> { +fn write_impl( + cx: &mut compositor::Context, + path: Option<&Cow>, + force: bool, +) -> anyhow::Result<()> { let config = cx.editor.config(); let jobs = &mut cx.jobs; let (view, doc) = current!(cx.editor); + let path = path.map(AsRef::as_ref); if config.insert_final_newline { insert_final_newline(doc, view.id); @@ -370,36 +377,40 @@ fn insert_final_newline(doc: &mut Document, view_id: ViewId) { } } -fn write(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn write( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.next(), false) + write_impl(cx, args.first(), false) } fn force_write( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.next(), true) + write_impl(cx, args.first(), true) } fn write_buffer_close( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.next(), false)?; + write_impl(cx, args.first(), false)?; let document_ids = buffer_gather_paths_impl(cx.editor, args); buffer_close_by_ids_impl(cx, &document_ids, false) @@ -407,20 +418,24 @@ fn write_buffer_close( fn force_write_buffer_close( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.next(), true)?; + write_impl(cx, args.first(), true)?; let document_ids = buffer_gather_paths_impl(cx.editor, args); buffer_close_by_ids_impl(cx, &document_ids, false) } -fn new_file(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn new_file( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -430,7 +445,11 @@ fn new_file(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> an Ok(()) } -fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn format( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -447,7 +466,7 @@ fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh fn set_indent_style( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -468,9 +487,9 @@ fn set_indent_style( } // Attempt to parse argument as an indent style. - let style = match args.next() { + let style = match args.first() { Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs), - Some("0") => Some(Tabs), + Some(Cow::Borrowed("0")) => Some(Tabs), Some(arg) => arg .parse::() .ok() @@ -489,7 +508,7 @@ fn set_indent_style( /// Sets or reports the current document's line ending setting. fn set_line_ending( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -520,7 +539,7 @@ fn set_line_ending( } let arg = args - .next() + .first() .context("argument missing")? .to_ascii_lowercase(); @@ -559,12 +578,16 @@ fn set_line_ending( Ok(()) } -fn earlier(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn earlier( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let uk = args.raw().parse::().map_err(|s| anyhow!(s))?; + let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); let success = doc.earlier(view, uk); @@ -575,13 +598,16 @@ fn earlier(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyh Ok(()) } -fn later(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn later( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let uk = args.raw().parse::().map_err(|s| anyhow!(s))?; - + let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); let success = doc.later(view, uk); if !success { @@ -593,30 +619,30 @@ fn later(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow fn write_quit( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.next(), false)?; + write_impl(cx, args.first(), false)?; cx.block_try_flush_writes()?; - quit(cx, Args::empty(), event) + quit(cx, &[], event) } fn force_write_quit( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.next(), true)?; + write_impl(cx, args.first(), true)?; cx.block_try_flush_writes()?; - force_quit(cx, Args::empty(), event) + force_quit(cx, &[], event) } /// Results in an error if there are modified buffers remaining and sets editor @@ -723,7 +749,11 @@ pub fn write_all_impl( Ok(()) } -fn write_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn write_all( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -733,7 +763,7 @@ fn write_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> a fn force_write_all( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -745,7 +775,7 @@ fn force_write_all( fn write_all_quit( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -757,7 +787,7 @@ fn write_all_quit( fn force_write_all_quit( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -782,31 +812,41 @@ fn quit_all_impl(cx: &mut compositor::Context, force: bool) -> anyhow::Result<() Ok(()) } -fn quit_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { - if event != PromptEvent::Validate { - return Ok(()); - } - quit_all_impl(cx, false) -} - -fn force_quit_all( +fn quit_all( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + + quit_all_impl(cx, false) +} + +fn force_quit_all( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + quit_all_impl(cx, true) } -fn cquit(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn cquit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let exit_code = args - .next() + .first() .and_then(|code| code.parse::().ok()) .unwrap_or(1); @@ -816,7 +856,7 @@ fn cquit(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> an fn force_cquit( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -824,7 +864,7 @@ fn force_cquit( } let exit_code = args - .next() + .first() .and_then(|code| code.parse::().ok()) .unwrap_or(1); cx.editor.exit_code = exit_code; @@ -832,7 +872,11 @@ fn force_cquit( quit_all_impl(cx, true) } -fn theme(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn theme( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { let true_color = cx.editor.config.load().true_color || crate::true_color(); match event { PromptEvent::Abort => { @@ -842,7 +886,7 @@ fn theme(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> an if args.is_empty() { // Ensures that a preview theme gets cleaned up if the user backspaces until the prompt is empty. cx.editor.unset_theme_preview(); - } else if let Some(theme_name) = args.next() { + } else if let Some(theme_name) = args.first() { if let Ok(theme) = cx.editor.theme_loader.load(theme_name) { if !(true_color || theme.is_16_color()) { bail!("Unsupported theme: theme requires true color support"); @@ -852,7 +896,7 @@ fn theme(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> an }; } PromptEvent::Validate => { - if let Some(theme_name) = args.next() { + if let Some(theme_name) = args.first() { let theme = cx .editor .theme_loader @@ -875,142 +919,168 @@ fn theme(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> an fn yank_main_selection_to_clipboard( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + yank_primary_selection_impl(cx.editor, '+'); Ok(()) } -fn yank_joined(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn yank_joined( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + + ensure!(args.len() <= 1, ":yank-join takes at most 1 argument"); + + let doc = doc!(cx.editor); + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); let register = cx.editor.selected_register.unwrap_or('"'); - yank_joined_impl(cx.editor, args.raw(), register); + yank_joined_impl(cx.editor, separator, register); Ok(()) } fn yank_joined_to_clipboard( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - yank_joined_impl(cx.editor, args.raw(), '+'); + + let doc = doc!(cx.editor); + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); + yank_joined_impl(cx.editor, separator, '+'); Ok(()) } fn yank_main_selection_to_primary_clipboard( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + yank_primary_selection_impl(cx.editor, '*'); Ok(()) } fn yank_joined_to_primary_clipboard( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - yank_joined_impl(cx.editor, args.raw(), '*'); + let doc = doc!(cx.editor); + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); + yank_joined_impl(cx.editor, separator, '*'); Ok(()) } fn paste_clipboard_after( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + paste(cx.editor, '+', Paste::After, 1); Ok(()) } fn paste_clipboard_before( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + paste(cx.editor, '+', Paste::Before, 1); Ok(()) } fn paste_primary_clipboard_after( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + paste(cx.editor, '*', Paste::After, 1); Ok(()) } fn paste_primary_clipboard_before( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + paste(cx.editor, '*', Paste::Before, 1); Ok(()) } fn replace_selections_with_clipboard( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + replace_with_yanked_impl(cx.editor, '+', 1); Ok(()) } fn replace_selections_with_primary_clipboard( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + replace_with_yanked_impl(cx.editor, '*', 1); Ok(()) } fn show_clipboard_provider( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + cx.editor .set_status(cx.editor.registers.clipboard_provider_name()); Ok(()) @@ -1018,14 +1088,14 @@ fn show_clipboard_provider( fn change_current_directory( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let dir = match args.next() { + let dir = match args.first().map(AsRef::as_ref) { Some("-") => cx .editor .get_last_cwd() @@ -1035,7 +1105,12 @@ fn change_current_directory( None => Cow::Owned(home_dir()?), }; - cx.editor.set_cwd(&dir)?; + cx.editor.set_cwd(&dir).map_err(|err| { + anyhow!( + "Could not change working directory to '{}': {err}", + dir.display() + ) + })?; cx.editor.set_status(format!( "Current working directory is now {}", @@ -1047,7 +1122,7 @@ fn change_current_directory( fn show_current_directory( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1060,7 +1135,7 @@ fn show_current_directory( if cwd.exists() { cx.editor.set_status(message); } else { - cx.editor.set_error(format!("{message} (deleted)")); + cx.editor.set_error(format!("{} (deleted)", message)); } Ok(()) } @@ -1068,7 +1143,7 @@ fn show_current_directory( /// Sets the [`Document`]'s encoding.. fn set_encoding( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1076,7 +1151,7 @@ fn set_encoding( } let doc = doc_mut!(cx.editor); - if let Some(label) = args.next() { + if let Some(label) = args.first() { doc.set_encoding(label) } else { let encoding = doc.encoding().name().to_owned(); @@ -1088,7 +1163,7 @@ fn set_encoding( /// Shows info about the character under the primary cursor. fn get_character_info( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1211,7 +1286,11 @@ fn get_character_info( } /// Reload the [`Document`] from its source file. -fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn reload( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1230,7 +1309,11 @@ fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh Ok(()) } -fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn reload_all( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1286,7 +1369,11 @@ fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> } /// Update the [`Document`] if it has been modified. -fn update(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn update( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1301,7 +1388,7 @@ fn update(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyho fn lsp_workspace_command( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1357,8 +1444,7 @@ fn lsp_workspace_command( }; cx.jobs.callback(callback); } else { - let command = args.raw().to_string(); - + let command = args.join(" "); let matches: Vec<_> = ls_id_commands .filter(|(_ls_id, c)| *c == &command) .collect(); @@ -1392,7 +1478,7 @@ fn lsp_workspace_command( fn lsp_restart( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1438,7 +1524,11 @@ fn lsp_restart( Ok(()) } -fn lsp_stop(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn lsp_stop( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1465,7 +1555,7 @@ fn lsp_stop(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> an fn tree_sitter_scopes( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1498,7 +1588,7 @@ fn tree_sitter_scopes( fn tree_sitter_highlight_name( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { fn find_highlight_at_cursor( @@ -1571,50 +1661,81 @@ fn tree_sitter_highlight_name( Ok(()) } -fn vsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn vsplit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); - } else if args.is_empty() { + } + + if args.is_empty() { split(cx.editor, Action::VerticalSplit); } else { for arg in args { - cx.editor.open(&PathBuf::from(arg), Action::VerticalSplit)?; + cx.editor + .open(&PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; } } + Ok(()) } -fn hsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn hsplit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); - } else if args.is_empty() { + } + + if args.is_empty() { split(cx.editor, Action::HorizontalSplit); } else { for arg in args { cx.editor - .open(&PathBuf::from(arg), Action::HorizontalSplit)?; + .open(&PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; } } + Ok(()) } -fn vsplit_new(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn vsplit_new( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + cx.editor.new_file(Action::VerticalSplit); + Ok(()) } -fn hsplit_new(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn hsplit_new( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + cx.editor.new_file(Action::HorizontalSplit); + Ok(()) } -fn debug_eval(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn debug_eval( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1628,10 +1749,9 @@ fn debug_eval(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> a }; // TODO: support no frame_id - let frame_id = debugger.stack_frames[&thread_id][frame].id; - let expression = args.raw().to_string(); - let response = helix_lsp::block_on(debugger.eval(expression, Some(frame_id)))?; + let frame_id = debugger.stack_frames[&thread_id][frame].id; + let response = helix_lsp::block_on(debugger.eval(args.join(" "), Some(frame_id)))?; cx.editor.set_status(response.result); } Ok(()) @@ -1639,33 +1759,47 @@ fn debug_eval(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> a fn debug_start( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - dap_start_impl(cx, args.next(), None, Some(args.map(Into::into).collect())) + + let mut args = args.to_owned(); + let name = match args.len() { + 0 => None, + _ => Some(args.remove(0)), + }; + dap_start_impl(cx, name.as_deref(), None, Some(args)) } fn debug_remote( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let address = args.next().map(|addr| addr.parse()).transpose()?; - dap_start_impl( - cx, - args.next(), - address, - Some(args.map(Into::into).collect()), - ) + + let mut args = args.to_owned(); + let address = match args.len() { + 0 => None, + _ => Some(args.remove(0).parse()?), + }; + let name = match args.len() { + 0 => None, + _ => Some(args.remove(0)), + }; + dap_start_impl(cx, name.as_deref(), address, Some(args)) } -fn tutor(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn tutor( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1689,7 +1823,7 @@ fn abort_goto_line_number_preview(cx: &mut compositor::Context) { fn update_goto_line_number_preview( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], ) -> anyhow::Result<()> { cx.editor.last_selection.get_or_insert_with(|| { let (view, doc) = current!(cx.editor); @@ -1697,7 +1831,7 @@ fn update_goto_line_number_preview( }); let scrolloff = cx.editor.config().scrolloff; - let line = args.next().unwrap().parse::()?; + let line = args[0].parse::()?; goto_line_without_jumplist(cx.editor, NonZeroUsize::new(line)); let (view, doc) = current!(cx.editor); @@ -1708,7 +1842,7 @@ fn update_goto_line_number_preview( pub(super) fn goto_line_number( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { match event { @@ -1744,18 +1878,18 @@ pub(super) fn goto_line_number( // Fetch the current value of a config option and output as status. fn get_option( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - if args.arg_count() != 1 { + if args.len() != 1 { anyhow::bail!("Bad arguments. Usage: `:get key`"); } - let key = args.next().unwrap().to_lowercase(); + let key = &args[0].to_lowercase(); let key_error = || anyhow::anyhow!("Unknown key `{}`", key); let config = serde_json::json!(cx.editor.config().deref()); @@ -1770,61 +1904,46 @@ fn get_option( /// example to disable smart case search, use `:set search.smart-case false`. fn set_option( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let Some(key) = args.next().map(|arg| arg.to_lowercase()) else { - anyhow::bail!("Bad arguments. Usage: `:set key field`, didn't provide `key`"); - }; - - let field = args.rest(); - - if field.is_empty() { - anyhow::bail!("Bad arguments. Usage: `:set key field`, didn't provide `field`"); + if args.len() != 2 { + anyhow::bail!("Bad arguments. Usage: `:set key field`"); } + let (key, arg) = (&args[0].to_lowercase(), &args[1]); - let mut config = serde_json::json!(&*cx.editor.config()); + let key_error = || anyhow::anyhow!("Unknown key `{}`", key); + let field_error = |_| anyhow::anyhow!("Could not parse field `{}`", arg); + + let mut config = serde_json::json!(&cx.editor.config().deref()); let pointer = format!("/{}", key.replace('.', "/")); - let value = config - .pointer_mut(&pointer) - .ok_or_else(|| anyhow::anyhow!("Unknown key `{key}`"))?; + let value = config.pointer_mut(&pointer).ok_or_else(key_error)?; *value = if value.is_string() { // JSON strings require quotes, so we can't .parse() directly - Value::String(field.to_string()) + Value::String(arg.to_string()) } else { - field - .parse() - .map_err(|err| anyhow::anyhow!("Could not parse field `{field}`: {err}"))? + arg.parse().map_err(field_error)? }; - - let config = serde_json::from_value(config).expect( - "`Config` was already deserialized, serialization is just a 'repacking' and should be valid", - ); + let config = serde_json::from_value(config).map_err(field_error)?; cx.editor .config_events .0 .send(ConfigEvent::Update(config))?; - - cx.editor - .set_status(format!("'{key}' is now set to {field}")); - Ok(()) } /// Toggle boolean config option at runtime. Access nested values by dot -/// syntax. -/// Example: -/// - `:toggle search.smart-case` (bool) -/// - `:toggle line-number relative absolute` (string) +/// syntax, for example to toggle smart case search, use `:toggle search.smart- +/// case`. fn toggle_option( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1834,98 +1953,73 @@ fn toggle_option( if args.is_empty() { anyhow::bail!("Bad arguments. Usage: `:toggle key [values]?`"); } - let key = args.next().unwrap().to_lowercase(); + let key = &args[0].to_lowercase(); - let mut config = serde_json::json!(&*cx.editor.config()); + let key_error = || anyhow::anyhow!("Unknown key `{}`", key); + + let mut config = serde_json::json!(&cx.editor.config().deref()); let pointer = format!("/{}", key.replace('.', "/")); - let value = config - .pointer_mut(&pointer) - .ok_or_else(|| anyhow::anyhow!("Unknown key `{}`", key))?; + let value = config.pointer_mut(&pointer).ok_or_else(key_error)?; *value = match value { Value::Bool(ref value) => { ensure!( - args.next().is_none(), + args.len() == 1, "Bad arguments. For boolean configurations use: `:toggle key`" ); Value::Bool(!value) } Value::String(ref value) => { ensure!( - // key + arguments - args.arg_count() >= 3, + args.len() > 2, "Bad arguments. For string configurations use: `:toggle key val1 val2 ...`", ); Value::String( - args.clone() + args[1..] + .iter() .skip_while(|e| *e != value) .nth(1) - .unwrap_or_else(|| args.nth(1).unwrap()) + .unwrap_or_else(|| &args[1]) .to_string(), ) } Value::Number(ref value) => { ensure!( - // key + arguments - args.arg_count() >= 3, + args.len() > 2, "Bad arguments. For number configurations use: `:toggle key val1 val2 ...`", ); - let value = value.to_string(); - Value::Number( - args.clone() - .skip_while(|e| *e != value) + args[1..] + .iter() + .skip_while(|&e| value.to_string() != *e.to_string()) .nth(1) - .unwrap_or_else(|| args.nth(1).unwrap()) + .unwrap_or_else(|| &args[1]) .parse()?, ) } - Value::Array(value) => { - let mut lists = serde_json::Deserializer::from_str(args.rest()).into_iter::(); - - let (Some(first), Some(second)) = - (lists.next().transpose()?, lists.next().transpose()?) - else { - anyhow::bail!( - "Bad arguments. For list configurations use: `:toggle key [...] [...]`", - ) - }; - - match (&first, &second) { - (Value::Array(list), Value::Array(_)) => { - if list == value { - second - } else { - first - } - } - _ => anyhow::bail!("values must be lists"), - } - } - Value::Null | Value::Object(_) => { + Value::Null | Value::Object(_) | Value::Array(_) => { anyhow::bail!("Configuration {key} does not support toggle yet") } }; let status = format!("'{key}' is now set to {value}"); - let config = serde_json::from_value(config)?; + let config = serde_json::from_value(config) + .map_err(|err| anyhow::anyhow!("Cannot parse `{:?}`, {}", &args, err))?; cx.editor .config_events .0 .send(ConfigEvent::Update(config))?; - cx.editor.set_status(status); - Ok(()) } /// Change the language of the current buffer at runtime. fn language( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1934,22 +2028,21 @@ fn language( if args.is_empty() { let doc = doc!(cx.editor); - let language = doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME); + let language = &doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME); cx.editor.set_status(language.to_string()); return Ok(()); } - if args.arg_count() != 1 { + if args.len() != 1 { anyhow::bail!("Bad arguments. Usage: `:set-language language`"); } let doc = doc_mut!(cx.editor); - let language_id = args.next().unwrap(); - if language_id == DEFAULT_LANGUAGE_NAME { - doc.set_language(None, None); + if args[0] == DEFAULT_LANGUAGE_NAME { + doc.set_language(None, None) } else { - doc.set_language_by_language_id(language_id, cx.editor.syn_loader.clone())?; + doc.set_language_by_language_id(&args[0], cx.editor.syn_loader.clone())?; } doc.detect_indent_and_line_ending(); @@ -1962,25 +2055,31 @@ fn language( Ok(()) } -fn sort(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn sort(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + sort_impl(cx, args, false) } fn sort_reverse( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + sort_impl(cx, args, true) } -fn sort_impl(cx: &mut compositor::Context, _args: Args, reverse: bool) -> anyhow::Result<()> { +fn sort_impl( + cx: &mut compositor::Context, + _args: &[Cow], + reverse: bool, +) -> anyhow::Result<()> { let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -2012,7 +2111,11 @@ fn sort_impl(cx: &mut compositor::Context, _args: Args, reverse: bool) -> anyhow Ok(()) } -fn reflow(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn reflow( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2026,7 +2129,7 @@ fn reflow(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> a // - The configured text-width for this language in languages.toml // - The configured text-width in the config.toml let text_width: usize = args - .next() + .first() .map(|num| num.parse::()) .transpose()? .or_else(|| doc.language_config().and_then(|config| config.text_width)) @@ -2051,7 +2154,7 @@ fn reflow(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> a fn tree_sitter_subtree( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2090,12 +2193,13 @@ fn tree_sitter_subtree( fn open_config( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + cx.editor .open(&helix_loader::config_file(), Action::Replace)?; Ok(()) @@ -2103,28 +2207,34 @@ fn open_config( fn open_workspace_config( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + cx.editor .open(&helix_loader::workspace_config_file(), Action::Replace)?; Ok(()) } -fn open_log(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn open_log( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + cx.editor.open(&helix_loader::log_file(), Action::Replace)?; Ok(()) } fn refresh_config( cx: &mut compositor::Context, - _args: Args, + _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2137,58 +2247,62 @@ fn refresh_config( fn append_output( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + ensure!(!args.is_empty(), "Shell command required"); - let cmd = helix_core::shellwords::unescape(args.raw()); - shell(cx, &cmd, &ShellBehavior::Append); + shell(cx, &args.join(" "), &ShellBehavior::Append); Ok(()) } fn insert_output( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + ensure!(!args.is_empty(), "Shell command required"); - let cmd = helix_core::shellwords::unescape(args.raw()); - shell(cx, &cmd, &ShellBehavior::Insert); + shell(cx, &args.join(" "), &ShellBehavior::Insert); Ok(()) } -fn pipe_to(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn pipe_to( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { pipe_impl(cx, args, event, &ShellBehavior::Ignore) } -fn pipe(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn pipe(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { pipe_impl(cx, args, event, &ShellBehavior::Replace) } fn pipe_impl( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, behavior: &ShellBehavior, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } + ensure!(!args.is_empty(), "Shell command required"); - let cmd = helix_core::shellwords::unescape(args.raw()); - shell(cx, &cmd, behavior); + shell(cx, &args.join(" "), behavior); Ok(()) } fn run_shell_command( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2196,8 +2310,7 @@ fn run_shell_command( } let shell = cx.editor.config().shell.clone(); - - let args = helix_core::shellwords::unescape(args.raw()).into_owned(); + let args = args.join(" "); let callback = async move { let output = shell_impl_async(&shell, &args, None).await?; @@ -2225,7 +2338,7 @@ fn run_shell_command( fn reset_diff_change( cx: &mut compositor::Context, - args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2233,8 +2346,10 @@ fn reset_diff_change( } ensure!(args.is_empty(), ":reset-diff-change takes no arguments"); - let scrolloff = cx.editor.config().scrolloff; - let (view, doc) = current!(cx.editor); + let editor = &mut cx.editor; + let scrolloff = editor.config().scrolloff; + + let (view, doc) = current!(editor); let Some(handle) = doc.diff_handle() else { bail!("Diff is not available in the current buffer") }; @@ -2276,42 +2391,40 @@ fn reset_diff_change( fn clear_register( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!( - args.arg_count() <= 1, - ":clear-register takes at most 1 argument" - ); - + ensure!(args.len() <= 1, ":clear-register takes at most 1 argument"); if args.is_empty() { cx.editor.registers.clear(); cx.editor.set_status("All registers cleared"); return Ok(()); } - let register = args.next().unwrap(); - ensure!( - register.chars().count() == 1, - format!("Invalid register {register}") + args[0].chars().count() == 1, + format!("Invalid register {}", args[0]) ); - - let register = register.chars().next().unwrap_or_default(); + let register = args[0].chars().next().unwrap_or_default(); if cx.editor.registers.remove(register) { - cx.editor.set_status(format!("Register {register} cleared")); + cx.editor + .set_status(format!("Register {} cleared", register)); } else { cx.editor - .set_error(format!("Register {register} not found")); + .set_error(format!("Register {} not found", register)); } Ok(()) } -fn redraw(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn redraw( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2332,22 +2445,20 @@ fn redraw(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh fn move_buffer( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.arg_count() == 1, format!(":move takes one argument")); - - let old_path = doc!(cx.editor) + ensure!(args.len() == 1, format!(":move takes one argument")); + let doc = doc!(cx.editor); + let old_path = doc .path() .context("Scratch buffer cannot be moved. Use :write instead")? .clone(); - - let new_path = args.next().unwrap(); - + let new_path = args.first().unwrap().to_string(); if let Err(err) = cx.editor.move_path(&old_path, new_path.as_ref()) { bail!("Could not move file: {err}"); } @@ -2356,14 +2467,14 @@ fn move_buffer( fn yank_diagnostic( cx: &mut compositor::Context, - mut args: Args, + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let reg = match args.next() { + let reg = match args.first() { Some(s) => { ensure!(s.chars().count() == 1, format!("Invalid register {s}")); s.chars().next().unwrap() @@ -2394,7 +2505,7 @@ fn yank_diagnostic( Ok(()) } -fn read(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { +fn read(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2403,10 +2514,10 @@ fn read(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> any let (view, doc) = current!(cx.editor); ensure!(!args.is_empty(), "file name is expected"); - ensure!(args.arg_count() == 1, "only the file name is expected"); + ensure!(args.len() == 1, "only the file name is expected"); - let filename = args.next().unwrap(); - let path = helix_stdx::path::expand_tilde(Path::new(filename)); + let filename = args.first().unwrap(); + let path = helix_stdx::path::expand_tilde(PathBuf::from(filename.to_string())); ensure!( path.exists() && path.is_file(), @@ -3165,11 +3276,9 @@ pub(super) fn command_mode(cx: &mut Context) { Some(':'), |editor: &Editor, input: &str| { let shellwords = Shellwords::from(input); - let command = shellwords.command(); + let words = shellwords.words(); - if command.is_empty() - || (shellwords.args().next().is_none() && !shellwords.ends_with_whitespace()) - { + if words.is_empty() || (words.len() == 1 && !shellwords.ends_with_whitespace()) { fuzzy_match( input, TYPABLE_COMMAND_LIST.iter().map(|command| command.name), @@ -3181,61 +3290,67 @@ pub(super) fn command_mode(cx: &mut Context) { } else { // Otherwise, use the command's completer and the last shellword // as completion input. - let (word, len) = shellwords - .args() - .last() - .map_or(("", 0), |last| (last, last.len())); + let (word, word_len) = if words.len() == 1 || shellwords.ends_with_whitespace() { + (&Cow::Borrowed(""), 0) + } else { + (words.last().unwrap(), words.last().unwrap().len()) + }; - TYPABLE_COMMAND_MAP - .get(command) - .map(|tc| tc.completer_for_argument_number(argument_number_of(&shellwords))) - .map_or_else(Vec::new, |completer| { - completer(editor, word) - .into_iter() - .map(|(range, mut file)| { - file.content = shellwords::escape(file.content); + let argument_number = argument_number_of(&shellwords); - // offset ranges to input - let offset = input.len() - len; - let range = (range.start + offset)..; - (range, file) - }) - .collect() - }) + if let Some(completer) = TYPABLE_COMMAND_MAP + .get(&words[0] as &str) + .map(|tc| tc.completer_for_argument_number(argument_number)) + { + completer(editor, word) + .into_iter() + .map(|(range, mut file)| { + file.content = shellwords::escape(file.content); + + // offset ranges to input + let offset = input.len() - word_len; + let range = (range.start + offset)..; + (range, file) + }) + .collect() + } else { + Vec::new() + } } }, // completion move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - let shellwords = Shellwords::from(input); - let command = shellwords.command(); - - if command.is_empty() { + let parts = input.split_whitespace().collect::>(); + if parts.is_empty() { return; } - // If input is `:NUMBER`, interpret as line number and go there. - if command.parse::().is_ok() { - if let Err(err) = typed::goto_line_number(cx, Args::from(command), event) { - cx.editor.set_error(format!("{err}")); + // If command is numeric, interpret as line number and go there. + if parts.len() == 1 && parts[0].parse::().ok().is_some() { + if let Err(e) = typed::goto_line_number(cx, &[Cow::from(parts[0])], event) { + cx.editor.set_error(format!("{}", e)); } return; } // Handle typable commands - if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(command) { - if let Err(err) = (cmd.fun)(cx, shellwords.args(), event) { - cx.editor.set_error(format!("{err}")); + if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { + let shellwords = Shellwords::from(input); + let args = shellwords.words(); + + if let Err(e) = (cmd.fun)(cx, &args[1..], event) { + cx.editor.set_error(format!("{}", e)); } } else if event == PromptEvent::Validate { - cx.editor.set_error(format!("no such command: '{command}'")); + cx.editor + .set_error(format!("no such command: '{}'", parts[0])); } }, ); - prompt.doc_fn = Box::new(|input: &str| { - let shellwords = Shellwords::from(input); + let part = input.split(' ').next().unwrap_or_default(); if let Some(typed::TypableCommand { doc, aliases, .. }) = - typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) + typed::TYPABLE_COMMAND_MAP.get(part) { if aliases.is_empty() { return Some((*doc).into()); @@ -3252,10 +3367,11 @@ pub(super) fn command_mode(cx: &mut Context) { } fn argument_number_of(shellwords: &Shellwords) -> usize { - shellwords - .args() - .arg_count() - .saturating_sub(1 - usize::from(shellwords.ends_with_whitespace())) + if shellwords.ends_with_whitespace() { + shellwords.words().len().saturating_sub(1) + } else { + shellwords.words().len().saturating_sub(2) + } } #[test] diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index aa9cafd3..020ecaf4 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -597,14 +597,18 @@ mod tests { let expectation = KeyTrie::Node(KeyTrieNode::new( "", hashmap! { - key => KeyTrie::Sequence(vec![ + key => KeyTrie::Sequence(vec!{ MappableCommand::select_all, MappableCommand::Typable { name: "pipe".to_string(), - args: String::from("sed -E 's/\\s+$//g'"), - doc: String::new(), + args: vec!{ + "sed".to_string(), + "-E".to_string(), + "'s/\\s+$//g'".to_string() + }, + doc: "".to_string(), }, - ]) + }) }, vec![key], )); diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index 32badaa4..3e2d4b52 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -665,6 +665,14 @@ async fn test_join_selections_comment() -> anyhow::Result<()> { )) .await?; + test(( + "#[|\t// Join comments +\t// with indent]#", + ":lang goJ", + "#[|\t// Join comments with indent]#", + )) + .await?; + Ok(()) } diff --git a/helix-term/tests/test/commands/insert.rs b/helix-term/tests/test/commands/insert.rs index f7aa4a02..f23876df 100644 --- a/helix-term/tests/test/commands/insert.rs +++ b/helix-term/tests/test/commands/insert.rs @@ -184,6 +184,127 @@ async fn test_open_above() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn test_open_above_with_multiple_cursors() -> anyhow::Result<()> { + // the primary cursor is also in the top line + test(( + indoc! {"#[H|]#elix + #(i|)#s + #(c|)#ool"}, + "O", + indoc! { + "#[\n|]# + Helix + #(\n|)# + is + #(\n|)# + cool + " + }, + )) + .await?; + + // now with some additional indentation + test(( + indoc! {"····#[H|]#elix + ····#(i|)#s + ····#(c|)#ool"} + .replace("·", " "), + ":indent-style 4O", + indoc! { + "····#[\n|]# + ····Helix + ····#(\n|)# + ····is + ····#(\n|)# + ····cool + " + } + .replace("·", " "), + )) + .await?; + + // the first line is within a comment, the second not. + // However, if we open above, the first newly added line should start within a comment + // while the other should be a normal line + test(( + indoc! {"fn main() { + // #[VIP|]# comment + l#(e|)#t yes = false; + }"}, + ":lang rustO", + indoc! {"fn main() { + // #[\n|]# + // VIP comment + #(\n|)# + let yes = false; + }"}, + )) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_open_below_with_multiple_cursors() -> anyhow::Result<()> { + // the primary cursor is also in the top line + test(( + indoc! {"#[H|]#elix + #(i|)#s + #(c|)#ool"}, + "o", + indoc! {"Helix + #[\n|]# + is + #(\n|)# + cool + #(\n|)# + " + }, + )) + .await?; + + // now with some additional indentation + test(( + indoc! {"····#[H|]#elix + ····#(i|)#s + ····#(c|)#ool"} + .replace("·", " "), + ":indent-style 4o", + indoc! { + "····Helix + ····#[\n|]# + ····is + ····#(\n|)# + ····cool + ····#(\n|)# + " + } + .replace("·", " "), + )) + .await?; + + // the first line is within a comment, the second not. + // However, if we open below, the first newly added line should start within a comment + // while the other should be a normal line + test(( + indoc! {"fn main() { + // #[VIP|]# comment + l#(e|)#t yes = false; + }"}, + ":lang rusto", + indoc! {"fn main() { + // VIP comment + // #[\n|]# + let yes = false; + #(\n|)# + }"}, + )) + .await?; + + Ok(()) +} + /// NOTE: To make the `open_above` comment-aware, we're setting the language for each test to rust. #[tokio::test(flavor = "multi_thread")] async fn test_open_above_with_comments() -> anyhow::Result<()> { diff --git a/helix-tui/Cargo.toml b/helix-tui/Cargo.toml index 96f008a0..97765800 100644 --- a/helix-tui/Cargo.toml +++ b/helix-tui/Cargo.toml @@ -18,7 +18,7 @@ default = ["crossterm"] helix-view = { path = "../helix-view", features = ["term"] } helix-core = { path = "../helix-core" } -bitflags = "2.6" +bitflags.workspace = true cassowary = "0.3" unicode-segmentation = "1.12" crossterm = { version = "0.28", optional = true } diff --git a/helix-vcs/Cargo.toml b/helix-vcs/Cargo.toml index 93b9def8..eb1fc880 100644 --- a/helix-vcs/Cargo.toml +++ b/helix-vcs/Cargo.toml @@ -29,4 +29,4 @@ log = "0.4" git = ["gix"] [dev-dependencies] -tempfile = "3.14" +tempfile.workspace = true diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index d064ad5e..0d3120d5 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true categories.workspace = true repository.workspace = true homepage.workspace = true +temp.worspace = true [features] default = [] @@ -24,13 +25,13 @@ helix-lsp = { path = "../helix-lsp" } helix-dap = { path = "../helix-dap" } helix-vcs = { path = "../helix-vcs" } -bitflags = "2.6" +bitflags.workspace = true anyhow = "1" crossterm = { version = "0.28", optional = true } -tempfile = "3.14" same-file = "1.0.1" filetime = "0.2" +tempfile = "3.14" # Conversion traits once_cell = "1.20" diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index b7cf7c7e..04fd0fdd 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -980,7 +980,13 @@ impl Document { { use std::process::Stdio; let text = self.text().clone(); + let mut process = tokio::process::Command::new(&fmt_cmd); + + if let Some(doc_dir) = self.path.as_ref().and_then(|path| path.parent()) { + process.current_dir(doc_dir); + } + process .args(fmt_args) .stdin(Stdio::piped()) diff --git a/languages.toml b/languages.toml index 50e7d823..68b28001 100644 --- a/languages.toml +++ b/languages.toml @@ -21,6 +21,7 @@ cl-lsp = { command = "cl-lsp", args = [ "stdio" ] } clangd = { command = "clangd" } clojure-lsp = { command = "clojure-lsp" } cmake-language-server = { command = "cmake-language-server" } +codeql = { command = "codeql", args = ["execute", "language-server", "--check-errors=ON_CHANGE"] } crystalline = { command = "crystalline", args = ["--stdio"] } cs = { command = "cs", args = ["launch", "--contrib", "smithy-language-server", "--", "0"] } csharp-ls = { command = "csharp-ls" } @@ -487,6 +488,8 @@ name = "jsonc" scope = "source.json" injection-regex = "jsonc" file-types = ["jsonc", { glob = "tsconfig.json" }] +comment-token = "//" +block-comment-tokens = { start = "/*", end = "*/" } grammar = "json" language-servers = [ "vscode-json-language-server" ] auto-format = true @@ -1292,7 +1295,7 @@ formatter = { command = "dune", args = ["format-dune-file"] } name = "lua" injection-regex = "lua" scope = "source.lua" -file-types = ["lua"] +file-types = ["lua", "rockspec"] shebangs = ["lua", "luajit"] roots = [".luarc.json", ".luacheckrc", ".stylua.toml", "selene.toml", ".git"] comment-token = "--" @@ -1347,7 +1350,7 @@ source = { git = "https://github.com/ikatyang/tree-sitter-vue", rev = "91fe27547 [[language]] name = "yaml" scope = "source.yaml" -file-types = ["yml", "yaml", { glob = ".prettierrc" }, { glob = ".clangd" }, { glob = ".clang-format" }] +file-types = ["yml", "yaml", { glob = ".prettierrc" }, { glob = ".clangd" }, { glob = ".clang-format" }, { glob = ".clang-tidy" }] comment-token = "#" indent = { tab-width = 2, unit = " " } language-servers = [ "yaml-language-server", "ansible-language-server" ] @@ -2897,7 +2900,7 @@ indent = { tab-width = 2, unit = " " } [[grammar]] name = "matlab" -source = { git = "https://github.com/acristoffers/tree-sitter-matlab", rev = "6071891a8c39600203eba20513666cf93b4d650a" } +source = { git = "https://github.com/acristoffers/tree-sitter-matlab", rev = "b0a0198b182574cd3ca0447264c83331901b9338" } [[language]] name = "ponylang" @@ -4033,3 +4036,32 @@ indent = { tab-width = 4, unit = " " } [[grammar]] name = "nginx" source = { git = "https://gitlab.com/joncoole/tree-sitter-nginx", rev = "b4b61db443602b69410ab469c122c01b1e685aa0" } + +[[language]] +name = "codeql" +scope = "source.ql" +file-types = ["ql", "qll"] +comment-token = "//" +block-comment-tokens = { start = "/*", end = "*/" } +indent = { tab-width = 2, unit = " " } +injection-regex = "codeql" +grammar = "ql" +language-servers = ["codeql"] + +[[grammar]] +name = "ql" +source = { git = "https://github.com/tree-sitter/tree-sitter-ql", rev = "1fd627a4e8bff8c24c11987474bd33112bead857" } + +[[language]] +name = "gren" +scope = "source.gren" +injection-regex = "gren" +file-types = ["gren"] +roots = ["gren.json"] +comment-tokens = "--" +block-comment-tokens = { start = "{-", end = "-}" } +indent = { tab-width = 4, unit = " " } + +[[grammar]] +name = "gren" +source = { git = "https://github.com/MaeBrooks/tree-sitter-gren", rev = "76554f4f2339f5a24eed19c58f2079b51c694152" } diff --git a/runtime/queries/codeql/highlights.scm b/runtime/queries/codeql/highlights.scm new file mode 100644 index 00000000..aed7b538 --- /dev/null +++ b/runtime/queries/codeql/highlights.scm @@ -0,0 +1,104 @@ +[ + "and" + "any" + "as" + "asc" + "avg" + "by" + "class" + "concat" + "count" + "desc" + "else" + "exists" + "extends" + "forall" + "forex" + "from" + "if" + "implements" + "implies" + "import" + "in" + "instanceof" + "max" + "min" + "module" + "newtype" + "not" + "or" + "order" + "rank" + "select" + "strictconcat" + "strictcount" + "strictsum" + "sum" + "then" + "where" + + (false) + (predicate) + (result) + (specialId) + (super) + (this) + (true) +] @keyword + +[ + "boolean" + "float" + "int" + "date" + "string" +] @type.builtin + +(annotName) @attribute + +[ + "<" + "<=" + "=" + ">" + ">=" + "-" + "!=" + "/" + "*" + "%" + "+" + "::" +] @operator + +[ + "(" + ")" + "{" + "}" + "[" + "]" +] @punctuation.bracket + +[ + "," + "|" +] @punctuation.delimiter + +(className) @type + +(varName) @variable + +(integer) @constant.numeric.integer +(float) @constant.numeric.float + +(string) @string + +(aritylessPredicateExpr (literalId) @function) +(predicateName) @function + +[ + (line_comment) + (block_comment) + (qldoc) +] @comment diff --git a/runtime/queries/codeql/textobjects.scm b/runtime/queries/codeql/textobjects.scm new file mode 100644 index 00000000..8ca02e3c --- /dev/null +++ b/runtime/queries/codeql/textobjects.scm @@ -0,0 +1,16 @@ +(qldoc) @comment.around +(block_comment) @comment.around +(line_comment) @comment.inside +(line_comment)+ @comment.around + +(classlessPredicate + ((varDecl) @parameter.inside . ","?) @parameter.around + (body "{" (_)* @function.inside "}")) @function.around +(memberPredicate + ((varDecl) @parameter.inside . ","?) @parameter.around + (body "{" (_)* @function.inside "}")) @function.around + +(dataclass + ("{" (_)* @class.inside "}")?) @class.around +(datatype) @class.around +(datatypeBranch) @class.around diff --git a/runtime/queries/ecma/highlights.scm b/runtime/queries/ecma/highlights.scm index dc8ce5e7..b8df07a5 100644 --- a/runtime/queries/ecma/highlights.scm +++ b/runtime/queries/ecma/highlights.scm @@ -16,8 +16,31 @@ (#match? @variable.builtin "^(arguments|module|console|window|document)$") (#is-not? local)) -((identifier) @function.builtin - (#eq? @function.builtin "require") +(call_expression + (identifier) @function.builtin + (#any-of? @function.builtin + "eval" + "fetch" + "isFinite" + "isNaN" + "parseFloat" + "parseInt" + "decodeURI" + "decodeURIComponent" + "encodeURI" + "encodeURIComponent" + "require" + "alert" + "prompt" + "btoa" + "atob" + "confirm" + "structuredClone" + "setTimeout" + "clearTimeout" + "setInterval" + "clearInterval" + "queueMicrotask") (#is-not? local)) ; Function and method definitions diff --git a/runtime/queries/gren/highlights.scm b/runtime/queries/gren/highlights.scm new file mode 100644 index 00000000..d38523cf --- /dev/null +++ b/runtime/queries/gren/highlights.scm @@ -0,0 +1,81 @@ +; Keywords +[ + "if" + "then" + "else" + "let" + "in" + ] @keyword.control +(when) @keyword.control +(is) @keyword.control + +(colon) @keyword.operator +(backslash) @keyword +(as) @keyword +(port) @keyword +(exposing) @keyword +(alias) @keyword +(infix) @keyword + +(arrow) @keyword.operator +(dot) @keyword.operator + +(type_annotation(lower_case_identifier) @function) +(port_annotation(lower_case_identifier) @function) +(file (value_declaration (function_declaration_left(lower_case_identifier) @function))) + +(field name: (lower_case_identifier) @attribute) +(field_access_expr(lower_case_identifier) @attribute) + +(operator_identifier) @keyword.operator +(eq) @keyword.operator.assignment + +[ + "(" + ")" + "[" + "]" + "{" + "}" +] @punctuation.bracket + +"|" @keyword +"," @punctuation.delimiter + +[ + "|>" +] @keyword + + +(import) @keyword.control.import +(module) @keyword.other + +(number_constant_expr) @constant.numeric + +(type) @type + +(type_declaration(upper_case_identifier) @type) +(type_ref) @type +(type_alias_declaration name: (upper_case_identifier) @type) + +(union_pattern constructor: (upper_case_qid (upper_case_identifier) @label (dot) (upper_case_identifier) @variable.other.member)) +(union_pattern constructor: (upper_case_qid (upper_case_identifier) @variable.other.member)) + +(union_variant(upper_case_identifier) @variable.other.member) +(value_expr name: (value_qid (upper_case_identifier) @label)) +(value_expr (upper_case_qid (upper_case_identifier) @label (dot) (upper_case_identifier) @variable.other.member)) +(value_expr(upper_case_qid(upper_case_identifier)) @variable.other.member) + +; comments +(line_comment) @comment +(block_comment) @comment + +; strings +(string_escape) @constant.character.escape + +(open_quote) @string +(close_quote) @string +(regular_string_part) @string + +(open_char) @constant.character +(close_char) @constant.character diff --git a/runtime/queries/gren/locals.scm b/runtime/queries/gren/locals.scm new file mode 100644 index 00000000..ab103115 --- /dev/null +++ b/runtime/queries/gren/locals.scm @@ -0,0 +1,14 @@ +(value_declaration) @local.scope +(type_alias_declaration) @local.scope +(type_declaration) @local.scope +(type_annotation) @local.scope +(port_annotation) @local.scope +(infix_declaration) @local.scope +(let_in_expr) @local.scope + +(function_declaration_left (lower_pattern (lower_case_identifier)) @local.definition) +(function_declaration_left (lower_case_identifier) @local.definition) + +(value_expr(value_qid(upper_case_identifier)) @local.reference) +(value_expr(value_qid(lower_case_identifier)) @local.reference) +(type_ref (upper_case_qid) @local.reference) diff --git a/runtime/queries/gren/tags.scm b/runtime/queries/gren/tags.scm new file mode 100644 index 00000000..d6ac5cd9 --- /dev/null +++ b/runtime/queries/gren/tags.scm @@ -0,0 +1,19 @@ +(value_declaration (function_declaration_left (lower_case_identifier) @name)) @definition.function + +(function_call_expr (value_expr (value_qid) @name)) @reference.function +(exposed_value (lower_case_identifier) @name) @reference.function +(type_annotation ((lower_case_identifier) @name) (colon)) @reference.function + +(type_declaration ((upper_case_identifier) @name) ) @definition.type + +(type_ref (upper_case_qid (upper_case_identifier) @name)) @reference.type +(exposed_type (upper_case_identifier) @name) @reference.type + +(type_declaration (union_variant (upper_case_identifier) @name)) @definition.union + +(value_expr (upper_case_qid (upper_case_identifier) @name)) @reference.union + + +(module_declaration + (upper_case_qid (upper_case_identifier)) @name +) @definition.module \ No newline at end of file diff --git a/runtime/queries/gren/textobjects.scm b/runtime/queries/gren/textobjects.scm new file mode 100644 index 00000000..38565784 --- /dev/null +++ b/runtime/queries/gren/textobjects.scm @@ -0,0 +1,56 @@ + +(line_comment) @comment.inside +(line_comment)+ @comment.around +(block_comment) @comment.inside +(block_comment)+ @comment.around + +((type_annotation)? + (value_declaration + (function_declaration_left (lower_case_identifier)) + (eq) + (_) @function.inside + ) +) @function.around + +(parenthesized_expr + (anonymous_function_expr + ( + (arrow) + (_) @function.inside + ) + ) +) @function.around + +(value_declaration + (function_declaration_left + (lower_pattern + (lower_case_identifier) @parameter.inside @parameter.around + ) + ) +) + +(value_declaration + (function_declaration_left + (pattern) @parameter.inside @parameter.around + ) +) + +(value_declaration + (function_declaration_left + (record_pattern + (lower_pattern + (lower_case_identifier) @parameter.inside + ) + ) @parameter.around + ) +) + +(parenthesized_expr + (anonymous_function_expr + ( + (backslash) + (pattern) @parameter.inside + (arrow) + ) + ) +) diff --git a/runtime/queries/haskell/highlights.scm b/runtime/queries/haskell/highlights.scm index 3d416de8..8f079185 100644 --- a/runtime/queries/haskell/highlights.scm +++ b/runtime/queries/haskell/highlights.scm @@ -7,6 +7,255 @@ (char) @constant.character (string) @string +(exp_apply + (exp_name + (variable) @function.builtin + (#any-of? @function.builtin + ; built in functions from the Haskell prelude (https://hackage.haskell.org/package/base-4.21.0.0/docs/Prelude.html) + ; basic data types + "not" + "maybe" + "either" + + ; tuples + "fst" + "snd" + "curry" + "uncurry" + + ; Ord + "compare" + "min" + "max" + + ; Enum + "succ" + "pred" + "toEnum" + "fromEnum" + "enumFrom" + "enumFromThen" + "enumFromThenTo" + + ; Num + "negate" + "abs" + "signum" + "fromInteger" + + ; Real + "toRational" + + ; Integral + "quot" + "rem" + "div" + "mod" + "quotRem" + "divMod" + "toInteger" + + ; Fractional + "recip" + "fromRational" + + ; Floating + "exp" + "log" + "sqrt" + "logBase" + "sin" + "cos" + "tan" + "asin" + "acos" + "atan" + "sinh" + "cosh" + "tanh" + "asinh" + "acosh" + "atanh" + + ; RealFrac + "properFraction" + "truncate" + "round" + "ceiling" + "floor" + + ; RealFloat + "floatRadix" + "floatDigits" + "floatRange" + "decodeFloat" + "encodeFloat" + "exponent" + "significand" + "scaleFloat" + "isNaN" + "isInfinite" + "isDenormalized" + "isNegativeZero" + "isIEEE" + "atan2" + + ; Numeric functions + "subtract" + "even" + "odd" + "gcd" + "lcm" + "fromIntegral" + "realToFrac" + + ; Monoid + "mempty" + "mconcat" + "mappend" + + ; Functor + "fmap" + + ; Applicative + "liftA2" + "pure" + + ; Monad + "return" + + ; MonadFail + "fail" + "mapM_" + "sequence_" + + ; Foldable + "foldMap" + "foldr" + "foldl" + "foldl'" + "foldr1" + "foldl1" + "elem" + "maximum" + "minimum" + "sum" + "product" + + ; Traversable + "traverse" + "sequenceA" + "mapM" + "sequence" + + ; miscellaneous + "id" + "const" + "flip" + "until" + "asTypeOf" + "error" + "errorWithoutStackTrace" + "undefined" + + ; List + "map" + "filter" + "head" + "last" + "tail" + "init" + "null" + "length" + "reverse" + + ; Foldable + "and" + "or" + "any" + "all" + "concat" + "concatMap" + + ; Building lists + "scanl" + "scanl1" + "scanr" + "scanr1" + + ; Infinite lists + "iterate" + "repeat" + "replicate" + "cycle" + + ; Sublists + "take" + "drop" + "takeWhile" + "dropWhile" + "span" + "break" + "splitAt" + + ; Searching lists + "notElem" + "lookup" + + ; zipping and unzipping + "zip" + "zip3" + "zipWith" + "zipWith3" + "unzip" + "unzip3" + + ; String + "lines" + "words" + "unlines" + "unwords" + + ; Converting to String + "show" + "showList" + "shows" + "showChar" + "showString" + "showParen" + + ; Converting from String + "readsPrec" + "readList" + "reads" + "readParen" + "read" + "lex" + + ; Input and output + "putChar" + "putStr" + "putStrLn" + "print" + "getChar" + "getLine" + "getContents" + "interact" + + ; Files + "readFile" + "writeFile" + "appendFile" + "readIO" + "readLn" + + ; Exception handling + "ioError" + "userError") + ) +) + + (con_unit) @constant.builtin ; unit, as in () (comment) @comment diff --git a/runtime/queries/matlab/indents.scm b/runtime/queries/matlab/indents.scm index b2a8e55d..4aacccd8 100644 --- a/runtime/queries/matlab/indents.scm +++ b/runtime/queries/matlab/indents.scm @@ -1,4 +1,5 @@ [ + (arguments_statement) (if_statement) (for_statement) (while_statement) diff --git a/runtime/queries/rust/highlights.scm b/runtime/queries/rust/highlights.scm index b3a0f4d7..898bde6a 100644 --- a/runtime/queries/rust/highlights.scm +++ b/runtime/queries/rust/highlights.scm @@ -63,6 +63,14 @@ (#any-of? @type.enum.variant.builtin "Some" "None" "Ok" "Err")) +(call_expression + (identifier) @function.builtin + (#any-of? @function.builtin + "drop" + "size_of" + "size_of_val" + "align_of" + "align_of_val")) ((type_identifier) @type.builtin (#any-of? @@ -311,6 +319,8 @@ ((identifier) @type (#match? @type "^[A-Z]")) +(never_type "!" @type) + ; ------- ; Functions ; ------- @@ -453,6 +463,7 @@ ; Remaining Identifiers ; ------- +; We do not style ? as an operator on purpose as it allows styling ? differently, as many highlighters do. @operator.special might have been a better scope, but @special is already documented so the change would break themes (including the intent of the default theme) "?" @special (type_identifier) @type diff --git a/runtime/themes/dark_plus.toml b/runtime/themes/dark_plus.toml index 61878664..28ec163d 100644 --- a/runtime/themes/dark_plus.toml +++ b/runtime/themes/dark_plus.toml @@ -24,7 +24,7 @@ "operator" = "text" "punctuation" = "text" "punctuation.delimiter" = "text" -"special" = "text" +"special" = "light_blue" "string" = "orange" "string.regexp" = "gold" "tag" = "blue2" @@ -72,9 +72,10 @@ "ui.bufferline.background" = { bg = "background" } "ui.text" = { fg = "text" } "ui.text.focus" = { fg = "white" } -"ui.text.directory" = { fg = "blue3" } +"ui.text.directory" = { fg = "blue2" } "ui.text.inactive" = { fg = "dark_gray" } "ui.virtual.whitespace" = { fg = "#3e3e3d" } +"ui.virtual.wrap" = { fg = "#3e3e3d" } "ui.virtual.ruler" = { bg = "borders" } "ui.virtual.indent-guide" = { fg = "dark_gray4" } "ui.virtual.inlay-hint" = { fg = "dark_gray5"}