From 1915a9a8cf5305b5923e8fc9c3b8513900c00623 Mon Sep 17 00:00:00 2001 From: Michael Davis Date: Thu, 3 Nov 2022 20:55:13 -0500 Subject: [PATCH 1/3] Fix whitespace handling in command-mode completion 8584b38cfbe6ffe3e5d539ad953c413e44e90bfa switched to shellwords for completion in command-mode. This changes the conditions for choosing whether to complete the command or use the command's completer. This change processes the input as shellwords up-front and uses shellword logic about whitespace to determine whether the command or argument should be completed. --- helix-core/src/shellwords.rs | 84 ++++++++++++++++++++++++++++---- helix-term/src/commands/typed.rs | 18 ++++--- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs index e8c5945b726b..3375bef19df2 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -17,18 +17,18 @@ pub fn escape(input: &str) -> Cow<'_, str> { } } +enum State { + OnWhitespace, + Unquoted, + UnquotedEscaped, + Quoted, + QuoteEscaped, + Dquoted, + DquoteEscaped, +} + /// Get the vec of escaped / quoted / doublequoted filenames from the input str pub fn shellwords(input: &str) -> Vec> { - enum State { - OnWhitespace, - Unquoted, - UnquotedEscaped, - Quoted, - QuoteEscaped, - Dquoted, - DquoteEscaped, - } - use State::*; let mut state = Unquoted; @@ -140,6 +140,70 @@ pub fn shellwords(input: &str) -> Vec> { args } +/// Checks that the input ends with an ascii whitespace character which is +/// not escaped. +/// +/// # Examples +/// +/// ```rust +/// use helix_core::shellwords::ends_with_whitespace; +/// assert_eq!(ends_with_whitespace(" "), true); +/// assert_eq!(ends_with_whitespace(":open "), true); +/// assert_eq!(ends_with_whitespace(":open foo.txt "), true); +/// assert_eq!(ends_with_whitespace(":open"), false); +/// #[cfg(unix)] +/// assert_eq!(ends_with_whitespace(":open a\\ "), false); +/// #[cfg(unix)] +/// assert_eq!(ends_with_whitespace(":open a\\ b.txt"), false); +/// ``` +pub fn ends_with_whitespace(input: &str) -> bool { + use State::*; + + // Fast-lane: the input must end with a whitespace character + // regardless of quoting. + if !input.ends_with(|c: char| c.is_ascii_whitespace()) { + return false; + } + + let mut state = Unquoted; + + for c in input.chars() { + state = match state { + OnWhitespace => match c { + '"' => Dquoted, + '\'' => Quoted, + '\\' if cfg!(unix) => UnquotedEscaped, + '\\' => OnWhitespace, + c if c.is_ascii_whitespace() => OnWhitespace, + _ => Unquoted, + }, + Unquoted => match c { + '\\' if cfg!(unix) => UnquotedEscaped, + '\\' => Unquoted, + c if c.is_ascii_whitespace() => OnWhitespace, + _ => Unquoted, + }, + UnquotedEscaped => Unquoted, + Quoted => match c { + '\\' if cfg!(unix) => QuoteEscaped, + '\\' => Quoted, + '\'' => OnWhitespace, + _ => Quoted, + }, + QuoteEscaped => Quoted, + Dquoted => match c { + '\\' if cfg!(unix) => DquoteEscaped, + '\\' => Dquoted, + '"' => OnWhitespace, + _ => Dquoted, + }, + DquoteEscaped => Dquoted, + } + } + + matches!(state, OnWhitespace) +} + #[cfg(test)] mod test { use super::*; diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index f4dfce7a8c53..304b30f93ea0 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2183,10 +2183,11 @@ pub(super) fn command_mode(cx: &mut Context) { static FUZZY_MATCHER: Lazy = Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); - // simple heuristic: if there's no just one part, complete command name. - // if there's a space, per command completion kicks in. - // we use .this over split_whitespace() because we care about empty segments - if input.split(' ').count() <= 1 { + let parts = shellwords::shellwords(input); + let ends_with_whitespace = shellwords::ends_with_whitespace(input); + + if parts.is_empty() || (parts.len() == 1 && !ends_with_whitespace) { + // If the command has not been finished yet, complete commands. let mut matches: Vec<_> = typed::TYPABLE_COMMAND_LIST .iter() .filter_map(|command| { @@ -2202,8 +2203,13 @@ pub(super) fn command_mode(cx: &mut Context) { .map(|(name, _)| (0.., name.into())) .collect() } else { - let parts = shellwords::shellwords(input); - let part = parts.last().unwrap(); + // Otherwise, use the command's completer and the last shellword + // as completion input. + let part = if parts.len() == 1 { + &Cow::Borrowed("") + } else { + parts.last().unwrap() + }; if let Some(typed::TypableCommand { completer: Some(completer), From 70dfa9d1e4ed81e310800223e7185f2883b88e77 Mon Sep 17 00:00:00 2001 From: Michael Davis Date: Thu, 3 Nov 2022 20:58:54 -0500 Subject: [PATCH 2/3] Escape filenames in command completion This changes the completion items to be rendered with shellword escaping, so a file `a b.txt` is rendered as `a\ b.txt` which matches how it should be inputted. --- helix-core/src/shellwords.rs | 14 +++++++------- helix-term/src/commands/typed.rs | 1 + helix-term/src/ui/prompt.rs | 6 +----- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs index 3375bef19df2..7742896c2866 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; /// Auto escape for shellwords usage. -pub fn escape(input: &str) -> Cow<'_, str> { +pub fn escape(input: Cow) -> Cow { if !input.chars().any(|x| x.is_ascii_whitespace()) { - Cow::Borrowed(input) + input } else if cfg!(unix) { Cow::Owned(input.chars().fold(String::new(), |mut buf, c| { if c.is_ascii_whitespace() { @@ -311,15 +311,15 @@ mod test { #[test] #[cfg(unix)] fn test_escaping_unix() { - assert_eq!(escape("foobar"), Cow::Borrowed("foobar")); - assert_eq!(escape("foo bar"), Cow::Borrowed("foo\\ bar")); - assert_eq!(escape("foo\tbar"), Cow::Borrowed("foo\\\tbar")); + 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_escaping_windows() { - assert_eq!(escape("foobar"), Cow::Borrowed("foobar")); - assert_eq!(escape("foo bar"), Cow::Borrowed("\"foo bar\"")); + assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar")); + assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\"")); } } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 304b30f93ea0..36080d39265e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2219,6 +2219,7 @@ pub(super) fn command_mode(cx: &mut Context) { completer(editor, part) .into_iter() .map(|(range, file)| { + let file = shellwords::escape(file); // offset ranges to input let offset = input.len() - part.len(); let range = (range.start + offset)..; diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index ca2872a7d0a4..51ef688d7647 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -1,6 +1,5 @@ use crate::compositor::{Component, Compositor, Context, Event, EventResult}; use crate::{alt, ctrl, key, shift, ui}; -use helix_core::shellwords; use helix_view::input::KeyEvent; use helix_view::keyboard::KeyCode; use std::{borrow::Cow, ops::RangeFrom}; @@ -336,10 +335,7 @@ impl Prompt { let (range, item) = &self.completion[index]; - // since we are using shellwords to parse arguments, make sure - // that whitespace in files is properly escaped. - let item = shellwords::escape(item); - self.line.replace_range(range.clone(), &item); + self.line.replace_range(range.clone(), item); self.move_end(); } From b4f2714c8bd9f81ca5905c5155306f1c0e4c950c Mon Sep 17 00:00:00 2001 From: Michael Davis Date: Thu, 3 Nov 2022 21:02:26 -0500 Subject: [PATCH 3/3] Fix command-mode completion behavior when input is escaped If `a\ b.txt` were a local file, `:o a\ ` would fill the prompt with `:o aa\ b.txt` because the replacement range was calculated using the shellwords-parsed part. Escaping the part before calculating its length fixes this edge-case. --- helix-term/src/commands/typed.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 36080d39265e..2f387bfd1a2e 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2216,12 +2216,15 @@ pub(super) fn command_mode(cx: &mut Context) { .. }) = typed::TYPABLE_COMMAND_MAP.get(&parts[0] as &str) { + let part_len = shellwords::escape(part.clone()).len(); + completer(editor, part) .into_iter() .map(|(range, file)| { let file = shellwords::escape(file); + // offset ranges to input - let offset = input.len() - part.len(); + let offset = input.len() - part_len; let range = (range.start + offset)..; (range, file) })