Unnamed repository; edit this file 'description' to name the repository.
refactor(shellwords)!: change arg handling strategy (#11149)
RoloEdits 2025-01-06
parent 377e369 · commit 64b38d1
-rw-r--r--helix-core/src/shellwords.rs945
-rw-r--r--helix-term/src/commands.rs36
-rw-r--r--helix-term/src/commands/dap.rs2
-rw-r--r--helix-term/src/commands/typed.rs719
-rw-r--r--helix-term/src/keymap.rs12
5 files changed, 1022 insertions, 692 deletions
diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs
index 9d873c36..edfd9ad1 100644
--- a/helix-core/src/shellwords.rs
+++ b/helix-core/src/shellwords.rs
@@ -1,6 +1,358 @@
+use smartstring::{LazyCompact, SmartString};
use std::borrow::Cow;
+/// A utility for parsing shell-like command lines.
+///
+/// The `Shellwords` struct takes an input string and allows extracting the command and its arguments.
+///
+/// # Features
+///
+/// - Parses command and arguments from input strings.
+/// - Supports single, double, and backtick quoted arguments.
+/// - Respects backslash escaping in arguments.
+///
+/// # Examples
+///
+/// Basic usage:
+///
+/// ```
+/// # use helix_core::shellwords::Shellwords;
+/// let shellwords = Shellwords::from(":o helix-core/src/shellwords.rs");
+/// assert_eq!(":o", shellwords.command());
+/// assert_eq!("helix-core/src/shellwords.rs", shellwords.args().next().unwrap());
+/// ```
+///
+/// Empty command:
+///
+/// ```
+/// # use helix_core::shellwords::Shellwords;
+/// let shellwords = Shellwords::from(" ");
+/// assert!(shellwords.command().is_empty());
+/// ```
+///
+/// # Iterator
+///
+/// The `args` method returns a non-allocating iterator, `Args`, over the arguments of the input.
+///
+/// ```
+/// # use helix_core::shellwords::Shellwords;
+/// let shellwords = Shellwords::from(":o a b c");
+/// let mut args = shellwords.args();
+/// assert_eq!(Some("a"), args.next());
+/// assert_eq!(Some("b"), args.next());
+/// assert_eq!(Some("c"), args.next());
+/// assert_eq!(None, args.next());
+/// ```
+#[derive(Clone, Copy)]
+pub struct Shellwords<'a> {
+ input: &'a str,
+}
+
+impl<'a> From<&'a str> for Shellwords<'a> {
+ #[inline]
+ fn from(input: &'a str) -> Self {
+ Self { input }
+ }
+}
+
+impl<'a> From<&'a String> for Shellwords<'a> {
+ #[inline]
+ fn from(input: &'a String) -> Self {
+ Self { input }
+ }
+}
+
+impl<'a> From<&'a Cow<'a, str>> for Shellwords<'a> {
+ #[inline]
+ fn from(input: &'a Cow<str>) -> Self {
+ Self { input }
+ }
+}
+
+impl<'a> Shellwords<'a> {
+ #[inline]
+ #[must_use]
+ pub fn command(&self) -> &str {
+ self.input
+ .split_once(' ')
+ .map_or(self.input, |(command, _)| command)
+ }
+
+ #[inline]
+ #[must_use]
+ pub fn args(&self) -> Args<'a> {
+ let args = self.input.split_once(' ').map_or("", |(_, args)| args);
+ Args::parse(args)
+ }
+
+ #[inline]
+ pub fn input(&self) -> &str {
+ self.input
+ }
+
+ /// Checks that the input ends with a whitespace character which is not escaped.
+ ///
+ /// # Examples
+ ///
+ /// ```rust
+ /// # use helix_core::shellwords::Shellwords;
+ /// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true);
+ /// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true);
+ /// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true);
+ /// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false);
+ /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), true);
+ /// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false);
+ /// ```
+ #[inline]
+ pub fn ends_with_whitespace(&self) -> bool {
+ self.input.ends_with(' ')
+ }
+}
+
+/// An iterator over an input string which yields arguments.
+///
+/// Splits on whitespace, but respects quoted substrings (using double quotes, single quotes, or backticks).
+#[derive(Debug, Clone)]
+pub struct Args<'a> {
+ input: &'a str,
+ idx: usize,
+ start: usize,
+}
+
+impl<'a> Args<'a> {
+ #[inline]
+ fn parse(input: &'a str) -> Self {
+ Self {
+ input,
+ idx: 0,
+ start: 0,
+ }
+ }
+
+ #[inline]
+ pub fn is_empty(&self) -> bool {
+ self.input.is_empty()
+ }
+
+ /// Returns the args exactly as input.
+ ///
+ /// # Examples
+ /// ```
+ /// # use helix_core::shellwords::Args;
+ /// let args = Args::from(r#"sed -n "s/test t/not /p""#);
+ /// assert_eq!(r#"sed -n "s/test t/not /p""#, args.raw());
+ ///
+ /// let args = Args::from(r#"cat "file name with space.txt""#);
+ /// assert_eq!(r#"cat "file name with space.txt""#, args.raw());
+ /// ```
+ #[inline]
+ pub fn raw(&self) -> &str {
+ self.input
+ }
+
+ /// Returns the remainder of the args exactly as input.
+ ///
+ /// # Examples
+ /// ```
+ /// # use helix_core::shellwords::Args;
+ /// let mut args = Args::from(r#"sed -n "s/test t/not /p""#);
+ /// assert_eq!("sed", args.next().unwrap());
+ /// assert_eq!(r#"-n "s/test t/not /p""#, args.rest());
+ /// ```
+ ///
+ /// Never calling `next` and using `rest` is functionally equivalent to calling `raw`.
+ #[inline]
+ pub fn rest(&self) -> &str {
+ &self.input[self.idx..]
+ }
+
+ /// Returns a reference to the `next()` value without advancing the iterator.
+ ///
+ /// Unlike `std::iter::Peakable::peek` this does not return a double reference, `&&str`
+ /// but a normal `&str`.
+ #[inline]
+ #[must_use]
+ pub fn peek(&self) -> Option<&str> {
+ self.clone().next()
+ }
+
+ /// Returns the total number of arguments given in a command.
+ ///
+ /// This count is aware of all parsing rules for `Args`.
+ #[must_use]
+ pub fn arg_count(&self) -> usize {
+ Self {
+ input: self.input,
+ idx: 0,
+ start: 0,
+ }
+ .fold(0, |acc, _| acc + 1)
+ }
+
+ /// Convenient function to return an empty `Args`.
+ ///
+ /// When used in any iteration, it will always return `None`.
+ #[inline(always)]
+ pub const fn empty() -> Self {
+ Self {
+ input: "",
+ idx: 0,
+ start: 0,
+ }
+ }
+}
+
+impl<'a> Iterator for Args<'a> {
+ type Item = &'a str;
+
+ #[inline]
+ #[allow(clippy::too_many_lines)]
+ fn next(&mut self) -> Option<Self::Item> {
+ // The parser loop is split into three main blocks to handle different types of input processing:
+ //
+ // 1. Quote block:
+ // - Detects an unescaped quote character, either starting an in-quote scan or, if already in-quote,
+ // locating the closing quote to return the quoted argument.
+ // - Handles cases where mismatched quotes are ignored and when quotes appear as the last character.
+ //
+ // 2. Whitespace block:
+ // - Handles arguments separated by whitespace (space or tab), respecting quotes so quoted phrases
+ // remain grouped together.
+ // - Splits arguments by whitespace when outside of a quoted context and updates boundaries accordingly.
+ //
+ // 3. Catch-all block:
+ // - Handles any other character, updating the `is_escaped` status if a backslash is encountered,
+ // advancing the loop to the next character.
+
+ let bytes = self.input.as_bytes();
+ let mut in_quotes = false;
+ let mut quote = b'\0';
+ let mut is_escaped = false;
+
+ while self.idx < bytes.len() {
+ match bytes[self.idx] {
+ b'"' | b'\'' | b'`' if !is_escaped => {
+ if in_quotes {
+ // Found the proper closing quote, so can return the arg and advance the state along.
+ if bytes[self.idx] == quote {
+ let arg = Some(&self.input[self.start..self.idx]);
+ self.idx += 1;
+ self.start = self.idx;
+ return arg;
+ }
+ // If quote does not match the type of the opening quote, then do nothing and advance.
+ self.idx += 1;
+ } else if self.idx == bytes.len() - 1 {
+ // Special case for when a quote is the last input in args.
+ // e.g: :read "file with space.txt""
+ // This preserves the quote as an arg:
+ // - `file with space`
+ // - `"`
+ let arg = Some(&self.input[self.idx..]);
+ self.idx = bytes.len();
+ self.start = bytes.len();
+ return arg;
+ } else {
+ // Found opening quote.
+ in_quotes = true;
+ // Kind of quote that was found.
+ quote = bytes[self.idx];
+
+ if self.start < self.idx {
+ // When part of the input ends in a quote, `one two" three`, this properly returns the `two`
+ // before advancing to the quoted arg for the next iteration:
+ // - `one` <- previous arg
+ // - `two` <- this step
+ // - ` three` <- next arg
+ let arg = Some(&self.input[self.start..self.idx]);
+ self.idx += 1;
+ self.start = self.idx;
+ return arg;
+ }
+
+ // Advance after quote.
+ self.idx += 1;
+ // Exclude quote from arg output.
+ self.start = self.idx;
+ }
+ }
+ b' ' | b'\t' if !in_quotes => {
+ // Found a true whitespace separator that wasn't inside quotes.
+
+ // Check if there is anything to return or if its just advancing over whitespace.
+ // `start` will only be less than `idx` when there is something to return.
+ if self.start < self.idx {
+ let arg = Some(&self.input[self.start..self.idx]);
+ self.idx += 1;
+ self.start = self.idx;
+ return arg;
+ }
+
+ // Advance beyond the whitespace.
+ self.idx += 1;
+
+ // This is where `start` will be set to the start of an arg boundary, either encountering a word
+ // boundary or a quote boundary. If it finds a quote, then it will be advanced again in that part
+ // of the code. Either way, all that remains for the check above will be to return a full arg.
+ self.start = self.idx;
+ }
+ _ => {
+ // If previous loop didn't find any backslash and was already escaped it will change to false
+ // as the backslash chain was broken.
+ //
+ // If the previous loop had no backslash escape, and found one this iteration, then its the start
+ // of an escape chain.
+ is_escaped = match (is_escaped, bytes[self.idx]) {
+ (false, b'\\') => true, // Set `is_escaped` if the current byte is a backslash
+ _ => false, //Reset `is_escaped` if it was true, otherwise keep `is_escaped` as false
+ };
+
+ // Advance to next `char`.
+ self.idx += 1;
+ }
+ }
+ }
+
+ // Fallback that catches when the loop would have exited but failed to return the arg between start and the end.
+ if self.start < bytes.len() {
+ let arg = Some(&self.input[self.start..]);
+ self.start = bytes.len();
+ return arg;
+ }
+
+ // All args have been parsed.
+ None
+ }
+
+ fn count(self) -> usize
+ where
+ Self: Sized,
+ {
+ panic!("use `arg_count` instead to get the number of arguments.");
+ }
+}
+
+impl<'a> From<&'a String> for Args<'a> {
+ fn from(args: &'a String) -> Self {
+ Args::parse(args)
+ }
+}
+
+impl<'a> From<&'a str> for Args<'a> {
+ fn from(args: &'a str) -> Self {
+ Args::parse(args)
+ }
+}
+
+impl<'a> From<&'a Cow<'_, str>> for Args<'a> {
+ fn from(args: &'a Cow<str>) -> Self {
+ Args::parse(args)
+ }
+}
+
/// Auto escape for shellwords usage.
+#[inline]
+#[must_use]
pub fn escape(input: Cow<str>) -> Cow<str> {
if !input.chars().any(|x| x.is_ascii_whitespace()) {
input
@@ -13,186 +365,141 @@ pub fn escape(input: Cow<str>) -> Cow<str> {
buf
}))
} else {
- Cow::Owned(format!("\"{}\"", input))
+ Cow::Owned(format!("\"{input}\""))
}
}
-enum State {
- OnWhitespace,
- Unquoted,
- UnquotedEscaped,
- Quoted,
- QuoteEscaped,
- Dquoted,
- DquoteEscaped,
-}
+/// Unescapes a string, converting escape sequences into their literal characters.
+///
+/// This function handles the following escape sequences:
+/// - `\\n` is converted to `\n` (newline)
+/// - `\\t` is converted to `\t` (tab)
+/// - `\\u{...}` is converted to the corresponding Unicode character
+///
+/// Other escape sequences, such as `\\` followed by any character not listed above, will remain unchanged.
+///
+/// If input is invalid, for example if there is invalid unicode, \u{999999999}, it will return the input as is.
+///
+/// # Examples
+///
+/// Basic usage:
+///
+/// ```
+/// # use helix_core::shellwords::unescape;
+/// let unescaped = unescape("hello\\nworld");
+/// assert_eq!("hello\nworld", unescaped);
+/// ```
+///
+/// Unescaping tabs:
+///
+/// ```
+/// # use helix_core::shellwords::unescape;
+/// let unescaped = unescape("hello\\tworld");
+/// assert_eq!("hello\tworld", unescaped);
+/// ```
+///
+/// Unescaping Unicode characters:
+///
+/// ```
+/// # use helix_core::shellwords::unescape;
+/// let unescaped = unescape("hello\\u{1f929}world");
+/// assert_eq!("hello\u{1f929}world", unescaped);
+/// assert_eq!("hello🤩world", unescaped);
+/// ```
+///
+/// Handling backslashes:
+///
+/// ```
+/// # use helix_core::shellwords::unescape;
+/// let unescaped = unescape(r"hello\\world");
+/// assert_eq!(r"hello\\world", unescaped);
+///
+/// let unescaped = unescape(r"hello\\\\world");
+/// assert_eq!(r"hello\\\\world", unescaped);
+/// ```
+///
+/// # Note
+///
+/// This function is opinionated, with a clear purpose of handling user input, not a general or generic unescaping utility, and does not unescape sequences like `\\'` or `\\\"`, leaving them as is.
+#[inline]
+#[must_use]
+pub fn unescape(input: &str) -> Cow<'_, str> {
+ enum State {
+ Normal,
+ Escaped,
+ Unicode,
+ }
-pub struct Shellwords<'a> {
- state: State,
- /// Shellwords where whitespace and escapes has been resolved.
- words: Vec<Cow<'a, str>>,
- /// 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::<LazyCompact>::new_const();
-impl<'a> From<&'a str> for Shellwords<'a> {
- fn from(input: &'a str) -> Self {
- use State::*;
-
- let mut state = Unquoted;
- let mut words = Vec::new();
- let mut parts = Vec::new();
- let mut escaped = String::with_capacity(input.len());
-
- let mut part_start = 0;
- let mut unescaped_start = 0;
- let mut end = 0;
-
- for (i, c) in input.char_indices() {
- state = match state {
- OnWhitespace => match c {
- '"' => {
- end = i;
- Dquoted
- }
- '\'' => {
- end = i;
- Quoted
- }
- '\\' => {
- if cfg!(unix) {
- escaped.push_str(&input[unescaped_start..i]);
- unescaped_start = i + 1;
- UnquotedEscaped
- } else {
- OnWhitespace
- }
- }
- 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
+ for (idx, ch) in input.char_indices() {
+ match state {
+ State::Normal => match ch {
+ '\\' => {
+ if !is_escaped {
+ // PERF: As not every separator will be escaped, we use `String::new` as that has no initial
+ // allocation. If an escape is found, then we reserve capacity thats the len of the separator,
+ // as the new unescaped string will be at least that long.
+ unescaped.reserve(input.len());
+ if idx > 0 {
+ // First time finding an escape, so all prior chars can be added to the new unescaped
+ // version if its not the very first char found.
+ unescaped.push_str(&input[0..idx]);
}
}
- '\'' => {
- end = i;
- OnWhitespace
- }
- _ => Quoted,
- },
- QuoteEscaped => Quoted,
- Dquoted => match c {
- '\\' => {
- if cfg!(unix) {
- escaped.push_str(&input[unescaped_start..i]);
- unescaped_start = i + 1;
- DquoteEscaped
- } else {
- Dquoted
- }
+ state = State::Escaped;
+ is_escaped = true;
+ }
+ _ => {
+ if is_escaped {
+ unescaped.push(ch);
}
- '"' => {
- end = i;
- OnWhitespace
+ }
+ },
+ State::Escaped => {
+ match ch {
+ 'n' => unescaped.push('\n'),
+ 't' => unescaped.push('\t'),
+ 'u' => {
+ state = State::Unicode;
+ continue;
}
- _ => 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();
+ // Uncomment if you want to handle '\\' to '\'
+ // '\\' => 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,
- }
- }
-}
-
-impl<'a> Shellwords<'a> {
- /// Checks that the input ends with a whitespace character which is not escaped.
- ///
- /// # Examples
- ///
- /// ```rust
- /// use helix_core::shellwords::Shellwords;
- /// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true);
- /// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true);
- /// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true);
- /// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false);
- /// #[cfg(unix)]
- /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false);
- /// #[cfg(unix)]
- /// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false);
- /// ```
- pub fn ends_with_whitespace(&self) -> bool {
- matches!(self.state, State::OnWhitespace)
- }
-
- /// Returns the list of shellwords calculated from the input string.
- pub fn words(&self) -> &[Cow<'a, str>] {
- &self.words
}
- /// Returns a list of strings which correspond to [`Self::words`] but represent the original
- /// text in the input string - including escape characters - without separating whitespace.
- pub fn parts(&self) -> &[&'a str] {
- &self.parts
+ if is_escaped {
+ unescaped.into()
+ } else {
+ input.into()
}
}
@@ -201,114 +508,202 @@ mod test {
use super::*;
#[test]
- #[cfg(windows)]
- fn test_normal() {
+ fn base() {
let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
let shellwords = Shellwords::from(input);
- let result = shellwords.words().to_vec();
- let expected = vec![
- Cow::from(":o"),
- Cow::from("single_word"),
- Cow::from("twó"),
- Cow::from("wörds"),
- Cow::from("\\three\\"),
- Cow::from("\\"),
- Cow::from("with\\ escaping\\\\"),
+ let args = vec![
+ "single_word",
+ "twó",
+ "wörds",
+ r"\three\",
+ r#"\"with\"#,
+ r"escaping\\",
];
- // TODO test is_owned and is_borrowed, once they get stabilized.
- assert_eq!(expected, result);
+
+ assert_eq!(":o", shellwords.command());
+ assert_eq!(args, shellwords.args().collect::<Vec<_>>());
}
#[test]
- #[cfg(unix)]
- fn test_normal() {
- let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
- let shellwords = Shellwords::from(input);
- let result = shellwords.words().to_vec();
- let expected = vec![
- Cow::from(":o"),
- Cow::from("single_word"),
- Cow::from("twó"),
- Cow::from("wörds"),
- Cow::from(r#"three "with escaping\"#),
- ];
- // TODO test is_owned and is_borrowed, once they get stabilized.
- assert_eq!(expected, result);
+ fn should_have_empty_args() {
+ let shellwords = Shellwords::from(":quit");
+ assert!(
+ shellwords.args().is_empty(),
+ "args: `{}`",
+ shellwords.args().next().unwrap()
+ );
+ assert!(shellwords.args().next().is_none());
}
#[test]
- #[cfg(unix)]
- fn test_quoted() {
+ fn should_return_empty_command() {
+ let shellwords = Shellwords::from(" ");
+ assert!(shellwords.command().is_empty());
+ }
+
+ #[test]
+ fn should_support_unicode_args() {
+ assert_eq!(
+ Shellwords::from(":sh echo 𒀀").args().collect::<Vec<_>>(),
+ &["echo", "𒀀"]
+ );
+ assert_eq!(
+ Shellwords::from(":sh echo 𒀀 hello world𒀀")
+ .args()
+ .collect::<Vec<_>>(),
+ &["echo", "𒀀", "hello", "world𒀀"]
+ );
+ }
+
+ #[test]
+ fn should_preserve_quote_if_last_argument() {
+ let sh = Shellwords::from(r#":read "file with space.txt"""#);
+ let mut args = sh.args();
+ assert_eq!("file with space.txt", args.next().unwrap());
+ assert_eq!(r#"""#, args.next().unwrap());
+ }
+
+ #[test]
+ fn should_return_rest_of_non_closed_quote_as_one_argument() {
+ let sh = Shellwords::from(r":rename 'should be one \'argument");
+ assert_eq!(r"should be one \'argument", sh.args().next().unwrap());
+ }
+
+ #[test]
+ fn should_respect_escaped_quote_in_what_looks_like_non_closed_arg() {
+ let sh = Shellwords::from(r":rename 'should be one \\'argument");
+ let mut args = sh.args();
+ assert_eq!(r"should be one \\", args.next().unwrap());
+ assert_eq!(r"argument", args.next().unwrap());
+ }
+
+ #[test]
+ fn should_split_args() {
+ assert_eq!(Shellwords::from(":o a").args().collect::<Vec<_>>(), &["a"]);
+ assert_eq!(
+ Shellwords::from(":o a\\ ").args().collect::<Vec<_>>(),
+ &["a\\"]
+ );
+ }
+
+ #[test]
+ fn should_parse_args_even_with_leading_whitespace() {
+ // Three spaces
+ assert_eq!(
+ Shellwords::from(":o a").args().collect::<Vec<_>>(),
+ &["a"]
+ );
+ }
+
+ #[test]
+ fn should_peek_next_arg_and_not_consume() {
+ let mut args = Shellwords::from(":o a").args();
+
+ assert_eq!(Some("a"), args.peek());
+ assert_eq!(Some("a"), args.next());
+ assert_eq!(None, args.next());
+ }
+
+ #[test]
+ fn should_parse_single_quotes_while_respecting_escapes() {
let quoted =
r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#;
let shellwords = Shellwords::from(quoted);
- let result = shellwords.words().to_vec();
+ let result = shellwords.args().collect::<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"),
+ "single_word",
+ "twó wörds",
+ "",
+ " ",
+ r#"\three\' \"with\ escaping\\"#,
+ "quote incomplete",
];
assert_eq!(expected, result);
}
#[test]
- #[cfg(unix)]
- fn test_dquoted() {
+ fn should_parse_double_quotes_while_respecting_escapes() {
let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#;
let shellwords = Shellwords::from(dquoted);
- let result = shellwords.words().to_vec();
+ let result = shellwords.args().collect::<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"),
+ "single_word",
+ "twó wörds",
+ "",
+ " ",
+ r#"\three\' \"with\ escaping\\"#,
+ "dquote incomplete",
];
assert_eq!(expected, result);
}
#[test]
- #[cfg(unix)]
- fn test_mixed() {
+ fn should_respect_escapes_with_mixed_quotes() {
let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#;
let shellwords = Shellwords::from(dquoted);
- let result = shellwords.words().to_vec();
+ let result = shellwords.args().collect::<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
+ "single_word",
+ "twó wörds",
+ r#"\three\' \"with\ escaping\\"#,
+ "no space before",
+ "and after",
+ "$#%^@",
+ "%^&(%^",
+ r")(*&^%",
+ r"a\\\\\b",
+ // Last ' is important, as if the user input an accidental quote at the end, this should be checked in
+ // commands where there should only be one input and return an error rather than silently succeed.
+ "'",
];
assert_eq!(expected, result);
}
#[test]
- fn test_lists() {
- let input =
- r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "quotes"]'"#;
+ fn should_return_rest() {
+ let input = r#":set statusline.center ["file-type","file-encoding"]"#;
let shellwords = Shellwords::from(input);
- let result = shellwords.words().to_vec();
- let expected = vec![
- Cow::from(":set"),
- Cow::from("statusline.center"),
- Cow::from(r#"["file-type","file-encoding"]"#),
- Cow::from(r#"["list", "in", "quotes"]"#),
- ];
- assert_eq!(expected, result);
+ let mut args = shellwords.args();
+ assert_eq!(":set", shellwords.command());
+ assert_eq!(Some("statusline.center"), args.next());
+ assert_eq!(r#"["file-type","file-encoding"]"#, args.rest());
+ }
+
+ #[test]
+ fn should_return_no_args() {
+ let mut args = Args::parse("");
+ assert!(args.next().is_none());
+ assert!(args.is_empty());
+ assert!(args.arg_count() == 0);
+ }
+
+ #[test]
+ fn should_leave_escaped_quotes() {
+ let input = r#"\" \` \' \"with \'with \`with"#;
+ let result = Args::parse(input).collect::<Vec<_>>();
+ assert_eq!(r#"\""#, result[0]);
+ assert_eq!(r"\`", result[1]);
+ assert_eq!(r"\'", result[2]);
+ assert_eq!(r#"\"with"#, result[3]);
+ assert_eq!(r"\'with", result[4]);
+ assert_eq!(r"\`with", result[5]);
+ }
+
+ #[test]
+ fn should_leave_literal_newline_alone() {
+ let result = Args::parse(r"\n").collect::<Vec<_>>();
+ assert_eq!(r"\n", result[0]);
+ }
+
+ #[test]
+ fn should_leave_literal_unicode_alone() {
+ let result = Args::parse(r"\u{C}").collect::<Vec<_>>();
+ assert_eq!(r"\u{C}", result[0]);
}
#[test]
#[cfg(unix)]
- fn test_escaping_unix() {
+ fn should_escape_unix() {
assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
assert_eq!(escape("foo bar".into()), Cow::Borrowed("foo\\ bar"));
assert_eq!(escape("foo\tbar".into()), Cow::Borrowed("foo\\\tbar"));
@@ -316,35 +711,79 @@ mod test {
#[test]
#[cfg(windows)]
- fn test_escaping_windows() {
+ fn should_escape_windows() {
assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\""));
}
#[test]
- #[cfg(unix)]
- fn test_parts() {
- assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
- assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\ "]);
+ fn should_unescape_newline() {
+ let unescaped = unescape("hello\\nworld");
+ assert_eq!("hello\nworld", unescaped);
}
#[test]
- #[cfg(windows)]
- fn test_parts() {
- assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
- assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\"]);
+ fn should_unescape_tab() {
+ let unescaped = unescape("hello\\tworld");
+ assert_eq!("hello\tworld", unescaped);
}
#[test]
- fn test_multibyte_at_end() {
- assert_eq!(Shellwords::from("𒀀").parts(), &["𒀀"]);
- assert_eq!(
- Shellwords::from(":sh echo 𒀀").parts(),
- &[":sh", "echo", "𒀀"]
- );
- assert_eq!(
- Shellwords::from(":sh echo 𒀀 hello world𒀀").parts(),
- &[":sh", "echo", "𒀀", "hello", "world𒀀"]
- );
+ fn should_unescape_unicode() {
+ let unescaped = unescape("hello\\u{1f929}world");
+ assert_eq!("hello\u{1f929}world", unescaped, "char: 🤩 ");
+ assert_eq!("hello🤩world", unescaped);
+ }
+
+ #[test]
+ fn should_return_original_input_due_to_bad_unicode() {
+ let unescaped = unescape("hello\\u{999999999}world");
+ assert_eq!("hello\\u{999999999}world", unescaped);
+ }
+
+ #[test]
+ fn should_not_unescape_slash() {
+ let unescaped = unescape(r"hello\\world");
+ assert_eq!(r"hello\\world", unescaped);
+
+ let unescaped = unescape(r"hello\\\\world");
+ assert_eq!(r"hello\\\\world", unescaped);
+ }
+
+ #[test]
+ fn should_not_unescape_slash_single_quote() {
+ let unescaped = unescape("\\'");
+ assert_eq!(r"\'", unescaped);
+ }
+
+ #[test]
+ fn should_not_unescape_slash_double_quote() {
+ let unescaped = unescape("\\\"");
+ assert_eq!(r#"\""#, unescaped);
+ }
+
+ #[test]
+ fn should_not_change_anything() {
+ let unescaped = unescape("'");
+ assert_eq!("'", unescaped);
+ let unescaped = unescape(r#"""#);
+ assert_eq!(r#"""#, unescaped);
+ }
+
+ #[test]
+ fn should_only_unescape_newline_not_slash_single_quote() {
+ let unescaped = unescape("\\n\'");
+ assert_eq!("\n'", unescaped);
+ let unescaped = unescape("\\n\\'");
+ assert_eq!("\n\\'", unescaped);
+ }
+
+ #[test]
+ fn should_unescape_args() {
+ // 1f929: 🤩
+ let args = Args::parse(r#"'hello\u{1f929} world' '["hello", "\u{1f929}", "world"]'"#)
+ .collect::<Vec<_>>();
+ assert_eq!("hello\u{1f929} world", unescape(args[0]));
+ assert_eq!(r#"["hello", "🤩", "world"]"#, unescape(args[1]));
}
}
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index a93fa445..06d892ad 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -30,7 +30,9 @@ use helix_core::{
object, pos_at_coords,
regex::{self, Regex},
search::{self, CharMatcher},
- selection, shellwords, surround,
+ selection,
+ shellwords::{self, Args},
+ surround,
syntax::{BlockCommentToken, LanguageServerFeature},
text_annotations::{Overlay, TextAnnotations},
textobject,
@@ -207,7 +209,7 @@ use helix_view::{align_view, Align};
pub enum MappableCommand {
Typable {
name: String,
- args: Vec<String>,
+ args: String,
doc: String,
},
Static {
@@ -242,15 +244,17 @@ impl MappableCommand {
pub fn execute(&self, cx: &mut Context) {
match &self {
Self::Typable { name, args, doc: _ } => {
- let args: Vec<Cow<str>> = args.iter().map(Cow::from).collect();
if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) {
let mut cx = compositor::Context {
editor: cx.editor,
jobs: cx.jobs,
scroll: None,
};
- if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) {
- cx.editor.set_error(format!("{}", e));
+
+ if let Err(err) =
+ (command.fun)(&mut cx, Args::from(args), PromptEvent::Validate)
+ {
+ cx.editor.set_error(format!("{err}"));
}
}
}
@@ -621,21 +625,15 @@ impl std::str::FromStr for MappableCommand {
fn from_str(s: &str) -> Result<Self, Self::Err> {
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::<Vec<String>>();
+ let (name, args) = suffix.split_once(' ').unwrap_or((suffix, ""));
typed::TYPABLE_COMMAND_MAP
.get(name)
.map(|cmd| MappableCommand::Typable {
name: cmd.name.to_owned(),
doc: format!(":{} {:?}", cmd.name, args),
- args,
+ args: args.to_string(),
})
- .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s))
+ .ok_or_else(|| anyhow!("No TypableCommand named '{}'", name))
} else if let Some(suffix) = s.strip_prefix('@') {
helix_view::input::parse_macro(suffix).map(|keys| Self::Macro {
name: s.to_string(),
@@ -3254,7 +3252,7 @@ pub fn command_palette(cx: &mut Context) {
.iter()
.map(|cmd| MappableCommand::Typable {
name: cmd.name.to_owned(),
- args: Vec::new(),
+ args: String::new(),
doc: cmd.doc.to_owned(),
}),
);
@@ -4328,13 +4326,19 @@ fn yank_joined_impl(editor: &mut Editor, separator: &str, register: char) {
let (view, doc) = current!(editor);
let text = doc.text().slice(..);
+ let separator = if separator.is_empty() {
+ doc.line_ending.as_str()
+ } else {
+ separator
+ };
+
let selection = doc.selection(view.id);
let selections = selection.len();
let joined = selection
.fragments(text)
.fold(String::new(), |mut acc, fragment| {
if !acc.is_empty() {
- acc.push_str(separator);
+ acc.push_str(&shellwords::unescape(separator));
}
acc.push_str(&fragment);
acc
diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs
index 83dd936c..a35fa23a 100644
--- a/helix-term/src/commands/dap.rs
+++ b/helix-term/src/commands/dap.rs
@@ -109,6 +109,7 @@ fn dap_callback<T, F>(
jobs.callback(callback);
}
+// TODO: transition to `shellwords::Args` instead of `Option<Vec<Cow>>>`
pub fn dap_start_impl(
cx: &mut compositor::Context,
name: Option<&str>,
@@ -312,6 +313,7 @@ pub fn dap_restart(cx: &mut Context) {
);
}
+// TODO: transition to `shellwords::Args` instead of `Vec<String>`
fn debug_parameter_prompt(
completions: Vec<DebugConfigCompletion>,
config_name: String,
diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index c21743d0..27e2c75d 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -13,6 +13,7 @@ use helix_stdx::path::home_dir;
use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME};
use helix_view::editor::{CloseError, ConfigEvent};
use serde_json::Value;
+use shellwords::Args;
use ui::completers::{self, Completer};
#[derive(Clone)]
@@ -21,17 +22,17 @@ pub struct TypableCommand {
pub aliases: &'static [&'static str],
pub doc: &'static str,
// params, flags, helper, completer
- pub fun: fn(&mut compositor::Context, &[Cow<str>], PromptEvent) -> anyhow::Result<()>,
+ pub fun: fn(&mut compositor::Context, Args, PromptEvent) -> anyhow::Result<()>,
/// What completion methods, if any, does this command have?
pub signature: CommandSignature,
}
impl TypableCommand {
fn completer_for_argument_number(&self, n: usize) -> &Completer {
- match self.signature.positional_args.get(n) {
- Some(completer) => completer,
- _ => &self.signature.var_args,
- }
+ self.signature
+ .positional_args
+ .get(n)
+ .unwrap_or(&self.signature.var_args)
}
}
@@ -67,7 +68,7 @@ impl CommandSignature {
}
}
-fn quit(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) -> anyhow::Result<()> {
+fn quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
log::debug!("quitting...");
if event != PromptEvent::Validate {
@@ -78,7 +79,7 @@ fn quit(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) ->
// last view and we have unsaved changes
if cx.editor.tree.views().count() == 1 {
- buffers_remaining_impl(cx.editor)?
+ buffers_remaining_impl(cx.editor)?;
}
cx.block_try_flush_writes()?;
@@ -87,11 +88,7 @@ fn quit(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) ->
Ok(())
}
-fn force_quit(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn force_quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -104,12 +101,13 @@ fn force_quit(
Ok(())
}
-fn open(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) -> anyhow::Result<()> {
+fn open(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- ensure!(!args.is_empty(), "wrong argument count");
+ ensure!(!args.is_empty(), ":open needs at least one argument");
+
for arg in args {
let (path, pos) = args::parse_file(arg);
let path = helix_stdx::path::expand_tilde(path);
@@ -175,7 +173,7 @@ fn buffer_close_by_ids_impl(
Ok(())
}
-fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow<str>]) -> Vec<DocumentId> {
+fn buffer_gather_paths_impl(editor: &mut Editor, args: Args) -> Vec<DocumentId> {
// No arguments implies current document
if args.is_empty() {
let doc_id = view!(editor).doc;
@@ -186,7 +184,7 @@ fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow<str>]) -> Vec<Docum
let mut document_ids = vec![];
for arg in args {
let doc_id = editor.documents().find_map(|doc| {
- let arg_path = Some(Path::new(arg.as_ref()));
+ let arg_path = Some(Path::new(arg));
if doc.path().map(|p| p.as_path()) == arg_path
|| doc.relative_path().as_deref() == arg_path
{
@@ -214,7 +212,7 @@ fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow<str>]) -> Vec<Docum
fn buffer_close(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -227,7 +225,7 @@ fn buffer_close(
fn force_buffer_close(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -249,7 +247,7 @@ fn buffer_gather_others_impl(editor: &mut Editor) -> Vec<DocumentId> {
fn buffer_close_others(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -262,7 +260,7 @@ fn buffer_close_others(
fn force_buffer_close_others(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -279,7 +277,7 @@ fn buffer_gather_all_impl(editor: &mut Editor) -> Vec<DocumentId> {
fn buffer_close_all(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -292,7 +290,7 @@ fn buffer_close_all(
fn force_buffer_close_all(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -305,7 +303,7 @@ fn force_buffer_close_all(
fn buffer_next(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -318,7 +316,7 @@ fn buffer_next(
fn buffer_previous(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -329,15 +327,10 @@ fn buffer_previous(
Ok(())
}
-fn write_impl(
- cx: &mut compositor::Context,
- path: Option<&Cow<str>>,
- force: bool,
-) -> anyhow::Result<()> {
+fn write_impl(cx: &mut compositor::Context, path: Option<&str>, force: bool) -> anyhow::Result<()> {
let config = cx.editor.config();
let jobs = &mut cx.jobs;
let (view, doc) = current!(cx.editor);
- let path = path.map(AsRef::as_ref);
if config.insert_final_newline {
insert_final_newline(doc, view.id);
@@ -379,40 +372,36 @@ fn insert_final_newline(doc: &mut Document, view_id: ViewId) {
}
}
-fn write(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn write(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- write_impl(cx, args.first(), false)
+ write_impl(cx, args.next(), false)
}
fn force_write(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- write_impl(cx, args.first(), true)
+ write_impl(cx, args.next(), true)
}
fn write_buffer_close(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- write_impl(cx, args.first(), false)?;
+ write_impl(cx, args.next(), false)?;
let document_ids = buffer_gather_paths_impl(cx.editor, args);
buffer_close_by_ids_impl(cx, &document_ids, false)
@@ -420,24 +409,20 @@ fn write_buffer_close(
fn force_write_buffer_close(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- write_impl(cx, args.first(), true)?;
+ write_impl(cx, args.next(), true)?;
let document_ids = buffer_gather_paths_impl(cx.editor, args);
buffer_close_by_ids_impl(cx, &document_ids, false)
}
-fn new_file(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn new_file(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -447,11 +432,7 @@ fn new_file(
Ok(())
}
-fn format(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -468,7 +449,7 @@ fn format(
fn set_indent_style(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -489,9 +470,9 @@ fn set_indent_style(
}
// Attempt to parse argument as an indent style.
- let style = match args.first() {
+ let style = match args.next() {
Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs),
- Some(Cow::Borrowed("0")) => Some(Tabs),
+ Some("0") => Some(Tabs),
Some(arg) => arg
.parse::<u8>()
.ok()
@@ -510,7 +491,7 @@ fn set_indent_style(
/// Sets or reports the current document's line ending setting.
fn set_line_ending(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -541,7 +522,7 @@ fn set_line_ending(
}
let arg = args
- .first()
+ .next()
.context("argument missing")?
.to_ascii_lowercase();
@@ -580,16 +561,12 @@ fn set_line_ending(
Ok(())
}
-fn earlier(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- 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::<UndoKind>().map_err(|s| anyhow!(s))?;
+ let uk = args.raw().parse::<UndoKind>().map_err(|s| anyhow!(s))?;
let (view, doc) = current!(cx.editor);
let success = doc.earlier(view, uk);
@@ -600,16 +577,13 @@ fn earlier(
Ok(())
}
-fn later(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- 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::<UndoKind>().map_err(|s| anyhow!(s))?;
+ let uk = args.raw().parse::<UndoKind>().map_err(|s| anyhow!(s))?;
+
let (view, doc) = current!(cx.editor);
let success = doc.later(view, uk);
if !success {
@@ -621,30 +595,30 @@ fn later(
fn write_quit(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- write_impl(cx, args.first(), false)?;
+ write_impl(cx, args.next(), false)?;
cx.block_try_flush_writes()?;
- quit(cx, &[], event)
+ quit(cx, Args::empty(), event)
}
fn force_write_quit(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- write_impl(cx, args.first(), true)?;
+ write_impl(cx, args.next(), true)?;
cx.block_try_flush_writes()?;
- force_quit(cx, &[], event)
+ force_quit(cx, Args::empty(), event)
}
/// Results in an error if there are modified buffers remaining and sets editor
@@ -744,11 +718,7 @@ pub fn write_all_impl(
Ok(())
}
-fn write_all(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn write_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -758,7 +728,7 @@ fn write_all(
fn force_write_all(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -770,7 +740,7 @@ fn force_write_all(
fn write_all_quit(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -782,7 +752,7 @@ fn write_all_quit(
fn force_write_all_quit(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -807,41 +777,31 @@ fn quit_all_impl(cx: &mut compositor::Context, force: bool) -> anyhow::Result<()
Ok(())
}
-fn quit_all(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn quit_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
quit_all_impl(cx, false)
}
fn force_quit_all(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
quit_all_impl(cx, true)
}
-fn cquit(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn cquit(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
let exit_code = args
- .first()
+ .next()
.and_then(|code| code.parse::<i32>().ok())
.unwrap_or(1);
@@ -851,7 +811,7 @@ fn cquit(
fn force_cquit(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -859,7 +819,7 @@ fn force_cquit(
}
let exit_code = args
- .first()
+ .next()
.and_then(|code| code.parse::<i32>().ok())
.unwrap_or(1);
cx.editor.exit_code = exit_code;
@@ -867,11 +827,7 @@ fn force_cquit(
quit_all_impl(cx, true)
}
-fn theme(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn theme(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> {
let true_color = cx.editor.config.load().true_color || crate::true_color();
match event {
PromptEvent::Abort => {
@@ -881,7 +837,7 @@ fn theme(
if args.is_empty() {
// Ensures that a preview theme gets cleaned up if the user backspaces until the prompt is empty.
cx.editor.unset_theme_preview();
- } else if let Some(theme_name) = args.first() {
+ } else if let Some(theme_name) = args.next() {
if let Ok(theme) = cx.editor.theme_loader.load(theme_name) {
if !(true_color || theme.is_16_color()) {
bail!("Unsupported theme: theme requires true color support");
@@ -891,7 +847,7 @@ fn theme(
};
}
PromptEvent::Validate => {
- if let Some(theme_name) = args.first() {
+ if let Some(theme_name) = args.next() {
let theme = cx
.editor
.theme_loader
@@ -914,168 +870,142 @@ fn theme(
fn yank_main_selection_to_clipboard(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
yank_primary_selection_impl(cx.editor, '+');
Ok(())
}
-fn yank_joined(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn yank_joined(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
- ensure!(args.len() <= 1, ":yank-join takes at most 1 argument");
-
- let doc = doc!(cx.editor);
- let default_sep = Cow::Borrowed(doc.line_ending.as_str());
- let separator = args.first().unwrap_or(&default_sep);
let register = cx.editor.selected_register.unwrap_or('"');
- yank_joined_impl(cx.editor, separator, register);
+ yank_joined_impl(cx.editor, args.raw(), register);
Ok(())
}
fn yank_joined_to_clipboard(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
- let doc = doc!(cx.editor);
- let default_sep = Cow::Borrowed(doc.line_ending.as_str());
- let separator = args.first().unwrap_or(&default_sep);
- yank_joined_impl(cx.editor, separator, '+');
+ yank_joined_impl(cx.editor, args.raw(), '+');
Ok(())
}
fn yank_main_selection_to_primary_clipboard(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
yank_primary_selection_impl(cx.editor, '*');
Ok(())
}
fn yank_joined_to_primary_clipboard(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- let doc = doc!(cx.editor);
- let default_sep = Cow::Borrowed(doc.line_ending.as_str());
- let separator = args.first().unwrap_or(&default_sep);
- yank_joined_impl(cx.editor, separator, '*');
+ yank_joined_impl(cx.editor, args.raw(), '*');
Ok(())
}
fn paste_clipboard_after(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
paste(cx.editor, '+', Paste::After, 1);
Ok(())
}
fn paste_clipboard_before(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
paste(cx.editor, '+', Paste::Before, 1);
Ok(())
}
fn paste_primary_clipboard_after(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
paste(cx.editor, '*', Paste::After, 1);
Ok(())
}
fn paste_primary_clipboard_before(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
paste(cx.editor, '*', Paste::Before, 1);
Ok(())
}
fn replace_selections_with_clipboard(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
replace_with_yanked_impl(cx.editor, '+', 1);
Ok(())
}
fn replace_selections_with_primary_clipboard(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
replace_with_yanked_impl(cx.editor, '*', 1);
Ok(())
}
fn show_clipboard_provider(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
cx.editor
.set_status(cx.editor.registers.clipboard_provider_name());
Ok(())
@@ -1083,20 +1013,20 @@ fn show_clipboard_provider(
fn change_current_directory(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- let dir = match args.first().map(AsRef::as_ref) {
+ let dir = match args.next() {
Some("-") => cx
.editor
.last_cwd
.clone()
- .ok_or(anyhow!("No previous working directory"))?,
- Some(input_path) => helix_stdx::path::expand_tilde(Path::new(input_path)).to_path_buf(),
+ .ok_or_else(|| anyhow!("No previous working directory"))?,
+ Some(path) => helix_stdx::path::expand_tilde(Path::new(path)).to_path_buf(),
None => home_dir()?,
};
@@ -1112,7 +1042,7 @@ fn change_current_directory(
fn show_current_directory(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -1125,7 +1055,7 @@ fn show_current_directory(
if cwd.exists() {
cx.editor.set_status(message);
} else {
- cx.editor.set_error(format!("{} (deleted)", message));
+ cx.editor.set_error(format!("{message} (deleted)"));
}
Ok(())
}
@@ -1133,7 +1063,7 @@ fn show_current_directory(
/// Sets the [`Document`]'s encoding..
fn set_encoding(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -1141,7 +1071,7 @@ fn set_encoding(
}
let doc = doc_mut!(cx.editor);
- if let Some(label) = args.first() {
+ if let Some(label) = args.next() {
doc.set_encoding(label)
} else {
let encoding = doc.encoding().name().to_owned();
@@ -1153,7 +1083,7 @@ fn set_encoding(
/// Shows info about the character under the primary cursor.
fn get_character_info(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -1276,11 +1206,7 @@ fn get_character_info(
}
/// Reload the [`Document`] from its source file.
-fn reload(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn reload(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -1299,11 +1225,7 @@ fn reload(
Ok(())
}
-fn reload_all(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn reload_all(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -1359,11 +1281,7 @@ fn reload_all(
}
/// Update the [`Document`] if it has been modified.
-fn update(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn update(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -1378,7 +1296,7 @@ fn update(
fn lsp_workspace_command(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -1434,7 +1352,8 @@ fn lsp_workspace_command(
};
cx.jobs.callback(callback);
} else {
- let command = args.join(" ");
+ let command = args.raw().to_string();
+
let matches: Vec<_> = ls_id_commands
.filter(|(_ls_id, c)| *c == &command)
.collect();
@@ -1468,7 +1387,7 @@ fn lsp_workspace_command(
fn lsp_restart(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -1514,11 +1433,7 @@ fn lsp_restart(
Ok(())
}
-fn lsp_stop(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn lsp_stop(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -1545,7 +1460,7 @@ fn lsp_stop(
fn tree_sitter_scopes(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -1578,7 +1493,7 @@ fn tree_sitter_scopes(
fn tree_sitter_highlight_name(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
fn find_highlight_at_cursor(
@@ -1651,81 +1566,50 @@ fn tree_sitter_highlight_name(
Ok(())
}
-fn vsplit(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn vsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
- }
-
- if args.is_empty() {
+ } else if args.is_empty() {
split(cx.editor, Action::VerticalSplit);
} else {
for arg in args {
- cx.editor
- .open(&PathBuf::from(arg.as_ref()), Action::VerticalSplit)?;
+ cx.editor.open(&PathBuf::from(arg), Action::VerticalSplit)?;
}
}
-
Ok(())
}
-fn hsplit(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn hsplit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
- }
-
- if args.is_empty() {
+ } else if args.is_empty() {
split(cx.editor, Action::HorizontalSplit);
} else {
for arg in args {
cx.editor
- .open(&PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?;
+ .open(&PathBuf::from(arg), Action::HorizontalSplit)?;
}
}
-
Ok(())
}
-fn vsplit_new(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn vsplit_new(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
cx.editor.new_file(Action::VerticalSplit);
-
Ok(())
}
-fn hsplit_new(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn hsplit_new(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
cx.editor.new_file(Action::HorizontalSplit);
-
Ok(())
}
-fn debug_eval(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn debug_eval(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -1739,9 +1623,10 @@ fn debug_eval(
};
// TODO: support no frame_id
-
let frame_id = debugger.stack_frames[&thread_id][frame].id;
- let response = helix_lsp::block_on(debugger.eval(args.join(" "), Some(frame_id)))?;
+ let expression = args.raw().to_string();
+
+ let response = helix_lsp::block_on(debugger.eval(expression, Some(frame_id)))?;
cx.editor.set_status(response.result);
}
Ok(())
@@ -1749,47 +1634,33 @@ fn debug_eval(
fn debug_start(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
- let mut args = args.to_owned();
- let name = match args.len() {
- 0 => None,
- _ => Some(args.remove(0)),
- };
- dap_start_impl(cx, name.as_deref(), None, Some(args))
+ dap_start_impl(cx, args.next(), None, Some(args.map(Into::into).collect()))
}
fn debug_remote(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
- let mut args = args.to_owned();
- let address = match args.len() {
- 0 => None,
- _ => Some(args.remove(0).parse()?),
- };
- let name = match args.len() {
- 0 => None,
- _ => Some(args.remove(0)),
- };
- dap_start_impl(cx, name.as_deref(), address, Some(args))
+ let address = args.next().map(|addr| addr.parse()).transpose()?;
+ dap_start_impl(
+ cx,
+ args.next(),
+ address,
+ Some(args.map(Into::into).collect()),
+ )
}
-fn tutor(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn tutor(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -1813,7 +1684,7 @@ fn abort_goto_line_number_preview(cx: &mut compositor::Context) {
fn update_goto_line_number_preview(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
) -> anyhow::Result<()> {
cx.editor.last_selection.get_or_insert_with(|| {
let (view, doc) = current!(cx.editor);
@@ -1821,7 +1692,7 @@ fn update_goto_line_number_preview(
});
let scrolloff = cx.editor.config().scrolloff;
- let line = args[0].parse::<usize>()?;
+ let line = args.next().unwrap().parse::<usize>()?;
goto_line_without_jumplist(cx.editor, NonZeroUsize::new(line));
let (view, doc) = current!(cx.editor);
@@ -1832,7 +1703,7 @@ fn update_goto_line_number_preview(
pub(super) fn goto_line_number(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
match event {
@@ -1868,18 +1739,18 @@ pub(super) fn goto_line_number(
// Fetch the current value of a config option and output as status.
fn get_option(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- if args.len() != 1 {
+ if args.arg_count() != 1 {
anyhow::bail!("Bad arguments. Usage: `:get key`");
}
- let key = &args[0].to_lowercase();
+ let key = args.next().unwrap().to_lowercase();
let key_error = || anyhow::anyhow!("Unknown key `{}`", key);
let config = serde_json::json!(cx.editor.config().deref());
@@ -1894,46 +1765,61 @@ fn get_option(
/// example to disable smart case search, use `:set search.smart-case false`.
fn set_option(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- if args.len() != 2 {
- anyhow::bail!("Bad arguments. Usage: `:set key field`");
- }
- let (key, arg) = (&args[0].to_lowercase(), &args[1]);
+ let Some(key) = args.next().map(|arg| arg.to_lowercase()) else {
+ anyhow::bail!("Bad arguments. Usage: `:set key field`, didn't provide `key`");
+ };
- let key_error = || anyhow::anyhow!("Unknown key `{}`", key);
- let field_error = |_| anyhow::anyhow!("Could not parse field `{}`", arg);
+ let field = args.rest();
+
+ if field.is_empty() {
+ anyhow::bail!("Bad arguments. Usage: `:set key field`, didn't provide `field`");
+ }
- 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<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -1943,73 +1829,98 @@ fn toggle_option(
if args.is_empty() {
anyhow::bail!("Bad arguments. Usage: `:toggle key [values]?`");
}
- let key = &args[0].to_lowercase();
+ let key = args.next().unwrap().to_lowercase();
- let key_error = || anyhow::anyhow!("Unknown key `{}`", key);
-
- let mut config = serde_json::json!(&cx.editor.config().deref());
+ let mut config = serde_json::json!(&*cx.editor.config());
let pointer = format!("/{}", key.replace('.', "/"));
- let value = config.pointer_mut(&pointer).ok_or_else(key_error)?;
+ let value = config
+ .pointer_mut(&pointer)
+ .ok_or_else(|| anyhow::anyhow!("Unknown key `{}`", key))?;
*value = match value {
Value::Bool(ref value) => {
ensure!(
- args.len() == 1,
+ args.next().is_none(),
"Bad arguments. For boolean configurations use: `:toggle key`"
);
Value::Bool(!value)
}
Value::String(ref value) => {
ensure!(
- args.len() > 2,
+ // key + arguments
+ args.arg_count() >= 3,
"Bad arguments. For string configurations use: `:toggle key val1 val2 ...`",
);
Value::String(
- args[1..]
- .iter()
+ args.clone()
.skip_while(|e| *e != value)
.nth(1)
- .unwrap_or_else(|| &args[1])
+ .unwrap_or_else(|| args.nth(1).unwrap())
.to_string(),
)
}
Value::Number(ref value) => {
ensure!(
- args.len() > 2,
+ // key + arguments
+ args.arg_count() >= 3,
"Bad arguments. For number configurations use: `:toggle key val1 val2 ...`",
);
+ let value = value.to_string();
+
Value::Number(
- args[1..]
- .iter()
- .skip_while(|&e| value.to_string() != *e.to_string())
+ args.clone()
+ .skip_while(|e| *e != value)
.nth(1)
- .unwrap_or_else(|| &args[1])
+ .unwrap_or_else(|| args.nth(1).unwrap())
.parse()?,
)
}
- Value::Null | Value::Object(_) | Value::Array(_) => {
+ Value::Array(value) => {
+ let mut lists = serde_json::Deserializer::from_str(args.rest()).into_iter::<Value>();
+
+ let (Some(first), Some(second)) =
+ (lists.next().transpose()?, lists.next().transpose()?)
+ else {
+ anyhow::bail!(
+ "Bad arguments. For list configurations use: `:toggle key [...] [...]`",
+ )
+ };
+
+ match (&first, &second) {
+ (Value::Array(list), Value::Array(_)) => {
+ if list == value {
+ second
+ } else {
+ first
+ }
+ }
+ _ => anyhow::bail!("values must be lists"),
+ }
+ }
+ Value::Null | Value::Object(_) => {
anyhow::bail!("Configuration {key} does not support toggle yet")
}
};
let status = format!("'{key}' is now set to {value}");
- let config = serde_json::from_value(config)
- .map_err(|err| anyhow::anyhow!("Cannot parse `{:?}`, {}", &args, err))?;
+ let config = serde_json::from_value(config)?;
cx.editor
.config_events
.0
.send(ConfigEvent::Update(config))?;
+
cx.editor.set_status(status);
+
Ok(())
}
/// Change the language of the current buffer at runtime.
fn language(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -2018,21 +1929,22 @@ fn language(
if args.is_empty() {
let doc = doc!(cx.editor);
- let language = &doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME);
+ let language = doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME);
cx.editor.set_status(language.to_string());
return Ok(());
}
- if args.len() != 1 {
+ if args.arg_count() != 1 {
anyhow::bail!("Bad arguments. Usage: `:set-language language`");
}
let doc = doc_mut!(cx.editor);
- if args[0] == DEFAULT_LANGUAGE_NAME {
- doc.set_language(None, None)
+ let language_id = args.next().unwrap();
+ if language_id == DEFAULT_LANGUAGE_NAME {
+ doc.set_language(None, None);
} else {
- doc.set_language_by_language_id(&args[0], cx.editor.syn_loader.clone())?;
+ doc.set_language_by_language_id(language_id, cx.editor.syn_loader.clone())?;
}
doc.detect_indent_and_line_ending();
@@ -2045,31 +1957,25 @@ fn language(
Ok(())
}
-fn sort(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) -> anyhow::Result<()> {
+fn sort(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
sort_impl(cx, args, false)
}
fn sort_reverse(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
sort_impl(cx, args, true)
}
-fn sort_impl(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- reverse: bool,
-) -> anyhow::Result<()> {
+fn sort_impl(cx: &mut compositor::Context, _args: Args, reverse: bool) -> anyhow::Result<()> {
let scrolloff = cx.editor.config().scrolloff;
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
@@ -2101,11 +2007,7 @@ fn sort_impl(
Ok(())
}
-fn reflow(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn reflow(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -2119,7 +2021,7 @@ fn reflow(
// - The configured text-width for this language in languages.toml
// - The configured text-width in the config.toml
let text_width: usize = args
- .first()
+ .next()
.map(|num| num.parse::<usize>())
.transpose()?
.or_else(|| doc.language_config().and_then(|config| config.text_width))
@@ -2144,7 +2046,7 @@ fn reflow(
fn tree_sitter_subtree(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -2183,13 +2085,12 @@ fn tree_sitter_subtree(
fn open_config(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
cx.editor
.open(&helix_loader::config_file(), Action::Replace)?;
Ok(())
@@ -2197,34 +2098,28 @@ fn open_config(
fn open_workspace_config(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
cx.editor
.open(&helix_loader::workspace_config_file(), Action::Replace)?;
Ok(())
}
-fn open_log(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn open_log(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
cx.editor.open(&helix_loader::log_file(), Action::Replace)?;
Ok(())
}
fn refresh_config(
cx: &mut compositor::Context,
- _args: &[Cow<str>],
+ _args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -2237,62 +2132,58 @@ fn refresh_config(
fn append_output(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
ensure!(!args.is_empty(), "Shell command required");
- shell(cx, &args.join(" "), &ShellBehavior::Append);
+ let cmd = helix_core::shellwords::unescape(args.raw());
+ shell(cx, &cmd, &ShellBehavior::Append);
Ok(())
}
fn insert_output(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
ensure!(!args.is_empty(), "Shell command required");
- shell(cx, &args.join(" "), &ShellBehavior::Insert);
+ let cmd = helix_core::shellwords::unescape(args.raw());
+ shell(cx, &cmd, &ShellBehavior::Insert);
Ok(())
}
-fn pipe_to(
- cx: &mut compositor::Context,
- args: &[Cow<str>],
- 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<str>], 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<str>],
+ args: Args,
event: PromptEvent,
behavior: &ShellBehavior,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
-
ensure!(!args.is_empty(), "Shell command required");
- shell(cx, &args.join(" "), behavior);
+ let cmd = helix_core::shellwords::unescape(args.raw());
+ shell(cx, &cmd, behavior);
Ok(())
}
fn run_shell_command(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -2300,7 +2191,8 @@ fn run_shell_command(
}
let shell = cx.editor.config().shell.clone();
- let args = args.join(" ");
+
+ let args = helix_core::shellwords::unescape(args.raw()).into_owned();
let callback = async move {
let output = shell_impl_async(&shell, &args, None).await?;
@@ -2328,7 +2220,7 @@ fn run_shell_command(
fn reset_diff_change(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
@@ -2336,10 +2228,8 @@ fn reset_diff_change(
}
ensure!(args.is_empty(), ":reset-diff-change takes no arguments");
- let editor = &mut cx.editor;
- let scrolloff = editor.config().scrolloff;
-
- let (view, doc) = current!(editor);
+ let scrolloff = cx.editor.config().scrolloff;
+ let (view, doc) = current!(cx.editor);
let Some(handle) = doc.diff_handle() else {
bail!("Diff is not available in the current buffer")
};
@@ -2381,40 +2271,42 @@ fn reset_diff_change(
fn clear_register(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- ensure!(args.len() <= 1, ":clear-register takes at most 1 argument");
+ ensure!(
+ args.arg_count() <= 1,
+ ":clear-register takes at most 1 argument"
+ );
+
if args.is_empty() {
cx.editor.registers.clear();
cx.editor.set_status("All registers cleared");
return Ok(());
}
+ let register = args.next().unwrap();
+
ensure!(
- args[0].chars().count() == 1,
- format!("Invalid register {}", args[0])
+ register.chars().count() == 1,
+ format!("Invalid register {register}")
);
- let register = args[0].chars().next().unwrap_or_default();
+
+ let register = register.chars().next().unwrap_or_default();
if cx.editor.registers.remove(register) {
- cx.editor
- .set_status(format!("Register {} cleared", register));
+ cx.editor.set_status(format!("Register {register} cleared"));
} else {
cx.editor
- .set_error(format!("Register {} not found", register));
+ .set_error(format!("Register {register} not found"));
}
Ok(())
}
-fn redraw(
- cx: &mut compositor::Context,
- _args: &[Cow<str>],
- event: PromptEvent,
-) -> anyhow::Result<()> {
+fn redraw(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -2435,20 +2327,22 @@ fn redraw(
fn move_buffer(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- ensure!(args.len() == 1, format!(":move takes one argument"));
- let doc = doc!(cx.editor);
- let old_path = doc
+ ensure!(args.arg_count() == 1, format!(":move takes one argument"));
+
+ let old_path = doc!(cx.editor)
.path()
.context("Scratch buffer cannot be moved. Use :write instead")?
.clone();
- let new_path = args.first().unwrap().to_string();
+
+ let new_path = args.next().unwrap();
+
if let Err(err) = cx.editor.move_path(&old_path, new_path.as_ref()) {
bail!("Could not move file: {err}");
}
@@ -2457,14 +2351,14 @@ fn move_buffer(
fn yank_diagnostic(
cx: &mut compositor::Context,
- args: &[Cow<str>],
+ mut args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
- let reg = match args.first() {
+ let reg = match args.next() {
Some(s) => {
ensure!(s.chars().count() == 1, format!("Invalid register {s}"));
s.chars().next().unwrap()
@@ -2495,7 +2389,7 @@ fn yank_diagnostic(
Ok(())
}
-fn read(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) -> anyhow::Result<()> {
+fn read(cx: &mut compositor::Context, mut args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
@@ -2504,10 +2398,10 @@ fn read(cx: &mut compositor::Context, args: &[Cow<str>], event: PromptEvent) ->
let (view, doc) = current!(cx.editor);
ensure!(!args.is_empty(), "file name is expected");
- ensure!(args.len() == 1, "only the file name is expected");
+ ensure!(args.arg_count() == 1, "only the file name is expected");
- let filename = args.first().unwrap();
- let path = helix_stdx::path::expand_tilde(PathBuf::from(filename.to_string()));
+ let filename = args.next().unwrap();
+ let path = helix_stdx::path::expand_tilde(Path::new(filename));
ensure!(
path.exists() && path.is_file(),
@@ -3170,9 +3064,11 @@ pub(super) fn command_mode(cx: &mut Context) {
Some(':'),
|editor: &Editor, input: &str| {
let shellwords = Shellwords::from(input);
- let words = shellwords.words();
+ let command = shellwords.command();
- if words.is_empty() || (words.len() == 1 && !shellwords.ends_with_whitespace()) {
+ if command.is_empty()
+ || (shellwords.args().next().is_none() && !shellwords.ends_with_whitespace())
+ {
fuzzy_match(
input,
TYPABLE_COMMAND_LIST.iter().map(|command| command.name),
@@ -3184,67 +3080,61 @@ pub(super) fn command_mode(cx: &mut Context) {
} else {
// Otherwise, use the command's completer and the last shellword
// as completion input.
- let (word, word_len) = if words.len() == 1 || shellwords.ends_with_whitespace() {
- (&Cow::Borrowed(""), 0)
- } else {
- (words.last().unwrap(), words.last().unwrap().len())
- };
-
- let argument_number = argument_number_of(&shellwords);
-
- 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, len) = shellwords
+ .args()
+ .last()
+ .map_or(("", 0), |last| (last, last.len()));
+
+ TYPABLE_COMMAND_MAP
+ .get(command)
+ .map(|tc| tc.completer_for_argument_number(argument_number_of(&shellwords)))
+ .map_or_else(Vec::new, |completer| {
+ completer(editor, word)
+ .into_iter()
+ .map(|(range, mut file)| {
+ file.content = shellwords::escape(file.content);
+
+ // offset ranges to input
+ let offset = input.len() - len;
+ let range = (range.start + offset)..;
+ (range, file)
+ })
+ .collect()
+ })
}
}, // completion
move |cx: &mut compositor::Context, input: &str, event: PromptEvent| {
- let parts = input.split_whitespace().collect::<Vec<&str>>();
- 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::<usize>().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::<usize>().is_ok() {
+ if let Err(err) = typed::goto_line_number(cx, Args::from(command), event) {
+ cx.editor.set_error(format!("{err}"));
}
return;
}
// Handle typable commands
- if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) {
- let shellwords = Shellwords::from(input);
- let args = shellwords.words();
-
- if let Err(e) = (cmd.fun)(cx, &args[1..], event) {
- cx.editor.set_error(format!("{}", e));
+ if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(command) {
+ if let Err(err) = (cmd.fun)(cx, shellwords.args(), event) {
+ cx.editor.set_error(format!("{err}"));
}
} else if event == PromptEvent::Validate {
- cx.editor
- .set_error(format!("no such command: '{}'", parts[0]));
+ cx.editor.set_error(format!("no such command: '{command}'"));
}
},
);
+
prompt.doc_fn = Box::new(|input: &str| {
- let part = input.split(' ').next().unwrap_or_default();
+ let shellwords = Shellwords::from(input);
if let Some(typed::TypableCommand { doc, aliases, .. }) =
- typed::TYPABLE_COMMAND_MAP.get(part)
+ typed::TYPABLE_COMMAND_MAP.get(shellwords.command())
{
if aliases.is_empty() {
return Some((*doc).into());
@@ -3261,11 +3151,10 @@ pub(super) fn command_mode(cx: &mut Context) {
}
fn argument_number_of(shellwords: &Shellwords) -> usize {
- if shellwords.ends_with_whitespace() {
- shellwords.words().len().saturating_sub(1)
- } else {
- shellwords.words().len().saturating_sub(2)
- }
+ shellwords
+ .args()
+ .arg_count()
+ .saturating_sub(1 - usize::from(shellwords.ends_with_whitespace()))
}
#[test]
diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs
index 020ecaf4..aa9cafd3 100644
--- a/helix-term/src/keymap.rs
+++ b/helix-term/src/keymap.rs
@@ -597,18 +597,14 @@ mod tests {
let expectation = KeyTrie::Node(KeyTrieNode::new(
"",
hashmap! {
- key => KeyTrie::Sequence(vec!{
+ key => KeyTrie::Sequence(vec![
MappableCommand::select_all,
MappableCommand::Typable {
name: "pipe".to_string(),
- args: vec!{
- "sed".to_string(),
- "-E".to_string(),
- "'s/\\s+$//g'".to_string()
- },
- doc: "".to_string(),
+ args: String::from("sed -E 's/\\s+$//g'"),
+ doc: String::new(),
},
- })
+ ])
},
vec![key],
));