diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs index afc83496f..6edf3cc7e 100644 --- a/helix-core/src/shellwords.rs +++ b/helix-core/src/shellwords.rs @@ -1,5 +1,22 @@ use std::borrow::Cow; +/// Auto escape for shellwords usage. +pub fn escape(input: &str) -> Cow<'_, str> { + if !input.chars().any(|x| x.is_ascii_whitespace()) { + Cow::Borrowed(input) + } else if cfg!(unix) { + Cow::Owned(input.chars().fold(String::new(), |mut buf, c| { + if c.is_ascii_whitespace() { + buf.push('\\'); + } + buf.push(c); + buf + })) + } else { + Cow::Owned(format!("\"{}\"", input)) + } +} + /// Get the vec of escaped / quoted / doublequoted filenames from the input str pub fn shellwords(input: &str) -> Vec> { enum State { @@ -226,4 +243,18 @@ mod test { ]; assert_eq!(expected, result); } + + #[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")); + } + + #[test] + #[cfg(windows)] + fn test_escaping_windows() { + assert_eq!(escape("foobar"), Cow::Borrowed("foobar")); + assert_eq!(escape("foo bar"), Cow::Borrowed("\"foo bar\"")); + } } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 0cf75ada9..f4dfce7a8 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2183,12 +2183,10 @@ pub(super) fn command_mode(cx: &mut Context) { static FUZZY_MATCHER: Lazy = Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); - // we use .this over split_whitespace() because we care about empty segments - let parts = input.split(' ').collect::>(); - // simple heuristic: if there's no just one part, complete command name. // if there's a space, per command completion kicks in. - if parts.len() <= 1 { + // we use .this over split_whitespace() because we care about empty segments + if input.split(' ').count() <= 1 { let mut matches: Vec<_> = typed::TYPABLE_COMMAND_LIST .iter() .filter_map(|command| { @@ -2204,12 +2202,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(); if let Some(typed::TypableCommand { completer: Some(completer), .. - }) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) + }) = typed::TYPABLE_COMMAND_MAP.get(&parts[0] as &str) { completer(editor, part) .into_iter() diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index db3bd62d7..d0991d3c0 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -1,5 +1,6 @@ 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}; @@ -335,7 +336,10 @@ impl Prompt { let (range, item) = &self.completion[index]; - self.line.replace_range(range.clone(), item); + // 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.move_end(); }