diff --git a/crates/bashkit/src/builtins/dirstack.rs b/crates/bashkit/src/builtins/dirstack.rs new file mode 100644 index 00000000..280c5c4b --- /dev/null +++ b/crates/bashkit/src/builtins/dirstack.rs @@ -0,0 +1,221 @@ +//! Directory stack builtins - pushd, popd, dirs +//! +//! Stack stored in variables: _DIRSTACK_SIZE, _DIRSTACK_0, _DIRSTACK_1, etc. + +use async_trait::async_trait; +use std::path::PathBuf; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +fn get_stack_size(ctx: &Context<'_>) -> usize { + ctx.variables + .get("_DIRSTACK_SIZE") + .and_then(|s| s.parse().ok()) + .unwrap_or(0) +} + +fn get_stack_entry(ctx: &Context<'_>, idx: usize) -> Option { + ctx.variables.get(&format!("_DIRSTACK_{}", idx)).cloned() +} + +fn set_stack_size(ctx: &mut Context<'_>, size: usize) { + ctx.variables + .insert("_DIRSTACK_SIZE".to_string(), size.to_string()); +} + +fn push_stack(ctx: &mut Context<'_>, dir: &str) { + let size = get_stack_size(ctx); + ctx.variables + .insert(format!("_DIRSTACK_{}", size), dir.to_string()); + set_stack_size(ctx, size + 1); +} + +fn pop_stack(ctx: &mut Context<'_>) -> Option { + let size = get_stack_size(ctx); + if size == 0 { + return None; + } + let entry = get_stack_entry(ctx, size - 1); + ctx.variables.remove(&format!("_DIRSTACK_{}", size - 1)); + set_stack_size(ctx, size - 1); + entry +} + +fn normalize_path(base: &std::path::Path, target: &str) -> PathBuf { + let path = if target.starts_with('/') { + PathBuf::from(target) + } else { + base.join(target) + }; + super::resolve_path(&PathBuf::from("/"), &path.to_string_lossy()) +} + +/// The pushd builtin - push directory onto stack and cd. +/// +/// Usage: pushd [dir] +/// +/// Without args, swaps top two directories. +/// With dir, pushes current dir onto stack and cd to dir. +pub struct Pushd; + +#[async_trait] +impl Builtin for Pushd { + async fn execute(&self, mut ctx: Context<'_>) -> Result { + if ctx.args.is_empty() { + // Swap top two: current dir <-> top of stack + let top = pop_stack(&mut ctx); + match top { + Some(dir) => { + let old_cwd = ctx.cwd.to_string_lossy().to_string(); + let new_path = normalize_path(ctx.cwd, &dir); + if ctx.fs.exists(&new_path).await.unwrap_or(false) { + push_stack(&mut ctx, &old_cwd); + *ctx.cwd = new_path; + // Print stack + let output = format_stack(&ctx); + Ok(ExecResult::ok(format!("{}\n", output))) + } else { + // Restore stack + push_stack(&mut ctx, &dir); + Ok(ExecResult::err( + format!("pushd: {}: No such file or directory\n", dir), + 1, + )) + } + } + None => Ok(ExecResult::err( + "pushd: no other directory\n".to_string(), + 1, + )), + } + } else { + let target = &ctx.args[0].clone(); + let new_path = normalize_path(ctx.cwd, target); + + if ctx.fs.exists(&new_path).await.unwrap_or(false) { + let meta = ctx.fs.stat(&new_path).await; + if meta.map(|m| m.file_type.is_dir()).unwrap_or(false) { + let old_cwd = ctx.cwd.to_string_lossy().to_string(); + push_stack(&mut ctx, &old_cwd); + *ctx.cwd = new_path; + let output = format_stack(&ctx); + Ok(ExecResult::ok(format!("{}\n", output))) + } else { + Ok(ExecResult::err( + format!("pushd: {}: Not a directory\n", target), + 1, + )) + } + } else { + Ok(ExecResult::err( + format!("pushd: {}: No such file or directory\n", target), + 1, + )) + } + } + } +} + +/// The popd builtin - pop directory from stack and cd. +/// +/// Usage: popd +/// +/// Removes top directory from stack and cd to it. +pub struct Popd; + +#[async_trait] +impl Builtin for Popd { + async fn execute(&self, mut ctx: Context<'_>) -> Result { + match pop_stack(&mut ctx) { + Some(dir) => { + let new_path = normalize_path(ctx.cwd, &dir); + *ctx.cwd = new_path; + let output = format_stack(&ctx); + Ok(ExecResult::ok(format!("{}\n", output))) + } + None => Ok(ExecResult::err( + "popd: directory stack empty\n".to_string(), + 1, + )), + } + } +} + +/// The dirs builtin - display directory stack. +/// +/// Usage: dirs [-c] [-l] [-p] [-v] +/// +/// -c: clear the stack +/// -l: long listing (no ~ substitution) +/// -p: one entry per line +/// -v: numbered one entry per line +pub struct Dirs; + +#[async_trait] +impl Builtin for Dirs { + async fn execute(&self, mut ctx: Context<'_>) -> Result { + let mut clear = false; + let mut per_line = false; + let mut verbose = false; + + for arg in ctx.args.iter() { + match arg.as_str() { + "-c" => clear = true, + "-p" => per_line = true, + "-v" => { + verbose = true; + per_line = true; + } + "-l" => {} // long listing (we don't do ~ substitution anyway) + _ => {} + } + } + + if clear { + let size = get_stack_size(&ctx); + for i in 0..size { + ctx.variables.remove(&format!("_DIRSTACK_{}", i)); + } + set_stack_size(&mut ctx, 0); + return Ok(ExecResult::ok(String::new())); + } + + let cwd = ctx.cwd.to_string_lossy().to_string(); + let size = get_stack_size(&ctx); + + if verbose { + let mut output = format!(" 0 {}\n", cwd); + for i in (0..size).rev() { + if let Some(dir) = get_stack_entry(&ctx, i) { + output.push_str(&format!(" {} {}\n", size - i, dir)); + } + } + Ok(ExecResult::ok(output)) + } else if per_line { + let mut output = format!("{}\n", cwd); + for i in (0..size).rev() { + if let Some(dir) = get_stack_entry(&ctx, i) { + output.push_str(&format!("{}\n", dir)); + } + } + Ok(ExecResult::ok(output)) + } else { + let output = format_stack(&ctx); + Ok(ExecResult::ok(format!("{}\n", output))) + } + } +} + +fn format_stack(ctx: &Context<'_>) -> String { + let cwd = ctx.cwd.to_string_lossy().to_string(); + let size = get_stack_size(ctx); + let mut parts = vec![cwd]; + for i in (0..size).rev() { + if let Some(dir) = get_stack_entry(ctx, i) { + parts.push(dir); + } + } + parts.join(" ") +} diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 782410b7..73aba499 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -31,6 +31,7 @@ mod curl; mod cuttr; mod date; mod diff; +mod dirstack; mod disk; mod echo; mod environ; @@ -81,6 +82,7 @@ pub use curl::{Curl, Wget}; pub use cuttr::{Cut, Tr}; pub use date::Date; pub use diff::Diff; +pub use dirstack::{Dirs, Popd, Pushd}; pub use disk::{Df, Du}; pub use echo::Echo; pub use environ::{Env, History, Printenv}; diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index a8f1d1c6..d0c4d807 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -263,6 +263,9 @@ impl Interpreter { builtins.insert("rev".to_string(), Box::new(builtins::Rev)); builtins.insert("yes".to_string(), Box::new(builtins::Yes)); builtins.insert("expr".to_string(), Box::new(builtins::Expr)); + builtins.insert("pushd".to_string(), Box::new(builtins::Pushd)); + builtins.insert("popd".to_string(), Box::new(builtins::Popd)); + builtins.insert("dirs".to_string(), Box::new(builtins::Dirs)); builtins.insert("sort".to_string(), Box::new(builtins::Sort)); builtins.insert("uniq".to_string(), Box::new(builtins::Uniq)); builtins.insert("cut".to_string(), Box::new(builtins::Cut)); diff --git a/crates/bashkit/tests/spec_cases/bash/dirstack.test.sh b/crates/bashkit/tests/spec_cases/bash/dirstack.test.sh new file mode 100644 index 00000000..7fff37ef --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/dirstack.test.sh @@ -0,0 +1,132 @@ +### pushd_basic +# pushd changes directory and pushes old dir +mkdir -p /tmp/pushd_test +pushd /tmp/pushd_test > /dev/null +pwd +### expect +/tmp/pushd_test +### end + +### popd_basic +# popd returns to previous directory +mkdir -p /tmp/popd_test +cd /tmp +pushd /tmp/popd_test > /dev/null +popd > /dev/null +pwd +### expect +/tmp +### end + +### pushd_shows_stack +# pushd prints directory stack +### bash_diff +mkdir -p /tmp/dir1 +cd /tmp +pushd /tmp/dir1 +### expect +/tmp/dir1 /tmp +### end + +### popd_shows_stack +# popd prints directory stack +### bash_diff +mkdir -p /tmp/dir2 +cd /tmp +pushd /tmp/dir2 > /dev/null +popd +### expect +/tmp +### end + +### pushd_multiple +# Multiple pushd calls build stack +### bash_diff +mkdir -p /tmp/a /tmp/b +cd /tmp +pushd /tmp/a > /dev/null +pushd /tmp/b > /dev/null +pwd +popd > /dev/null +pwd +popd > /dev/null +pwd +### expect +/tmp/b +/tmp/a +/tmp +### end + +### dirs_shows_stack +# dirs displays current stack +### bash_diff +mkdir -p /tmp/d1 /tmp/d2 +cd /tmp +pushd /tmp/d1 > /dev/null +pushd /tmp/d2 > /dev/null +dirs +### expect +/tmp/d2 /tmp/d1 /tmp +### end + +### dirs_clear +# dirs -c clears the stack +### bash_diff +mkdir -p /tmp/dc +cd /tmp +pushd /tmp/dc > /dev/null +dirs -c +dirs +### expect +/tmp/dc +### end + +### dirs_per_line +# dirs -p shows one entry per line +### bash_diff +mkdir -p /tmp/dp1 /tmp/dp2 +cd /tmp +pushd /tmp/dp1 > /dev/null +pushd /tmp/dp2 > /dev/null +dirs -p +### expect +/tmp/dp2 +/tmp/dp1 +/tmp +### end + +### popd_empty_stack +# popd on empty stack returns error +popd 2>/dev/null +echo "exit:$?" +### expect +exit:1 +### end + +### pushd_nonexistent +# pushd to nonexistent directory returns error +pushd /tmp/nonexistent_pushd_dir 2>/dev/null +echo "exit:$?" +### expect +exit:1 +### end + +### pushd_no_args_empty +# pushd with no args and empty stack returns error +pushd 2>/dev/null +echo "exit:$?" +### expect +exit:1 +### end + +### pushd_swap +# pushd with no args swaps top two dirs +### bash_diff +mkdir -p /tmp/sw1 +cd /tmp +pushd /tmp/sw1 > /dev/null +pushd > /dev/null +pwd +### expect +/tmp +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 7fde47fd..d2867078 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1399 (1394 pass, 5 skip) +**Total spec test cases:** 1411 (1406 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 981 | Yes | 976 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 993 | Yes | 988 | 5 | `bash_spec_tests` in CI | | AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g | | Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect | | Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E | | JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env | | Python | 57 | Yes | 57 | 0 | embedded Python (Monty) | -| **Total** | **1399** | **Yes** | **1394** | **5** | | +| **Total** | **1411** | **Yes** | **1406** | **5** | | ### Bash Spec Tests Breakdown @@ -170,6 +170,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | string-ops.test.sh | 14 | string replacement (prefix/suffix anchored), `${var:?}`, case conversion | | read-builtin.test.sh | 10 | `read` builtin, IFS splitting, `-r`, `-a` (array), `-n` (nchars), here-string | | expr.test.sh | 13 | `expr` arithmetic, string ops, pattern matching, exit codes | +| dirstack.test.sh | 12 | `pushd`, `popd`, `dirs` directory stack operations | ## Shell Features @@ -188,6 +189,7 @@ Features that may be added in the future (not intentionally excluded): | ~~`type`/`which` builtins~~ | ~~Medium~~ | Implemented: `-t`, `-a`, `-p` flags | | ~~`declare` builtin~~ | ~~Medium~~ | Implemented: `-i`, `-r`, `-x`, `-a`, `-p`, `-n`, `-l`, `-u` | | ~~`ln` builtin~~ | ~~Medium~~ | Implemented: symbolic links (`-s`, `-f`) | +| ~~Directory stack `pushd`/`popd`/`dirs`~~ | ~~Low-Medium~~ | Implemented: push, pop, swap, clear, display | | `alias` | Low | Interactive feature | | History expansion | Out of scope | Interactive only | @@ -210,7 +212,7 @@ Features that may be added in the future (not intentionally excluded): ### Implemented -**94 core builtins + 3 feature-gated = 97 total** +**97 core builtins + 3 feature-gated = 100 total** `echo`, `printf`, `cat`, `nl`, `cd`, `pwd`, `true`, `false`, `exit`, `test`, `[`, `export`, `set`, `unset`, `local`, `source`, `.`, `read`, `shift`, `break`, @@ -224,6 +226,7 @@ Features that may be added in the future (not intentionally excluded): `od`, `xxd`, `hexdump`, `strings`, `tar`, `gzip`, `gunzip`, `file`, `less`, `stat`, `watch`, `env`, `printenv`, `history`, `df`, `du`, +`pushd`, `popd`, `dirs`, `git` (requires `git` feature, see [010-git-support.md](010-git-support.md)), `python`, `python3` (requires `python` feature, see [011-python-builtin.md](011-python-builtin.md))