diff --git a/core/Cargo.toml b/core/Cargo.toml index 3a472123..cc14b25d 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -30,7 +30,7 @@ yazi-prebuild = "^0" [target.'cfg(target_os = "windows")'.dependencies] clipboard-win = "^4" -cmdexpand = {git = "https://github.com/ndtoan96/cmdexpand.git", tag = "v0.1.0"} +nom = "^7" [target.'cfg(not(target_os = "netbsd"))'.dependencies] trash = "^3" diff --git a/core/src/external/shell.rs b/core/src/external/shell.rs index 10c5709f..a701f820 100644 --- a/core/src/external/shell.rs +++ b/core/src/external/shell.rs @@ -46,14 +46,11 @@ pub fn shell(opt: ShellOpt) -> Result { #[cfg(target_os = "windows")] { let args: Vec = opt.args.iter().map(|s| s.to_string_lossy().to_string()).collect(); - let cmd = cmdexpand::Expander::new(&opt.cmd.to_string_lossy().to_string()) - .disable_context(true) - .add_args(&args) - .expand()?; + let expanded_cmd = cmdexpand::expand_cmd(opt.cmd.to_string_lossy().as_ref(), &args)?; Ok( Command::new("cmd") .arg("/C") - .arg(cmd) + .arg(expanded_cmd) .stdin(if opt.piped { Stdio::piped() } else { Stdio::inherit() }) .stdout(if opt.piped { Stdio::piped() } else { Stdio::inherit() }) .stderr(if opt.piped { Stdio::piped() } else { Stdio::inherit() }) @@ -62,3 +59,162 @@ pub fn shell(opt: ShellOpt) -> Result { ) } } + +#[cfg(target_os = "windows")] +mod cmdexpand { + use anyhow::{anyhow, Result}; + use nom::branch::alt; + use nom::bytes::complete::{is_not, tag, take_while1}; + use nom::character::complete::{anychar, char, digit1, space0, space1}; + use nom::combinator::recognize; + use nom::multi::{many0, many1}; + use nom::sequence::{delimited, pair, preceded, tuple}; + use nom::IResult; + + enum CommandPart<'a> { + Space(&'a str), + Text(&'a str), + } + + enum TextPart<'a> { + NormalText(&'a str), + PercentNumber(usize), + PercentStar, + } + + #[derive(Debug, Copy, Clone)] + enum Quote { + DoubleQuote, + SingleQuote, + NoQuote, + } + + pub fn expand_cmd(cmd: &str, args: &[T]) -> Result + where + T: AsRef, + { + let parts = parse_cmd(cmd)?; + let mut expanded = String::new(); + for part in parts { + match part { + CommandPart::Space(s) => expanded.push_str(s), + CommandPart::Text(text) => { + expanded.push_str(&expand_text(text, args)?); + } + } + } + Ok(expanded) + } + + fn escaped_char(input: &str) -> IResult<&str, &str> { + recognize(pair(char('\\'), anychar))(input) + } + + fn parse_cmd(cmd: &str) -> Result> { + fn double_quote_text(input: &str) -> IResult<&str, &str> { + recognize(delimited(char('"'), is_not("\""), char('"')))(input) + } + + fn single_quote_text(input: &str) -> IResult<&str, &str> { + recognize(delimited(char('\''), is_not("'"), char('\'')))(input) + } + + fn no_quote_text(input: &str) -> IResult<&str, &str> { + take_while1(|c: char| !c.is_whitespace())(input) + } + + let (_, (leading_space, command_name, args, trailing_space)) = tuple(( + space0, + alt((double_quote_text, single_quote_text, no_quote_text)), + many0(pair(space1, alt((double_quote_text, single_quote_text, no_quote_text)))), + space0, + ))(cmd) + .map_err(|_| anyhow!("Cannot parse command `{cmd}`"))?; + let mut parts = Vec::new(); + if !leading_space.is_empty() { + parts.push(CommandPart::Space(leading_space)); + } + parts.push(CommandPart::Text(command_name)); + for (space, arg) in args { + parts.push(CommandPart::Space(space)); + parts.push(CommandPart::Text(arg)); + } + if !trailing_space.is_empty() { + parts.push(CommandPart::Space(trailing_space)); + } + Ok(parts) + } + + fn expand_text(text: &str, args: &[T]) -> Result + where + T: AsRef, + { + let quote = if text.starts_with("\"") { + Quote::DoubleQuote + } else if text.starts_with("'") { + Quote::SingleQuote + } else { + Quote::NoQuote + }; + + let parts = parse_text(text)?; + let mut expanded = String::new(); + for part in parts { + match part { + TextPart::NormalText(s) => expanded.push_str(s), + TextPart::PercentNumber(i) => { + if i > 0 { + let replace_text = args + .get(i - 1) + .map(|content| preprocess(content.as_ref(), quote)) + .unwrap_or_default(); + expanded.push_str(&replace_text); + } else { + // Not sure what to do with %0 + } + } + TextPart::PercentStar => { + for (i, arg) in args.iter().enumerate() { + expanded.push_str(&preprocess(arg.as_ref(), quote)); + if i + 1 < args.len() { + expanded.push_str(" "); + } + } + } + } + } + Ok(expanded) + } + + fn parse_text(text: &str) -> Result> { + fn normal_text(input: &str) -> IResult<&str, TextPart> { + let (input, output) = recognize(many1(alt((escaped_char, is_not("\\%")))))(input)?; + Ok((input, TextPart::NormalText(output))) + } + + fn percent_star(input: &str) -> IResult<&str, TextPart> { + let (input, _) = tag("%*")(input)?; + Ok((input, TextPart::PercentStar)) + } + + fn percent_number(input: &str) -> IResult<&str, TextPart> { + let (input, output) = preceded(char('%'), digit1)(input)?; + let num: usize = output.parse().unwrap(); + Ok((input, TextPart::PercentNumber(num))) + } + + let (_, parts) = many0(alt((normal_text, percent_star, percent_number)))(text) + .map_err(|_| anyhow!("Cannot parse text `{text}`"))?; + Ok(parts) + } + + fn preprocess(content: &str, quote: Quote) -> String { + let inner_space = content.chars().any(|c| c.is_whitespace()); + match (quote, inner_space) { + (Quote::NoQuote, true) => format!("\"{}\"", content.replace("\"", "\\\"")), + (Quote::NoQuote, false) => content.to_string(), + (Quote::SingleQuote, _) => content.replace("'", "\\'").to_string(), + (Quote::DoubleQuote, _) => content.replace("\"", "\\\"").to_string(), + } + } +}