Skip to content

Commit b844983

Browse files
chaliyclaude
andauthored
feat(interpreter): implement set -x xtrace debugging (#253)
## Summary - Implement `set -x` xtrace which traces simple commands to stderr before execution - Trace shows expanded argument values prefixed by PS4 (default `+ `) - Like real bash, xtrace output goes to the shell's stderr and is not affected by per-command redirections like `2>&1` - `set +x` properly disables tracing (and is itself traced) ## Test plan - [x] 5 spec tests verify stdout is unaffected by xtrace - [x] 6 unit tests verify stderr contains correct xtrace output - [x] Unit test verifies `2>&1` does NOT capture xtrace (matching real bash) - [x] Unit test verifies `set +x` is traced then disables tracing - [x] All existing tests pass - [x] `cargo clippy` clean, `cargo fmt` clean --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d7c78d commit b844983

File tree

4 files changed

+171
-9
lines changed

4 files changed

+171
-9
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,7 @@ struct CallFrame {
118118
pub struct ShellOptions {
119119
/// Exit immediately if a command exits with non-zero status (set -e)
120120
pub errexit: bool,
121-
/// Print commands before execution (set -x) - stored but not enforced
122-
#[allow(dead_code)]
121+
/// Print commands before execution (set -x)
123122
pub xtrace: bool,
124123
/// Return rightmost non-zero exit code from pipeline (set -o pipefail)
125124
pub pipefail: bool,
@@ -394,6 +393,16 @@ impl Interpreter {
394393
.unwrap_or(false)
395394
}
396395

396+
/// Check if xtrace (set -x) is enabled
397+
fn is_xtrace_enabled(&self) -> bool {
398+
self.options.xtrace
399+
|| self
400+
.variables
401+
.get("SHOPT_x")
402+
.map(|v| v == "1")
403+
.unwrap_or(false)
404+
}
405+
397406
/// Set execution limits.
398407
pub fn set_limits(&mut self, limits: ExecutionLimits) {
399408
self.limits = limits;
@@ -1865,7 +1874,6 @@ impl Interpreter {
18651874
// compatibility with scripts that set them, but not enforced
18661875
// in virtual mode:
18671876
// -e (errexit): would need per-command exit code checking
1868-
// -x (xtrace): would need trace output to stderr
18691877
// -v (verbose): would need input echoing
18701878
// -u (nounset): would need unset variable detection
18711879
// -o (option): would need set -o pipeline
@@ -2536,6 +2544,32 @@ impl Interpreter {
25362544
}
25372545
}
25382546

2547+
// Emit xtrace (set -x): build trace line for stderr
2548+
let xtrace_line = if self.is_xtrace_enabled() {
2549+
let ps4 = self
2550+
.variables
2551+
.get("PS4")
2552+
.cloned()
2553+
.unwrap_or_else(|| "+ ".to_string());
2554+
let mut trace = ps4;
2555+
trace.push_str(&name);
2556+
for word in &command.args {
2557+
let expanded = self.expand_word(word).await.unwrap_or_default();
2558+
trace.push(' ');
2559+
if expanded.contains(' ') || expanded.contains('\t') || expanded.is_empty() {
2560+
trace.push('\'');
2561+
trace.push_str(&expanded.replace('\'', "'\\''"));
2562+
trace.push('\'');
2563+
} else {
2564+
trace.push_str(&expanded);
2565+
}
2566+
}
2567+
trace.push('\n');
2568+
Some(trace)
2569+
} else {
2570+
None
2571+
};
2572+
25392573
// Dispatch to the appropriate handler
25402574
let result = self.execute_dispatched_command(&name, command, stdin).await;
25412575

@@ -2563,7 +2597,16 @@ impl Interpreter {
25632597
}
25642598
}
25652599

2566-
result
2600+
// Prepend xtrace to stderr (like real bash, xtrace goes to the
2601+
// shell's stderr, unaffected by per-command redirections like 2>&1).
2602+
if let Some(trace) = xtrace_line {
2603+
result.map(|mut r| {
2604+
r.stderr = trace + &r.stderr;
2605+
r
2606+
})
2607+
} else {
2608+
result
2609+
}
25672610
}
25682611

25692612
/// Execute a command after name resolution and prefix assignment setup.
@@ -6836,4 +6879,78 @@ mod tests {
68366879
assert!(result.stdout.contains("first: a"));
68376880
assert!(result.stdout.contains("all: a b c"));
68386881
}
6882+
6883+
#[tokio::test]
6884+
async fn test_xtrace_basic() {
6885+
// set -x sends trace to stderr
6886+
let result = run_script("set -x; echo hello").await;
6887+
assert_eq!(result.exit_code, 0);
6888+
assert_eq!(result.stdout, "hello\n");
6889+
assert!(
6890+
result.stderr.contains("+ echo hello"),
6891+
"stderr should contain xtrace: {:?}",
6892+
result.stderr
6893+
);
6894+
}
6895+
6896+
#[tokio::test]
6897+
async fn test_xtrace_multiple_commands() {
6898+
let result = run_script("set -x; echo one; echo two").await;
6899+
assert_eq!(result.stdout, "one\ntwo\n");
6900+
assert!(result.stderr.contains("+ echo one"));
6901+
assert!(result.stderr.contains("+ echo two"));
6902+
}
6903+
6904+
#[tokio::test]
6905+
async fn test_xtrace_expanded_variables() {
6906+
// Trace shows expanded values, not variable names
6907+
let result = run_script("x=hello; set -x; echo $x").await;
6908+
assert_eq!(result.stdout, "hello\n");
6909+
assert!(
6910+
result.stderr.contains("+ echo hello"),
6911+
"xtrace should show expanded value: {:?}",
6912+
result.stderr
6913+
);
6914+
}
6915+
6916+
#[tokio::test]
6917+
async fn test_xtrace_disable() {
6918+
// set +x disables tracing; set +x itself is traced
6919+
let result = run_script("set -x; echo traced; set +x; echo not_traced").await;
6920+
assert_eq!(result.stdout, "traced\nnot_traced\n");
6921+
assert!(result.stderr.contains("+ echo traced"));
6922+
assert!(
6923+
result.stderr.contains("+ set +x"),
6924+
"set +x should be traced: {:?}",
6925+
result.stderr
6926+
);
6927+
assert!(
6928+
!result.stderr.contains("+ echo not_traced"),
6929+
"echo after set +x should NOT be traced: {:?}",
6930+
result.stderr
6931+
);
6932+
}
6933+
6934+
#[tokio::test]
6935+
async fn test_xtrace_no_trace_without_flag() {
6936+
let result = run_script("echo hello").await;
6937+
assert_eq!(result.stdout, "hello\n");
6938+
assert!(
6939+
result.stderr.is_empty(),
6940+
"no xtrace without set -x: {:?}",
6941+
result.stderr
6942+
);
6943+
}
6944+
6945+
#[tokio::test]
6946+
async fn test_xtrace_not_captured_by_redirect() {
6947+
// 2>&1 should NOT capture xtrace (matches real bash behavior)
6948+
let result = run_script("set -x; echo hello 2>&1").await;
6949+
assert_eq!(result.stdout, "hello\n");
6950+
assert!(
6951+
result.stderr.contains("+ echo hello"),
6952+
"xtrace should stay in stderr even with 2>&1: {:?}",
6953+
result.stderr
6954+
);
6955+
}
68396956
}

crates/bashkit/tests/spec_cases/bash/variables.test.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,3 +535,48 @@ echo "$OLDPWD" | grep -q "/" && echo "oldpwd_set"
535535
### expect
536536
oldpwd_set
537537
### end
538+
539+
### xtrace_stdout_unaffected
540+
# set -x does not alter stdout
541+
set -x
542+
echo hello
543+
### expect
544+
hello
545+
### end
546+
547+
### xtrace_multiple_stdout
548+
# set -x does not alter stdout for multiple commands
549+
set -x
550+
echo one
551+
echo two
552+
### expect
553+
one
554+
two
555+
### end
556+
557+
### xtrace_disable_stdout
558+
# set +x properly disables tracing, stdout unaffected
559+
set -x
560+
echo traced
561+
set +x
562+
echo not_traced
563+
### expect
564+
traced
565+
not_traced
566+
### end
567+
568+
### xtrace_expanded_vars_stdout
569+
# set -x with variables does not alter stdout
570+
x=hello
571+
set -x
572+
echo $x
573+
### expect
574+
hello
575+
### end
576+
577+
### xtrace_no_output_without_flag
578+
# Without set -x, no trace output
579+
echo hello
580+
### expect
581+
hello
582+
### end

specs/009-implementation-status.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
103103

104104
## Spec Test Coverage
105105

106-
**Total spec test cases:** 1292 (1287 pass, 5 skip)
106+
**Total spec test cases:** 1297 (1292 pass, 5 skip)
107107

108108
| Category | Cases | In CI | Pass | Skip | Notes |
109109
|----------|-------|-------|------|------|-------|
110-
| Bash (core) | 874 | Yes | 869 | 5 | `bash_spec_tests` in CI |
110+
| Bash (core) | 879 | Yes | 874 | 5 | `bash_spec_tests` in CI |
111111
| AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g |
112112
| Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect |
113113
| Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E |
114114
| JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env |
115115
| Python | 57 | Yes | 57 | 0 | embedded Python (Monty) |
116-
| **Total** | **1292** | **Yes** | **1287** | **5** | |
116+
| **Total** | **1297** | **Yes** | **1292** | **5** | |
117117

118118
### Bash Spec Tests Breakdown
119119

@@ -157,7 +157,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See
157157
| test-operators.test.sh | 17 | file/string tests |
158158
| time.test.sh | 11 | Wall-clock only (user/sys always 0) |
159159
| timeout.test.sh | 17 | |
160-
| variables.test.sh | 73 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\<newline>` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS |
160+
| 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 |
161161
| wc.test.sh | 35 | word count (5 skipped) |
162162
| type.test.sh | 15 | `type`, `which`, `hash` builtins |
163163
| declare.test.sh | 10 | `declare`/`typeset`, `-i`, `-r`, `-x`, `-a`, `-p` |

supply-chain/config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,7 @@ version = "1.1.4"
991991
criteria = "safe-to-run"
992992

993993
[[exemptions.rustls]]
994-
version = "0.23.36"
994+
version = "0.23.37"
995995
criteria = "safe-to-deploy"
996996

997997
[[exemptions.rustls-native-certs]]

0 commit comments

Comments
 (0)