diff --git a/Cargo.lock b/Cargo.lock index a936167..2d319f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,9 +738,13 @@ dependencies = [ [[package]] name = "sys_traits" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "638f0e61b5134e56b2abdf4c704fd44672603f15ca09013f314649056f3fee4d" +checksum = "f3374191d43a934854e99a46cd47f8124369e690353e0f8db42769218d083690" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] [[package]] name = "tempfile" diff --git a/Cargo.toml b/Cargo.toml index eb48c5d..e3f3421 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ monch = "0.5.0" thiserror = "2.0.9" tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "sync", "time"], optional = true } deno_path_util = "0.3.2" -sys_traits = { version = "0.1.8", features = ["real"] } +sys_traits = { version = "0.1.9", features = ["real", "winapi", "libc"] } [target.'cfg(unix)'.dependencies] nix = { version = "0.29.0", features = ["signal"], optional = true } diff --git a/src/parser.rs b/src/parser.rs index 217c9e3..54e803d 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -248,6 +248,8 @@ pub enum WordPart { Text(String), /// Variable substitution (ex. `$MY_VAR`) Variable(String), + /// Tilde expansion (ex. `~`) + Tilde, /// Command substitution (ex. `$(command)`) Command(SequentialList), /// Quoted string (ex. `"hello"` or `'test'`) @@ -751,30 +753,44 @@ fn parse_word_parts( or7( parse_special_shell_var, parse_escaped_dollar_sign, + parse_escaped_char('~'), parse_escaped_char('`'), parse_escaped_char('"'), parse_escaped_char('('), - parse_escaped_char(')'), - if_true(parse_escaped_char('\''), move |_| { - mode == ParseWordPartsMode::DoubleQuotes - }), + or( + parse_escaped_char(')'), + if_true(parse_escaped_char('\''), move |_| { + mode == ParseWordPartsMode::DoubleQuotes + }), + ), ) } + fn append_char(result: &mut Vec, c: char) { + if let Some(WordPart::Text(text)) = result.last_mut() { + text.push(c); + } else { + result.push(WordPart::Text(c.to_string())); + } + } + move |input| { enum PendingPart<'a> { Char(char), Variable(&'a str), + Tilde, Command(SequentialList), Parts(Vec), } + let original_input = input; let (input, parts) = many0(or7( - or( + or3( map(tag("$?"), |_| PendingPart::Variable("?")), map(first_escaped_char(mode), PendingPart::Char), + map(parse_command_substitution, PendingPart::Command), ), - map(parse_command_substitution, PendingPart::Command), + map(ch('~'), |_| PendingPart::Tilde), map(preceded(ch('$'), parse_env_var_name), PendingPart::Variable), |input| { let (_, _) = ch('`')(input)?; @@ -812,22 +828,32 @@ fn parse_word_parts( ))(input)?; let mut result = Vec::new(); - for part in parts { + let mut parts = parts.into_iter().enumerate().peekable(); + while let Some((i, part)) = parts.next() { match part { PendingPart::Char(c) => { - if let Some(WordPart::Text(text)) = result.last_mut() { - text.push(c); + append_char(&mut result, c); + } + PendingPart::Tilde => { + if i == 0 { + if matches!(parts.peek(), None | Some((_, PendingPart::Char('/')))) + { + result.push(WordPart::Tilde); + } else { + return ParseError::fail( + original_input, + "Unsupported tilde expansion.", + ); + } } else { - result.push(WordPart::Text(c.to_string())); + append_char(&mut result, '~'); } } PendingPart::Command(s) => result.push(WordPart::Command(s)), PendingPart::Variable(v) => { - result.push(WordPart::Variable(v.to_string())) - } - PendingPart::Parts(parts) => { - result.extend(parts); + result.push(WordPart::Variable(v.to_string())); } + PendingPart::Parts(parts) => result.extend(parts), } } @@ -1427,6 +1453,25 @@ mod test { ); } + #[test] + fn tilde_expansion() { + run_test( + parse_word_parts(ParseWordPartsMode::Unquoted), + r#"~test"#, + Err("Unsupported tilde expansion."), + ); + run_test( + parse_word_parts(ParseWordPartsMode::Unquoted), + r#"~+/test"#, + Err("Unsupported tilde expansion."), + ); + run_test( + parse_word_parts(ParseWordPartsMode::Unquoted), + r#"~/test"#, + Ok(vec![WordPart::Tilde, WordPart::Text("/test".to_string())]), + ); + } + #[test] fn test_parse_word() { run_test(parse_unquoted_word, "if", Err("Unsupported reserved word.")); diff --git a/src/shell/execute.rs b/src/shell/execute.rs index 57b45a8..f5ac522 100644 --- a/src/shell/execute.rs +++ b/src/shell/execute.rs @@ -750,11 +750,13 @@ pub enum EvaluateWordTextError { NotUtf8Pattern { part: OsString }, #[error("glob: no matches found '{}'", pattern)] NoFilesMatched { pattern: String }, - #[error("Invalid utf-8: {}", err)] + #[error("invalid utf-8: {}", err)] InvalidUtf8 { #[from] err: FromUtf8Error, }, + #[error("failed resolving home directory for tilde expansion")] + NoHomeDirectory, } impl EvaluateWordTextError { @@ -904,6 +906,11 @@ fn evaluate_word_parts( None } WordPart::Variable(name) => state.get_var(OsStr::new(&name)).cloned(), + WordPart::Tilde => Some( + sys_traits::impls::real_home_dir_with_env(state) + .map(|s| s.into_os_string()) + .ok_or(EvaluateWordTextError::NoHomeDirectory)?, + ), WordPart::Command(list) => Some( evaluate_command_substitution( list, diff --git a/src/shell/types.rs b/src/shell/types.rs index 535340f..ec291a1 100644 --- a/src/shell/types.rs +++ b/src/shell/types.rs @@ -206,6 +206,12 @@ impl ShellState { } } +impl sys_traits::BaseEnvVar for ShellState { + fn base_env_var_os(&self, key: &OsStr) -> Option { + self.env_vars.get(key).cloned() + } +} + #[derive(Debug, PartialEq, Eq)] pub enum EnvChange { // `export ENV_VAR=VALUE` diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 1110b7f..d5ce143 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1429,6 +1429,30 @@ async fn paren_escapes() { .await; } +#[tokio::test] +async fn tilde_expansion() { + let text = concat!( + r"export HOME=/home/dir && echo ~/test && echo ~ && ", + r"echo \~ && export HOME=/test && echo ~ && ", + r"HOME=/nope echo ~ && ", + r"HOME=/nope $(echo echo ~) && ", + r"echo ~/sub/~/sub" + ); + let text = if cfg!(windows) { + // windows uses a different env var + text.replace("HOME", "USERPROFILE") + } else { + text.to_string() + }; + TestBuilder::new() + .command(&text) + .assert_stdout( + "/home/dir/test\n/home/dir\n~\n/test\n/test\n/test\n/test/sub/~/sub\n", + ) + .run() + .await; +} + #[tokio::test] async fn cross_platform_shebang() { // with -S