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
125 changes: 121 additions & 4 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,7 @@ struct CallFrame {
pub struct ShellOptions {
/// Exit immediately if a command exits with non-zero status (set -e)
pub errexit: bool,
/// Print commands before execution (set -x) - stored but not enforced
#[allow(dead_code)]
/// Print commands before execution (set -x)
pub xtrace: bool,
/// Return rightmost non-zero exit code from pipeline (set -o pipefail)
pub pipefail: bool,
Expand Down Expand Up @@ -394,6 +393,16 @@ impl Interpreter {
.unwrap_or(false)
}

/// Check if xtrace (set -x) is enabled
fn is_xtrace_enabled(&self) -> bool {
self.options.xtrace
|| self
.variables
.get("SHOPT_x")
.map(|v| v == "1")
.unwrap_or(false)
}

/// Set execution limits.
pub fn set_limits(&mut self, limits: ExecutionLimits) {
self.limits = limits;
Expand Down Expand Up @@ -1865,7 +1874,6 @@ impl Interpreter {
// compatibility with scripts that set them, but not enforced
// in virtual mode:
// -e (errexit): would need per-command exit code checking
// -x (xtrace): would need trace output to stderr
// -v (verbose): would need input echoing
// -u (nounset): would need unset variable detection
// -o (option): would need set -o pipeline
Expand Down Expand Up @@ -2536,6 +2544,32 @@ impl Interpreter {
}
}

// Emit xtrace (set -x): build trace line for stderr
let xtrace_line = if self.is_xtrace_enabled() {
let ps4 = self
.variables
.get("PS4")
.cloned()
.unwrap_or_else(|| "+ ".to_string());
let mut trace = ps4;
trace.push_str(&name);
for word in &command.args {
let expanded = self.expand_word(word).await.unwrap_or_default();
trace.push(' ');
if expanded.contains(' ') || expanded.contains('\t') || expanded.is_empty() {
trace.push('\'');
trace.push_str(&expanded.replace('\'', "'\\''"));
trace.push('\'');
} else {
trace.push_str(&expanded);
}
}
trace.push('\n');
Some(trace)
} else {
None
};

// Dispatch to the appropriate handler
let result = self.execute_dispatched_command(&name, command, stdin).await;

Expand Down Expand Up @@ -2563,7 +2597,16 @@ impl Interpreter {
}
}

result
// Prepend xtrace to stderr (like real bash, xtrace goes to the
// shell's stderr, unaffected by per-command redirections like 2>&1).
if let Some(trace) = xtrace_line {
result.map(|mut r| {
r.stderr = trace + &r.stderr;
r
})
} else {
result
}
}

/// Execute a command after name resolution and prefix assignment setup.
Expand Down Expand Up @@ -6836,4 +6879,78 @@ mod tests {
assert!(result.stdout.contains("first: a"));
assert!(result.stdout.contains("all: a b c"));
}

#[tokio::test]
async fn test_xtrace_basic() {
// set -x sends trace to stderr
let result = run_script("set -x; echo hello").await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "hello\n");
assert!(
result.stderr.contains("+ echo hello"),
"stderr should contain xtrace: {:?}",
result.stderr
);
}

#[tokio::test]
async fn test_xtrace_multiple_commands() {
let result = run_script("set -x; echo one; echo two").await;
assert_eq!(result.stdout, "one\ntwo\n");
assert!(result.stderr.contains("+ echo one"));
assert!(result.stderr.contains("+ echo two"));
}

#[tokio::test]
async fn test_xtrace_expanded_variables() {
// Trace shows expanded values, not variable names
let result = run_script("x=hello; set -x; echo $x").await;
assert_eq!(result.stdout, "hello\n");
assert!(
result.stderr.contains("+ echo hello"),
"xtrace should show expanded value: {:?}",
result.stderr
);
}

#[tokio::test]
async fn test_xtrace_disable() {
// set +x disables tracing; set +x itself is traced
let result = run_script("set -x; echo traced; set +x; echo not_traced").await;
assert_eq!(result.stdout, "traced\nnot_traced\n");
assert!(result.stderr.contains("+ echo traced"));
assert!(
result.stderr.contains("+ set +x"),
"set +x should be traced: {:?}",
result.stderr
);
assert!(
!result.stderr.contains("+ echo not_traced"),
"echo after set +x should NOT be traced: {:?}",
result.stderr
);
}

#[tokio::test]
async fn test_xtrace_no_trace_without_flag() {
let result = run_script("echo hello").await;
assert_eq!(result.stdout, "hello\n");
assert!(
result.stderr.is_empty(),
"no xtrace without set -x: {:?}",
result.stderr
);
}

#[tokio::test]
async fn test_xtrace_not_captured_by_redirect() {
// 2>&1 should NOT capture xtrace (matches real bash behavior)
let result = run_script("set -x; echo hello 2>&1").await;
assert_eq!(result.stdout, "hello\n");
assert!(
result.stderr.contains("+ echo hello"),
"xtrace should stay in stderr even with 2>&1: {:?}",
result.stderr
);
}
}
45 changes: 45 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/variables.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,48 @@ echo "$OLDPWD" | grep -q "/" && echo "oldpwd_set"
### expect
oldpwd_set
### end

### xtrace_stdout_unaffected
# set -x does not alter stdout
set -x
echo hello
### expect
hello
### end

### xtrace_multiple_stdout
# set -x does not alter stdout for multiple commands
set -x
echo one
echo two
### expect
one
two
### end

### xtrace_disable_stdout
# set +x properly disables tracing, stdout unaffected
set -x
echo traced
set +x
echo not_traced
### expect
traced
not_traced
### end

### xtrace_expanded_vars_stdout
# set -x with variables does not alter stdout
x=hello
set -x
echo $x
### expect
hello
### end

### xtrace_no_output_without_flag
# Without set -x, no trace output
echo hello
### expect
hello
### end
8 changes: 4 additions & 4 deletions specs/009-implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See

## Spec Test Coverage

**Total spec test cases:** 1292 (1287 pass, 5 skip)
**Total spec test cases:** 1297 (1292 pass, 5 skip)

| Category | Cases | In CI | Pass | Skip | Notes |
|----------|-------|-------|------|------|-------|
| Bash (core) | 874 | Yes | 869 | 5 | `bash_spec_tests` in CI |
| Bash (core) | 879 | Yes | 874 | 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** | **1292** | **Yes** | **1287** | **5** | |
| **Total** | **1297** | **Yes** | **1292** | **5** | |

### Bash Spec Tests Breakdown

Expand Down Expand Up @@ -157,7 +157,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
| test-operators.test.sh | 17 | file/string tests |
| time.test.sh | 11 | Wall-clock only (user/sys always 0) |
| timeout.test.sh | 17 | |
| variables.test.sh | 73 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\<newline>` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS |
| variables.test.sh | 78 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\<newline>` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS, `set -x` xtrace |
| wc.test.sh | 35 | word count (5 skipped) |
| type.test.sh | 15 | `type`, `which`, `hash` builtins |
| declare.test.sh | 10 | `declare`/`typeset`, `-i`, `-r`, `-x`, `-a`, `-p` |
Expand Down
2 changes: 1 addition & 1 deletion supply-chain/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -991,7 +991,7 @@ version = "1.1.4"
criteria = "safe-to-run"

[[exemptions.rustls]]
version = "0.23.36"
version = "0.23.37"
criteria = "safe-to-deploy"

[[exemptions.rustls-native-certs]]
Expand Down
Loading