diff --git a/helix-core/src/args.rs b/helix-core/src/args.rs new file mode 100644 index 000000000000..2910d1df50c0 --- /dev/null +++ b/helix-core/src/args.rs @@ -0,0 +1,862 @@ +use std::{ + borrow::Cow, + ops::{Index, RangeFrom}, +}; + +use anyhow::ensure; + +use crate::shellwords::unescape; + +/// Represents different ways that arguments can be handled when parsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseMode { + /// Treat the input as is, with no splitting or processing. + Raw, + /// Splits on whitespace while respected quoted substrings. + /// + /// Return value includes the start and end quotes. + RawParams, + /// Treat the entire input as one positional that only unescapes backslashes (I.e. resolve `\\` to `\` but not `\t` or `\n`). + UnescapeBackslash, + /// Split the input into multiple parameters, while escaping backslashes (`\\` to `\`), but not other literals. + UnescapeBackslashParams, + /// Treat the entire input as one positional with minimal processing (I.e. expand `\t` and `\n` but don't split on spaces or handle quotes). + Literal, + /// Treat the entire input as one positional with full processing (I.e. expand `\t`, `\n` and also `\\`). + LiteralUnescapeBackslash, + /// Split the input into multiple parameters, while escaping literals (`\n`, `\t`, `\u{C}`, etc.), but not backslashes. + LiteralParams, + /// Split the input into multiple parameters, while escaping literals (`\n`, `\t`, `\u{C}`, etc.), including backslashes. + LiteralUnescapeBackslashParams, +} + +impl ParseMode { + pub const fn default() -> Self { + Self::Raw + } +} + +#[derive(Clone)] +pub struct Signature { + /// The min-max of the amount of positional arguments a command accepts. + /// + /// - **0**: (0, Some(0)) + /// - **0-1**: (0, Some(1)) + /// - **1**: (1, Some(1)) + /// - **1-10**: (1, Some(10)) + /// - **Unbounded**: (0, None) + pub positionals: (usize, Option), + pub parse_mode: ParseMode, +} + +pub fn ensure_signature(name: &str, signature: &Signature, count: usize) -> anyhow::Result<()> { + match signature.positionals { + (0, Some(0)) => ensure!(count == 0, "`:{}` doesn't take any arguments", name), + (min, Some(max)) if min == max => ensure!( + (min..=max).contains(&count), + "`:{}` needs `{min}` argument{}, got {count}", + name, + if min > 1 { "'s" } else { "" } + ), + (min, Some(max)) if min == max => ensure!( + (min..=max).contains(&count), + "`:{}` needs at least `{min}` argument{} and at most `{max}`, got `{count}`", + name, + if min > 1 { "'s" } else { "" } + ), + (min, _) => ensure!( + (min..).contains(&count), + "`:{}` needs at least `{min}` argument{}", + name, + if min > 1 { "'s" } else { "" } + ), + } + + Ok(()) +} + +/// An abstraction for arguments that were passed in to a command. +#[derive(Debug, Clone)] +pub struct Args<'a> { + positionals: Vec>, +} + +impl<'a> Args<'a> { + /// Creates an instance of `Args`, with behavior shaped from a signature. + /// + /// When validate is `true` then it will check if the signature matches + /// the number of arguments that the command is expecting. + #[inline] + pub fn from_signature( + name: &str, + signature: &Signature, + args: &'a str, + validate: bool, + ) -> anyhow::Result { + // Checking with a `Params` mode means that the number of arguments can be counted and validated + // even if the actual parse type is `Raw`, `Literal` or `UnescapeBackslash`, which only yield + // a single item otherwise. + let args = ArgsParser::from(args).with_mode(ParseMode::RawParams); + + if validate { + ensure_signature(name, signature, args.clone().count())?; + } + + Ok(Args { + positionals: args.with_mode(signature.parse_mode).collect(), + }) + } + + /// Returns the count of how many arguments there are. + #[inline] + pub fn len(&self) -> usize { + self.positionals.len() + } + + /// Returns if there were no arguments passed in. + #[inline] + pub fn is_empty(&self) -> bool { + self.positionals.is_empty() + } + + /// Returns a reference to an element if one exists at the index. + #[inline] + pub fn get(&self, index: usize) -> Option<&Cow<'_, str>> { + self.positionals.get(index) + } + + /// Returns the first argument, if any. + #[inline] + pub fn first(&self) -> Option<&Cow<'_, str>> { + self.positionals.first() + } + + /// Returns the last argument, if any. + #[inline] + pub fn last(&self) -> Option<&Cow<'_, str>> { + self.positionals.last() + } + + /// Produces an `Iterator` over the arguments that were passed along. + #[inline] + pub fn iter(&self) -> impl Iterator> { + self.positionals.iter() + } + + /// Represents when there are no arguments. + #[inline(always)] + pub fn empty() -> Self { + Self { + positionals: Vec::new(), + } + } +} + +impl<'a> From<&'a str> for Args<'a> { + #[inline] + fn from(args: &'a str) -> Self { + Args { + positionals: ArgsParser::from(args).collect(), + } + } +} + +impl<'a> IntoIterator for Args<'a> { + type Item = Cow<'a, str>; + type IntoIter = std::vec::IntoIter>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.positionals.into_iter() + } +} + +impl<'a> IntoIterator for &'a Args<'a> { + type Item = &'a Cow<'a, str>; + type IntoIter = std::slice::Iter<'a, Cow<'a, str>>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.positionals.iter() + } +} + +impl<'a> AsRef<[Cow<'a, str>]> for Args<'a> { + #[inline] + fn as_ref(&self) -> &[Cow<'a, str>] { + self.positionals.as_ref() + } +} + +impl PartialEq<&[&str]> for Args<'_> { + #[inline] + fn eq(&self, other: &&[&str]) -> bool { + let this = self.positionals.iter(); + let other = other.iter().copied(); + + for (left, right) in this.zip(other) { + if left != right { + return false; + } + } + + true + } +} + +impl<'a> Index for Args<'a> { + type Output = str; + + #[inline] + fn index(&self, index: usize) -> &Self::Output { + let cow = &self.positionals[index]; + cow.as_ref() + } +} + +impl<'a> Index> for Args<'a> { + type Output = [Cow<'a, str>]; + + #[inline] + fn index(&self, index: RangeFrom) -> &Self::Output { + &self.positionals[index] + } +} + +/// 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 ArgsParser<'a> { + input: &'a str, + idx: usize, + start: usize, + mode: ParseMode, + is_finished: bool, +} + +impl<'a> ArgsParser<'a> { + #[inline] + const fn new(input: &'a str) -> ArgsParser<'a> { + Self { + input, + idx: 0, + start: 0, + mode: ParseMode::Raw, + is_finished: false, + } + } + + #[inline] + #[must_use] + pub fn with_mode(mut self, mode: ParseMode) -> ArgsParser<'a> { + self.mode = mode; + self + } + + #[inline] + pub fn set_mode(&mut self, mode: ParseMode) { + self.mode = mode; + } + + #[inline] + #[must_use] + pub const fn is_empty(&self) -> bool { + self.input.is_empty() + } + + /// Returns the args exactly as input. + /// + /// # Examples + /// ``` + /// # use helix_core::args::ArgsParser; + /// let args = ArgsParser::from(r#"sed -n "s/test t/not /p""#); + /// assert_eq!(r#"sed -n "s/test t/not /p""#, args.raw()); + /// + /// let args = ArgsParser::from(r#"cat "file name with space.txt""#); + /// assert_eq!(r#"cat "file name with space.txt""#, args.raw()); + /// ``` + #[inline] + pub const fn raw(&self) -> &str { + self.input + } + + /// Returns the remainder of the args exactly as input. + /// + /// # Examples + /// ``` + /// # use helix_core::args::{ArgsParser, ParseMode}; + /// let mut args = ArgsParser::from(r#"sed -n "s/test t/not /p""#).with_mode(ParseMode::RawParams); + /// 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..] + } +} + +impl<'a> Iterator for ArgsParser<'a> { + type Item = Cow<'a, str>; + + #[inline] + #[allow(clippy::too_many_lines)] + fn next(&mut self) -> Option { + // Special case so that `ArgsParser::new("")` and `Args::from("")` result in no iterations + // being done, and `ArgsParser::new("").count == 0` and `Args::from("").is_empty` is `true`. + if self.input.is_empty() { + return None; + } + + match self.mode { + ParseMode::Raw if !self.is_finished => { + self.start = self.input.len(); + self.idx = self.input.len(); + self.is_finished = true; + + return Some(Cow::from(self.input)); + } + ParseMode::Literal if !self.is_finished => { + self.start = self.input.len(); + self.idx = self.input.len(); + self.is_finished = true; + + return Some(unescape(self.input, true, false)); + } + ParseMode::LiteralUnescapeBackslash if !self.is_finished => { + self.start = self.input.len(); + self.idx = self.input.len(); + self.is_finished = true; + + return Some(unescape(self.input, true, true)); + } + ParseMode::UnescapeBackslash if !self.is_finished => { + self.start = self.input.len(); + self.idx = self.input.len(); + self.is_finished = true; + + return Some(unescape(self.input, false, true)); + } + _ => {} + } + + // 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 output = match self.mode { + ParseMode::RawParams => { + // Include start and end quotes in return value + let arg = &self.input[self.start - 1..=self.idx]; + self.idx += 1; + self.start = self.idx; + Cow::from(arg) + } + ParseMode::LiteralParams => { + let arg = &self.input[self.start..self.idx]; + self.idx += 1; + self.start = self.idx; + + unescape(arg, true, false) + } + + ParseMode::LiteralUnescapeBackslashParams => { + let arg = &self.input[self.start..self.idx]; + self.idx += 1; + self.start = self.idx; + + unescape(arg, true, true) + } + ParseMode::UnescapeBackslashParams => { + let arg = &self.input[self.start..self.idx]; + self.idx += 1; + self.start = self.idx; + + unescape(arg, false, true) + } + _ => { + unreachable!( + "other variants are returned early at start of `next` {:?}", + self.mode + ) + } + }; + + return Some(output); + } + // 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 = &self.input[self.idx..]; + self.idx = bytes.len(); + self.start = bytes.len(); + + let output = match self.mode { + ParseMode::RawParams => Cow::from(arg), + ParseMode::LiteralParams => unescape(arg, true, false), + ParseMode::LiteralUnescapeBackslashParams => unescape(arg, true, true), + ParseMode::UnescapeBackslashParams => unescape(arg, false, true), + _ => { + unreachable!( + "other variants are returned early at start of `next` {:?}", + self.mode + ) + } + }; + + return Some(output); + } 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 = &self.input[self.start..self.idx]; + self.idx += 1; + self.start = self.idx; + + let output = match self.mode { + ParseMode::RawParams => Cow::from(arg), + ParseMode::LiteralParams => unescape(arg, true, false), + ParseMode::LiteralUnescapeBackslashParams => { + unescape(arg, true, true) + } + + ParseMode::UnescapeBackslashParams => unescape(arg, false, true), + _ => { + unreachable!( + "other variants are returned early at start of `next` {:?}", + self.mode + ) + } + }; + + return Some(output); + } + + // Advance after quote. + self.idx += 1; + // Exclude quote from arg output. + self.start = self.idx; + } + } + b' ' | b'\t' if !in_quotes && !is_escaped => { + // 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 = &self.input[self.start..self.idx]; + self.idx += 1; + self.start = self.idx; + + let output = match self.mode { + ParseMode::RawParams => Cow::from(arg), + ParseMode::LiteralParams => unescape(arg, true, false), + ParseMode::LiteralUnescapeBackslashParams => unescape(arg, true, true), + ParseMode::UnescapeBackslashParams => unescape(arg, false, true), + _ => { + unreachable!( + "other variants are returned early at start of `next` {:?}", + self.mode + ) + } + }; + + return Some(output); + } + + // 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 + }; + + 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 = &self.input[self.start..]; + + let output = match self.mode { + ParseMode::RawParams => { + Cow::from(&self.input[self.start - usize::from(in_quotes)..]) + } + ParseMode::LiteralParams => unescape(arg, true, false), + ParseMode::LiteralUnescapeBackslashParams => unescape(arg, true, true), + ParseMode::UnescapeBackslashParams => unescape(arg, false, true), + _ => { + unreachable!( + "other variants are returned early at start of `next` {:?}", + self.mode + ) + } + }; + + self.start = bytes.len(); + + return Some(output); + } + + // All args have been parsed. + None + } +} + +impl<'a> From<&'a str> for ArgsParser<'a> { + #[inline] + fn from(args: &'a str) -> Self { + ArgsParser::new(args) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn should_parse_arguments_with_no_unescaping() { + let mut parser = ArgsParser::from(r#"single_word twó wörds \\three\ \"with\ escaping\\"#) + .with_mode(ParseMode::RawParams); + + assert_eq!(Cow::from("single_word"), parser.next().unwrap()); + assert_eq!(Cow::from("twó"), parser.next().unwrap()); + assert_eq!(Cow::from("wörds"), parser.next().unwrap()); + assert_eq!( + Cow::from(r#"\\three\ \"with\ escaping\\"#), + parser.next().unwrap() + ); + } + + #[test] + fn should_honor_parser_mode() { + let parser = Args::from_signature( + "", + &Signature { + positionals: (0, None), + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + }, + r#"single_word twó wörds \\three\ \"with\ escaping\\"#, + true, + ) + .unwrap(); + + assert_eq!(Cow::from("single_word"), parser[0]); + assert_eq!(Cow::from("twó"), parser[1]); + assert_eq!(Cow::from("wörds"), parser[2]); + assert_eq!(Cow::from(r#"\three "with escaping\"#), parser[3]); + } + + #[test] + fn should_split_args_no_slash_unescaping() { + let args: Vec> = + ArgsParser::from(r#"single_word twó wörds \\three\ \"with\ escaping\\"#) + .with_mode(ParseMode::RawParams) + .collect(); + + assert_eq!( + vec![ + "single_word", + "twó", + "wörds", + r#"\\three\ \"with\ escaping\\"# + ], + args + ); + } + + #[test] + fn should_have_empty_args() { + let args = Args::from(""); + let mut parser = ArgsParser::new(""); + + assert!(args.first().is_none()); + assert!(args.is_empty()); + assert!(parser.next().is_none()); + assert!(parser.is_empty()); + } + + #[test] + fn should_preserve_quote_if_last_argument() { + let mut args = + ArgsParser::from(r#" "file with space.txt"""#).with_mode(ParseMode::LiteralParams); + assert_eq!("file with space.txt", args.next().unwrap()); + assert_eq!(r#"""#, args.last().unwrap()); + } + + #[test] + fn should_respect_escaped_quote_in_what_looks_like_non_closed_arg() { + let mut args = + ArgsParser::from(r"'should be one \'argument").with_mode(ParseMode::LiteralParams); + assert_eq!(r"should be one 'argument", args.next().unwrap()); + assert_eq!(None, args.next()); + } + + #[test] + fn should_escape_whitespace() { + assert_eq!( + Some(Cow::from("a ")), + ArgsParser::from(r"a\ ") + .with_mode(ParseMode::Literal) + .next(), + ); + assert_eq!( + Some(Cow::from("a\t")), + ArgsParser::from(r"a\t") + .with_mode(ParseMode::Literal) + .next(), + ); + assert_eq!( + Some(Cow::from("a b.txt")), + ArgsParser::from(r"a\ b.txt") + .with_mode(ParseMode::Literal) + .next(), + ); + } + + #[test] + fn should_parse_args_even_with_leading_whitespace() { + let mut parser = ArgsParser::new(" a").with_mode(ParseMode::RawParams); + // Three spaces + assert_eq!(Cow::from("a"), parser.next().unwrap()); + } + + #[test] + fn should_parse_single_quotes_while_respecting_escapes() { + let parser = ArgsParser::from( + r#"'single_word' 'twó wörds' '' ' ''\\three\' \"with\ escaping\\' 'quote incomplete"#, + ) + .with_mode(ParseMode::LiteralUnescapeBackslashParams); + let expected = [ + "single_word", + "twó wörds", + "", + " ", + r#"\three' "with escaping\"#, + "quote incomplete", + ]; + + for (expected, actual) in expected.into_iter().zip(parser) { + assert_eq!(expected, actual); + } + } + + #[test] + fn should_parse_double_quotes_while_respecting_escapes() { + let parser = ArgsParser::from( + r#""single_word" "twó wörds" "" " ""\\three\' \"with\ escaping\\" "dquote incomplete"#, + ) + .with_mode(ParseMode::LiteralUnescapeBackslashParams); + let expected = [ + "single_word", + "twó wörds", + "", + " ", + r#"\three' "with escaping\"#, + "dquote incomplete", + ]; + + for (expected, actual) in expected.into_iter().zip(parser) { + assert_eq!(expected, actual); + } + } + + #[test] + fn should_respect_escapes_with_mixed_quotes() { + let args = ArgsParser::from(r#"single_word 'twó wörds' "\\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#) + .with_mode(ParseMode::LiteralUnescapeBackslashParams); + let expected = [ + "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. + "'", + ]; + + for (expected, actual) in expected.into_iter().zip(args) { + assert_eq!(expected, actual); + } + } + + #[test] + fn should_return_rest_from_parser() { + let mut parser = ArgsParser::from(r#"statusline.center ["file-type","file-encoding"]"#) + .with_mode(ParseMode::RawParams); + + assert_eq!(Some("statusline.center"), parser.next().as_deref()); + assert_eq!(r#"["file-type","file-encoding"]"#, parser.rest()); + } + + #[test] + fn should_leave_escaped_quotes() { + let mut args = + ArgsParser::new(r#"\" \` \' \"with \'with \`with"#).with_mode(ParseMode::LiteralParams); + assert_eq!(Some(Cow::from(r#"""#)), args.next()); + assert_eq!(Some(Cow::from(r"`")), args.next()); + assert_eq!(Some(Cow::from(r"'")), args.next()); + assert_eq!(Some(Cow::from(r#""with"#)), args.next()); + assert_eq!(Some(Cow::from(r"'with")), args.next()); + assert_eq!(Some(Cow::from(r"`with")), args.next()); + } + + #[test] + fn should_leave_literal_newline_alone() { + let mut arg = ArgsParser::new(r"\n").with_mode(ParseMode::LiteralParams); + assert_eq!(Some(Cow::from("\n")), arg.next()); + } + + #[test] + fn should_leave_literal_unicode_alone() { + let mut arg = ArgsParser::new(r"\u{C}").with_mode(ParseMode::LiteralParams); + assert_eq!(Some(Cow::from("\u{C}")), arg.next()); + } + + #[test] + fn should_escape_literal_unicode() { + let mut arg = ArgsParser::new(r"\u{C}").with_mode(ParseMode::RawParams); + assert_eq!(Some(Cow::from("\\u{C}")), arg.next()); + } + + #[test] + fn should_unescape_args() { + // 1f929: 🤩 + let args = ArgsParser::new(r#"'hello\u{1f929} world' '["hello", "\u{1f929}", "world"]'"#) + .with_mode(ParseMode::LiteralParams) + .collect::>(); + + assert_eq!("hello\u{1f929} world", unescape(&args[0], true, false)); + assert_eq!( + r#"["hello", "🤩", "world"]"#, + unescape(&args[1], true, false) + ); + } + + #[test] + fn should_parse_a_slash_b_correctly() { + let args = ArgsParser::new(r"a\b") + .with_mode(ParseMode::LiteralParams) + .collect::>(); + + assert_eq!(r"a\b", &args[0]); + } + + #[test] + fn should_end_in_unterminated_quotes() { + let mut args = ArgsParser::new(r#"a.txt "b "#).with_mode(ParseMode::RawParams); + let last = args.by_ref().last(); + + assert_eq!(Some(Cow::from(r#""b "#)), last); + } + + #[test] + fn should_end_with_raw_escaped_space() { + let mut args = ArgsParser::new(r"helix-term\src\commands\typed.rs\ "); + assert_eq!( + Some(Cow::from(r"helix-term\src\commands\typed.rs\ ")), + args.next() + ); + } + + #[test] + fn should_remain_in_bounds_when_raw_params_parsing_path() { + let mut args = ArgsParser::from(r#""C:\\Users\\Helix\\AppData\\Local\\Temp\\.tmp3Dugy8""#); + + assert_eq!( + Some(Cow::from( + r#""C:\\Users\\Helix\\AppData\\Local\\Temp\\.tmp3Dugy8""# + )), + args.next() + ); + } + + #[test] + fn should_only_unescape_backslash() { + let mut args = ArgsParser::from(r"C:\\Users\\Helix\\AppData\\Local\\Temp\\.tmp3Dugy8") + .with_mode(ParseMode::UnescapeBackslash); + + assert_eq!( + Some(Cow::from(r"C:\Users\Helix\AppData\Local\Temp\.tmp3Dugy8")), + args.next() + ); + } + #[test] + fn should_only_unescape_backslash_params() { + let mut args = ArgsParser::from(r#""C:\\Users\\Helix\\AppData\\Local\\Temp\\.tmp3Dugy8""#) + .with_mode(ParseMode::UnescapeBackslashParams); + + assert_eq!( + Some(Cow::from(r"C:\Users\Helix\AppData\Local\Temp\.tmp3Dugy8")), + args.next() + ); + } + + #[test] + fn should_not_parse_blackslash_t_into_tab() { + let mut parser = ArgsParser::new(r"helix-term\src\commands\typed") + .with_mode(ParseMode::UnescapeBackslashParams); + + assert_eq!( + Some(Cow::from(r"helix-term\src\commands\typed")), + parser.next() + ); + } +} diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 2bf75f6906d3..2932dcf1b86f 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -1,5 +1,6 @@ pub use encoding_rs as encoding; +pub mod args; pub mod auto_pairs; pub mod case_conversion; pub mod chars; diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs index 9d873c366c99..56c7e0c2abac 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -1,6 +1,146 @@ +use smartstring::{LazyCompact, SmartString}; use std::borrow::Cow; +use crate::args::{ArgsParser, ParseMode}; + +/// A utility for parsing shell-like command lines. +/// +/// The `Shellwords` struct takes an input string and allows extracting the command and its 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()); +/// ``` +/// +/// Empty command: +/// +/// ``` +/// # use helix_core::shellwords::Shellwords; +/// let shellwords = Shellwords::from(" "); +/// assert!(shellwords.command().is_empty()); +/// ``` +/// +/// Arguments: +/// +/// ``` +/// # use helix_core::shellwords::Shellwords; +/// +/// let shellwords = Shellwords::from(":o a b c"); +/// assert_eq!("a b c", shellwords.args()); +/// ``` +#[derive(Clone, Copy)] +pub struct Shellwords<'a> { + input: &'a str, +} + +impl<'a> Shellwords<'a> { + #[inline] + #[must_use] + pub fn command(&self) -> &str { + self.input + .split_once(' ') + .map_or(self.input, |(command, _)| command) + } + + /// Returns the ramining text after the command, splitting on horizontal whitespace. + #[inline] + #[must_use] + pub fn args(&self) -> &str { + self.input + .split_once([' ', '\t']) + .map_or("", |(_, args)| args) + } + + /// Returns the input that was passed in to create a `Shellwords` instance exactly as is. + #[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(r#":open "a "#).ends_with_whitespace(), false); + /// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false); + /// #[cfg(windows)] + /// assert_eq!(Shellwords::from(":open a\\\t").ends_with_whitespace(), true); + /// #[cfg(windows)] + /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), true); + /// #[cfg(unix)] + /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false); + /// #[cfg(unix)] + /// assert_eq!(Shellwords::from(":open a\\\t").ends_with_whitespace(), false); + /// ``` + #[inline] + #[must_use] + pub fn ends_with_whitespace(&self) -> bool { + ArgsParser::from(self.args()) + .with_mode(ParseMode::RawParams) + .last() + .map_or( + self.input.ends_with(' ') || self.input.ends_with('\t'), + |last| { + let input_ends_with_whitespace = + self.input.ends_with(' ') || self.input.ends_with('\t'); + let last_starts_with_quote = + last.starts_with('"') || last.starts_with('\'') || last.starts_with('`'); + let last_ends_with_quote = + last.ends_with('"') || last.ends_with('\'') || last.ends_with('`'); + let last_is_in_unterminated_quote = + last_starts_with_quote && !last_ends_with_quote; + + if cfg!(windows) { + input_ends_with_whitespace && !last_is_in_unterminated_quote + } else { + let last_with_escaped_whitespace = + last.ends_with("\\ ") || last.ends_with("\\\t"); + let ends_in_true_whitespace = + !last_with_escaped_whitespace && input_ends_with_whitespace; + + ends_in_true_whitespace && !last_is_in_unterminated_quote + } + }, + ) + } +} + +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 } + } +} + /// Auto escape for shellwords usage. +#[inline] +#[must_use] pub fn escape(input: Cow) -> Cow { if !input.chars().any(|x| x.is_ascii_whitespace()) { input @@ -17,334 +157,247 @@ pub fn escape(input: Cow) -> Cow { } } -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) +/// - `\\"` is converted to `"` (double-quote) +/// - `\\'` is converted to `'` (single-quote) +/// - `\\ ` is converted to ` ` (space) +/// - `\\u{...}` is converted to the corresponding Unicode character +/// - backticks are also converted the same as quotes. +/// +/// 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. +#[inline] +#[must_use] +pub(super) fn unescape( + input: &str, + unescape_literals: bool, + unescape_blackslash: bool, +) -> 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 + for (idx, ch) in input.char_indices() { + match state { + State::Normal => match ch { + '\\' => { + // Special case if last `char` encountered is a `\` + if idx + 1 == input.len() { + unescaped.push('\\'); + break; } - '\'' => { - end = i; - Quoted - } - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[unescaped_start..i]); - unescaped_start = i + 1; - UnquotedEscaped - } else { - OnWhitespace - } - } - 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 + + 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]); } } - '\'' => { - end = i; - OnWhitespace + state = State::Escaped; + is_escaped = true; + } + _ => { + if is_escaped { + unescaped.push(ch); } - _ => Quoted, - }, - QuoteEscaped => Quoted, - Dquoted => match c { - '\\' => { - if cfg!(unix) { - escaped.push_str(&input[unescaped_start..i]); - unescaped_start = i + 1; - DquoteEscaped - } else { - Dquoted - } + } + }, + State::Escaped => { + match ch { + 'n' if unescape_literals => unescaped.push('\n'), + 't' if unescape_literals => unescaped.push('\t'), + ' ' if unescape_literals => unescaped.push(' '), + '\'' if unescape_literals => unescaped.push('\''), + '"' if unescape_literals => unescaped.push('"'), + '`' if unescape_literals => unescaped.push('`'), + 'u' if unescape_literals => { + state = State::Unicode; + continue; } - '"' => { - 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(); + '\\' if unescape_blackslash => unescaped.push('\\'), + _ => { + unescaped.push('\\'); + unescaped.push(ch); } } - unescaped_start = i + 1; - part_start = i + 1; - end = 0; + state = State::Normal; } + 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), + }, } + } - debug_assert!(words.len() == parts.len()); - - Self { - state, - words, - parts, - } + if is_escaped { + unescaped.into() + } else { + input.into() } } -impl<'a> Shellwords<'a> { - /// Checks that the input ends with a whitespace character which is not escaped. - /// - /// # Examples - /// - /// ```rust - /// use helix_core::shellwords::Shellwords; - /// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true); - /// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true); - /// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true); - /// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false); - /// #[cfg(unix)] - /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false); - /// #[cfg(unix)] - /// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false); - /// ``` - pub fn ends_with_whitespace(&self) -> bool { - matches!(self.state, State::OnWhitespace) +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn base() { + let shellwords = + Shellwords::from(r#":o single_word twó wörds \\three\ \"with\ escaping\\"#); + + assert_eq!(":o", shellwords.command()); + assert_eq!( + r#"single_word twó wörds \\three\ \"with\ escaping\\"#, + shellwords.args() + ); } - /// Returns the list of shellwords calculated from the input string. - pub fn words(&self) -> &[Cow<'a, str>] { - &self.words + #[test] + fn should_return_empty_command() { + let shellwords = Shellwords::from(" "); + assert!(shellwords.command().is_empty()); } - /// 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 + #[test] + fn should_support_unicode_args() { + let shellwords = Shellwords::from(":yank-join 𒀀"); + assert_eq!(":yank-join", shellwords.command()); + assert_eq!(shellwords.args(), "𒀀"); } -} -#[cfg(test)] -mod test { - use super::*; + #[test] + #[cfg(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")); + } #[test] #[cfg(windows)] - 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("\\three\\"), - Cow::from("\\"), - Cow::from("with\\ escaping\\\\"), - ]; - // TODO test is_owned and is_borrowed, once they get stabilized. - assert_eq!(expected, result); + 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_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_unescape_newline() { + let unescaped = unescape("hello\\nworld", true, true); + assert_eq!("hello\nworld", unescaped); } #[test] - #[cfg(unix)] - fn test_quoted() { - let quoted = - r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#; - let shellwords = Shellwords::from(quoted); - let result = shellwords.words().to_vec(); - let expected = vec![ - Cow::from(":o"), - Cow::from("single_word"), - Cow::from("twó wörds"), - Cow::from(r#"three' "with escaping\"#), - Cow::from("quote incomplete"), - ]; - assert_eq!(expected, result); + fn should_unescape_tab() { + let unescaped = unescape("hello\\tworld", true, true); + assert_eq!("hello\tworld", unescaped); } #[test] - #[cfg(unix)] - fn test_dquoted() { - let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#; - let shellwords = Shellwords::from(dquoted); - let result = shellwords.words().to_vec(); - let expected = vec![ - Cow::from(":o"), - Cow::from("single_word"), - Cow::from("twó wörds"), - Cow::from(r#"three' "with escaping\"#), - Cow::from("dquote incomplete"), - ]; - assert_eq!(expected, result); + fn should_unescape_unicode() { + let unescaped = unescape("hello\\u{1f929}world", true, true); + assert_eq!("hello\u{1f929}world", unescaped, "char: 🤩 "); + assert_eq!("hello🤩world", unescaped); } #[test] - #[cfg(unix)] - fn test_mixed() { - let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#; - let shellwords = Shellwords::from(dquoted); - let result = shellwords.words().to_vec(); - let expected = vec![ - Cow::from(":o"), - Cow::from("single_word"), - Cow::from("twó wörds"), - Cow::from("three' \"with escaping\\"), - Cow::from("no space before"), - Cow::from("and after"), - Cow::from("$#%^@"), - Cow::from("%^&(%^"), - Cow::from(")(*&^%"), - Cow::from(r#"a\\b"#), - //last ' just changes to quoted but since we dont have anything after it, it should be ignored - ]; - assert_eq!(expected, result); + fn should_return_original_input_due_to_bad_unicode() { + let unescaped = unescape("hello\\u{999999999}world", true, true); + assert_eq!("hello\\u{999999999}world", unescaped); } #[test] - fn test_lists() { - let input = - r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "quotes"]'"#; - let shellwords = Shellwords::from(input); - let result = shellwords.words().to_vec(); - let expected = vec![ - Cow::from(":set"), - Cow::from("statusline.center"), - Cow::from(r#"["file-type","file-encoding"]"#), - Cow::from(r#"["list", "in", "quotes"]"#), - ]; - assert_eq!(expected, result); + fn should_not_unescape_slash() { + let unescaped = unescape(r"hello\\world", true, true); + assert_eq!(r"hello\world", unescaped); + + let unescaped = unescape(r"hello\\\\world", true, true); + assert_eq!(r"hello\\world", unescaped); } #[test] - #[cfg(unix)] - fn test_escaping_unix() { - assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar")); - assert_eq!(escape("foo bar".into()), Cow::Borrowed("foo\\ bar")); - assert_eq!(escape("foo\tbar".into()), Cow::Borrowed("foo\\\tbar")); + fn should_unescape_slash_single_quote() { + let unescaped = unescape(r"\\'", true, true); + assert_eq!(r"\'", unescaped); } #[test] - #[cfg(windows)] - fn test_escaping_windows() { - assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar")); - assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\"")); + fn should_unescape_slash_double_quote() { + let unescaped = unescape(r#"\\\""#, true, true); + assert_eq!(r#"\""#, unescaped); } #[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_not_change_anything() { + let unescaped = unescape("'", true, true); + assert_eq!("'", unescaped); + let unescaped = unescape(r#"""#, true, true); + assert_eq!(r#"""#, 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_only_unescape_newline_not_slash_single_quote() { + let unescaped = unescape("\\n\'", true, true); + assert_eq!("\n'", unescaped); + let unescaped = unescape(r"\\n\\'", true, true); + assert_eq!(r"\n\'", unescaped); } #[test] - fn test_multibyte_at_end() { - assert_eq!(Shellwords::from("𒀀").parts(), &["𒀀"]); + fn should_have_final_char_be_backslash() { assert_eq!( - Shellwords::from(":sh echo 𒀀").parts(), - &[":sh", "echo", "𒀀"] + Cow::from(r"helix-term\"), + unescape(r"helix-term\", true, false) ); assert_eq!( - Shellwords::from(":sh echo 𒀀 hello world𒀀").parts(), - &[":sh", "echo", "𒀀", "hello", "world𒀀"] + Cow::from(r".git\info\"), + unescape(r".git\info\", true, false) ); } + + #[test] + fn should_only_unescape_backslash() { + assert_eq!( + Cow::from(r"helix-term\"), + unescape(r"helix-term\\", false, true) + ); + } + + #[test] + fn should_check_end_in_whitespace_correctly() { + assert!(!Shellwords::from(r#":option "abc "#).ends_with_whitespace()); + assert!(!Shellwords::from(":option abc").ends_with_whitespace()); + assert!(Shellwords::from(r#":option "helix-term\a b.txt" "#).ends_with_whitespace()); + } } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a93fa445ee80..5c6fc2dfe2ec 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -15,6 +15,7 @@ use tui::text::Span; pub use typed::*; use helix_core::{ + args::Args, char_idx_at_visual_offset, chars::char_is_word, comment, @@ -30,7 +31,7 @@ use helix_core::{ object, pos_at_coords, regex::{self, Regex}, search::{self, CharMatcher}, - selection, shellwords, surround, + selection, surround, syntax::{BlockCommentToken, LanguageServerFeature}, text_annotations::{Overlay, TextAnnotations}, textobject, @@ -207,7 +208,7 @@ use helix_view::{align_view, Align}; pub enum MappableCommand { Typable { name: String, - args: Vec, + args: String, doc: String, }, Static { @@ -242,15 +243,24 @@ 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 args = + match Args::from_signature(command.name, &command.signature, args, true) { + Ok(args) => args, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; + 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, PromptEvent::Validate) { + cx.editor.set_error(format!("{err}")); } } } @@ -621,21 +631,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 +3258,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(), }), ); @@ -4324,10 +4328,12 @@ fn yank_impl(editor: &mut Editor, register: char) { } } -fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { +fn yank_joined_impl(editor: &mut Editor, separator: Option<&str>, register: char) { let (view, doc) = current!(editor); let text = doc.text().slice(..); + let separator = separator.unwrap_or(doc.line_ending.as_str()); + let selection = doc.selection(view.id); let selections = selection.len(); let joined = selection @@ -4350,10 +4356,10 @@ fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) { } fn yank_joined(cx: &mut Context) { - let separator = doc!(cx.editor).line_ending.as_str(); + let line_ending = doc!(cx.editor).line_ending.as_str(); yank_joined_impl( cx.editor, - separator, + Some(line_ending), cx.register .unwrap_or(cx.editor.config().default_yank_register), ); @@ -4362,13 +4368,13 @@ fn yank_joined(cx: &mut Context) { fn yank_joined_to_clipboard(cx: &mut Context) { let line_ending = doc!(cx.editor).line_ending; - yank_joined_impl(cx.editor, line_ending.as_str(), '+'); + yank_joined_impl(cx.editor, Some(line_ending.as_str()), '+'); exit_select_mode(cx); } fn yank_joined_to_primary_clipboard(cx: &mut Context) { let line_ending = doc!(cx.editor).line_ending; - yank_joined_impl(cx.editor, line_ending.as_str(), '*'); + yank_joined_impl(cx.editor, Some(line_ending.as_str()), '*'); exit_select_mode(cx); } diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index 83dd936cdff2..c89ab2e96794 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -14,9 +14,9 @@ use serde_json::{to_value, Value}; use tokio_stream::wrappers::UnboundedReceiverStream; use tui::text::Spans; -use std::collections::HashMap; use std::future::Future; use std::path::PathBuf; +use std::{borrow::Cow, collections::HashMap}; use anyhow::{anyhow, bail}; @@ -109,9 +109,10 @@ 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>, + name: Option<&Cow>, socket: Option, params: Option>>, ) -> Result<(), anyhow::Error> { @@ -147,7 +148,7 @@ pub fn dap_start_impl( // TODO: avoid refetching all of this... pass a config in let template = match name { - Some(name) => config.templates.iter().find(|t| t.name == name), + Some(name) => config.templates.iter().find(|t| t.name == name.as_ref()), None => config.templates.first(), } .ok_or_else(|| anyhow!("No debug config with given name"))?; @@ -262,7 +263,9 @@ pub fn dap_launch(cx: &mut Context) { (), |cx, template, _action| { if template.completion.is_empty() { - if let Err(err) = dap_start_impl(cx, Some(&template.name), None, None) { + if let Err(err) = + dap_start_impl(cx, Some(&Cow::from(template.name.clone())), None, None) + { cx.editor.set_error(err.to_string()); } } else { @@ -312,6 +315,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, @@ -373,7 +377,7 @@ fn debug_parameter_prompt( cx.jobs.callback(callback); } else if let Err(err) = dap_start_impl( cx, - Some(&config_name), + Some(&Cow::from(config_name.clone())), None, Some(params.iter().map(|x| x.into()).collect()), ) { diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 078bb80029f2..353677d39b6d 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -6,9 +6,14 @@ use crate::job::Job; use super::*; +use helix_core::args::Signature; use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; -use helix_core::{line_ending, shellwords::Shellwords}; +use helix_core::{ + args::{Args, ArgsParser, ParseMode}, + line_ending, + shellwords::Shellwords, +}; use helix_stdx::path::home_dir; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::editor::{CloseError, ConfigEvent}; @@ -20,23 +25,24 @@ pub struct TypableCommand { pub name: &'static str, pub aliases: &'static [&'static str], pub doc: &'static str, + pub signature: Signature, // 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, + completer: CommandCompleter, } 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.completer + .positional_args + .get(n) + .unwrap_or(&self.completer.var_args) } } #[derive(Clone)] -pub struct CommandSignature { +pub struct CommandCompleter { // Arguments with specific completion methods based on their position. positional_args: &'static [Completer], @@ -44,7 +50,7 @@ pub struct CommandSignature { var_args: Completer, } -impl CommandSignature { +impl CommandCompleter { const fn none() -> Self { Self { positional_args: &[], @@ -67,15 +73,13 @@ 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 { return Ok(()); } - ensure!(args.is_empty(), ":quit takes no arguments"); - // last view and we have unsaved changes if cx.editor.tree.views().count() == 1 { buffers_remaining_impl(cx.editor)? @@ -87,31 +91,24 @@ 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(()); } - ensure!(args.is_empty(), ":quit! takes no arguments"); - cx.block_try_flush_writes()?; cx.editor.close(view!(cx.editor).id); 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"); for arg in args { - let (path, pos) = args::parse_file(arg); + let (path, pos) = args::parse_file(&arg); let path = helix_stdx::path::expand_tilde(path); // If the path is a directory, open a file picker on that directory and update the status // message @@ -175,7 +172,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; @@ -212,7 +209,7 @@ fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow]) -> Vec], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -225,7 +222,7 @@ fn buffer_close( fn force_buffer_close( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -247,7 +244,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 { @@ -260,7 +257,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 { @@ -277,7 +274,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 { @@ -290,7 +287,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 { @@ -303,7 +300,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 { @@ -316,7 +313,7 @@ fn buffer_next( fn buffer_previous( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -335,7 +332,6 @@ fn write_impl( 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); @@ -344,6 +340,8 @@ fn write_impl( // Save an undo checkpoint for any outstanding changes. doc.append_changes_to_history(view); + let path: Option = path.map(|path| path.as_ref().into()); + let fmt = if config.auto_format { doc.auto_format().map(|fmt| { let callback = make_format_callback( @@ -351,7 +349,7 @@ fn write_impl( doc.version(), view.id, fmt, - Some((path.map(Into::into), force)), + Some((path.clone(), force)), ); jobs.add(Job::with_callback(callback).wait_before_exiting()); @@ -377,11 +375,7 @@ 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, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -389,11 +383,7 @@ fn write( write_impl(cx, args.first(), false) } -fn force_write( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn force_write(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -403,7 +393,7 @@ fn force_write( fn write_buffer_close( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -418,7 +408,7 @@ fn write_buffer_close( fn force_write_buffer_close( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -431,11 +421,7 @@ fn force_write_buffer_close( 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(()); } @@ -445,11 +431,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(()); } @@ -466,7 +448,7 @@ fn format( fn set_indent_style( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -487,9 +469,9 @@ fn set_indent_style( } // Attempt to parse argument as an indent style. - let style = match args.first() { + let style = match args.first().map(|arg| arg.as_ref()) { 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() @@ -508,7 +490,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], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -578,16 +560,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[0].parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); let success = doc.earlier(view, uk); @@ -598,16 +576,12 @@ 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[0].parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); let success = doc.later(view, uk); if !success { @@ -617,23 +591,19 @@ fn later( Ok(()) } -fn write_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn write_quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_impl(cx, args.first(), false)?; cx.block_try_flush_writes()?; - quit(cx, &[], event) + quit(cx, Args::empty(), event) } fn force_write_quit( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -642,7 +612,7 @@ fn force_write_quit( write_impl(cx, args.first(), 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 @@ -749,11 +719,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(()); } @@ -763,7 +729,7 @@ fn write_all( fn force_write_all( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -775,7 +741,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 { @@ -787,7 +753,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 { @@ -812,11 +778,7 @@ 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(()); } @@ -826,7 +788,7 @@ fn quit_all( fn force_quit_all( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -836,11 +798,7 @@ fn force_quit_all( quit_all_impl(cx, true) } -fn cquit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn cquit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -854,11 +812,7 @@ fn cquit( quit_all_impl(cx, false) } -fn force_cquit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn force_cquit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -872,11 +826,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, args: Args, event: PromptEvent) -> anyhow::Result<()> { let true_color = cx.editor.config.load().true_color || crate::true_color(); match event { PromptEvent::Abort => { @@ -919,7 +869,7 @@ fn theme( fn yank_main_selection_to_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -930,44 +880,32 @@ fn yank_main_selection_to_clipboard( 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('"'); + let separator = args.first().map(|sep| sep.as_ref()); yank_joined_impl(cx.editor, separator, 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); + let separator = args.first().map(|sep| sep.as_ref()); yank_joined_impl(cx.editor, separator, '+'); Ok(()) } fn yank_main_selection_to_primary_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -980,23 +918,20 @@ fn yank_main_selection_to_primary_clipboard( 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); + let separator = args.first().map(|sep| sep.as_ref()); yank_joined_impl(cx.editor, separator, '*'); Ok(()) } fn paste_clipboard_after( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1009,7 +944,7 @@ fn paste_clipboard_after( fn paste_clipboard_before( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1022,7 +957,7 @@ fn paste_clipboard_before( fn paste_primary_clipboard_after( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1035,7 +970,7 @@ fn paste_primary_clipboard_after( fn paste_primary_clipboard_before( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1048,7 +983,7 @@ fn paste_primary_clipboard_before( fn replace_selections_with_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1061,7 +996,7 @@ fn replace_selections_with_clipboard( fn replace_selections_with_primary_clipboard( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1074,7 +1009,7 @@ fn replace_selections_with_primary_clipboard( fn show_clipboard_provider( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1088,14 +1023,14 @@ fn show_clipboard_provider( fn change_current_directory( cx: &mut compositor::Context, - args: &[Cow], + 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.first().map(|arg| arg.as_ref()) { Some("-") => cx .editor .get_last_cwd() @@ -1117,7 +1052,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 { @@ -1138,7 +1073,7 @@ fn show_current_directory( /// Sets the [`Document`]'s encoding.. fn set_encoding( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1158,7 +1093,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 { @@ -1281,11 +1216,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(()); } @@ -1304,11 +1235,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(()); } @@ -1364,11 +1291,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(()); } @@ -1383,7 +1306,7 @@ fn update( fn lsp_workspace_command( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1439,7 +1362,7 @@ fn lsp_workspace_command( }; cx.jobs.callback(callback); } else { - let command = args.join(" "); + let command = args[0].to_string(); let matches: Vec<_> = ls_id_commands .filter(|(_ls_id, c)| *c == &command) .collect(); @@ -1473,7 +1396,7 @@ fn lsp_workspace_command( fn lsp_restart( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1519,11 +1442,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(()); } @@ -1550,7 +1469,7 @@ fn lsp_stop( fn tree_sitter_scopes( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -1583,7 +1502,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( @@ -1656,11 +1575,7 @@ 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(()); } @@ -1677,11 +1592,7 @@ fn vsplit( 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(()); } @@ -1698,11 +1609,7 @@ fn hsplit( 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(()); } @@ -1712,11 +1619,7 @@ fn vsplit_new( 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(()); } @@ -1726,11 +1629,7 @@ fn hsplit_new( 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(()); } @@ -1746,55 +1645,40 @@ 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[0].to_string(); + let response = helix_lsp::block_on(debugger.eval(expression, Some(frame_id)))?; cx.editor.set_status(response.result); } Ok(()) } -fn debug_start( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn debug_start(cx: &mut compositor::Context, 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)) + let name = args.first(); + let params = args.iter().cloned().collect(); + dap_start_impl(cx, name, None, Some(params)) } fn debug_remote( cx: &mut compositor::Context, - args: &[Cow], + 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.first().map(|addr| addr.parse()).transpose()?; + let name = args.get(1); + let params = args.clone().into_iter().collect(); + + dap_start_impl(cx, name, address, Some(params)) } -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(()); } @@ -1816,10 +1700,7 @@ fn abort_goto_line_number_preview(cx: &mut compositor::Context) { } } -fn update_goto_line_number_preview( - cx: &mut compositor::Context, - args: &[Cow], -) -> anyhow::Result<()> { +fn update_goto_line_number_preview(cx: &mut compositor::Context, args: Args) -> anyhow::Result<()> { cx.editor.last_selection.get_or_insert_with(|| { let (view, doc) = current!(cx.editor); doc.selection(view.id).clone() @@ -1837,14 +1718,12 @@ 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 { PromptEvent::Abort => abort_goto_line_number_preview(cx), PromptEvent::Validate => { - ensure!(!args.is_empty(), "Line number required"); - // If we are invoked directly via a keybinding, Validate is // sent without any prior Update events. Ensure the cursor // is moved to the appropriate location. @@ -1871,11 +1750,7 @@ 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], - event: PromptEvent, -) -> anyhow::Result<()> { +fn get_option(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -1884,7 +1759,7 @@ fn get_option( anyhow::bail!("Bad arguments. Usage: `:get key`"); } - let key = &args[0].to_lowercase(); + let key = args[0].to_lowercase(); let key_error = || anyhow::anyhow!("Unknown key `{}`", key); let config = serde_json::json!(cx.editor.config().deref()); @@ -1897,111 +1772,131 @@ fn get_option( /// Change config at runtime. Access nested values by dot syntax, for /// example to disable smart case search, use `:set search.smart-case false`. -fn set_option( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn set_option(cx: &mut compositor::Context, 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 (key, arg) = (&args[0].to_lowercase(), &args[1]); + let mut parser = ArgsParser::from(&args[0]); - let key_error = || anyhow::anyhow!("Unknown key `{}`", key); - let field_error = |_| anyhow::anyhow!("Could not parse field `{}`", arg); + let key = parser.next().map(|arg| arg.to_lowercase()).unwrap(); + let field = parser.rest(); - 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], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - if args.is_empty() { - anyhow::bail!("Bad arguments. Usage: `:toggle key [values]?`"); - } - let key = &args[0].to_lowercase(); + let mut args = ArgsParser::from(&args[0]); - let key_error = || anyhow::anyhow!("Unknown key `{}`", key); + let key = args.next().unwrap(); - 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, - "Bad arguments. For boolean configurations use: `:toggle key`" - ); - Value::Bool(!value) - } + Value::Bool(ref value) => Value::Bool(!value), Value::String(ref value) => { ensure!( - args.len() > 2, + args.clone().count() >= 2, "Bad arguments. For string configurations use: `:toggle key val1 val2 ...`", ); Value::String( - args[1..] - .iter() - .skip_while(|e| *e != value) + args.clone() + .skip_while(|e| e.as_ref() != value) .nth(1) - .unwrap_or_else(|| &args[1]) + .unwrap_or_else(|| args.next().unwrap()) .to_string(), ) } Value::Number(ref value) => { ensure!( - args.len() > 2, + args.clone().count() >= 2, "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.as_ref() != value) .nth(1) - .unwrap_or_else(|| &args[1]) + .unwrap_or_else(|| args.next().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 @@ -2012,18 +1907,14 @@ fn toggle_option( } /// Change the language of the current buffer at runtime. -fn language( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn language(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } 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(()); } @@ -2034,8 +1925,8 @@ fn language( let doc = doc_mut!(cx.editor); - if args[0] == DEFAULT_LANGUAGE_NAME { - doc.set_language(None, None) + if &args[0] == DEFAULT_LANGUAGE_NAME { + doc.set_language(None, None); } else { doc.set_language_by_language_id(&args[0], cx.editor.syn_loader.clone())?; } @@ -2050,7 +1941,7 @@ 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(()); } @@ -2060,7 +1951,7 @@ fn sort(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> fn sort_reverse( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2070,11 +1961,7 @@ fn sort_reverse( 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(..); @@ -2106,11 +1993,7 @@ fn sort_impl( Ok(()) } -fn reflow( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn reflow(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2149,7 +2032,7 @@ fn reflow( fn tree_sitter_subtree( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2188,7 +2071,7 @@ fn tree_sitter_subtree( fn open_config( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2202,7 +2085,7 @@ fn open_config( fn open_workspace_config( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2214,11 +2097,7 @@ fn open_workspace_config( 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(()); } @@ -2229,7 +2108,7 @@ fn open_log( fn refresh_config( cx: &mut compositor::Context, - _args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2242,62 +2121,52 @@ 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); + shell(cx, &args[0], &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); + shell(cx, &args[0], &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); + shell(cx, &args[0], behavior); Ok(()) } fn run_shell_command( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2305,7 +2174,8 @@ fn run_shell_command( } let shell = cx.editor.config().shell.clone(); - let args = args.join(" "); + + let args = args[0].to_string(); let callback = async move { let output = shell_impl_async(&shell, &args, None).await?; @@ -2333,18 +2203,15 @@ fn run_shell_command( fn reset_diff_change( cx: &mut compositor::Context, - args: &[Cow], + _args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - 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") }; @@ -2386,28 +2253,29 @@ fn reset_diff_change( fn clear_register( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } - ensure!(args.len() <= 1, ":clear-register takes at most 1 argument"); if args.is_empty() { cx.editor.registers.clear(); cx.editor.set_status("All registers cleared"); return Ok(()); } + let register = &args[0]; + 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)); @@ -2415,11 +2283,7 @@ fn clear_register( 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(()); } @@ -2438,22 +2302,16 @@ fn redraw( Ok(()) } -fn move_buffer( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, -) -> anyhow::Result<()> { +fn move_buffer(cx: &mut compositor::Context, 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 + 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[0]; if let Err(err) = cx.editor.move_path(&old_path, new_path.as_ref()) { bail!("Could not move file: {err}"); } @@ -2462,7 +2320,7 @@ fn move_buffer( fn yank_diagnostic( cx: &mut compositor::Context, - args: &[Cow], + args: Args, event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { @@ -2500,7 +2358,7 @@ fn yank_diagnostic( Ok(()) } -fn read(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { +fn read(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } @@ -2508,11 +2366,8 @@ fn read(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); - ensure!(!args.is_empty(), "file name is expected"); - ensure!(args.len() == 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 path = + helix_stdx::path::expand_tilde(Path::new(args.first().map(|path| path.as_ref()).unwrap())); ensure!( path.exists() && path.is_file(), @@ -2538,622 +2393,1021 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", aliases: &["q"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Close the current view.", fun: quit, - signature: CommandSignature::none(), + completer: CommandCompleter::none(), }, TypableCommand { name: "quit!", aliases: &["q!"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Force close the current view, ignoring unsaved changes.", fun: force_quit, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "open", aliases: &["o", "edit", "e"], + signature: Signature { + positionals: (1, None), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Open a file from disk into the current view.", fun: open, - signature: CommandSignature::all(completers::filename), + completer: CommandCompleter::all(completers::filename) }, TypableCommand { name: "buffer-close", aliases: &["bc", "bclose"], + signature: Signature { + positionals: (0, None), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Close the current buffer.", fun: buffer_close, - signature: CommandSignature::all(completers::buffer), + completer: CommandCompleter::all(completers::buffer) }, TypableCommand { name: "buffer-close!", aliases: &["bc!", "bclose!"], + signature: Signature { + positionals: (0, None), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Close the current buffer forcefully, ignoring unsaved changes.", fun: force_buffer_close, - signature: CommandSignature::all(completers::buffer) + completer: CommandCompleter::all(completers::buffer) }, TypableCommand { name: "buffer-close-others", aliases: &["bco", "bcloseother"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Close all buffers but the currently focused one.", fun: buffer_close_others, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "buffer-close-others!", aliases: &["bco!", "bcloseother!"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Force close all buffers but the currently focused one.", fun: force_buffer_close_others, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "buffer-close-all", aliases: &["bca", "bcloseall"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Close all buffers without quitting.", fun: buffer_close_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "buffer-close-all!", aliases: &["bca!", "bcloseall!"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Force close all buffers ignoring unsaved changes without quitting.", fun: force_buffer_close_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "buffer-next", aliases: &["bn", "bnext"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Goto next buffer.", fun: buffer_next, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "buffer-previous", aliases: &["bp", "bprev"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Goto previous buffer.", fun: buffer_previous, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "write", aliases: &["w"], + signature: Signature { + positionals: (0, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Write changes to disk. Accepts an optional path (:write some/path.txt)", fun: write, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]) }, TypableCommand { name: "write!", aliases: &["w!"], + signature: Signature { + positionals: (0, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Force write changes to disk creating necessary subdirectories. Accepts an optional path (:write! some/path.txt)", fun: force_write, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]) }, TypableCommand { name: "write-buffer-close", aliases: &["wbc"], + signature: Signature { + positionals: (0, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Write changes to disk and closes the buffer. Accepts an optional path (:write-buffer-close some/path.txt)", fun: write_buffer_close, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]) }, TypableCommand { name: "write-buffer-close!", aliases: &["wbc!"], + signature: Signature { + positionals: (0, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Force write changes to disk creating necessary subdirectories and closes the buffer. Accepts an optional path (:write-buffer-close! some/path.txt)", fun: force_write_buffer_close, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]) }, TypableCommand { name: "new", aliases: &["n"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Create a new scratch buffer.", fun: new_file, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "format", aliases: &["fmt"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Format the file using an external formatter or language server.", fun: format, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "indent-style", aliases: &[], + signature: Signature { + positionals: (0, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Set the indentation style for editing. ('t' for tabs or 1-16 for number of spaces.)", fun: set_indent_style, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "line-ending", aliases: &[], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::default(), + }, #[cfg(not(feature = "unicode-lines"))] doc: "Set the document's default line ending. Options: crlf, lf.", #[cfg(feature = "unicode-lines")] doc: "Set the document's default line ending. Options: crlf, lf, cr, ff, nel.", fun: set_line_ending, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "earlier", aliases: &["ear"], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Jump back to an earlier point in edit history. Accepts a number of steps or a time span.", fun: earlier, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "later", aliases: &["lat"], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Jump to a later point in edit history. Accepts a number of steps or a time span.", fun: later, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "write-quit", aliases: &["wq", "x"], + signature: Signature { + positionals: (0, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt)", fun: write_quit, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]) }, TypableCommand { name: "write-quit!", aliases: &["wq!", "x!"], + signature: Signature { + positionals: (0, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt)", fun: force_write_quit, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]) }, TypableCommand { name: "write-all", aliases: &["wa"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + }, doc: "Write changes from all buffers to disk.", fun: write_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "write-all!", aliases: &["wa!"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Forcefully write changes from all buffers to disk creating necessary subdirectories.", fun: force_write_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "write-quit-all", aliases: &["wqa", "xa"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Write changes from all buffers to disk and close all views.", fun: write_all_quit, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "write-quit-all!", aliases: &["wqa!", "xa!"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Write changes from all buffers to disk and close all views forcefully (ignoring unsaved changes).", fun: force_write_all_quit, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "quit-all", aliases: &["qa"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Close all views.", fun: quit_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "quit-all!", aliases: &["qa!"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Force close all views ignoring unsaved changes.", fun: force_quit_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "cquit", aliases: &["cq"], + signature: Signature { + positionals: (0, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2).", fun: cquit, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "cquit!", aliases: &["cq!"], + signature: Signature { + positionals: (0, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2).", fun: force_cquit, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "theme", aliases: &[], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Change the editor theme (show current theme if no name specified).", fun: theme, - signature: CommandSignature::positional(&[completers::theme]), + completer: CommandCompleter::positional(&[completers::theme]) }, TypableCommand { name: "yank-join", aliases: &[], + signature: Signature { + positionals: (0, Some(1)), + parse_mode: ParseMode::Literal, + }, doc: "Yank joined selections. A separator can be provided as first argument. Default value is newline.", fun: yank_joined, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "clipboard-yank", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Yank main selection into system clipboard.", fun: yank_main_selection_to_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "clipboard-yank-join", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::Literal, + }, doc: "Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. fun: yank_joined_to_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "primary-clipboard-yank", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Yank main selection into system primary clipboard.", fun: yank_main_selection_to_primary_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "primary-clipboard-yank-join", aliases: &[], + signature: Signature { + positionals: (0, Some(1)), + parse_mode: ParseMode::Literal, + }, doc: "Yank joined selections into system primary clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. fun: yank_joined_to_primary_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "clipboard-paste-after", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Paste system clipboard after selections.", fun: paste_clipboard_after, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "clipboard-paste-before", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Paste system clipboard before selections.", fun: paste_clipboard_before, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "clipboard-paste-replace", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Replace selections with content of system clipboard.", fun: replace_selections_with_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "primary-clipboard-paste-after", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Paste primary clipboard after selections.", fun: paste_primary_clipboard_after, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "primary-clipboard-paste-before", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Paste primary clipboard before selections.", fun: paste_primary_clipboard_before, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "primary-clipboard-paste-replace", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Replace selections with content of system primary clipboard.", fun: replace_selections_with_primary_clipboard, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "show-clipboard-provider", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Show clipboard provider name in status bar.", fun: show_clipboard_provider, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "change-current-directory", aliases: &["cd"], + signature: Signature { + positionals: (1, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Change the current working directory.", fun: change_current_directory, - signature: CommandSignature::positional(&[completers::directory]), + completer: CommandCompleter::positional(&[completers::directory]) }, TypableCommand { name: "show-directory", aliases: &["pwd"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Show the current working directory.", fun: show_current_directory, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "encoding", aliases: &[], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Set encoding. Based on `https://encoding.spec.whatwg.org`.", fun: set_encoding, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "character-info", aliases: &["char"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Get info about the character under the primary cursor.", fun: get_character_info, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "reload", aliases: &["rl"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Discard changes and reload from the source file.", fun: reload, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "reload-all", aliases: &["rla"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Discard changes and reload all documents from the source files.", fun: reload_all, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "update", aliases: &["u"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Write changes only if the file has been modified.", fun: update, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "lsp-workspace-command", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Open workspace command picker", fun: lsp_workspace_command, - signature: CommandSignature::positional(&[completers::lsp_workspace_command]), + completer: CommandCompleter::positional(&[completers::lsp_workspace_command]) }, TypableCommand { name: "lsp-restart", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Restarts the language servers used by the current doc", fun: lsp_restart, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "lsp-stop", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Stops the language servers that are used by the current doc", fun: lsp_stop, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "tree-sitter-scopes", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Display tree sitter scopes, primarily for theming and development.", fun: tree_sitter_scopes, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "tree-sitter-highlight-name", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Display name of tree-sitter highlight scope under the cursor.", fun: tree_sitter_highlight_name, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "debug-start", aliases: &["dbg"], + // correct postitional ? + signature: Signature { + positionals: (0, None), + parse_mode: ParseMode::RawParams, + }, doc: "Start a debug session from a given template with given parameters.", fun: debug_start, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "debug-remote", aliases: &["dbg-tcp"], + // correct postitional ? + signature: Signature { + positionals: (0, None), + parse_mode: ParseMode::RawParams, + }, doc: "Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters.", fun: debug_remote, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "debug-eval", aliases: &[], + // correct postitional ? + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Evaluate expression in current debug context.", fun: debug_eval, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "vsplit", aliases: &["vs"], + signature: Signature { + positionals: (0, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Open the file in a vertical split.", fun: vsplit, - signature: CommandSignature::all(completers::filename) + completer: CommandCompleter::all(completers::filename) }, TypableCommand { name: "vsplit-new", aliases: &["vnew"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Open a scratch buffer in a vertical split.", fun: vsplit_new, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "hsplit", aliases: &["hs", "sp"], + signature: Signature { + positionals: (0, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Open the file in a horizontal split.", fun: hsplit, - signature: CommandSignature::all(completers::filename) + completer: CommandCompleter::all(completers::filename) }, TypableCommand { name: "hsplit-new", aliases: &["hnew"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Open a scratch buffer in a horizontal split.", fun: hsplit_new, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "tutor", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Open the tutorial.", fun: tutor, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "goto", aliases: &["g"], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Goto line number.", fun: goto_line_number, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "set-language", aliases: &["lang"], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Set the language of current buffer (show current language if no value specified).", fun: language, - signature: CommandSignature::positional(&[completers::language]), + completer: CommandCompleter::positional(&[completers::language]) }, TypableCommand { name: "set-option", aliases: &["set"], + // TODO: Add support for completion of the options value(s), when appropriate. + signature: Signature { + positionals: (2, Some(2)), + parse_mode: ParseMode::default(), + }, doc: "Set a config option at runtime.\nFor example to disable smart case search, use `:set search.smart-case false`.", fun: set_option, - // TODO: Add support for completion of the options value(s), when appropriate. - signature: CommandSignature::positional(&[completers::setting]), + completer: CommandCompleter::positional(&[completers::setting]) }, TypableCommand { name: "toggle-option", aliases: &["toggle"], + signature: Signature { + positionals: (1, None), + parse_mode: ParseMode::default(), + }, + // TODO: Not just blooleans doc: "Toggle a boolean config option at runtime.\nFor example to toggle smart case search, use `:toggle search.smart-case`.", fun: toggle_option, - signature: CommandSignature::positional(&[completers::setting]), + completer: CommandCompleter::positional(&[completers::setting]) }, TypableCommand { name: "get-option", aliases: &["get"], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Get the current value of a config option.", fun: get_option, - signature: CommandSignature::positional(&[completers::setting]), + completer: CommandCompleter::positional(&[completers::setting]) }, TypableCommand { name: "sort", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Sort ranges in selection.", fun: sort, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "rsort", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Sort ranges in selection in reverse order.", fun: sort_reverse, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "reflow", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Hard-wrap the current selection of lines to a given width.", fun: reflow, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "tree-sitter-subtree", aliases: &["ts-subtree"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Display the smallest tree-sitter subtree that spans the primary selection, primarily for debugging queries.", fun: tree_sitter_subtree, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "config-reload", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Refresh user config.", fun: refresh_config, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "config-open", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Open the user config.toml file.", fun: open_config, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "config-open-workspace", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Open the workspace config.toml file.", fun: open_workspace_config, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "log-open", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Open the helix log file.", fun: open_log, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "insert-output", aliases: &[], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::Literal, + }, doc: "Run shell command, inserting output before each selection.", fun: insert_output, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "append-output", aliases: &[], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::Literal, + }, doc: "Run shell command, appending output after each selection.", fun: append_output, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "pipe", aliases: &[], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::Literal, + }, doc: "Pipe each selection to the shell command.", fun: pipe, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "pipe-to", aliases: &[], + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::Literal, + }, doc: "Pipe each selection to the shell command, ignoring output.", fun: pipe_to, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "run-shell-command", aliases: &["sh"], + // TODO: Is this right? path completions? + signature: Signature { + positionals: (1, Some(1)), + parse_mode: ParseMode::Literal, + }, doc: "Run a shell command", fun: run_shell_command, - signature: CommandSignature::all(completers::filename) + completer: CommandCompleter::all(completers::filename) }, TypableCommand { name: "reset-diff-change", aliases: &["diffget", "diffg"], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Reset the diff change at the cursor position.", fun: reset_diff_change, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "clear-register", aliases: &[], + signature: Signature { + positionals: (0, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Clear given register. If no argument is provided, clear all registers.", fun: clear_register, - signature: CommandSignature::all(completers::register), + completer: CommandCompleter::all(completers::register) }, TypableCommand { name: "redraw", aliases: &[], + signature: Signature { + positionals: (0, Some(0)), + parse_mode: ParseMode::default(), + }, doc: "Clear and re-render the whole UI", fun: redraw, - signature: CommandSignature::none(), + completer: CommandCompleter::none() }, TypableCommand { name: "move", aliases: &["mv"], + signature: Signature { + positionals: (1, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Move the current buffer and its corresponding file to a different path", fun: move_buffer, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]) }, TypableCommand { name: "yank-diagnostic", aliases: &[], + signature: Signature { + positionals: (0, Some(1)), + parse_mode: ParseMode::default(), + }, doc: "Yank diagnostic(s) under primary cursor to register, or clipboard by default", fun: yank_diagnostic, - signature: CommandSignature::all(completers::register), + completer: CommandCompleter::all(completers::register), }, TypableCommand { name: "read", aliases: &["r"], + signature: Signature { + positionals: (1, Some(1)), + #[cfg(unix)] + parse_mode: ParseMode::LiteralUnescapeBackslashParams, + #[cfg(windows)] + parse_mode: ParseMode::UnescapeBackslashParams, + }, doc: "Load a file into buffer", fun: read, - signature: CommandSignature::positional(&[completers::filename]), + completer: CommandCompleter::positional(&[completers::filename]), }, ]; @@ -3175,9 +3429,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(); + let args = Args::from(shellwords.args()); - if words.is_empty() || (words.len() == 1 && !shellwords.ends_with_whitespace()) { + if command.is_empty() || (args.first().is_none() && !shellwords.ends_with_whitespace()) + { fuzzy_match( input, TYPABLE_COMMAND_LIST.iter().map(|command| command.name), @@ -3189,67 +3445,78 @@ 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 mut completions = Vec::new(); - let argument_number = argument_number_of(&shellwords); + if let Some(command) = TYPABLE_COMMAND_MAP.get(command) { + let args = + ArgsParser::from(shellwords.args()).with_mode(command.signature.parse_mode); + let count = argument_number_of(&shellwords, args.clone().count()); - 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() + let word = args + .last() + .filter(|_| !shellwords.ends_with_whitespace()) + .unwrap_or_default(); + + let completer = command.completer_for_argument_number(count)(editor, &word); + + for (range, mut span) in completer { + span.content = helix_core::shellwords::escape(span.content); + + // offset ranges to input + let offset = input.len() - word.len(); + let range = (range.start + offset)..; + completions.push((range, span)); + } } + + completions } }, // 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 Some(command) = typed::TYPABLE_COMMAND_MAP.get(command) { + let args = match Args::from_signature( + command.name, + &command.signature, + shellwords.args(), + event == PromptEvent::Validate, + ) { + Ok(args) => args, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; - if let Err(e) = (cmd.fun)(cx, &args[1..], event) { - cx.editor.set_error(format!("{}", e)); + if let Err(err) = (command.fun)(cx, 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()); @@ -3265,28 +3532,41 @@ pub(super) fn command_mode(cx: &mut Context) { cx.push_layer(Box::new(prompt)); } -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) - } -} - -#[test] -fn test_argument_number_of() { - let cases = vec![ - ("set-option", 0), - ("set-option ", 0), - ("set-option a", 0), - ("set-option asdf", 0), - ("set-option asdf ", 1), - ("set-option asdf xyz", 1), - ("set-option asdf xyz abc", 2), - ("set-option asdf xyz abc ", 3), - ]; - - for case in cases { - assert_eq!(case.1, argument_number_of(&Shellwords::from(case.0))); +fn argument_number_of(shellwords: &Shellwords, count: usize) -> usize { + count.saturating_sub(1 - usize::from(shellwords.ends_with_whitespace() && count > 0)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_argument_number_of() { + let cases = vec![ + ("set-option", 0), + ("set-option ", 0), + ("set-option a", 0), + ("set-option asdf", 0), + ("set-option asdf ", 1), + ("set-option asdf xyz", 1), + ("set-option asdf xyz abc", 2), + (r#"set-option asdf xyz "abc "#, 2), + ("set-option asdf xyz abc ", 3), + ]; + + for case in cases { + let shellwords = Shellwords::from(case.0); + let mut parser = ArgsParser::from(shellwords.args()).with_mode(ParseMode::RawParams); + let args: Vec<_> = parser.by_ref().collect(); + + assert_eq!( + case.1, + argument_number_of(&shellwords, args.len()), + "`{}`: {:?}\n{:#?}", + case.0, + parser, + args + ); + } } } diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 020ecaf40f0f..aa9cafd31eff 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], ));