mirror of https://github.com/helix-editor/helix
Fix whitespace handling in command-mode completion
8584b38cfb
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.
pull/4626/head
parent
48a3965ab4
commit
1536a65289
|
@ -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
|
/// Get the vec of escaped / quoted / doublequoted filenames from the input str
|
||||||
pub fn shellwords(input: &str) -> Vec<Cow<'_, str>> {
|
pub fn shellwords(input: &str) -> Vec<Cow<'_, str>> {
|
||||||
enum State {
|
|
||||||
OnWhitespace,
|
|
||||||
Unquoted,
|
|
||||||
UnquotedEscaped,
|
|
||||||
Quoted,
|
|
||||||
QuoteEscaped,
|
|
||||||
Dquoted,
|
|
||||||
DquoteEscaped,
|
|
||||||
}
|
|
||||||
|
|
||||||
use State::*;
|
use State::*;
|
||||||
|
|
||||||
let mut state = Unquoted;
|
let mut state = Unquoted;
|
||||||
|
@ -140,6 +140,70 @@ pub fn shellwords(input: &str) -> Vec<Cow<'_, str>> {
|
||||||
args
|
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)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
@ -2183,10 +2183,11 @@ pub(super) fn command_mode(cx: &mut Context) {
|
||||||
static FUZZY_MATCHER: Lazy<fuzzy_matcher::skim::SkimMatcherV2> =
|
static FUZZY_MATCHER: Lazy<fuzzy_matcher::skim::SkimMatcherV2> =
|
||||||
Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default);
|
Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default);
|
||||||
|
|
||||||
// simple heuristic: if there's no just one part, complete command name.
|
let parts = shellwords::shellwords(input);
|
||||||
// if there's a space, per command completion kicks in.
|
let ends_with_whitespace = shellwords::ends_with_whitespace(input);
|
||||||
// we use .this over split_whitespace() because we care about empty segments
|
|
||||||
if input.split(' ').count() <= 1 {
|
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
|
let mut matches: Vec<_> = typed::TYPABLE_COMMAND_LIST
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|command| {
|
.filter_map(|command| {
|
||||||
|
@ -2202,8 +2203,13 @@ pub(super) fn command_mode(cx: &mut Context) {
|
||||||
.map(|(name, _)| (0.., name.into()))
|
.map(|(name, _)| (0.., name.into()))
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
let parts = shellwords::shellwords(input);
|
// Otherwise, use the command's completer and the last shellword
|
||||||
let part = parts.last().unwrap();
|
// as completion input.
|
||||||
|
let part = if parts.len() == 1 {
|
||||||
|
&Cow::Borrowed("")
|
||||||
|
} else {
|
||||||
|
parts.last().unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(typed::TypableCommand {
|
if let Some(typed::TypableCommand {
|
||||||
completer: Some(completer),
|
completer: Some(completer),
|
||||||
|
|
Loading…
Reference in New Issue