From 0654a1f0582fc21f18fe94f96a70116f95431841 Mon Sep 17 00:00:00 2001 From: Erasin Wang Date: Mon, 6 Jan 2025 00:27:38 +0800 Subject: [PATCH 1/7] Update onelight theme (#12399) --- book/src/themes.md | 1 + runtime/themes/onelight.toml | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/book/src/themes.md b/book/src/themes.md index 62279356..412d17ef 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -336,5 +336,6 @@ These scopes are used for theming the editor interface: | `diagnostic.error` | Diagnostics error (editing area) | | `diagnostic.unnecessary` | Diagnostics with unnecessary tag (editing area) | | `diagnostic.deprecated` | Diagnostics with deprecated tag (editing area) | +| `tabstop` | Snippet placeholder | [editor-section]: ./configuration.md#editor-section diff --git a/runtime/themes/onelight.toml b/runtime/themes/onelight.toml index e395716d..b812670d 100644 --- a/runtime/themes/onelight.toml +++ b/runtime/themes/onelight.toml @@ -91,13 +91,14 @@ "markup.quote" = { fg = "grey" } "markup.raw" = { fg = "brown" } "markup.raw.inline" = { fg = "green" } -"markup.raw.block" = { fg = "grey" } +"markup.raw.block" = { fg = "brown" } "diff" = { fg = "red" } "diff.plus" = { fg = "green" } "diff.minus" = { fg = "red" } "diff.delta" = { fg = "cyan" } "diff.delta.moved" = { fg = "cyan" } +"diff.delta.conflict" = {fg = "blue"} "ui.background" = { bg = "white" } "ui.background.separator" = { bg = "white" } @@ -133,6 +134,7 @@ "ui.text.focus" = { fg = "red", bg = "grey-300", modifiers = ["bold"] } "ui.text.inactive" = { fg = "grey" } "ui.text.info" = { fg = "black" } +"ui.text.directory" = { fg = "blue", underline = { style = "line" } } "ui.virtual" = { fg = "grey-500" } "ui.virtual.ruler" = { bg = "grey-200" } @@ -159,6 +161,9 @@ "ui.highlight" = { bg = "grey-300" } +"ui.picker.header" = { fg = "purple"} +"ui.picker.header.active" = { fg = "blue"} + "diagnostic.info" = { underline = { color = "blue", style = "dotted" } } "diagnostic.hint" = { underline = { color = "green", style = "dashed" } } "diagnostic.warning" = { underline = { color = "yellow", style = "curl" } } @@ -171,6 +176,8 @@ "warning" = { fg = "yellow", modifiers = ["bold"] } "error" = { fg = "red", modifiers = ["bold"] } +"tabstop" = { modifiers = ["italic"], bg = "grey-300" } + [palette] white = "#FAFAFA" yellow = "#FF6F00" From eed052e86bbf1419092265ef591453a17e89d63f Mon Sep 17 00:00:00 2001 From: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com> Date: Sun, 5 Jan 2025 16:51:33 +0000 Subject: [PATCH 2/7] feat: highlight `:` as a delimiter in Rust (#12408) --- runtime/queries/rust/highlights.scm | 1 + 1 file changed, 1 insertion(+) diff --git a/runtime/queries/rust/highlights.scm b/runtime/queries/rust/highlights.scm index 2981075f..b3a0f4d7 100644 --- a/runtime/queries/rust/highlights.scm +++ b/runtime/queries/rust/highlights.scm @@ -113,6 +113,7 @@ "." ";" "," + ":" ] @punctuation.delimiter [ From 2b8f8df1afc2ae019c809b0f633dfd5c905944c1 Mon Sep 17 00:00:00 2001 From: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com> Date: Sun, 5 Jan 2025 16:54:45 +0000 Subject: [PATCH 3/7] feat: correct Swift highlights (#12409) - Adds injections for the `comment` language - Correct highlight of the `nil` value. Same highlight as `null` in javascript, java and others - Recognize `<` and `>` as punctuation, used in generics (same color as the syntax used in other languages) - `protocol` function methods are recognized - When accessing object properties, like `hello.world`, the `world` is properly recognized as being a member - Recognize the `\` as an operator --- runtime/queries/swift/highlights.scm | 9 +++++++-- runtime/queries/swift/injections.scm | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/runtime/queries/swift/highlights.scm b/runtime/queries/swift/highlights.scm index 42411d90..df05a643 100644 --- a/runtime/queries/swift/highlights.scm +++ b/runtime/queries/swift/highlights.scm @@ -4,7 +4,7 @@ ["\\(" ")"] @punctuation.special) ["." ";" ":" "," ] @punctuation.delimiter -["(" ")" "[" "]" "{" "}"] @punctuation.bracket +["(" ")" "[" "]" "{" "}" "<" ">"] @punctuation.bracket ; Identifiers (attribute) @variable @@ -24,6 +24,7 @@ ] @keyword (function_declaration (simple_identifier) @function.method) +(protocol_function_declaration (simple_identifier) @function.method) (init_declaration ["init" @constructor]) (deinit_declaration ["deinit" @constructor]) @@ -91,6 +92,9 @@ (#match? @type "^[A-Z]")) (call_expression (simple_identifier) @keyword (#eq? @keyword "defer")) ; defer { ... } +(navigation_suffix + (simple_identifier) @variable.other.member) + (try_operator) @operator (try_operator ["try" @keyword]) @@ -147,7 +151,7 @@ (integer_literal) @constant.numeric.integer (real_literal) @constant.numeric.float (boolean_literal) @constant.builtin.boolean -"nil" @variable.builtin +"nil" @constant.builtin "?" @type (type_annotation "!" @type) @@ -160,6 +164,7 @@ "?" "+" "-" + "\\" "*" "/" "%" diff --git a/runtime/queries/swift/injections.scm b/runtime/queries/swift/injections.scm index 0ac6cddf..fb78129a 100644 --- a/runtime/queries/swift/injections.scm +++ b/runtime/queries/swift/injections.scm @@ -4,3 +4,7 @@ ((regex_literal) @injection.content (#set! injection.language "regex")) + +((comment) @injection.content + (#set! injection.language "comment") + (#set! injection.include-children)) From fa4aa0fb425fa139f9744de1657075a419e042cb Mon Sep 17 00:00:00 2001 From: Nikita Revenco <154856872+NikitaRevenco@users.noreply.github.com> Date: Sun, 5 Jan 2025 16:55:28 +0000 Subject: [PATCH 4/7] docs: catppuccin themes should not be directly edited here (#12400) Co-authored-by: uncenter <47499684+uncenter@users.noreply.github.com> --- runtime/themes/catppuccin_mocha.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/runtime/themes/catppuccin_mocha.toml b/runtime/themes/catppuccin_mocha.toml index 1f88e7e9..8cb17f64 100644 --- a/runtime/themes/catppuccin_mocha.toml +++ b/runtime/themes/catppuccin_mocha.toml @@ -1,3 +1,4 @@ +# NOTE: For contributors looking to modify the theme, please submit a pull request at https://github.com/catppuccin/helix instead of updating this file. Changes are frequently synchronized from the catppuccin/helix theme repository. # Syntax highlighting # ------------------- "attribute" = "yellow" From 377e36908a15a4fee46a42ecd6cc8f76e171c23b Mon Sep 17 00:00:00 2001 From: Seigo Mori <85787242+s3igo@users.noreply.github.com> Date: Mon, 6 Jan 2025 01:58:31 +0900 Subject: [PATCH 5/7] Add cursorline color to iceberg theme (#12404) --- runtime/themes/iceberg-dark.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/runtime/themes/iceberg-dark.toml b/runtime/themes/iceberg-dark.toml index b6e0cf52..08d2fd97 100644 --- a/runtime/themes/iceberg-dark.toml +++ b/runtime/themes/iceberg-dark.toml @@ -68,6 +68,7 @@ "ui.cursor.normal" = { bg = "gray" } "ui.cursor.primary" = { modifiers = ["reversed"] } "ui.cursor.select" = { bg = "gray" } +"ui.cursorline.primary" = { bg = "linenr_bg" } "ui.gutter" = { fg = "linenr_fg", bg = "linenr_bg" } "ui.help" = { fg = "background_fg", bg = "cursorlinenr_bg" } "ui.linenr" = { fg = "linenr_fg", bg = "linenr_bg" } From 64b38d1a28dfce4dabb502d395a42a842ec03ee9 Mon Sep 17 00:00:00 2001 From: RoloEdits Date: Sun, 5 Jan 2025 10:18:30 -0800 Subject: [PATCH 6/7] refactor(shellwords)!: change arg handling strategy (#11149) --- helix-core/src/shellwords.rs | 949 ++++++++++++++++++++++--------- helix-term/src/commands.rs | 36 +- helix-term/src/commands/dap.rs | 2 + helix-term/src/commands/typed.rs | 715 ++++++++++------------- helix-term/src/keymap.rs | 12 +- 5 files changed, 1022 insertions(+), 692 deletions(-) diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs index 9d873c36..edfd9ad1 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -1,6 +1,358 @@ +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 @@ -13,186 +365,141 @@ pub fn escape(input: Cow) -> Cow { buf })) } else { - Cow::Owned(format!("\"{}\"", input)) + Cow::Owned(format!("\"{input}\"")) } } -enum State { - OnWhitespace, - Unquoted, - UnquotedEscaped, - Quoted, - QuoteEscaped, - Dquoted, - DquoteEscaped, -} +/// 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, + } -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>, -} + 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(); -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 + 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]); } } - c if c.is_ascii_whitespace() => { - end = i; - OnWhitespace - } - _ => Unquoted, - }, - Unquoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[unescaped_start..i]); - unescaped_start = i + 1; - UnquotedEscaped - } else { - Unquoted - } - } - c if c.is_ascii_whitespace() => { - end = i; - OnWhitespace - } - _ => 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; - } - - 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(); + state = State::Escaped; + is_escaped = true; + } + _ => { + if is_escaped { + unescaped.push(ch); } } - unescaped_start = i + 1; - part_start = i + 1; - end = 0; + }, + State::Escaped => { + match ch { + 'n' => unescaped.push('\n'), + 't' => unescaped.push('\t'), + 'u' => { + state = State::Unicode; + continue; + } + // Uncomment if you want to handle '\\' to '\' + // '\\' => unescaped.push('\\'), + _ => { + unescaped.push('\\'); + unescaped.push(ch); + } + } + state = State::Normal; } - } - - debug_assert!(words.len() == parts.len()); - - Self { - state, - words, - parts, + 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; + } + _ => unicode.push(ch), + }, } } -} -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 + if is_escaped { + unescaped.into() + } else { + input.into() } } @@ -201,114 +508,202 @@ mod test { use super::*; #[test] - #[cfg(windows)] - fn test_normal() { + fn base() { 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("\\three\\"), - Cow::from("\\"), - Cow::from("with\\ escaping\\\\"), + let args = vec![ + "single_word", + "twó", + "wörds", + r"\three\", + r#"\"with\"#, + r"escaping\\", ]; - // TODO test is_owned and is_borrowed, once they get stabilized. - assert_eq!(expected, result); + + assert_eq!(":o", shellwords.command()); + assert_eq!(args, shellwords.args().collect::>()); } #[test] - #[cfg(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); + 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] - #[cfg(unix)] - fn test_quoted() { + 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.words().to_vec(); + let result = shellwords.args().collect::>(); 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"), + "single_word", + "twó wörds", + "", + " ", + r#"\three\' \"with\ escaping\\"#, + "quote incomplete", ]; assert_eq!(expected, result); } #[test] - #[cfg(unix)] - fn test_dquoted() { + 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.words().to_vec(); + let result = shellwords.args().collect::>(); 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"), + "single_word", + "twó wörds", + "", + " ", + r#"\three\' \"with\ escaping\\"#, + "dquote incomplete", ]; assert_eq!(expected, result); } #[test] - #[cfg(unix)] - fn test_mixed() { + 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.words().to_vec(); + let result = shellwords.args().collect::>(); 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 + "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 test_lists() { - let input = - r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "quotes"]'"#; + fn should_return_rest() { + let input = r#":set statusline.center ["file-type","file-encoding"]"#; 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); + 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 test_escaping_unix() { + fn should_escape_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")); @@ -316,35 +711,79 @@ mod test { #[test] #[cfg(windows)] - fn test_escaping_windows() { + fn should_escape_windows() { assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar")); assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\"")); } #[test] - #[cfg(unix)] - fn test_parts() { - assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]); - assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\ "]); + fn should_unescape_newline() { + let unescaped = unescape("hello\\nworld"); + assert_eq!("hello\nworld", unescaped); } #[test] - #[cfg(windows)] - fn test_parts() { - assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]); - assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\"]); + fn should_unescape_tab() { + let unescaped = unescape("hello\\tworld"); + assert_eq!("hello\tworld", unescaped); } #[test] - 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𒀀"] - ); + 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])); } } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a93fa445..06d892ad 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -30,7 +30,9 @@ use helix_core::{ object, pos_at_coords, regex::{self, Regex}, search::{self, CharMatcher}, - selection, shellwords, surround, + selection, + shellwords::{self, Args}, + surround, syntax::{BlockCommentToken, LanguageServerFeature}, text_annotations::{Overlay, TextAnnotations}, textobject, @@ -207,7 +209,7 @@ use helix_view::{align_view, Align}; pub enum MappableCommand { Typable { name: String, - args: Vec, + args: String, doc: String, }, Static { @@ -242,15 +244,17 @@ 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(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { - cx.editor.set_error(format!("{}", e)); + + if let Err(err) = + (command.fun)(&mut cx, Args::from(args), PromptEvent::Validate) + { + cx.editor.set_error(format!("{err}")); } } } @@ -621,21 +625,15 @@ impl std::str::FromStr for MappableCommand { fn from_str(s: &str) -> Result { if let Some(suffix) = s.strip_prefix(':') { - 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::>(); + let (name, args) = suffix.split_once(' ').unwrap_or((suffix, "")); typed::TYPABLE_COMMAND_MAP .get(name) .map(|cmd| MappableCommand::Typable { name: cmd.name.to_owned(), doc: format!(":{} {:?}", cmd.name, args), - args, + args: args.to_string(), }) - .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s)) + .ok_or_else(|| anyhow!("No TypableCommand named '{}'", name)) } else if let Some(suffix) = s.strip_prefix('@') { helix_view::input::parse_macro(suffix).map(|keys| Self::Macro { name: s.to_string(), @@ -3254,7 +3252,7 @@ pub fn command_palette(cx: &mut Context) { .iter() .map(|cmd| MappableCommand::Typable { name: cmd.name.to_owned(), - args: Vec::new(), + args: String::new(), doc: cmd.doc.to_owned(), }), ); @@ -4328,13 +4326,19 @@ 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(separator); + acc.push_str(&shellwords::unescape(separator)); } acc.push_str(&fragment); acc diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index 83dd936c..a35fa23a 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -109,6 +109,7 @@ 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>, @@ -312,6 +313,7 @@ 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 c21743d0..27e2c75d 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -13,6 +13,7 @@ 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)] @@ -21,17 +22,17 @@ pub struct TypableCommand { pub aliases: &'static [&'static str], pub doc: &'static str, // params, flags, helper, completer - pub fun: fn(&mut compositor::Context, &[Cow], PromptEvent) -> anyhow::Result<()>, + pub fun: fn(&mut compositor::Context, Args, 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 { - match self.signature.positional_args.get(n) { - Some(completer) => completer, - _ => &self.signature.var_args, - } + self.signature + .positional_args + .get(n) + .unwrap_or(&self.signature.var_args) } } @@ -67,7 +68,7 @@ impl CommandSignature { } } -fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { log::debug!("quitting..."); if event != PromptEvent::Validate { @@ -78,7 +79,7 @@ fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> // 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()?; @@ -87,11 +88,7 @@ fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> Ok(()) } -fn force_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn force_quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -104,12 +101,13 @@ fn force_quit( Ok(()) } -fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn open(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(!args.is_empty(), "wrong argument count"); + ensure!(!args.is_empty(), ":open needs at least one argument"); + for arg in args { let (path, pos) = args::parse_file(arg); let path = helix_stdx::path::expand_tilde(path); @@ -175,7 +173,7 @@ fn buffer_close_by_ids_impl( Ok(()) } -fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow]) -> Vec { +fn buffer_gather_paths_impl(editor: &mut Editor, args: Args) -> Vec { // No arguments implies current document if args.is_empty() { let doc_id = view!(editor).doc; @@ -186,7 +184,7 @@ fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow]) -> Vec]) -> Vec], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -227,7 +225,7 @@ fn buffer_close( fn force_buffer_close( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -249,7 +247,7 @@ fn buffer_gather_others_impl(editor: &mut Editor) -> Vec { fn buffer_close_others( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -262,7 +260,7 @@ fn buffer_close_others( fn force_buffer_close_others( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -279,7 +277,7 @@ fn buffer_gather_all_impl(editor: &mut Editor) -> Vec { fn buffer_close_all( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -292,7 +290,7 @@ fn buffer_close_all( fn force_buffer_close_all( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -305,7 +303,7 @@ fn force_buffer_close_all( fn buffer_next( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -318,7 +316,7 @@ fn buffer_next( fn buffer_previous( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -329,15 +327,10 @@ fn buffer_previous( Ok(()) } -fn write_impl( - cx: &mut compositor::Context, - path: Option<&Cow>, - force: bool, -) -> anyhow::Result<()> { +fn write_impl(cx: &mut compositor::Context, path: Option<&str>, 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); @@ -379,40 +372,36 @@ fn insert_final_newline(doc: &mut Document, view_id: ViewId) { } } -fn write( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn write(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.first(), false) + write_impl(cx, args.next(), false) } fn force_write( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.first(), true) + write_impl(cx, args.next(), true) } fn write_buffer_close( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.first(), false)?; + write_impl(cx, args.next(), false)?; let document_ids = buffer_gather_paths_impl(cx.editor, args); buffer_close_by_ids_impl(cx, &document_ids, false) @@ -420,24 +409,20 @@ fn write_buffer_close( fn force_write_buffer_close( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.first(), true)?; + write_impl(cx, args.next(), 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: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn new_file(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -447,11 +432,7 @@ fn new_file( Ok(()) } -fn format( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -468,7 +449,7 @@ fn format( fn set_indent_style( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -489,9 +470,9 @@ fn set_indent_style( } // Attempt to parse argument as an indent style. - let style = match args.first() { + let style = match args.next() { Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs), - Some(Cow::Borrowed("0")) => Some(Tabs), + Some("0") => Some(Tabs), Some(arg) => arg .parse::() .ok() @@ -510,7 +491,7 @@ fn set_indent_style( /// Sets or reports the current document's line ending setting. fn set_line_ending( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -541,7 +522,7 @@ fn set_line_ending( } let arg = args - .first() + .next() .context("argument missing")? .to_ascii_lowercase(); @@ -580,16 +561,12 @@ fn set_line_ending( Ok(()) } -fn earlier( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn earlier(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; + let uk = args.raw().parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); let success = doc.earlier(view, uk); @@ -600,16 +577,13 @@ fn earlier( Ok(()) } -fn later( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn later(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; + let uk = args.raw().parse::().map_err(|s| anyhow!(s))?; + let (view, doc) = current!(cx.editor); let success = doc.later(view, uk); if !success { @@ -621,30 +595,30 @@ fn later( fn write_quit( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.first(), false)?; + write_impl(cx, args.next(), false)?; cx.block_try_flush_writes()?; - quit(cx, &[], event) + quit(cx, Args::empty(), event) } fn force_write_quit( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - write_impl(cx, args.first(), true)?; + write_impl(cx, args.next(), true)?; cx.block_try_flush_writes()?; - force_quit(cx, &[], event) + force_quit(cx, Args::empty(), event) } /// Results in an error if there are modified buffers remaining and sets editor @@ -744,11 +718,7 @@ pub fn write_all_impl( Ok(()) } -fn write_all( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn write_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -758,7 +728,7 @@ fn write_all( fn force_write_all( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -770,7 +740,7 @@ fn force_write_all( fn write_all_quit( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -782,7 +752,7 @@ fn write_all_quit( fn force_write_all_quit( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -807,41 +777,31 @@ fn quit_all_impl(cx: &mut compositor::Context, force: bool) -> anyhow::Result<() Ok(()) } -fn quit_all( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +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( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - quit_all_impl(cx, true) } -fn cquit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn cquit(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let exit_code = args - .first() + .next() .and_then(|code| code.parse::().ok()) .unwrap_or(1); @@ -851,7 +811,7 @@ fn cquit( fn force_cquit( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -859,7 +819,7 @@ fn force_cquit( } let exit_code = args - .first() + .next() .and_then(|code| code.parse::().ok()) .unwrap_or(1); cx.editor.exit_code = exit_code; @@ -867,11 +827,7 @@ fn force_cquit( quit_all_impl(cx, true) } -fn theme( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn theme(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { let true_color = cx.editor.config.load().true_color || crate::true_color(); match event { PromptEvent::Abort => { @@ -881,7 +837,7 @@ fn theme( 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.first() { + } else if let Some(theme_name) = args.next() { 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"); @@ -891,7 +847,7 @@ fn theme( }; } PromptEvent::Validate => { - if let Some(theme_name) = args.first() { + if let Some(theme_name) = args.next() { let theme = cx .editor .theme_loader @@ -914,168 +870,142 @@ fn theme( fn yank_main_selection_to_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, 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: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn yank_joined(cx: &mut compositor::Context, args: Args, 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, separator, register); + yank_joined_impl(cx.editor, args.raw(), register); Ok(()) } fn yank_joined_to_clipboard( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - - 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, '+'); + yank_joined_impl(cx.editor, args.raw(), '+'); Ok(()) } fn yank_main_selection_to_primary_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, 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: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - 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, '*'); + yank_joined_impl(cx.editor, args.raw(), '*'); Ok(()) } fn paste_clipboard_after( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, 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: &[Cow], + _args: Args, 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: &[Cow], + _args: Args, 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: &[Cow], + _args: Args, 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: &[Cow], + _args: Args, 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: &[Cow], + _args: Args, 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: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - cx.editor .set_status(cx.editor.registers.clipboard_provider_name()); Ok(()) @@ -1083,20 +1013,20 @@ fn show_clipboard_provider( fn change_current_directory( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let dir = match args.first().map(AsRef::as_ref) { + let dir = match args.next() { Some("-") => cx .editor .last_cwd .clone() - .ok_or(anyhow!("No previous working directory"))?, - Some(input_path) => helix_stdx::path::expand_tilde(Path::new(input_path)).to_path_buf(), + .ok_or_else(|| anyhow!("No previous working directory"))?, + Some(path) => helix_stdx::path::expand_tilde(Path::new(path)).to_path_buf(), None => home_dir()?, }; @@ -1112,7 +1042,7 @@ fn change_current_directory( fn show_current_directory( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1125,7 +1055,7 @@ fn show_current_directory( if cwd.exists() { cx.editor.set_status(message); } else { - cx.editor.set_error(format!("{} (deleted)", message)); + cx.editor.set_error(format!("{message} (deleted)")); } Ok(()) } @@ -1133,7 +1063,7 @@ fn show_current_directory( /// Sets the [`Document`]'s encoding.. fn set_encoding( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1141,7 +1071,7 @@ fn set_encoding( } let doc = doc_mut!(cx.editor); - if let Some(label) = args.first() { + if let Some(label) = args.next() { doc.set_encoding(label) } else { let encoding = doc.encoding().name().to_owned(); @@ -1153,7 +1083,7 @@ fn set_encoding( /// Shows info about the character under the primary cursor. fn get_character_info( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1276,11 +1206,7 @@ fn get_character_info( } /// Reload the [`Document`] from its source file. -fn reload( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1299,11 +1225,7 @@ fn reload( Ok(()) } -fn reload_all( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1359,11 +1281,7 @@ fn reload_all( } /// Update the [`Document`] if it has been modified. -fn update( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn update(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1378,7 +1296,7 @@ fn update( fn lsp_workspace_command( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1434,7 +1352,8 @@ fn lsp_workspace_command( }; cx.jobs.callback(callback); } else { - let command = args.join(" "); + let command = args.raw().to_string(); + let matches: Vec<_> = ls_id_commands .filter(|(_ls_id, c)| *c == &command) .collect(); @@ -1468,7 +1387,7 @@ fn lsp_workspace_command( fn lsp_restart( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1514,11 +1433,7 @@ fn lsp_restart( Ok(()) } -fn lsp_stop( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn lsp_stop(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1545,7 +1460,7 @@ fn lsp_stop( fn tree_sitter_scopes( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1578,7 +1493,7 @@ fn tree_sitter_scopes( fn tree_sitter_highlight_name( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { fn find_highlight_at_cursor( @@ -1651,81 +1566,50 @@ fn tree_sitter_highlight_name( Ok(()) } -fn vsplit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn vsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); - } - - if args.is_empty() { + } else if args.is_empty() { split(cx.editor, Action::VerticalSplit); } else { for arg in args { - cx.editor - .open(&PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; + cx.editor.open(&PathBuf::from(arg), Action::VerticalSplit)?; } } - Ok(()) } -fn hsplit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn hsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); - } - - if args.is_empty() { + } else if args.is_empty() { split(cx.editor, Action::HorizontalSplit); } else { for arg in args { cx.editor - .open(&PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; + .open(&PathBuf::from(arg), Action::HorizontalSplit)?; } } - Ok(()) } -fn vsplit_new( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn vsplit_new(cx: &mut compositor::Context, _args: Args, 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: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn hsplit_new(cx: &mut compositor::Context, _args: Args, 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: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn debug_eval(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1739,9 +1623,10 @@ fn debug_eval( }; // TODO: support no 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)))?; + let expression = args.raw().to_string(); + + let response = helix_lsp::block_on(debugger.eval(expression, Some(frame_id)))?; cx.editor.set_status(response.result); } Ok(()) @@ -1749,47 +1634,33 @@ fn debug_eval( fn debug_start( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - - 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)) + dap_start_impl(cx, args.next(), None, Some(args.map(Into::into).collect())) } fn debug_remote( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - - 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)) + let address = args.next().map(|addr| addr.parse()).transpose()?; + dap_start_impl( + cx, + args.next(), + address, + Some(args.map(Into::into).collect()), + ) } -fn tutor( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn tutor(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1813,7 +1684,7 @@ fn abort_goto_line_number_preview(cx: &mut compositor::Context) { fn update_goto_line_number_preview( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, ) -> anyhow::Result<()> { cx.editor.last_selection.get_or_insert_with(|| { let (view, doc) = current!(cx.editor); @@ -1821,7 +1692,7 @@ fn update_goto_line_number_preview( }); let scrolloff = cx.editor.config().scrolloff; - let line = args[0].parse::()?; + let line = args.next().unwrap().parse::()?; goto_line_without_jumplist(cx.editor, NonZeroUsize::new(line)); let (view, doc) = current!(cx.editor); @@ -1832,7 +1703,7 @@ fn update_goto_line_number_preview( pub(super) fn goto_line_number( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { match event { @@ -1868,18 +1739,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, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - if args.len() != 1 { + if args.arg_count() != 1 { anyhow::bail!("Bad arguments. Usage: `:get key`"); } - let key = &args[0].to_lowercase(); + let key = args.next().unwrap().to_lowercase(); let key_error = || anyhow::anyhow!("Unknown key `{}`", key); let config = serde_json::json!(cx.editor.config().deref()); @@ -1894,46 +1765,61 @@ fn get_option( /// example to disable smart case search, use `:set search.smart-case false`. fn set_option( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - if args.len() != 2 { - anyhow::bail!("Bad arguments. Usage: `:set key field`"); + 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`"); } - let (key, arg) = (&args[0].to_lowercase(), &args[1]); - 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 mut config = serde_json::json!(&*cx.editor.config()); let pointer = format!("/{}", key.replace('.', "/")); - let value = config.pointer_mut(&pointer).ok_or_else(key_error)?; + let value = config + .pointer_mut(&pointer) + .ok_or_else(|| anyhow::anyhow!("Unknown key `{key}`"))?; *value = if value.is_string() { // JSON strings require quotes, so we can't .parse() directly - Value::String(arg.to_string()) + Value::String(field.to_string()) } else { - arg.parse().map_err(field_error)? + field + .parse() + .map_err(|err| anyhow::anyhow!("Could not parse field `{field}`: {err}"))? }; - let config = serde_json::from_value(config).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", + ); 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, for example to toggle smart case search, use `:toggle search.smart- -/// case`. +/// syntax. +/// Example: +/// - `:toggle search.smart-case` (bool) +/// - `:toggle line-number relative absolute` (string) fn toggle_option( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1943,73 +1829,98 @@ fn toggle_option( if args.is_empty() { anyhow::bail!("Bad arguments. Usage: `:toggle key [values]?`"); } - let key = &args[0].to_lowercase(); + let key = args.next().unwrap().to_lowercase(); - let key_error = || anyhow::anyhow!("Unknown key `{}`", key); - - let mut config = serde_json::json!(&cx.editor.config().deref()); + let mut config = serde_json::json!(&*cx.editor.config()); let pointer = format!("/{}", key.replace('.', "/")); - let value = config.pointer_mut(&pointer).ok_or_else(key_error)?; + let value = config + .pointer_mut(&pointer) + .ok_or_else(|| anyhow::anyhow!("Unknown key `{}`", key))?; *value = match value { Value::Bool(ref value) => { ensure!( - args.len() == 1, + args.next().is_none(), "Bad arguments. For boolean configurations use: `:toggle key`" ); Value::Bool(!value) } Value::String(ref value) => { ensure!( - args.len() > 2, + // key + arguments + args.arg_count() >= 3, "Bad arguments. For string configurations use: `:toggle key val1 val2 ...`", ); Value::String( - args[1..] - .iter() + args.clone() .skip_while(|e| *e != value) .nth(1) - .unwrap_or_else(|| &args[1]) + .unwrap_or_else(|| args.nth(1).unwrap()) .to_string(), ) } Value::Number(ref value) => { ensure!( - args.len() > 2, + // key + arguments + args.arg_count() >= 3, "Bad arguments. For number configurations use: `:toggle key val1 val2 ...`", ); + let value = value.to_string(); + Value::Number( - args[1..] - .iter() - .skip_while(|&e| value.to_string() != *e.to_string()) + args.clone() + .skip_while(|e| *e != value) .nth(1) - .unwrap_or_else(|| &args[1]) + .unwrap_or_else(|| args.nth(1).unwrap()) .parse()?, ) } - Value::Null | Value::Object(_) | Value::Array(_) => { + 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(_) => { 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) - .map_err(|err| anyhow::anyhow!("Cannot parse `{:?}`, {}", &args, err))?; + let config = serde_json::from_value(config)?; 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, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2018,21 +1929,22 @@ 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.len() != 1 { + if args.arg_count() != 1 { anyhow::bail!("Bad arguments. Usage: `:set-language language`"); } let doc = doc_mut!(cx.editor); - if args[0] == DEFAULT_LANGUAGE_NAME { - doc.set_language(None, None) + let language_id = args.next().unwrap(); + if language_id == DEFAULT_LANGUAGE_NAME { + doc.set_language(None, None); } else { - doc.set_language_by_language_id(&args[0], cx.editor.syn_loader.clone())?; + doc.set_language_by_language_id(language_id, cx.editor.syn_loader.clone())?; } doc.detect_indent_and_line_ending(); @@ -2045,31 +1957,25 @@ fn language( Ok(()) } -fn sort(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn sort(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - sort_impl(cx, args, false) } fn sort_reverse( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - sort_impl(cx, args, true) } -fn sort_impl( - cx: &mut compositor::Context, - _args: &[Cow], - reverse: bool, -) -> anyhow::Result<()> { +fn sort_impl(cx: &mut compositor::Context, _args: Args, reverse: bool) -> anyhow::Result<()> { let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -2101,11 +2007,7 @@ fn sort_impl( Ok(()) } -fn reflow( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn reflow(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2119,7 +2021,7 @@ fn reflow( // - The configured text-width for this language in languages.toml // - The configured text-width in the config.toml let text_width: usize = args - .first() + .next() .map(|num| num.parse::()) .transpose()? .or_else(|| doc.language_config().and_then(|config| config.text_width)) @@ -2144,7 +2046,7 @@ fn reflow( fn tree_sitter_subtree( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2183,13 +2085,12 @@ fn tree_sitter_subtree( fn open_config( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - cx.editor .open(&helix_loader::config_file(), Action::Replace)?; Ok(()) @@ -2197,34 +2098,28 @@ fn open_config( fn open_workspace_config( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, 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: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn open_log(cx: &mut compositor::Context, _args: Args, 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: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2237,62 +2132,58 @@ fn refresh_config( fn append_output( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(!args.is_empty(), "Shell command required"); - shell(cx, &args.join(" "), &ShellBehavior::Append); + let cmd = helix_core::shellwords::unescape(args.raw()); + shell(cx, &cmd, &ShellBehavior::Append); Ok(()) } fn insert_output( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(!args.is_empty(), "Shell command required"); - shell(cx, &args.join(" "), &ShellBehavior::Insert); + let cmd = helix_core::shellwords::unescape(args.raw()); + shell(cx, &cmd, &ShellBehavior::Insert); Ok(()) } -fn pipe_to( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn pipe_to(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { pipe_impl(cx, args, event, &ShellBehavior::Ignore) } -fn pipe(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn pipe(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { pipe_impl(cx, args, event, &ShellBehavior::Replace) } fn pipe_impl( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, behavior: &ShellBehavior, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(!args.is_empty(), "Shell command required"); - shell(cx, &args.join(" "), behavior); + let cmd = helix_core::shellwords::unescape(args.raw()); + shell(cx, &cmd, behavior); Ok(()) } fn run_shell_command( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2300,7 +2191,8 @@ fn run_shell_command( } let shell = cx.editor.config().shell.clone(); - let args = args.join(" "); + + let args = helix_core::shellwords::unescape(args.raw()).into_owned(); let callback = async move { let output = shell_impl_async(&shell, &args, None).await?; @@ -2328,7 +2220,7 @@ fn run_shell_command( fn reset_diff_change( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2336,10 +2228,8 @@ fn reset_diff_change( } ensure!(args.is_empty(), ":reset-diff-change takes no arguments"); - let editor = &mut cx.editor; - let scrolloff = editor.config().scrolloff; - - let (view, doc) = current!(editor); + let scrolloff = cx.editor.config().scrolloff; + let (view, doc) = current!(cx.editor); let Some(handle) = doc.diff_handle() else { bail!("Diff is not available in the current buffer") }; @@ -2381,40 +2271,42 @@ fn reset_diff_change( fn clear_register( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.len() <= 1, ":clear-register takes at most 1 argument"); + ensure!( + args.arg_count() <= 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!( - args[0].chars().count() == 1, - format!("Invalid register {}", args[0]) + register.chars().count() == 1, + format!("Invalid register {register}") ); - let register = args[0].chars().next().unwrap_or_default(); + + let register = register.chars().next().unwrap_or_default(); if cx.editor.registers.remove(register) { - cx.editor - .set_status(format!("Register {} cleared", register)); + cx.editor.set_status(format!("Register {register} cleared")); } else { cx.editor - .set_error(format!("Register {} not found", register)); + .set_error(format!("Register {register} not found")); } Ok(()) } -fn redraw( - cx: &mut compositor::Context, - _args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn redraw(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2435,20 +2327,22 @@ fn redraw( fn move_buffer( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.len() == 1, format!(":move takes one argument")); - let doc = doc!(cx.editor); - let old_path = doc + ensure!(args.arg_count() == 1, format!(":move takes one argument")); + + let old_path = doc!(cx.editor) .path() .context("Scratch buffer cannot be moved. Use :write instead")? .clone(); - let new_path = args.first().unwrap().to_string(); + + let new_path = args.next().unwrap(); + if let Err(err) = cx.editor.move_path(&old_path, new_path.as_ref()) { bail!("Could not move file: {err}"); } @@ -2457,14 +2351,14 @@ fn move_buffer( fn yank_diagnostic( cx: &mut compositor::Context, - args: &[Cow], + mut args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - let reg = match args.first() { + let reg = match args.next() { Some(s) => { ensure!(s.chars().count() == 1, format!("Invalid register {s}")); s.chars().next().unwrap() @@ -2495,7 +2389,7 @@ fn yank_diagnostic( Ok(()) } -fn read(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn read(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2504,10 +2398,10 @@ fn read(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> let (view, doc) = current!(cx.editor); ensure!(!args.is_empty(), "file name is expected"); - ensure!(args.len() == 1, "only the file name is expected"); + ensure!(args.arg_count() == 1, "only the file name is expected"); - let filename = args.first().unwrap(); - let path = helix_stdx::path::expand_tilde(PathBuf::from(filename.to_string())); + let filename = args.next().unwrap(); + let path = helix_stdx::path::expand_tilde(Path::new(filename)); ensure!( path.exists() && path.is_file(), @@ -3170,9 +3064,11 @@ pub(super) fn command_mode(cx: &mut Context) { Some(':'), |editor: &Editor, input: &str| { let shellwords = Shellwords::from(input); - let words = shellwords.words(); + let command = shellwords.command(); - if words.is_empty() || (words.len() == 1 && !shellwords.ends_with_whitespace()) { + if command.is_empty() + || (shellwords.args().next().is_none() && !shellwords.ends_with_whitespace()) + { fuzzy_match( input, TYPABLE_COMMAND_LIST.iter().map(|command| command.name), @@ -3184,67 +3080,61 @@ pub(super) fn command_mode(cx: &mut Context) { } else { // Otherwise, use the command's completer and the last shellword // as completion input. - let (word, word_len) = if words.len() == 1 || shellwords.ends_with_whitespace() { - (&Cow::Borrowed(""), 0) - } else { - (words.last().unwrap(), words.last().unwrap().len()) - }; + let (word, len) = shellwords + .args() + .last() + .map_or(("", 0), |last| (last, last.len())); - let argument_number = argument_number_of(&shellwords); + 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); - 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() - } + // offset ranges to input + let offset = input.len() - len; + let range = (range.start + offset)..; + (range, file) + }) + .collect() + }) } }, // completion move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - let parts = input.split_whitespace().collect::>(); - if parts.is_empty() { + let shellwords = Shellwords::from(input); + let command = shellwords.command(); + + if command.is_empty() { return; } - // 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)); + // 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}")); } return; } // Handle typable commands - 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)); + 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}")); } } else if event == PromptEvent::Validate { - cx.editor - .set_error(format!("no such command: '{}'", parts[0])); + cx.editor.set_error(format!("no such command: '{command}'")); } }, ); + prompt.doc_fn = Box::new(|input: &str| { - let part = input.split(' ').next().unwrap_or_default(); + let shellwords = Shellwords::from(input); if let Some(typed::TypableCommand { doc, aliases, .. }) = - typed::TYPABLE_COMMAND_MAP.get(part) + typed::TYPABLE_COMMAND_MAP.get(shellwords.command()) { if aliases.is_empty() { return Some((*doc).into()); @@ -3261,11 +3151,10 @@ pub(super) fn command_mode(cx: &mut Context) { } fn argument_number_of(shellwords: &Shellwords) -> usize { - if shellwords.ends_with_whitespace() { - shellwords.words().len().saturating_sub(1) - } else { - shellwords.words().len().saturating_sub(2) - } + shellwords + .args() + .arg_count() + .saturating_sub(1 - usize::from(shellwords.ends_with_whitespace())) } #[test] diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 020ecaf4..aa9cafd3 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -597,18 +597,14 @@ 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: vec!{ - "sed".to_string(), - "-E".to_string(), - "'s/\\s+$//g'".to_string() - }, - doc: "".to_string(), + args: String::from("sed -E 's/\\s+$//g'"), + doc: String::new(), }, - }) + ]) }, vec![key], )); From 69025dde28aeb2939c741dabb10fd8e45208b04b Mon Sep 17 00:00:00 2001 From: Sofus Addington Date: Fri, 27 Dec 2024 11:26:59 +0100 Subject: [PATCH 7/7] Pull diagnostics --- helix-core/src/syntax.rs | 2 + helix-lsp/src/client.rs | 34 ++++ helix-lsp/src/lib.rs | 2 + helix-term/src/application.rs | 124 ++++----------- helix-term/src/commands.rs | 4 + helix-term/src/events.rs | 3 +- helix-term/src/handlers.rs | 5 +- helix-term/src/handlers/diagnostics.rs | 211 ++++++++++++++++++++++++- helix-view/src/document.rs | 7 + helix-view/src/editor.rs | 94 ++++++++++- helix-view/src/events.rs | 1 + helix-view/src/handlers.rs | 1 + helix-view/src/handlers/lsp.rs | 4 + 13 files changed, 392 insertions(+), 100 deletions(-) diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 375846b0..20425050 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -332,6 +332,7 @@ pub enum LanguageServerFeature { WorkspaceSymbols, // Symbols, use bitflags, see above? Diagnostics, + PullDiagnostics, RenameSymbol, InlayHints, } @@ -355,6 +356,7 @@ impl Display for LanguageServerFeature { DocumentSymbols => "document-symbols", WorkspaceSymbols => "workspace-symbols", Diagnostics => "diagnostics", + PullDiagnostics => "pull-diagnostics", RenameSymbol => "rename-symbol", InlayHints => "inlay-hints", }; diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index cc1c4ce8..e868effc 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -345,6 +345,7 @@ impl Client { Some(OneOf::Left(true) | OneOf::Right(_)) ), LanguageServerFeature::Diagnostics => true, // there's no extra server capability + LanguageServerFeature::PullDiagnostics => capabilities.diagnostic_provider.is_some(), LanguageServerFeature::RenameSymbol => matches!( capabilities.rename_provider, Some(OneOf::Left(true)) | Some(OneOf::Right(_)) @@ -570,6 +571,9 @@ impl Client { did_rename: Some(true), ..Default::default() }), + diagnostic: Some(lsp::DiagnosticWorkspaceClientCapabilities { + refresh_support: Some(true), + }), ..Default::default() }), text_document: Some(lsp::TextDocumentClientCapabilities { @@ -647,6 +651,10 @@ impl Client { }), ..Default::default() }), + diagnostic: Some(lsp::DiagnosticClientCapabilities { + dynamic_registration: Some(false), + related_document_support: Some(true), + }), publish_diagnostics: Some(lsp::PublishDiagnosticsClientCapabilities { version_support: Some(true), tag_support: Some(lsp::TagSupport { @@ -1223,6 +1231,32 @@ impl Client { }) } + pub fn text_document_diagnostic( + &self, + text_document: lsp::TextDocumentIdentifier, + previous_result_id: Option, + ) -> Option>> { + let capabilities = self.capabilities(); + + // Return early if the server does not support pull diagnostic. + let identifier = match capabilities.diagnostic_provider.as_ref()? { + lsp::DiagnosticServerCapabilities::Options(cap) => cap.identifier.clone(), + lsp::DiagnosticServerCapabilities::RegistrationOptions(cap) => { + cap.diagnostic_options.identifier.clone() + } + }; + + let params = lsp::DocumentDiagnosticParams { + text_document, + identifier, + previous_result_id, + work_done_progress_params: lsp::WorkDoneProgressParams::default(), + partial_result_params: lsp::PartialResultParams::default(), + }; + + Some(self.call::(params)) + } + pub fn text_document_document_highlight( &self, text_document: lsp::TextDocumentIdentifier, diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index fd5cdb8b..c904da66 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -463,6 +463,7 @@ pub enum MethodCall { RegisterCapability(lsp::RegistrationParams), UnregisterCapability(lsp::UnregistrationParams), ShowDocument(lsp::ShowDocumentParams), + WorkspaceDiagnosticRefresh, } impl MethodCall { @@ -494,6 +495,7 @@ impl MethodCall { let params: lsp::ShowDocumentParams = params.parse()?; Self::ShowDocument(params) } + lsp::request::WorkspaceDiagnosticRefresh::METHOD => Self::WorkspaceDiagnosticRefresh, _ => { return Err(Error::Unhandled); } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 36cb295c..feb84fb6 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -11,7 +11,6 @@ use helix_view::{ align_view, document::{DocumentOpenError, DocumentSavedEventResult}, editor::{ConfigEvent, EditorEvent}, - events::DiagnosticsDidChange, graphics::Rect, theme, tree::Layout, @@ -33,7 +32,7 @@ use crate::{ use log::{debug, error, info, warn}; #[cfg(not(feature = "integration"))] use std::io::stdout; -use std::{collections::btree_map::Entry, io::stdin, path::Path, sync::Arc}; +use std::{io::stdin, path::Path, sync::Arc}; #[cfg(not(windows))] use anyhow::Context; @@ -741,9 +740,14 @@ impl Application { doc.text(), language_id, )); + + handlers::diagnostics::pull_diagnostics_for_document( + doc, + language_server, + ); } } - Notification::PublishDiagnostics(mut params) => { + Notification::PublishDiagnostics(params) => { let uri = match helix_core::Uri::try_from(params.uri) { Ok(uri) => uri, Err(err) => { @@ -756,100 +760,20 @@ impl Application { log::error!("Discarding publishDiagnostic notification sent by an uninitialized server: {}", language_server.name()); return; } - // have to inline the function because of borrow checking... - let doc = self.editor.documents.values_mut() - .find(|doc| doc.uri().is_some_and(|u| u == uri)) - .filter(|doc| { - if let Some(version) = params.version { - if version != doc.version() { - log::info!("Version ({version}) is out of date for {uri:?} (expected ({}), dropping PublishDiagnostic notification", doc.version()); - return false; - } - } - true - }); - let mut unchanged_diag_sources = Vec::new(); - if let Some(doc) = &doc { - let lang_conf = doc.language.clone(); + let diagnostics: Vec<(lsp::Diagnostic, LanguageServerId)> = params + .diagnostics + .into_iter() + .map(|d| (d, server_id)) + .collect(); - if let Some(lang_conf) = &lang_conf { - if let Some(old_diagnostics) = self.editor.diagnostics.get(&uri) { - if !lang_conf.persistent_diagnostic_sources.is_empty() { - // Sort diagnostics first by severity and then by line numbers. - // Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order - params - .diagnostics - .sort_by_key(|d| (d.severity, d.range.start)); - } - for source in &lang_conf.persistent_diagnostic_sources { - let new_diagnostics = params - .diagnostics - .iter() - .filter(|d| d.source.as_ref() == Some(source)); - let old_diagnostics = old_diagnostics - .iter() - .filter(|(d, d_server)| { - *d_server == server_id - && d.source.as_ref() == Some(source) - }) - .map(|(d, _)| d); - if new_diagnostics.eq(old_diagnostics) { - unchanged_diag_sources.push(source.clone()) - } - } - } - } - } - - let diagnostics = params.diagnostics.into_iter().map(|d| (d, server_id)); - - // Insert the original lsp::Diagnostics here because we may have no open document - // for diagnosic message and so we can't calculate the exact position. - // When using them later in the diagnostics picker, we calculate them on-demand. - let diagnostics = match self.editor.diagnostics.entry(uri) { - Entry::Occupied(o) => { - let current_diagnostics = o.into_mut(); - // there may entries of other language servers, which is why we can't overwrite the whole entry - current_diagnostics.retain(|(_, lsp_id)| *lsp_id != server_id); - current_diagnostics.extend(diagnostics); - current_diagnostics - // Sort diagnostics first by severity and then by line numbers. - } - Entry::Vacant(v) => v.insert(diagnostics.collect()), - }; - - // Sort diagnostics first by severity and then by line numbers. - // Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order - diagnostics - .sort_by_key(|(d, server_id)| (d.severity, d.range.start, *server_id)); - - if let Some(doc) = doc { - let diagnostic_of_language_server_and_not_in_unchanged_sources = - |diagnostic: &lsp::Diagnostic, ls_id| { - ls_id == server_id - && diagnostic.source.as_ref().map_or(true, |source| { - !unchanged_diag_sources.contains(source) - }) - }; - let diagnostics = Editor::doc_diagnostics_with_filter( - &self.editor.language_servers, - &self.editor.diagnostics, - doc, - diagnostic_of_language_server_and_not_in_unchanged_sources, - ); - doc.replace_diagnostics( - diagnostics, - &unchanged_diag_sources, - Some(server_id), - ); - - let doc = doc.id(); - helix_event::dispatch(DiagnosticsDidChange { - editor: &mut self.editor, - doc, - }); - } + self.editor.add_diagnostics( + diagnostics, + server_id, + uri, + params.version, + None, + ); } Notification::ShowMessage(params) => { if self.config.load().editor.lsp.display_messages { @@ -1124,6 +1048,16 @@ impl Application { let result = self.handle_show_document(params, offset_encoding); Ok(json!(result)) } + Ok(MethodCall::WorkspaceDiagnosticRefresh) => { + for document in self.editor.documents() { + let language_server = language_server!(); + handlers::diagnostics::pull_diagnostics_for_document( + document, + language_server, + ); + } + Ok(serde_json::Value::Null) + } }; tokio::spawn(language_server!().reply(id, reply)); diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 06d892ad..5860be5e 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -903,6 +903,10 @@ fn goto_buffer(editor: &mut Editor, direction: Direction, count: usize) { let id = *id; + if let Some(doc) = editor.document(id) { + helix_event::dispatch(helix_view::events::DocumentDidOpen { doc }); + }; + editor.switch(id, Action::Replace); } diff --git a/helix-term/src/events.rs b/helix-term/src/events.rs index 15d81152..9d06627c 100644 --- a/helix-term/src/events.rs +++ b/helix-term/src/events.rs @@ -1,7 +1,7 @@ use helix_event::{events, register_event}; use helix_view::document::Mode; use helix_view::events::{ - DiagnosticsDidChange, DocumentDidChange, DocumentFocusLost, SelectionDidChange, + DiagnosticsDidChange, DocumentDidChange, DocumentDidOpen, DocumentFocusLost, SelectionDidChange, }; use crate::commands; @@ -18,6 +18,7 @@ pub fn register() { register_event::(); register_event::(); register_event::(); + register_event::(); register_event::(); register_event::(); register_event::(); diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index 31e15330..a5b8a0ad 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -7,6 +7,7 @@ use crate::config::Config; use crate::events; use crate::handlers::auto_save::AutoSaveHandler; use crate::handlers::completion::CompletionHandler; +use crate::handlers::diagnostics::PullDiagnosticsHandler; use crate::handlers::signature_help::SignatureHelpHandler; pub use completion::trigger_auto_completion; @@ -14,7 +15,7 @@ pub use helix_view::handlers::Handlers; mod auto_save; pub mod completion; -mod diagnostics; +pub mod diagnostics; mod signature_help; mod snippet; @@ -24,11 +25,13 @@ pub fn setup(config: Arc>) -> Handlers { let completions = CompletionHandler::new(config).spawn(); let signature_hints = SignatureHelpHandler::new().spawn(); let auto_save = AutoSaveHandler::new().spawn(); + let pull_diagnostics = PullDiagnosticsHandler::new().spawn(); let handlers = Handlers { completions, signature_hints, auto_save, + pull_diagnostics, }; completion::register_hooks(&handlers); diff --git a/helix-term/src/handlers/diagnostics.rs b/helix-term/src/handlers/diagnostics.rs index 3e44d416..a1af4ff9 100644 --- a/helix-term/src/handlers/diagnostics.rs +++ b/helix-term/src/handlers/diagnostics.rs @@ -1,12 +1,23 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use helix_core::syntax::LanguageServerFeature; +use helix_core::Uri; use helix_event::{register_hook, send_blocking}; +use helix_lsp::lsp::{self, Diagnostic}; +use helix_lsp::LanguageServerId; use helix_view::document::Mode; -use helix_view::events::DiagnosticsDidChange; +use helix_view::events::{DiagnosticsDidChange, DocumentDidChange, DocumentDidOpen}; use helix_view::handlers::diagnostics::DiagnosticEvent; +use helix_view::handlers::lsp::PullDiagnosticsEvent; use helix_view::handlers::Handlers; +use helix_view::{DocumentId, Editor}; +use tokio::time::Instant; use crate::events::OnModeSwitch; +use crate::job; -pub(super) fn register_hooks(_handlers: &Handlers) { +pub(super) fn register_hooks(handlers: &Handlers) { register_hook!(move |event: &mut DiagnosticsDidChange<'_>| { if event.editor.mode != Mode::Insert { for (view, _) in event.editor.tree.views_mut() { @@ -21,4 +32,200 @@ pub(super) fn register_hooks(_handlers: &Handlers) { } Ok(()) }); + + let tx = handlers.pull_diagnostics.clone(); + register_hook!(move |event: &mut DocumentDidChange<'_>| { + if event + .doc + .has_language_server_with_feature(LanguageServerFeature::PullDiagnostics) + { + let document_id = event.doc.id(); + send_blocking(&tx, PullDiagnosticsEvent { document_id }); + } + Ok(()) + }); + + register_hook!(move |event: &mut DocumentDidOpen<'_>| { + if event + .doc + .has_language_server_with_feature(LanguageServerFeature::PullDiagnostics) + { + let document_id = event.doc.id(); + job::dispatch_blocking(move |editor, _| { + let Some(doc) = editor.document(document_id) else { + return; + }; + + let language_servers = + doc.language_servers_with_feature(LanguageServerFeature::PullDiagnostics); + + for language_server in language_servers { + pull_diagnostics_for_document(doc, language_server); + } + }) + } + + Ok(()) + }); +} + +#[derive(Debug)] +pub(super) struct PullDiagnosticsHandler { + document_ids: HashSet, +} + +impl PullDiagnosticsHandler { + pub fn new() -> PullDiagnosticsHandler { + PullDiagnosticsHandler { + document_ids: [].into(), + } + } +} + +impl helix_event::AsyncHook for PullDiagnosticsHandler { + type Event = PullDiagnosticsEvent; + + fn handle_event( + &mut self, + event: Self::Event, + _: Option, + ) -> Option { + self.document_ids.insert(event.document_id); + Some(Instant::now() + Duration::from_millis(120)) + } + + fn finish_debounce(&mut self) { + for document_id in self.document_ids.clone() { + job::dispatch_blocking(move |editor, _| { + let doc = editor.document(document_id); + let Some(doc) = doc else { + return; + }; + + let language_servers = + doc.language_servers_with_feature(LanguageServerFeature::PullDiagnostics); + + for language_server in language_servers { + pull_diagnostics_for_document(doc, language_server); + } + }) + } + } +} + +pub fn pull_diagnostics_for_document( + doc: &helix_view::Document, + language_server: &helix_lsp::Client, +) { + let Some(future) = language_server + .text_document_diagnostic(doc.identifier(), doc.previous_diagnostic_id.clone()) + else { + return; + }; + + let Some(uri) = doc.uri() else { + return; + }; + + let server_id = language_server.id(); + let document_id = doc.id(); + + tokio::spawn(async move { + match future.await { + Ok(res) => { + job::dispatch(move |editor, _| { + let response = match serde_json::from_value(res) { + Ok(result) => result, + Err(_) => return, + }; + + handle_pull_diagnostics_response(editor, response, server_id, uri, document_id) + }) + .await + } + Err(err) => log::error!("Pull diagnostic request failed: {err}"), + } + }); +} + +fn handle_pull_diagnostics_response( + editor: &mut Editor, + response: lsp::DocumentDiagnosticReport, + server_id: LanguageServerId, + uri: Uri, + document_id: DocumentId, +) { + let Some(doc) = editor.document_mut(document_id) else { + return; + }; + + match response { + lsp::DocumentDiagnosticReport::Full(report) => { + // Original file diagnostic + add_diagnostics_to_editor( + editor, + uri, + report.full_document_diagnostic_report.items, + report.full_document_diagnostic_report.result_id, + server_id, + ); + + // Related files diagnostic + handle_document_diagnostic_report_kind( + editor, + document_id, + report.related_documents, + server_id, + ); + } + lsp::DocumentDiagnosticReport::Unchanged(report) => { + doc.previous_diagnostic_id = + Some(report.unchanged_document_diagnostic_report.result_id); + + handle_document_diagnostic_report_kind( + editor, + document_id, + report.related_documents, + server_id, + ); + } + } +} + +fn add_diagnostics_to_editor( + editor: &mut Editor, + uri: Uri, + report: Vec, + result_id: Option, + server_id: LanguageServerId, +) { + let diagnostics: Vec<(Diagnostic, LanguageServerId)> = + report.into_iter().map(|d| (d, server_id)).collect(); + + editor.add_diagnostics(diagnostics, server_id, uri, None, result_id); +} + +fn handle_document_diagnostic_report_kind( + editor: &mut Editor, + document_id: DocumentId, + report: Option>, + server_id: LanguageServerId, +) { + for (url, report) in report.into_iter().flatten() { + match report { + lsp::DocumentDiagnosticReportKind::Full(report) => { + let Ok(uri) = Uri::try_from(url) else { + return; + }; + + add_diagnostics_to_editor(editor, uri, report.items, report.result_id, server_id); + } + lsp::DocumentDiagnosticReportKind::Unchanged(report) => { + let Some(doc) = editor.document_mut(document_id) else { + return; + }; + doc.previous_diagnostic_id = Some(report.result_id); + } + } + } } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index dcdc8dc2..7daf5d8e 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -194,6 +194,8 @@ pub struct Document { pub focused_at: std::time::Instant, pub readonly: bool, + + pub previous_diagnostic_id: Option, } /// Inlay hints for a single `(Document, View)` combo. @@ -687,6 +689,7 @@ impl Document { focused_at: std::time::Instant::now(), readonly: false, jump_labels: HashMap::new(), + previous_diagnostic_id: None, } } @@ -2153,6 +2156,10 @@ impl Document { pub fn reset_all_inlay_hints(&mut self) { self.inlay_hints = Default::default(); } + + pub fn has_language_server_with_feature(&self, feature: LanguageServerFeature) -> bool { + self.language_servers_with_feature(feature).next().is_some() + } } #[derive(Debug, Default)] diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 6c585a8a..ecac8f23 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -4,7 +4,7 @@ use crate::{ document::{ DocumentOpenError, DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint, }, - events::DocumentFocusLost, + events::{DocumentDidOpen, DocumentFocusLost}, graphics::{CursorKind, Rect}, handlers::Handlers, info::Info, @@ -1772,6 +1772,11 @@ impl Editor { }; self.switch(id, action); + + if let Some(doc) = self.document_mut(id) { + helix_event::dispatch(DocumentDidOpen { doc }); + }; + Ok(id) } @@ -2209,6 +2214,93 @@ impl Editor { current_view.id } } + + pub fn add_diagnostics( + &mut self, + diagnostics: Vec<(lsp::Diagnostic, LanguageServerId)>, + server_id: LanguageServerId, + uri: helix_core::Uri, + document_version: Option, + result_id: Option, + ) { + let Some(doc) = self + .documents + .values_mut() + .find(|doc| doc.uri().is_some_and(|u| u == uri)) + else { + return; + }; + + if let Some(version) = document_version { + if version != doc.version() { + log::info!("Version ({version}) is out of date for {uri:?} (expected ({}), dropping PublishDiagnostic notification", doc.version()); + return; + } + } + + let mut unchanged_diag_sources = Vec::new(); + if let Some(old_diagnostics) = self.diagnostics.get(&uri) { + if let Some(lang_conf) = doc.language_config() { + for source in &lang_conf.persistent_diagnostic_sources { + let new_diagnostics = diagnostics + .iter() + .filter(|d| d.0.source.as_ref() == Some(source)); + let old_diagnostics = old_diagnostics + .iter() + .filter(|(d, d_server)| { + *d_server == server_id && d.source.as_ref() == Some(source) + }) + .map(|(d, _)| d); + if new_diagnostics.map(|x| &x.0).eq(old_diagnostics) { + unchanged_diag_sources.push(source.clone()) + } + } + } + } + + // Insert the original lsp::Diagnostics here because we may have no open document + // for diagnosic message and so we can't calculate the exact position. + // When using them later in the diagnostics picker, we calculate them on-demand. + let diagnostics = match self.diagnostics.entry(uri) { + std::collections::btree_map::Entry::Occupied(o) => { + let current_diagnostics = o.into_mut(); + // there may entries of other language servers, which is why we can't overwrite the whole entry + current_diagnostics.retain(|(_, lsp_id)| *lsp_id != server_id); + current_diagnostics.extend(diagnostics); + current_diagnostics + // Sort diagnostics first by severity and then by line numbers. + } + std::collections::btree_map::Entry::Vacant(v) => v.insert(diagnostics), + }; + + // Sort diagnostics first by severity and then by line numbers. + // Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order + diagnostics.sort_by_key(|(d, server_id)| (d.severity, d.range.start, *server_id)); + + let diagnostic_of_language_server_and_not_in_unchanged_sources = + |diagnostic: &lsp::Diagnostic, ls_id| { + ls_id == server_id + && diagnostic + .source + .as_ref() + .map_or(true, |source| !unchanged_diag_sources.contains(source)) + }; + let diagnostics = Editor::doc_diagnostics_with_filter( + &self.language_servers, + &self.diagnostics, + doc, + diagnostic_of_language_server_and_not_in_unchanged_sources, + ); + doc.replace_diagnostics(diagnostics, &unchanged_diag_sources, Some(server_id)); + + if result_id.is_some() { + doc.previous_diagnostic_id = result_id; + } + + let doc = doc.id(); + + helix_event::dispatch(crate::events::DiagnosticsDidChange { editor: self, doc }); + } } fn try_restore_indent(doc: &mut Document, view: &mut View) { diff --git a/helix-view/src/events.rs b/helix-view/src/events.rs index eb97268c..cb3ed90c 100644 --- a/helix-view/src/events.rs +++ b/helix-view/src/events.rs @@ -11,6 +11,7 @@ events! { changes: &'a ChangeSet, ghost_transaction: bool } + DocumentDidOpen<'a> { doc: &'a Document} SelectionDidChange<'a> { doc: &'a mut Document, view: ViewId } DiagnosticsDidChange<'a> { editor: &'a mut Editor, doc: DocumentId } // called **after** a document loses focus (but not when its closed) diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs index 93336beb..519ccca8 100644 --- a/helix-view/src/handlers.rs +++ b/helix-view/src/handlers.rs @@ -19,6 +19,7 @@ pub struct Handlers { pub completions: Sender, pub signature_hints: Sender, pub auto_save: Sender, + pub pull_diagnostics: Sender, } impl Handlers { diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index 1fd2289d..37315da5 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -47,6 +47,10 @@ pub enum SignatureHelpEvent { RequestComplete { open: bool }, } +pub struct PullDiagnosticsEvent { + pub document_id: DocumentId, +} + #[derive(Debug)] pub struct ApplyEditError { pub kind: ApplyEditErrorKind,