Skip to content

Commit aa32ab9

Browse files
chaliyclaude
andauthored
feat: type/which/declare/ln builtins, errexit, nounset fix, sort -z, cut -z (#233)
## Summary - **New builtins**: `type` (with -t/-a/-p/-f/-P), `which`, `hash` (no-op), `declare`/`typeset` (with -p/-r/-x/-a/-i), `ln` (with -s/-f) - **Fix errexit (set -e)**: Top-level execute loop now checks errexit; fixed `!` pipeline negation parsing (was never parsed) - **Fix nounset (set -u)**: `${var:-default}` and similar expansions no longer trigger unbound variable error - **Add sort -z / cut -z**: Zero-terminated input/output support; also fixed `\0` escape in tr's `expand_char_set` ## Test plan - [x] 15 new tests in type.test.sh (type/which/hash with flags) - [x] 10 new tests in declare.test.sh (declare/typeset with flags) - [x] 5 new tests in ln.test.sh (symlink creation, force, errors) - [x] Removed skip markers from nounset_default_value_ok, neg_errexit_stops, sort_zero_terminated, cut_zero_terminated - [x] `cargo test --all-features` passes - [x] `cargo clippy --all-targets --all-features -- -D warnings` clean - [x] Updated specs/009-implementation-status.md --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9db495c commit aa32ab9

File tree

14 files changed

+756
-49
lines changed

14 files changed

+756
-49
lines changed

crates/bashkit/src/builtins/cuttr.rs

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ impl Builtin for Cut {
3434
let mut mode = CutMode::Fields;
3535
let mut complement = false;
3636
let mut only_delimited = false;
37+
let mut zero_terminated = false;
3738
let mut output_delimiter: Option<String> = None;
3839
let mut files = Vec::new();
3940

@@ -68,6 +69,8 @@ impl Builtin for Cut {
6869
mode = CutMode::Chars;
6970
} else if arg == "-s" {
7071
only_delimited = true;
72+
} else if arg == "-z" {
73+
zero_terminated = true;
7174
} else if arg == "--complement" {
7275
complement = true;
7376
} else if let Some(od) = arg.strip_prefix("--output-delimiter=") {
@@ -142,26 +145,30 @@ impl Builtin for Cut {
142145
};
143146

144147
let mut output = String::new();
148+
let line_sep = if zero_terminated { '\0' } else { '\n' };
149+
let out_sep = if zero_terminated { "\0" } else { "\n" };
150+
151+
let process_input = |text: &str, output: &mut String| {
152+
for line in text.split(line_sep) {
153+
if line.is_empty() {
154+
continue;
155+
}
156+
if let Some(result) = process_line(line) {
157+
output.push_str(&result);
158+
output.push_str(out_sep);
159+
}
160+
}
161+
};
145162

146163
if files.is_empty() || files.iter().all(|f| f.as_str() == "-") {
147164
if let Some(stdin) = ctx.stdin {
148-
for line in stdin.lines() {
149-
if let Some(result) = process_line(line) {
150-
output.push_str(&result);
151-
output.push('\n');
152-
}
153-
}
165+
process_input(stdin, &mut output);
154166
}
155167
} else {
156168
for file in &files {
157169
if file.as_str() == "-" {
158170
if let Some(stdin) = ctx.stdin {
159-
for line in stdin.lines() {
160-
if let Some(result) = process_line(line) {
161-
output.push_str(&result);
162-
output.push('\n');
163-
}
164-
}
171+
process_input(stdin, &mut output);
165172
}
166173
continue;
167174
}
@@ -175,12 +182,7 @@ impl Builtin for Cut {
175182
match ctx.fs.read_file(&path).await {
176183
Ok(content) => {
177184
let text = String::from_utf8_lossy(&content);
178-
for line in text.lines() {
179-
if let Some(result) = process_line(line) {
180-
output.push_str(&result);
181-
output.push('\n');
182-
}
183-
}
185+
process_input(&text, &mut output);
184186
}
185187
Err(e) => {
186188
return Ok(ExecResult::err(format!("cut: {}: {}\n", file, e), 1));
@@ -464,6 +466,11 @@ fn expand_char_set(spec: &str) -> Vec<char> {
464466
i += 2;
465467
continue;
466468
}
469+
b'0' => {
470+
chars.push('\0');
471+
i += 2;
472+
continue;
473+
}
467474
b'\\' => {
468475
chars.push('\\');
469476
i += 2;

crates/bashkit/src/builtins/fileops.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,83 @@ impl Builtin for Chmod {
479479
}
480480
}
481481

482+
/// The ln builtin - create links.
483+
///
484+
/// Usage: ln [-s] [-f] TARGET LINK_NAME
485+
/// ln [-s] [-f] TARGET... DIRECTORY
486+
///
487+
/// Options:
488+
/// -s Create symbolic link (default in Bashkit; hard links not supported in VFS)
489+
/// -f Force: remove existing destination files
490+
///
491+
/// Note: In Bashkit's virtual filesystem, all links are symbolic.
492+
/// Hard links are not supported; `-s` is implied.
493+
pub struct Ln;
494+
495+
#[async_trait]
496+
impl Builtin for Ln {
497+
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
498+
let mut force = false;
499+
let mut files: Vec<&str> = Vec::new();
500+
501+
for arg in ctx.args.iter() {
502+
if arg.starts_with('-') && arg.len() > 1 {
503+
for c in arg[1..].chars() {
504+
match c {
505+
's' => {} // symbolic — always symbolic in VFS
506+
'f' => force = true,
507+
_ => {
508+
return Ok(ExecResult::err(
509+
format!("ln: invalid option -- '{}'\n", c),
510+
1,
511+
));
512+
}
513+
}
514+
}
515+
} else {
516+
files.push(arg);
517+
}
518+
}
519+
520+
if files.len() < 2 {
521+
return Ok(ExecResult::err("ln: missing file operand\n".to_string(), 1));
522+
}
523+
524+
let target = files[0];
525+
let link_name = files[1];
526+
let link_path = resolve_path(ctx.cwd, link_name);
527+
528+
// If link already exists
529+
if ctx.fs.exists(&link_path).await.unwrap_or(false) {
530+
if force {
531+
// Remove existing
532+
let _ = ctx.fs.remove(&link_path, false).await;
533+
} else {
534+
return Ok(ExecResult::err(
535+
format!(
536+
"ln: failed to create symbolic link '{}': File exists\n",
537+
link_name
538+
),
539+
1,
540+
));
541+
}
542+
}
543+
544+
let target_path = Path::new(target);
545+
if let Err(e) = ctx.fs.symlink(target_path, &link_path).await {
546+
return Ok(ExecResult::err(
547+
format!(
548+
"ln: failed to create symbolic link '{}': {}\n",
549+
link_name, e
550+
),
551+
1,
552+
));
553+
}
554+
555+
Ok(ExecResult::ok(String::new()))
556+
}
557+
}
558+
482559
#[cfg(test)]
483560
#[allow(clippy::unwrap_used)]
484561
mod tests {

crates/bashkit/src/builtins/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ pub use disk::{Df, Du};
8181
pub use echo::Echo;
8282
pub use environ::{Env, History, Printenv};
8383
pub use export::Export;
84-
pub use fileops::{Chmod, Cp, Mkdir, Mv, Rm, Touch};
84+
pub use fileops::{Chmod, Cp, Ln, Mkdir, Mv, Rm, Touch};
8585
pub use flow::{Break, Colon, Continue, Exit, False, Return, True};
8686
pub use grep::Grep;
8787
pub use headtail::{Head, Tail};

crates/bashkit/src/builtins/sortuniq.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ impl Builtin for Sort {
9595
let mut delimiter: Option<char> = None;
9696
let mut key_field: Option<usize> = None;
9797
let mut output_file: Option<String> = None;
98+
let mut zero_terminated = false;
9899
let mut files = Vec::new();
99100

100101
let mut i = 0;
@@ -147,6 +148,7 @@ impl Builtin for Sort {
147148
'c' | 'C' => check_sorted = true,
148149
'h' => human_numeric = true,
149150
'M' => month_sort = true,
151+
'z' => zero_terminated = true,
150152
_ => {}
151153
}
152154
}
@@ -159,10 +161,14 @@ impl Builtin for Sort {
159161
// Collect all input
160162
let mut all_lines = Vec::new();
161163

164+
let line_sep = if zero_terminated { '\0' } else { '\n' };
165+
162166
if files.is_empty() {
163167
if let Some(stdin) = ctx.stdin {
164-
for line in stdin.lines() {
165-
all_lines.push(line.to_string());
168+
for line in stdin.split(line_sep) {
169+
if !line.is_empty() {
170+
all_lines.push(line.to_string());
171+
}
166172
}
167173
}
168174
} else {
@@ -176,8 +182,10 @@ impl Builtin for Sort {
176182
match ctx.fs.read_file(&path).await {
177183
Ok(content) => {
178184
let text = String::from_utf8_lossy(&content);
179-
for line in text.lines() {
180-
all_lines.push(line.to_string());
185+
for line in text.split(line_sep) {
186+
if !line.is_empty() {
187+
all_lines.push(line.to_string());
188+
}
181189
}
182190
}
183191
Err(e) => {
@@ -266,9 +274,10 @@ impl Builtin for Sort {
266274
all_lines.dedup();
267275
}
268276

269-
let mut output = all_lines.join("\n");
277+
let sep = if zero_terminated { "\0" } else { "\n" };
278+
let mut output = all_lines.join(sep);
270279
if !output.is_empty() {
271-
output.push('\n');
280+
output.push_str(sep);
272281
}
273282

274283
// Write to output file if -o specified

0 commit comments

Comments
 (0)