Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions crates/bashkit/src/builtins/awk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ enum AwkAction {
Break,
Continue,
Delete(String, AwkExpr), // delete arr[key]
Getline, // getline — read next input record into $0
#[allow(dead_code)] // Exit code support for future
Exit(Option<AwkExpr>),
Expression(AwkExpr),
Expand Down Expand Up @@ -484,6 +485,9 @@ impl<'a> AwkParser<'a> {
if self.matches_keyword("delete") {
return self.parse_delete();
}
if self.matches_keyword("getline") {
return Ok(AwkAction::Getline);
}
if self.matches_keyword("exit") {
self.skip_whitespace();
if self.pos < self.input.len() {
Expand Down Expand Up @@ -1049,7 +1053,7 @@ impl<'a> AwkParser<'a> {
let remaining = &self.input[self.pos..];
let keywords = [
"in", "if", "else", "while", "for", "do", "break", "continue", "next", "exit",
"delete", "print", "printf",
"delete", "getline", "print", "printf",
];
for kw in keywords {
if remaining.starts_with(kw) {
Expand Down Expand Up @@ -1510,13 +1514,19 @@ enum AwkFlow {
struct AwkInterpreter {
state: AwkState,
output: String,
/// Lines of current input file (set before main loop)
input_lines: Vec<String>,
/// Current line index within input_lines
line_index: usize,
}

impl AwkInterpreter {
fn new() -> Self {
Self {
state: AwkState::default(),
output: String::new(),
input_lines: Vec::new(),
line_index: 0,
}
}

Expand Down Expand Up @@ -2380,6 +2390,15 @@ impl AwkInterpreter {
AwkFlow::Continue
}
AwkAction::Next => AwkFlow::Next,
AwkAction::Getline => {
// Advance to next input line and update $0, NR, NF, FNR
self.line_index += 1;
if self.line_index < self.input_lines.len() {
let line = self.input_lines[self.line_index].clone();
self.state.set_line(&line);
}
AwkFlow::Continue
}
AwkAction::Break => AwkFlow::Break,
AwkAction::Continue => AwkFlow::LoopContinue,
AwkAction::Exit(expr) => {
Expand Down Expand Up @@ -2556,8 +2575,13 @@ impl Builtin for Awk {

'files: for input in inputs {
interp.state.fnr = 0;
for line in input.lines() {
interp.state.set_line(line);
// Index-based iteration so getline can advance the index
interp.input_lines = input.lines().map(|l| l.to_string()).collect();
interp.line_index = 0;

while interp.line_index < interp.input_lines.len() {
let line = interp.input_lines[interp.line_index].clone();
interp.state.set_line(&line);

for rule in &program.main_rules {
// Check pattern
Expand Down Expand Up @@ -2587,6 +2611,7 @@ impl Builtin for Awk {
}
}
}
interp.line_index += 1;
}
}

Expand Down Expand Up @@ -3024,6 +3049,29 @@ mod tests {
assert_eq!(result.stdout, "line1\n");
}

#[tokio::test]
async fn test_awk_getline_basic() {
let result = run_awk(&["{getline; print}"], Some("line1\nline2"))
.await
.unwrap();
assert_eq!(result.stdout, "line2\n");
}

#[tokio::test]
async fn test_awk_getline_updates_fields() {
let result = run_awk(&["{getline; print $1}"], Some("a b\nc d"))
.await
.unwrap();
assert_eq!(result.stdout, "c\n");
}

#[tokio::test]
async fn test_awk_getline_at_eof() {
// getline at EOF should keep current $0
let result = run_awk(&["{getline; print}"], Some("only")).await.unwrap();
assert_eq!(result.stdout, "only\n");
}

#[tokio::test]
async fn test_awk_revenue_calculation() {
// This is the exact eval task pattern
Expand Down
4 changes: 2 additions & 2 deletions crates/bashkit/src/builtins/grep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,8 @@ impl GrepOptions {
i += 1;
}

// First positional is pattern (if no -e patterns)
if opts.patterns.is_empty() {
// First positional is pattern (if no -e patterns and no -f file)
if opts.patterns.is_empty() && opts.pattern_file.is_none() {
if positional.is_empty() {
return Err(Error::Execution("grep: missing pattern".to_string()));
}
Expand Down
118 changes: 114 additions & 4 deletions crates/bashkit/src/builtins/jq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ use crate::interpreter::ExecResult;
/// produce deeply nested parse trees in jaq.
const MAX_JQ_JSON_DEPTH: usize = 100;

/// RAII guard that restores process env vars when dropped.
/// Ensures cleanup even on early-return error paths.
struct EnvRestoreGuard(Vec<(String, Option<String>)>);

impl Drop for EnvRestoreGuard {
fn drop(&mut self) {
for (k, old) in &self.0 {
match old {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
}
}

/// jq command - JSON processor
pub struct Jq;

Expand Down Expand Up @@ -328,20 +343,40 @@ impl Builtin for Jq {
json_vals.into_iter().map(Val::from).collect()
};

// Expose bashkit's shell env/variables to the process environment so
// jaq's built-in `env` function (which reads std::env::vars()) works.
// Include both ctx.env (prefix assignments like FOO=bar jq ...)
// and ctx.variables (set via export builtin).
// Uses a drop guard to ensure cleanup on all return paths.
let mut seen = std::collections::HashSet::new();
let mut env_backup: Vec<(String, Option<String>)> = Vec::new();
for (k, v) in ctx.env.iter().chain(ctx.variables.iter()) {
if seen.insert(k.clone()) {
let old = std::env::var(k).ok();
std::env::set_var(k, v);
env_backup.push((k.clone(), old));
}
}
let _env_guard = EnvRestoreGuard(env_backup);

// Track for -e exit status
let mut has_output = false;
let mut all_null_or_false = true;

for jaq_input in inputs_to_process {
// Create empty inputs iterator
let inputs = RcIter::new(core::iter::empty());
// Shared input iterator: main loop pops one value per filter run,
// and jaq's input/inputs functions consume from the same source.
let shared_inputs = RcIter::new(inputs_to_process.into_iter().map(Ok::<Val, String>));

for jaq_input in &shared_inputs {
let jaq_input: Val =
jaq_input.map_err(|e| Error::Execution(format!("jq: input error: {}", e)))?;

// Run the filter, passing any --arg/--argjson variable values
let var_vals: Vec<Val> = var_bindings
.iter()
.map(|(_, v)| Val::from(v.clone()))
.collect();
let ctx = Ctx::new(var_vals, &inputs);
let ctx = Ctx::new(var_vals, &shared_inputs);
for result in filter.run((ctx, jaq_input)) {
match result {
Ok(val) => {
Expand Down Expand Up @@ -594,6 +629,27 @@ mod tests {
/// TM-DOS-027: Deeply nested JSON arrays must be rejected
/// Note: serde_json has a built-in recursion limit (~128 levels) that fires first.
/// Our check_json_depth is defense-in-depth for values within serde's limit.
#[tokio::test]
async fn test_jq_input_reads_next() {
let result = run_jq_with_args(&["input"], "1\n2").await.unwrap();
assert_eq!(result.trim(), "2");
}

#[tokio::test]
async fn test_jq_inputs_collects_remaining() {
let result = run_jq_with_args(&["-c", "[inputs]"], "1\n2\n3")
.await
.unwrap();
assert_eq!(result.trim(), "[2,3]");
}

#[tokio::test]
async fn test_jq_inputs_single_value() {
// With single input, inputs yields empty array
let result = run_jq_with_args(&["-c", "[inputs]"], "42").await.unwrap();
assert_eq!(result.trim(), "[]");
}

#[tokio::test]
async fn test_jq_json_depth_limit_arrays() {
// Build 150-level nested JSON: [[[[....[1]....]]]]
Expand Down Expand Up @@ -787,6 +843,60 @@ mod tests {
assert_eq!(arr[1]["id"], 2);
}

// --- env tests ---

#[tokio::test]
async fn test_jq_env_access() {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let mut env = HashMap::new();
env.insert("TESTVAR".to_string(), "hello".to_string());
let args = vec!["-n".to_string(), "env.TESTVAR".to_string()];

let ctx = Context {
args: &args,
env: &env,
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
};

let result = jq.execute(ctx).await.unwrap();
assert_eq!(result.stdout.trim(), "\"hello\"");
}

#[tokio::test]
async fn test_jq_env_missing_var() {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args = vec!["-n".to_string(), "env.NO_SUCH_VAR_999".to_string()];

let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
};

let result = jq.execute(ctx).await.unwrap();
assert_eq!(result.stdout.trim(), "null");
}

// --- Argument parsing bug regression tests ---

#[tokio::test]
Expand Down
48 changes: 44 additions & 4 deletions crates/bashkit/src/builtins/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,61 @@ impl Builtin for Read {
None => return Ok(ExecResult::err("", 1)),
};

// Parse flags
let mut raw_mode = false; // -r: don't interpret backslashes
let mut prompt = None::<String>; // -p prompt
let mut var_args = Vec::new();
let mut args_iter = ctx.args.iter();
while let Some(arg) = args_iter.next() {
if arg.starts_with('-') && arg.len() > 1 {
for flag in arg[1..].chars() {
match flag {
'r' => raw_mode = true,
'p' => {
// -p takes next arg as prompt
if let Some(p) = args_iter.next() {
prompt = Some(p.clone());
}
}
_ => {} // ignore unknown flags
}
}
} else {
var_args.push(arg.as_str());
}
}
let _ = prompt; // prompt is for interactive use, ignored in non-interactive

// Get first line
let line = input.lines().next().unwrap_or("");
let line = if raw_mode {
// -r: treat backslashes literally
input.lines().next().unwrap_or("").to_string()
} else {
// Without -r: handle backslash line continuation
let mut result = String::new();
for l in input.lines() {
if let Some(stripped) = l.strip_suffix('\\') {
result.push_str(stripped);
} else {
result.push_str(l);
break;
}
}
result
};

// If no variable names given, use REPLY
let var_names: Vec<&str> = if ctx.args.is_empty() {
let var_names: Vec<&str> = if var_args.is_empty() {
vec!["REPLY"]
} else {
ctx.args.iter().map(|s| s.as_str()).collect()
var_args
};

// Split line by IFS (default: space, tab, newline)
let ifs = ctx.env.get("IFS").map(|s| s.as_str()).unwrap_or(" \t\n");
let words: Vec<&str> = if ifs.is_empty() {
// Empty IFS means no word splitting
vec![line]
vec![&line]
} else {
line.split(|c: char| ifs.contains(c))
.filter(|s| !s.is_empty())
Expand Down
Loading
Loading