Skip to content

Commit 7695b83

Browse files
committed
feat(rust-cli): implement POSIX §2.6.1 tilde expansion globally
Tilde expansion was previously hard-coded only inside the `cd` command. It now runs as the first step of `expand_variables`, so every command that uses variable expansion — `mkdir`, `touch`, `echo`, `cp`, `mv`, external commands, etc. — gets tilde expansion for free. Supported forms (per POSIX §2.6.1): ~ → $HOME ~/path → $HOME/path ~+ → $PWD ~+/path → $PWD/path ~- → $OLDPWD ~-/path → $OLDPWD/path Not expanded inside single or double quotes ('~' / "~" stay literal). This is enforced by `quoted_word_to_string` escaping `~` → `\~` in quoted contexts; `expand_tilde` recognises the escape and emits a literal `~`. ~user (getpwnam lookup) is not yet implemented; the tilde is left literal in that case. The `cd`-specific `~/` handling is removed since it's now redundant. Test results: 721 passing, 0 failing, 14 ignored (up from 712). https://claude.ai/code/session_01EMHrh5Jq32pb98KXoSKLA4
1 parent da555d8 commit 7695b83

3 files changed

Lines changed: 181 additions & 7 deletions

File tree

impl/rust-cli/src/executable.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -333,13 +333,10 @@ impl ExecutableCommand for Command {
333333
}
334334
}
335335
} else if p.starts_with('/') {
336-
// Absolute path
336+
// Absolute path (also handles expanded ~/... since
337+
// tilde expansion in expand_variables already turned
338+
// ~/path into /home/user/path)
337339
PathBuf::from(p)
338-
} else if p.starts_with("~/") {
339-
// Home-relative path
340-
let home = dirs::home_dir()
341-
.ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
342-
home.join(&p[2..])
343340
} else {
344341
// Relative to current directory
345342
state.root.join(p)

impl/rust-cli/src/parser.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2290,7 +2290,59 @@ fn apply_substring(value: &str, offset: i32, length: Option<usize>) -> String {
22902290
}
22912291
}
22922292

