pub fn split(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut started = false; let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
c if c.is_whitespace() => {
if started {
out.push(std::mem::take(&mut cur));
started = false;
}
}
'"' => {
started = true;
read_double_quoted(&mut chars, &mut cur);
}
'\'' => {
started = true;
read_single_quoted(&mut chars, &mut cur);
}
c => {
started = true;
cur.push(c);
}
}
}
if started {
out.push(cur);
}
out
}
pub fn join(args: &[String]) -> String {
args.iter()
.map(|a| {
if a.is_empty()
|| a.chars()
.any(|c| c.is_whitespace() || c == '"' || c == '\'')
{
let escaped = a.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
} else {
a.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}
const SHELL_METACHARS: [char; 5] = ['|', '&', '>', '<', ';'];
pub fn shell_syntax(line: &str) -> Option<char> {
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' => {
let mut sink = String::new();
read_double_quoted(&mut chars, &mut sink);
}
'\'' => {
let mut sink = String::new();
read_single_quoted(&mut chars, &mut sink);
}
c if SHELL_METACHARS.contains(&c) => return Some(c),
_ => {}
}
}
None
}
fn read_double_quoted(chars: &mut impl Iterator<Item = char>, cur: &mut String) {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' => match chars.next() {
Some(n @ ('\\' | '"')) => cur.push(n),
Some(n) => {
cur.push('\\');
cur.push(n);
}
None => cur.push('\\'),
},
c => cur.push(c),
}
}
}
fn read_single_quoted(chars: &mut impl Iterator<Item = char>, cur: &mut String) {
for c in chars {
if c == '\'' {
break;
}
cur.push(c);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_on_whitespace_and_honours_both_quote_styles() {
assert_eq!(
split("cargo build --offline"),
["cargo", "build", "--offline"]
);
assert_eq!(
split(r#"cmd "two words" 'and more'"#),
["cmd", "two words", "and more"]
);
assert!(split(" ").is_empty());
}
#[test]
fn a_windows_path_round_trips() {
let args = vec![r"C:\Program Files\app\a.exe".to_string(), "-v".to_string()];
assert_eq!(split(&join(&args)), args);
}
#[test]
fn every_metacharacter_is_recognized() {
for (line, expected) in [
("cargo build | tee log.txt", '|'),
("cargo build > out.txt", '>'),
("cargo build < in.txt", '<'),
("cargo build && cargo test", '&'),
("cargo build; cargo test", ';'),
("cargo build 2>&1", '>'),
] {
assert_eq!(shell_syntax(line), Some(expected), "line: {line}");
}
}
#[test]
fn a_quoted_metacharacter_is_an_argument_not_a_pipeline() {
assert_eq!(shell_syntax(r#"grep "a|b" src"#), None);
assert_eq!(shell_syntax("grep 'a>b' src"), None);
assert_eq!(shell_syntax("cargo build --offline"), None);
assert_eq!(shell_syntax(""), None);
}
}