Skip to content

Commit 0d11591

Browse files
authored
feat: support tilde expansion (#146)
1 parent 636cec4 commit 0d11591

File tree

6 files changed

+104
-18
lines changed

6 files changed

+104
-18
lines changed

Cargo.lock

Lines changed: 6 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ monch = "0.5.0"
2525
thiserror = "2.0.9"
2626
tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "sync", "time"], optional = true }
2727
deno_path_util = "0.3.2"
28-
sys_traits = { version = "0.1.8", features = ["real"] }
28+
sys_traits = { version = "0.1.9", features = ["real", "winapi", "libc"] }
2929

3030
[target.'cfg(unix)'.dependencies]
3131
nix = { version = "0.29.0", features = ["signal"], optional = true }

src/parser.rs

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ pub enum WordPart {
248248
Text(String),
249249
/// Variable substitution (ex. `$MY_VAR`)
250250
Variable(String),
251+
/// Tilde expansion (ex. `~`)
252+
Tilde,
251253
/// Command substitution (ex. `$(command)`)
252254
Command(SequentialList),
253255
/// Quoted string (ex. `"hello"` or `'test'`)
@@ -751,30 +753,44 @@ fn parse_word_parts(
751753
or7(
752754
parse_special_shell_var,
753755
parse_escaped_dollar_sign,
756+
parse_escaped_char('~'),
754757
parse_escaped_char('`'),
755758
parse_escaped_char('"'),
756759
parse_escaped_char('('),
757-
parse_escaped_char(')'),
758-
if_true(parse_escaped_char('\''), move |_| {
759-
mode == ParseWordPartsMode::DoubleQuotes
760-
}),
760+
or(
761+
parse_escaped_char(')'),
762+
if_true(parse_escaped_char('\''), move |_| {
763+
mode == ParseWordPartsMode::DoubleQuotes
764+
}),
765+
),
761766
)
762767
}
763768

769+
fn append_char(result: &mut Vec<WordPart>, c: char) {
770+
if let Some(WordPart::Text(text)) = result.last_mut() {
771+
text.push(c);
772+
} else {
773+
result.push(WordPart::Text(c.to_string()));
774+
}
775+
}
776+
764777
move |input| {
765778
enum PendingPart<'a> {
766779
Char(char),
767780
Variable(&'a str),
781+
Tilde,
768782
Command(SequentialList),
769783
Parts(Vec<WordPart>),
770784
}
771785

786+
let original_input = input;
772787
let (input, parts) = many0(or7(
773-
or(
788+
or3(
774789
map(tag("$?"), |_| PendingPart::Variable("?")),
775790
map(first_escaped_char(mode), PendingPart::Char),
791+
map(parse_command_substitution, PendingPart::Command),
776792
),
777-
map(parse_command_substitution, PendingPart::Command),
793+
map(ch('~'), |_| PendingPart::Tilde),
778794
map(preceded(ch('$'), parse_env_var_name), PendingPart::Variable),
779795
|input| {
780796
let (_, _) = ch('`')(input)?;
@@ -812,22 +828,32 @@ fn parse_word_parts(
812828
))(input)?;
813829

814830
let mut result = Vec::new();
815-
for part in parts {
831+
let mut parts = parts.into_iter().enumerate().peekable();
832+
while let Some((i, part)) = parts.next() {
816833
match part {
817834
PendingPart::Char(c) => {
818-
if let Some(WordPart::Text(text)) = result.last_mut() {
819-
text.push(c);
835+
append_char(&mut result, c);
836+
}
837+
PendingPart::Tilde => {
838+
if i == 0 {
839+
if matches!(parts.peek(), None | Some((_, PendingPart::Char('/'))))
840+
{
841+
result.push(WordPart::Tilde);
842+
} else {
843+
return ParseError::fail(
844+
original_input,
845+
"Unsupported tilde expansion.",
846+
);
847+
}
820848
} else {
821-
result.push(WordPart::Text(c.to_string()));
849+
append_char(&mut result, '~');
822850
}
823851
}
824852
PendingPart::Command(s) => result.push(WordPart::Command(s)),
825853
PendingPart::Variable(v) => {
826-
result.push(WordPart::Variable(v.to_string()))
827-
}
828-
PendingPart::Parts(parts) => {
829-
result.extend(parts);
854+
result.push(WordPart::Variable(v.to_string()));
830855
}
856+
PendingPart::Parts(parts) => result.extend(parts),
831857
}
832858
}
833859

@@ -1427,6 +1453,25 @@ mod test {
14271453
);
14281454
}
14291455

1456+
#[test]
1457+
fn tilde_expansion() {
1458+
run_test(
1459+
parse_word_parts(ParseWordPartsMode::Unquoted),
1460+
r#"~test"#,
1461+
Err("Unsupported tilde expansion."),
1462+
);
1463+
run_test(
1464+
parse_word_parts(ParseWordPartsMode::Unquoted),
1465+
r#"~+/test"#,
1466+
Err("Unsupported tilde expansion."),
1467+
);
1468+
run_test(
1469+
parse_word_parts(ParseWordPartsMode::Unquoted),
1470+
r#"~/test"#,
1471+
Ok(vec![WordPart::Tilde, WordPart::Text("/test".to_string())]),
1472+
);
1473+
}
1474+
14301475
#[test]
14311476
fn test_parse_word() {
14321477
run_test(parse_unquoted_word, "if", Err("Unsupported reserved word."));

src/shell/execute.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,11 +750,13 @@ pub enum EvaluateWordTextError {
750750
NotUtf8Pattern { part: OsString },
751751
#[error("glob: no matches found '{}'", pattern)]
752752
NoFilesMatched { pattern: String },
753-
#[error("Invalid utf-8: {}", err)]
753+
#[error("invalid utf-8: {}", err)]
754754
InvalidUtf8 {
755755
#[from]
756756
err: FromUtf8Error,
757757
},
758+
#[error("failed resolving home directory for tilde expansion")]
759+
NoHomeDirectory,
758760
}
759761

760762
impl EvaluateWordTextError {
@@ -904,6 +906,11 @@ fn evaluate_word_parts(
904906
None
905907
}
906908
WordPart::Variable(name) => state.get_var(OsStr::new(&name)).cloned(),
909+
WordPart::Tilde => Some(
910+
sys_traits::impls::real_home_dir_with_env(state)
911+
.map(|s| s.into_os_string())
912+
.ok_or(EvaluateWordTextError::NoHomeDirectory)?,
913+
),
907914
WordPart::Command(list) => Some(
908915
evaluate_command_substitution(
909916
list,

src/shell/types.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,12 @@ impl ShellState {
206206
}
207207
}
208208

209+
impl sys_traits::BaseEnvVar for ShellState {
210+
fn base_env_var_os(&self, key: &OsStr) -> Option<OsString> {
211+
self.env_vars.get(key).cloned()
212+
}
213+
}
214+
209215
#[derive(Debug, PartialEq, Eq)]
210216
pub enum EnvChange {
211217
// `export ENV_VAR=VALUE`

tests/integration_test.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,6 +1429,30 @@ async fn paren_escapes() {
14291429
.await;
14301430
}
14311431

1432+
#[tokio::test]
1433+
async fn tilde_expansion() {
1434+
let text = concat!(
1435+
r"export HOME=/home/dir && echo ~/test && echo ~ && ",
1436+
r"echo \~ && export HOME=/test && echo ~ && ",
1437+
r"HOME=/nope echo ~ && ",
1438+
r"HOME=/nope $(echo echo ~) && ",
1439+
r"echo ~/sub/~/sub"
1440+
);
1441+
let text = if cfg!(windows) {
1442+
// windows uses a different env var
1443+
text.replace("HOME", "USERPROFILE")
1444+
} else {
1445+
text.to_string()
1446+
};
1447+
TestBuilder::new()
1448+
.command(&text)
1449+
.assert_stdout(
1450+
"/home/dir/test\n/home/dir\n~\n/test\n/test\n/test\n/test/sub/~/sub\n",
1451+
)
1452+
.run()
1453+
.await;
1454+
}
1455+
14321456
#[tokio::test]
14331457
async fn cross_platform_shebang() {
14341458
// with -S

0 commit comments

Comments
 (0)