#[must_use]
pub fn shell_words(line: &str) -> Option<Vec<String>> {
let mut words = Vec::new();
let mut word: Option<String> = None;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
' ' | '\t' | '\n' => words.extend(word.take()),
'\'' => {
let word = word.get_or_insert_with(String::new);
loop {
match chars.next()? {
'\'' => break,
other => word.push(other),
}
}
}
'"' => {
let word = word.get_or_insert_with(String::new);
loop {
match chars.next()? {
'"' => break,
'\\' => match chars.next()? {
next @ ('"' | '\\' | '$' | '`') => word.push(next),
'\n' => {}
next => {
word.push('\\');
word.push(next);
}
},
other => word.push(other),
}
}
}
'\\' => match chars.next()? {
'\n' => {}
next => word.get_or_insert_with(String::new).push(next),
},
other => word.get_or_insert_with(String::new).push(other),
}
}
words.extend(word);
Some(words)
}
#[cfg(test)]
mod tests {
use super::*;
fn words(line: &str) -> Option<Vec<String>> {
shell_words(line)
}
fn some(words: &[&str]) -> Option<Vec<String>> {
Some(words.iter().map(|word| (*word).to_owned()).collect())
}
#[test]
fn a_quoted_path_with_spaces_is_one_word() {
assert_eq!(words(r#""/opt/My Editor/bin/ed" -w"#), some(&["/opt/My Editor/bin/ed", "-w"]));
assert_eq!(words("'/opt/My Editor/ed' --wait"), some(&["/opt/My Editor/ed", "--wait"]));
}
#[test]
fn escapes_follow_the_shell() {
assert_eq!(words(r"a\ b c"), some(&["a b", "c"]));
assert_eq!(words(r#"'x "y"'"#), some(&[r#"x "y""#]));
assert_eq!(words(r#""a \"b\" \n""#), some(&[r#"a "b" \n"#]), "only four escapes inside double quotes");
assert_eq!(words(r"'a\b'"), some(&[r"a\b"]), "nothing escapes inside single quotes");
assert_eq!(words("ed'it'or\"s\""), some(&["editors"]), "quotes join into one word");
}
#[test]
fn empty_quotes_are_a_word_and_blanks_are_not() {
assert_eq!(words("prog '' x"), some(&["prog", "", "x"]));
assert_eq!(words(" "), some(&[]));
assert_eq!(words(""), some(&[]));
}
#[test]
fn nothing_is_expanded() {
assert_eq!(words("$HOME/bin/ed ~/notes *.md"), some(&["$HOME/bin/ed", "~/notes", "*.md"]));
}
#[test]
fn an_unclosed_quote_or_a_trailing_backslash_is_no_command() {
assert_eq!(words("code 'unclosed"), None);
assert_eq!(words("code \"unclosed"), None);
assert_eq!(words("code \\"), None);
}
}