diff --git a/crates/bashkit-cli/src/interactive.rs b/crates/bashkit-cli/src/interactive.rs index d3a318df9..f7d343864 100644 --- a/crates/bashkit-cli/src/interactive.rs +++ b/crates/bashkit-cli/src/interactive.rs @@ -34,6 +34,7 @@ fn is_incomplete_input(err_msg: &str) -> bool { lower.contains("unterminated") || lower.contains("unexpected end of input") || lower.contains("unexpected eof") + || lower.contains("unexpected end of file") || lower.contains("syntax error: empty") || lower.contains("expected 'fi'") || lower.contains("expected 'done'") @@ -627,6 +628,14 @@ mod tests { assert!(is_incomplete_input("syntax error: empty brace group")); } + #[test] + fn incomplete_unexpected_end_of_file() { + // The parser's bash wording for input that ran out mid-construct. + assert!(is_incomplete_input( + "parse error at line 2, column 1: syntax error: unexpected end of file" + )); + } + #[test] fn incomplete_missing_closing_keyword() { assert!(is_incomplete_input("expected 'fi'")); diff --git a/crates/bashkit-cli/src/main.rs b/crates/bashkit-cli/src/main.rs index f0c88496a..c972f953b 100644 --- a/crates/bashkit-cli/src/main.rs +++ b/crates/bashkit-cli/src/main.rs @@ -495,7 +495,16 @@ fn run_oneshot(args: Args, mode: CliMode) -> Result { // reported on stderr and exits 2 (the partial-run case already // returns exit 2 from the library). Err(e @ bashkit::Error::Parse { .. }) => { - eprintln!("bash: syntax error: {e}"); + // Named like bash: `bash: -c: line 1: ...` or `script.sh: line 1: ...`. + let arg0 = args.script.as_ref().map(|p| p.display().to_string()); + let who = match (&args.command, arg0) { + (None, Some(path)) => path, + (_, arg0) => format!("{}: -c", arg0.as_deref().unwrap_or("bash")), + }; + let report = e + .syntax_report(&who, &script) + .unwrap_or_else(|| format!("bash: syntax error: {e}\n")); + eprint!("{report}"); return Ok(2); } Err(e) => return Err(anyhow::Error::new(e).context(context)), diff --git a/crates/bashkit-cli/tests/cli_oneshot.rs b/crates/bashkit-cli/tests/cli_oneshot.rs index d3924f4b4..3e066656c 100644 --- a/crates/bashkit-cli/tests/cli_oneshot.rs +++ b/crates/bashkit-cli/tests/cli_oneshot.rs @@ -214,31 +214,36 @@ fn parse_error_reports_on_stderr_and_exits_nonzero() { let out = run(&["-c", "if ["]); // bash exits 2 on a syntax error. assert_eq!(code(&out), 2); - let err = stderr(&out); - assert!( - err.starts_with("bash: syntax error: parse error"), - "stderr: {err}" + // Worded and numbered like bash: input ran out, so the line after the last. + assert_eq!( + stderr(&out), + "bash: -c: line 2: syntax error: unexpected end of file\n" ); - assert!(err.ends_with('\n'), "stderr: {err:?}"); assert_eq!(stdout(&out), "", "parse errors must not write to stdout"); } #[test] fn script_parse_error_exits_2_with_or_without_earlier_commands() { let dir = tempfile::tempdir().unwrap(); - for (body, want_stdout) in [ - ("[[ abc =~ ( ]]\n", ""), - ("echo ok\n[[ abc =~ ( ]]\n", "ok\n"), + for (body, want_stdout, line) in [ + ("[[ abc =~ ( ]]\n", "", 1), + ("echo ok\n[[ abc =~ ( ]]\n", "ok\n", 2), ] { let script = dir.path().join("bad.sh"); std::fs::write(&script, body).unwrap(); - let out = run(&[script.to_str().unwrap()]); + let path = script.to_str().unwrap(); + let out = run(&[path]); assert_eq!(code(&out), 2, "{body:?}"); assert_eq!(stdout(&out), want_stdout, "{body:?}"); - assert!( - stderr(&out).starts_with("bash: syntax error: "), - "{body:?}: {}", - stderr(&out) + // bash's two diagnostics, named by the script path. + assert_eq!( + stderr(&out), + format!( + "{path}: line {line}: unexpected EOF while looking for matching `)'\n\ + {path}: line {}: unexpected argument to conditional binary operator\n", + line + 1 + ), + "{body:?}" ); } } diff --git a/crates/bashkit/src/error.rs b/crates/bashkit/src/error.rs index 7d498ad88..b37a8e2b6 100644 --- a/crates/bashkit/src/error.rs +++ b/crates/bashkit/src/error.rs @@ -138,6 +138,28 @@ impl Error { } } + /// bash's report of this syntax error in `source`, as read by `who` + /// (`bash: -c`, a script path): `who: line N: message`, followed by the + /// offending source line for a `near unexpected token` error. `None` for + /// errors other than [`Error::Parse`]. + /// + /// ``` + /// let err = bashkit::parser::Parser::new("if then fi").parse().unwrap_err(); + /// assert_eq!( + /// err.syntax_report("bash: -c", "if then fi").unwrap(), + /// "bash: -c: line 1: syntax error near unexpected token `then'\n\ + /// bash: -c: line 1: `if then fi'\n" + /// ); + /// ``` + pub fn syntax_report(&self, who: &str, source: &str) -> Option { + match self { + Self::Parse { message, line, .. } => { + Some(syntax_report(who, source, message, *line, 0)) + } + _ => None, + } + } + /// THREAT[TM-INF-016]: Create an I/O error with sanitized message. /// Strips host-internal paths from the error message to prevent information /// leakage to the sandbox guest. @@ -251,6 +273,47 @@ fn sanitize_error_message(msg: &str) -> String { result } +/// See [`Error::syntax_report`]. `shift` moves the reported line number +/// (eval'd text counts from the eval's own line). A message line after the +/// first that reads `line N: text` is a further diagnostic bash prints for +/// the same error, on its own line number. +pub(crate) fn syntax_report( + who: &str, + source: &str, + message: &str, + line: usize, + shift: usize, +) -> String { + let message = if message.starts_with("syntax error") || message.starts_with("unexpected EOF") { + message.to_string() + } else { + format!("syntax error: {message}") + }; + if line == 0 { + return format!("{who}: {message}\n"); + } + let at = line + shift; + let mut lines = message.lines(); + let first = lines.next().unwrap_or_default(); + let mut out = format!("{who}: line {at}: {first}\n"); + if first.contains("near unexpected token") + && let Some(text) = source.lines().nth(line - 1) + { + out.push_str(&format!("{who}: line {at}: `{text}'\n")); + } + for more in lines { + let further = more.strip_prefix("line ").and_then(|rest| { + let (n, text) = rest.split_once(": ")?; + Some((n.parse::().ok()? + shift, text)) + }); + match further { + Some((n, text)) => out.push_str(&format!("{who}: line {n}: {text}\n")), + None => out.push_str(&format!("{who}: line {at}: {more}\n")), + } + } + out +} + #[cfg(test)] mod tests { /// A VFS message that only restates its errno is swapped for the diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index bddfb7065..e0f7816ab 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -35,12 +35,14 @@ pub(crate) mod pipe; mod redirection; mod state; mod time_command; +mod xtrace; #[allow(unused_imports)] pub use jobs::{JobInfo, JobState, JobTable, SharedJobTable}; pub use state::{BuiltinSideEffect, ControlFlow, ExecResult}; use time_command::{ - TimeUsage, render_time_format, sanitize_time_path, validate_time_format, verbose_time_report, + TimeUsage, render_time_format, render_timeformat, sanitize_time_path, validate_time_format, + verbose_time_report, }; // Re-export snapshot type for public API @@ -845,18 +847,25 @@ fn decode_file_bytes_for_path(_path: &Path, bytes: &[u8]) -> String { fn nested_partial_parse( (mut script, error): (Script, Option), noexec: bool, - shell_name: &str, + who: &str, + source: &str, ) -> Result