Skip to content

Commit 60ad9a4

Browse files
chaliyclaude
andauthored
feat(builtins): implement pushd, popd, dirs (#268)
## Summary - Add `pushd` builtin: push current directory onto stack, cd to target; swap top two with no args - Add `popd` builtin: pop top directory from stack and cd to it - Add `dirs` builtin: display stack with `-c` (clear), `-p` (per-line), `-v` (verbose/numbered) - Stack stored in internal `_DIRSTACK_*` variables - 100 builtins total (97 core + 3 feature-gated) ## Test plan - [x] `cargo clippy --all-targets --all-features -- -D warnings` passes - [x] `cargo test --all-features --test spec_tests bash_spec_tests` passes - [x] 12 new spec tests (Bash 993, Total 1411) - [x] Error cases: empty stack, nonexistent directory, no-args-empty Co-authored-by: Claude <noreply@anthropic.com>
1 parent e3a6025 commit 60ad9a4

File tree

5 files changed

+365
-4
lines changed

5 files changed

+365
-4
lines changed
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
//! Directory stack builtins - pushd, popd, dirs
2+
//!
3+
//! Stack stored in variables: _DIRSTACK_SIZE, _DIRSTACK_0, _DIRSTACK_1, etc.
4+
5+
use async_trait::async_trait;
6+
use std::path::PathBuf;
7+
8+
use super::{Builtin, Context};
9+
use crate::error::Result;
10+
use crate::interpreter::ExecResult;
11+
12+
fn get_stack_size(ctx: &Context<'_>) -> usize {
13+
ctx.variables
14+
.get("_DIRSTACK_SIZE")
15+
.and_then(|s| s.parse().ok())
16+
.unwrap_or(0)
17+
}
18+
19+
fn get_stack_entry(ctx: &Context<'_>, idx: usize) -> Option<String> {
20+
ctx.variables.get(&format!("_DIRSTACK_{}", idx)).cloned()
21+
}
22+
23+
fn set_stack_size(ctx: &mut Context<'_>, size: usize) {
24+
ctx.variables
25+
.insert("_DIRSTACK_SIZE".to_string(), size.to_string());
26+
}
27+
28+
fn push_stack(ctx: &mut Context<'_>, dir: &str) {
29+
let size = get_stack_size(ctx);
30+
ctx.variables
31+
.insert(format!("_DIRSTACK_{}", size), dir.to_string());
32+
set_stack_size(ctx, size + 1);
33+
}
34+
35+
fn pop_stack(ctx: &mut Context<'_>) -> Option<String> {
36+
let size = get_stack_size(ctx);
37+
if size == 0 {
38+
return None;
39+
}
40+
let entry = get_stack_entry(ctx, size - 1);
41+
ctx.variables.remove(&format!("_DIRSTACK_{}", size - 1));
42+
set_stack_size(ctx, size - 1);
43+
entry
44+
}
45+
46+
fn normalize_path(base: &std::path::Path, target: &str) -> PathBuf {
47+
let path = if target.starts_with('/') {
48+
PathBuf::from(target)
49+
} else {
50+
base.join(target)
51+
};
52+
super::resolve_path(&PathBuf::from("/"), &path.to_string_lossy())
53+
}
54+
55+
/// The pushd builtin - push directory onto stack and cd.
56+
///
57+
/// Usage: pushd [dir]
58+
///
59+
/// Without args, swaps top two directories.
60+
/// With dir, pushes current dir onto stack and cd to dir.
61+
pub struct Pushd;
62+
63+
#[async_trait]
64+
impl Builtin for Pushd {
65+
async fn execute(&self, mut ctx: Context<'_>) -> Result<ExecResult> {
66+
if ctx.args.is_empty() {
67+
// Swap top two: current dir <-> top of stack
68+
let top = pop_stack(&mut ctx);
69+
match top {
70+
Some(dir) => {
71+
let old_cwd = ctx.cwd.to_string_lossy().to_string();
72+
let new_path = normalize_path(ctx.cwd, &dir);
73+
if ctx.fs.exists(&new_path).await.unwrap_or(false) {
74+
push_stack(&mut ctx, &old_cwd);
75+
*ctx.cwd = new_path;
76+
// Print stack
77+
let output = format_stack(&ctx);
78+
Ok(ExecResult::ok(format!("{}\n", output)))
79+
} else {
80+
// Restore stack
81+
push_stack(&mut ctx, &dir);
82+
Ok(ExecResult::err(
83+
format!("pushd: {}: No such file or directory\n", dir),
84+
1,
85+
))
86+
}
87+
}
88+
None => Ok(ExecResult::err(
89+
"pushd: no other directory\n".to_string(),
90+
1,
91+
)),
92+
}
93+
} else {
94+
let target = &ctx.args[0].clone();
95+
let new_path = normalize_path(ctx.cwd, target);
96+
97+
if ctx.fs.exists(&new_path).await.unwrap_or(false) {
98+
let meta = ctx.fs.stat(&new_path).await;
99+
if meta.map(|m| m.file_type.is_dir()).unwrap_or(false) {
100+
let old_cwd = ctx.cwd.to_string_lossy().to_string();
101+
push_stack(&mut ctx, &old_cwd);
102+
*ctx.cwd = new_path;
103+
let output = format_stack(&ctx);
104+
Ok(ExecResult::ok(format!("{}\n", output)))
105+
} else {
106+
Ok(ExecResult::err(
107+
format!("pushd: {}: Not a directory\n", target),
108+
1,
109+
))
110+
}
111+
} else {
112+
Ok(ExecResult::err(
113+
format!("pushd: {}: No such file or directory\n", target),
114+
1,
115+
))
116+
}
117+
}
118+
}
119+
}
120+
121+
/// The popd builtin - pop directory from stack and cd.
122+
///
123+
/// Usage: popd
124+
///
125+
/// Removes top directory from stack and cd to it.
126+
pub struct Popd;
127+
128+
#[async_trait]
129+
impl Builtin for Popd {
130+
async fn execute(&self, mut ctx: Context<'_>) -> Result<ExecResult> {
131+
match pop_stack(&mut ctx) {
132+
Some(dir) => {
133+
let new_path = normalize_path(ctx.cwd, &dir);
134+
*ctx.cwd = new_path;
135+
let output = format_stack(&ctx);
136+
Ok(ExecResult::ok(format!("{}\n", output)))
137+
}
138+
None => Ok(ExecResult::err(
139+
"popd: directory stack empty\n".to_string(),
140+
1,
141+
)),
142+
}
143+
}
144+
}
145+
146+
/// The dirs builtin - display directory stack.
147+
///
148+
/// Usage: dirs [-c] [-l] [-p] [-v]
149+
///
150+
/// -c: clear the stack
151+
/// -l: long listing (no ~ substitution)
152+
/// -p: one entry per line
153+
/// -v: numbered one entry per line
154+
pub struct Dirs;
155+
156+
#[async_trait]
157+
impl Builtin for Dirs {
158+
async fn execute(&self, mut ctx: Context<'_>) -> Result<ExecResult> {
159+
let mut clear = false;
160+
let mut per_line = false;
161+
let mut verbose = false;
162+
163+
for arg in ctx.args.iter() {
164+
match arg.as_str() {
165+
"-c" => clear = true,
166+
"-p" => per_line = true,
167+
"-v" => {
168+
verbose = true;
169+
per_line = true;
170+
}
171+
"-l" => {} // long listing (we don't do ~ substitution anyway)
172+
_ => {}
173+
}
174+
}
175+
176+
if clear {
177+
let size = get_stack_size(&ctx);
178+
for i in 0..size {
179+
ctx.variables.remove(&format!("_DIRSTACK_{}", i));
180+
}
181+
set_stack_size(&mut ctx, 0);
182+
return Ok(ExecResult::ok(String::new()));
183+
}
184+
185+
let cwd = ctx.cwd.to_string_lossy().to_string();
186+
let size = get_stack_size(&ctx);
187+
188+
if verbose {
189+
let mut output = format!(" 0 {}\n", cwd);
190+
for i in (0..size).rev() {
191+
if let Some(dir) = get_stack_entry(&ctx, i) {
192+
output.push_str(&format!(" {} {}\n", size - i, dir));
193+
}
194+
}
195+
Ok(ExecResult::ok(output))
196+
} else if per_line {
197+
let mut output = format!("{}\n", cwd);
198+
for i in (0..size).rev() {
199+
if let Some(dir) = get_stack_entry(&ctx, i) {
200+
output.push_str(&format!("{}\n", dir));
201+
}
202+
}
203+
Ok(ExecResult::ok(output))
204+
} else {
205+
let output = format_stack(&ctx);
206+
Ok(ExecResult::ok(format!("{}\n", output)))
207+
}
208+
}
209+
}
210+
211+
fn format_stack(ctx: &Context<'_>) -> String {
212+
let cwd = ctx.cwd.to_string_lossy().to_string();
213+
let size = get_stack_size(ctx);
214+
let mut parts = vec![cwd];
215+
for i in (0..size).rev() {
216+
if let Some(dir) = get_stack_entry(ctx, i) {
217+
parts.push(dir);
218+
}
219+
}
220+
parts.join(" ")
221+
}

crates/bashkit/src/builtins/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ mod curl;
3131
mod cuttr;
3232
mod date;
3333
mod diff;
34+
mod dirstack;
3435
mod disk;
3536
mod echo;
3637
mod environ;
@@ -81,6 +82,7 @@ pub use curl::{Curl, Wget};
8182
pub use cuttr::{Cut, Tr};
8283
pub use date::Date;
8384
pub use diff::Diff;
85+
pub use dirstack::{Dirs, Popd, Pushd};
8486
pub use disk::{Df, Du};
8587
pub use echo::Echo;
8688
pub use environ::{Env, History, Printenv};

crates/bashkit/src/interpreter/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ impl Interpreter {
263263
builtins.insert("rev".to_string(), Box::new(builtins::Rev));
264264
builtins.insert("yes".to_string(), Box::new(builtins::Yes));
265265
builtins.insert("expr".to_string(), Box::new(builtins::Expr));
266+
builtins.insert("pushd".to_string(), Box::new(builtins::Pushd));
267+
builtins.insert("popd".to_string(), Box::new(builtins::Popd));
268+
builtins.insert("dirs".to_string(), Box::new(builtins::Dirs));
266269
builtins.insert("sort".to_string(), Box::new(builtins::Sort));
267270
builtins.insert("uniq".to_string(), Box::new(builtins::Uniq));
268271
builtins.insert("cut".to_string(), Box::new(builtins::Cut));
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
### pushd_basic
2+
# pushd changes directory and pushes old dir
3+
mkdir -p /tmp/pushd_test
4+
pushd /tmp/pushd_test > /dev/null
5+
pwd
6+
### expect
7+
/tmp/pushd_test
8+
### end
9+
10+
### popd_basic
11+
# popd returns to previous directory
12+
mkdir -p /tmp/popd_test
13+
cd /tmp
14+
pushd /tmp/popd_test > /dev/null
15+
popd > /dev/null
16+
pwd
17+
### expect
18+
/tmp
19+
### end
20+
21+
### pushd_shows_stack
22+
# pushd prints directory stack
23+
### bash_diff
24+
mkdir -p /tmp/dir1
25+
cd /tmp
26+
pushd /tmp/dir1
27+
### expect
28+
/tmp/dir1 /tmp
29+
### end
30+
31+
### popd_shows_stack
32+
# popd prints directory stack
33+
### bash_diff
34+
mkdir -p /tmp/dir2
35+
cd /tmp
36+
pushd /tmp/dir2 > /dev/null
37+
popd
38+
### expect
39+
/tmp
40+
### end
41+
42+
### pushd_multiple
43+
# Multiple pushd calls build stack
44+
### bash_diff
45+
mkdir -p /tmp/a /tmp/b
46+
cd /tmp
47+
pushd /tmp/a > /dev/null
48+
pushd /tmp/b > /dev/null
49+
pwd
50+
popd > /dev/null
51+
pwd
52+
popd > /dev/null
53+
pwd
54+
### expect
55+
/tmp/b
56+
/tmp/a
57+
/tmp
58+
### end
59+
60+
### dirs_shows_stack
61+
# dirs displays current stack
62+
### bash_diff
63+
mkdir -p /tmp/d1 /tmp/d2
64+
cd /tmp
65+
pushd /tmp/d1 > /dev/null
66+
pushd /tmp/d2 > /dev/null
67+
dirs
68+
### expect
69+
/tmp/d2 /tmp/d1 /tmp
70+
### end
71+
72+
### dirs_clear
73+
# dirs -c clears the stack
74+
### bash_diff
75+
mkdir -p /tmp/dc
76+
cd /tmp
77+
pushd /tmp/dc > /dev/null
78+
dirs -c
79+
dirs
80+
### expect
81+
/tmp/dc
82+
### end
83+
84+
### dirs_per_line
85+
# dirs -p shows one entry per line
86+
### bash_diff
87+
mkdir -p /tmp/dp1 /tmp/dp2
88+
cd /tmp
89+
pushd /tmp/dp1 > /dev/null
90+
pushd /tmp/dp2 > /dev/null
91+
dirs -p
92+
### expect
93+
/tmp/dp2
94+
/tmp/dp1
95+
/tmp
96+
### end
97+
98+
### popd_empty_stack
99+
# popd on empty stack returns error
100+
popd 2>/dev/null
101+
echo "exit:$?"
102+
### expect
103+
exit:1
104+
### end
105+
106+
### pushd_nonexistent
107+
# pushd to nonexistent directory returns error
108+
pushd /tmp/nonexistent_pushd_dir 2>/dev/null
109+
echo "exit:$?"
110+
### expect
111+
exit:1
112+
### end
113+
114+
### pushd_no_args_empty
115+
# pushd with no args and empty stack returns error
116+
pushd 2>/dev/null
117+
echo "exit:$?"
118+
### expect
119+
exit:1
120+
### end
121+
122+
### pushd_swap
123+
# pushd with no args swaps top two dirs
124+
### bash_diff
125+
mkdir -p /tmp/sw1
126+
cd /tmp
127+
pushd /tmp/sw1 > /dev/null
128+
pushd > /dev/null
129+
pwd
130+
### expect
131+
/tmp
132+
### end

0 commit comments

Comments
 (0)