2293+
/// POSIX §2.6.1 tilde expansion.
2294+
///
2295+
/// Expands an unquoted leading `~` in a word:
2296+
///
2297+
/// - `~` or `~/path` → `$HOME` / `$HOME/path`
2298+
/// - `~+` or `~+/...` → `$PWD` / `$PWD/...`
2299+
/// - `~-` or `~-/...` → `$OLDPWD` / `$OLDPWD/...`
2300+
///
2301+
/// `~user` is not yet supported (requires getpwnam). An escaped `\~`
2302+
/// (produced by `quoted_word_to_string` for quoted `~`) stays literal.
2303+
fn expand_tilde(input: &str) -> String {
2304+
// An escaped tilde `\~` means the tilde was inside quotes — leave it.
2305+
if input.starts_with("\\~") {
2306+
let mut s = String::with_capacity(input.len());
2307+
s.push('~');
2308+
s.push_str(&input[2..]);
2309+
return s;
2310+
}
2311+
2312+
if !input.starts_with('~') {
2313+
return input.to_string();
2314+
}
2315+
2316+
// After the `~`, the "prefix" extends until the first `/` or end-of-string.
2317+
let rest = &input[1..];
2318+
let (prefix, suffix) = match rest.find('/') {
2319+
Some(pos) => (&rest[..pos], &rest[pos..]),
2320+
None => (rest, ""),
2321+
};
2322+
2323+
let replacement = match prefix {
2324+
"" => std::env::var("HOME").ok(),
2325+
"+" => std::env::var("PWD").ok(),
2326+
"-" => std::env::var("OLDPWD").ok(),
2327+
_ => {
2328+
// ~user — not implemented yet; leave the tilde literal.
2329+
return input.to_string();
2330+
}
2331+
};
2332+
2333+
match replacement {
2334+
Some(home) => format!("{}{}", home, suffix),
2335+
None => input.to_string(),
2336+
}
2337+
}
2338+
22932339
pub fn expand_variables(input: &str, state: &crate::state::ShellState) -> String {
2340+
// Tilde expansion (POSIX §2.6.1) happens before variable expansion
2341+
// and only at the start of a word (the word has already been split by
2342+
// the tokeniser). An escaped `\~` (produced by quoted_word_to_string
2343+
// for `'~'` or `"~"`) is left literal.
2344+
let input = expand_tilde(input);
2345+
22942346
let mut result = String::new();
22952347
let mut chars = input.chars().peekable();
22962348

@@ -2400,9 +2452,10 @@ fn quoted_word_to_string(word: &QuotedWord) -> String {
24002452
if word.quote_type != QuoteType::None {
24012453
// Escape $ in single quotes so expand_variables() doesn't expand
24022454
// Escape glob metacharacters (* ? [ {) in all quotes
2455+
// Escape ~ in all quotes so tilde expansion stays literal
24032456
for ch in s.chars() {
24042457
if (word.quote_type == QuoteType::Single && ch == '$')
2405-
|| matches!(ch, '*' | '?' | '[' | '{')
2458+
|| matches!(ch, '*' | '?' | '[' | '{' | '~')
24062459
{
24072460
result.push('\\');
24082461
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
//! Tests for POSIX §2.6.1 tilde expansion.
3+
//!
4+
//! Tilde expansion was previously hard-coded only inside the `cd` command.
5+
//! It now runs globally via `expand_variables`, so `mkdir ~/foo`,
6+
//! `echo ~`, `touch ~/bar`, etc. all work.
7+
8+
use anyhow::Result;
9+
use tempfile::tempdir;
10+
use vsh::executable::ExecutableCommand;
11+
use vsh::parser::{expand_variables, parse_command};
12+
use vsh::state::ShellState;
13+
14+
// -------------------------------------------------------------------------
15+
// Unit tests for expand_tilde (via expand_variables)
16+
// -------------------------------------------------------------------------
17+
18+
#[test]
19+
fn tilde_alone_expands_to_home() {
20+
let temp = tempdir().unwrap();
21+
let state = ShellState::new(temp.path().to_str().unwrap()).unwrap();
22+
23+
let home = std::env::var("HOME").unwrap_or_default();
24+
assert_eq!(expand_variables("~", &state), home);
25+
}
26+
27+
#[test]
28+
fn tilde_slash_path_expands_to_home_slash_path() {
29+
let temp = tempdir().unwrap();
30+
let state = ShellState::new(temp.path().to_str().unwrap()).unwrap();
31+
32+
let home = std::env::var("HOME").unwrap_or_default();
33+
assert_eq!(
34+
expand_variables("~/Documents/foo", &state),
35+
format!("{}/Documents/foo", home)
36+
);
37+
}
38+
39+
#[test]
40+
fn tilde_plus_expands_to_pwd() {
41+
let temp = tempdir().unwrap();
42+
let state = ShellState::new(temp.path().to_str().unwrap()).unwrap();
43+
44+
std::env::set_var("PWD", "/some/where");
45+
assert_eq!(expand_variables("~+", &state), "/some/where");
46+
assert_eq!(
47+
expand_variables("~+/subdir", &state),
48+
"/some/where/subdir"
49+
);
50+
}
51+
52+
#[test]
53+
fn tilde_minus_expands_to_oldpwd() {
54+
let temp = tempdir().unwrap();
55+
let state = ShellState::new(temp.path().to_str().unwrap()).unwrap();
56+
57+
std::env::set_var("OLDPWD", "/old/dir");
58+
assert_eq!(expand_variables("~-", &state), "/old/dir");
59+
assert_eq!(expand_variables("~-/file", &state), "/old/dir/file");
60+
}
61+
62+
#[test]
63+
fn tilde_not_at_start_stays_literal() {
64+
let temp = tempdir().unwrap();
65+
let state = ShellState::new(temp.path().to_str().unwrap()).unwrap();
66+
67+
assert_eq!(expand_variables("foo~bar", &state), "foo~bar");
68+
}
69+
70+
#[test]
71+
fn escaped_tilde_stays_literal() {
72+
let temp = tempdir().unwrap();
73+
let state = ShellState::new(temp.path().to_str().unwrap()).unwrap();
74+
75+
// `\~` is what quoted_word_to_string produces for '~' or "~"
76+
assert_eq!(expand_variables("\\~", &state), "~");
77+
assert_eq!(expand_variables("\\~/foo", &state), "~/foo");
78+
}
79+
80+
#[test]
81+
fn unknown_tilde_user_stays_literal() {
82+
let temp = tempdir().unwrap();
83+
let state = ShellState::new(temp.path().to_str().unwrap()).unwrap();
84+
85+
assert_eq!(
86+
expand_variables("~nonexistent_user_xyz", &state),
87+
"~nonexistent_user_xyz"
88+
);
89+
}
90+
91+
// -------------------------------------------------------------------------
92+
// Integration: tilde expansion works in real commands (not just cd)
93+
// -------------------------------------------------------------------------
94+
95+
#[test]
96+
fn tilde_expands_in_variable_assignments() -> Result<()> {
97+
let temp = tempdir()?;
98+
let state = ShellState::new(temp.path().to_str().unwrap())?;
99+
100+
let home = std::env::var("HOME").unwrap_or_default();
101+
102+
// Verify tilde expansion works inside commands that use expand_variables
103+
let expanded = expand_variables("~/.config/vsh", &state);
104+
assert_eq!(expanded, format!("{}/.config/vsh", home));
105+
106+
Ok(())
107+
}
108+
109+
#[test]
110+
fn cd_with_tilde_still_works() -> Result<()> {
111+
let temp = tempdir()?;
112+
let mut state = ShellState::new(temp.path().to_str().unwrap())?;
113+
114+
let home = std::env::var("HOME").unwrap_or_default();
115+
116+
let cmd = parse_command("cd ~")?;
117+
cmd.execute(&mut state)?;
118+
119+
assert_eq!(
120+
state.root,
121+
std::fs::canonicalize(&home).unwrap_or_else(|_| std::path::PathBuf::from(&home))
122+
);
123+
Ok(())
124+
}

0 commit comments

Comments
 (0)