From 0be321c48635721155c19871bd2d53801ede7c25 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 25 Jun 2026 17:53:09 +1000 Subject: [PATCH 1/6] Render stderr stream in red --- Cargo.lock | 19 ++++ Cargo.toml | 1 + src/main.rs | 2 +- src/runner/checks/evaluator.rs | 26 ++--- src/runner/context.rs | 46 +++++++-- src/runner/driver.rs | 115 +++++++++++++++------ src/runner/library.rs | 180 +++++++++++++++++++++++++++++---- src/runner/mod.rs | 18 ++-- src/runner/runner.rs | 2 +- 9 files changed, 331 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c82acf0..5869ded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -382,6 +388,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -675,6 +693,7 @@ dependencies = [ "ignore", "lsp-server", "lsp-types", + "nix", "owo-colors", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9eebc0a..505812f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ crossterm = "0.29" ignore = "0.4" lsp-server = "0.7.9" lsp-types = "0.97" +nix = { version = "0.29", features = ["poll"] } owo-colors = "4" regex = "1.11.1" serde = { version = "1.0.209", features = [ "derive" ] } diff --git a/src/main.rs b/src/main.rs index 26cf923..f445153 100644 --- a/src/main.rs +++ b/src/main.rs @@ -839,7 +839,7 @@ fn main() { Some("automatic") => Mode::Automatic, Some("quiet") => Mode::Quiet, Some("interactive") => Mode::Interactive, - _ => unreachable!() + _ => unreachable!(), } }; diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index db6789b..6bc41d2 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -9,7 +9,7 @@ use crate::value; #[test] fn variable_lookup() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Variable(Identifier::new("missing")); let mut env = Environment::new(); match evaluate(&library, &context, &mut env, &op) { @@ -30,7 +30,7 @@ fn variable_lookup() { #[test] fn number_evaluates_to_quanticle() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Number(LangNumeric::Integral(42)); let mut env = Environment::new(); let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); @@ -40,7 +40,7 @@ fn number_evaluates_to_quanticle() { #[test] fn string_interpolation() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let mut env = Environment::new(); env.extend( "name".to_string(), @@ -68,7 +68,7 @@ fn string_interpolation() { #[test] fn multiline_joins_with_newlines() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Multiline(None, vec!["foo", "bar", "baz"]); let mut env = Environment::new(); let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); @@ -78,7 +78,7 @@ fn multiline_joins_with_newlines() { #[test] fn tablet_entries_evaluate() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Tablet(vec![ Entry { label: "name", @@ -109,7 +109,7 @@ fn tablet_entries_evaluate() { #[test] fn list_elements_evaluate() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::List(vec![ Operation::Number(LangNumeric::Integral(1)), Operation::Number(LangNumeric::Integral(4)), @@ -130,7 +130,7 @@ fn list_elements_evaluate() { #[test] fn bind_extends_env_for_subsequent_lookup() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let names = [Identifier::new("greeting")]; let bind = Operation::Bind { names: &names, @@ -150,7 +150,7 @@ fn bind_extends_env_for_subsequent_lookup() { #[test] fn sequence_evaluation() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let seq = Operation::Sequence(vec![ Operation::Number(LangNumeric::Integral(1)), Operation::Number(LangNumeric::Integral(2)), @@ -173,7 +173,7 @@ fn sequence_evaluation() { #[test] fn multi_name_bind_destructures_parametriq() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let mut env = Environment::new(); env.extend( "triple".to_string(), @@ -212,7 +212,7 @@ fn multi_name_bind_destructures_parametriq() { #[test] fn multi_name_bind_wrong_arity_errors() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let mut env = Environment::new(); env.extend( "pair".to_string(), @@ -243,7 +243,7 @@ fn multi_name_bind_wrong_arity_errors() { #[test] fn multi_name_bind_against_scalar_errors_as_not_tuple() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let mut env = Environment::new(); env.extend( "scalar".to_string(), @@ -266,7 +266,7 @@ fn multi_name_bind_against_scalar_errors_as_not_tuple() { #[test] fn execute_dispatches_resolved_builtin() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let id = library .resolve("seq") .expect("seq registered"); @@ -292,7 +292,7 @@ fn execute_dispatches_resolved_builtin() { #[test] fn execute_unresolved_function_errors() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Execute(Executable { target: ExecutableRef::Unresolved(Identifier::new("click")), arguments: Vec::new(), diff --git a/src/runner/context.rs b/src/runner/context.rs index 5bf1252..ae3d043 100644 --- a/src/runner/context.rs +++ b/src/runner/context.rs @@ -13,7 +13,10 @@ pub struct Context { } enum Sink { - Stdout, + // The terminal. `colour` records whether ANSI escapes are wanted, decided + // once by the caller from the same `raw_output || is_terminal()` gate the + // rest of the runner uses, so a redirected stream stays plain. + Stdout { colour: bool }, // An in-memory buffer, used by tests. Kept a distinct variant from // `Stdout` so the terminal path carries no interior-mutability wrapper. // The `RefCell` lets `write(&self)` append through the shared reference @@ -23,10 +26,13 @@ enum Sink { impl Context { /// The default context of native host capabilities, writing through to - /// standard output. Builtins that are pure functions to manipulate Values - /// ignore it. - pub fn native() -> Self { - Context { sink: Sink::Stdout } + /// standard output. `colour` says whether builtins that tee child output + /// (e.g. `exec`) may tag it with ANSI escapes. Builtins that are pure + /// functions to manipulate Values ignore the context entirely. + pub fn native(colour: bool) -> Self { + Context { + sink: Sink::Stdout { colour }, + } } /// A context that captures everything written through it in memory, for @@ -46,7 +52,7 @@ impl Context { /// calls `flush()` so output appears to the user live. pub fn write(&self, bytes: &[u8]) -> io::Result<()> { match &self.sink { - Sink::Stdout => { + Sink::Stdout { .. } => { let mut out = io::stdout(); out.write_all(bytes)?; out.flush() @@ -60,6 +66,22 @@ impl Context { } } + /// Pass a run of a child process's output through to the user, wrapping it + /// in red on a colour terminal when it came from `stderr` so the operator + /// can tell the streams apart; anything else passes through unchanged. The + /// colour decision lives here because the sink owns whether it is + /// colour-capable. The reset closes the run immediately, so the terminal is + /// never left reddened between writes — an interrupt finds it already clean. + pub fn write_run(&self, run: &[u8], stderr: bool) -> io::Result<()> { + if stderr && self.colour() { + self.write(b"\x1b[31m")?; + self.write(run)?; + self.write(b"\x1b[0m") + } else { + self.write(run) + } + } + /// Pass a complete, already known, text string through to the user. This /// is just a convenience over `write()` for whole messages such as status /// lines or announcements. @@ -68,13 +90,23 @@ impl Context { self.write(message.as_bytes()) } + /// Whether output written through this context should carry ANSI colour. + /// True only for a terminal sink the caller marked colour-capable; a capture + /// buffer is always plain. Internal to `write_run`'s decision. + fn colour(&self) -> bool { + match &self.sink { + Sink::Stdout { colour } => *colour, + Sink::Capture(_) => false, + } + } + /// The bytes captured by a `capture()` context; empty for a native one. pub fn captured(&self) -> Vec { match &self.sink { Sink::Capture(buffer) => buffer .borrow() .clone(), - Sink::Stdout => Vec::new(), + Sink::Stdout { .. } => Vec::new(), } } } diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 9b357f1..c64b624 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -16,7 +16,7 @@ use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::style::{ Attribute, Color, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor, Stylize, }; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, size, Clear, ClearType}; use crossterm::{cursor, queue}; use super::path::display_path; @@ -241,7 +241,11 @@ impl Driver for Console { if let UserInput::Quit = input { } else { let renderer = self.renderer(); - write_marker_line(&mut self.output, &format!("⇒ {}", display_path(qualified)), renderer); + write_marker_line( + &mut self.output, + &format!("⇒ {}", display_path(qualified)), + renderer, + ); } input } @@ -334,6 +338,8 @@ fn prompt_action( /// becoming the '$', reminiscent of a shell. fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserInput { let qualified = display_path(qualified); + // Seed the editable line + let script = script.trim_end(); let field = edit( script.to_string(), Value::Literali(script.to_string()), @@ -509,12 +515,7 @@ fn render_settle( /// Render the run's closing `⇐` boundary line, the rolled-up verdict glyph /// following the label just as a scope's `↙` sign-off carries its own. Quit /// renders nothing, as in `render_settle`. -fn render_conclude( - out: &mut W, - label: &str, - verdict: &UserInput, - renderer: &dyn Render, -) { +fn render_conclude(out: &mut W, label: &str, verdict: &UserInput, renderer: &dyn Render) { let (glyph, syntax) = match verdict_glyph(verdict) { Some(pair) => pair, None => return, @@ -529,7 +530,10 @@ fn render_conclude( /// a shell prompt (distinct from the operator's `▶` edit prompt). Trailing /// whitespace is trimmed so a code block's blank tail line is not echoed. fn render_command(out: &mut W, qualified: &str, script: &str, renderer: &dyn Render) { - let line = renderer.style(Syntax::Marker, &format!("{} $ {}", qualified, script.trim_end())); + let line = renderer.style( + Syntax::Marker, + &format!("{} $ {}", qualified, script.trim_end()), + ); let _ = writeln!(out, "{}", line); } @@ -945,8 +949,8 @@ fn draw( format!("{} {}", settle, qualified).dark_grey(), PROMPT_SYMBOL.blue(), )?; - let cursor_col = draw_tail(out, interaction, prefix)?; - place_cursor(out, cursor_col) + let (cursor_col, end_col) = draw_tail(out, interaction, prefix)?; + place_cursor(out, cursor_col, end_col) } /// Draw the read-only action prompt line: `» {path} {verb} {label} ▶`, the verb @@ -968,32 +972,41 @@ fn draw_action( } write!(out, " {} ", PROMPT_SYMBOL.blue())?; let prefix = action_prefix_width(qualified, verb, label); - let cursor_col = draw_tail(out, interaction, prefix)?; - place_cursor(out, cursor_col) + let (cursor_col, end_col) = draw_tail(out, interaction, prefix)?; + place_cursor(out, cursor_col, end_col) } /// Render the interaction state after the prompt prefix — the Esc menu, the -/// fail-reason buffer, or the field's content — returning the cursor column -/// (`None` hides it). Shared by the step/command prompt and the action prompt. +/// fail-reason buffer, or the field's content — returning the target cursor +/// column (`None` hides it) and the column writing finished at. Both are +/// absolute columns from the line start; when the content is wider than the +/// terminal they exceed its width and `place_cursor` resolves the wrap. Shared +/// by the step/command prompt and the action prompt. fn draw_tail( out: &mut W, interaction: &Interaction, prefix: u16, -) -> io::Result> { +) -> io::Result<(Option, u16)> { let mut cursor_col: Option = None; + let mut end_col: u16 = prefix; match interaction.menu { Some(active) => match &interaction.reason { Some(reason) => { write!(out, "{}{}", REASON_PREFIX, reason.buffer)?; + let lead = prefix + + REASON_PREFIX + .chars() + .count() as u16; cursor_col = Some( - prefix - + REASON_PREFIX - .chars() - .count() as u16 - + reason.buffer[..reason.cursor] - .chars() - .count() as u16, + lead + reason.buffer[..reason.cursor] + .chars() + .count() as u16, ); + end_col = lead + + reason + .buffer + .chars() + .count() as u16; } None => render_menu(out, interaction, active)?, }, @@ -1018,6 +1031,11 @@ fn draw_tail( .chars() .count() as u16, ); + end_col = prefix + + buffer + .chars() + .count() as u16 + + if *bracketed { 2 } else { 0 }; } Field::Frozen { .. } => { cursor_col = Some(prefix); @@ -1031,14 +1049,44 @@ fn draw_tail( } }, } - Ok(cursor_col) + Ok((cursor_col, end_col)) } /// Position (or hide) the cursor after a prompt line is drawn, then flush. -fn place_cursor(out: &mut W, cursor_col: Option) -> io::Result<()> { +/// `end_col` is where writing left the cursor; `cursor_col` is where it belongs. +/// When the content wrapped, an absolute `MoveToColumn` past the right margin +/// clamps to the edge, so the target column is resolved against the terminal +/// width into a move back from the end instead. +fn place_cursor(out: &mut W, cursor_col: Option, end_col: u16) -> io::Result<()> { match cursor_col { - Some(col) => queue!(out, cursor::Show, cursor::MoveToColumn(col))?, - None => queue!(out, cursor::Hide)?, + None => { + queue!(out, cursor::Hide)?; + } + Some(target) if target >= end_col => { + // The cursor belongs at the end of what was just written, which is + // exactly where writing left it — moving would only fight the wrap. + queue!(out, cursor::Show)?; + } + Some(target) => { + let width = size() + .map(|(cols, _)| cols) + .unwrap_or(80) + .max(1); + // The physical cursor sits at the end; step up to the target's row + // (a column that exactly fills a row leaves the cursor on it, not the + // next) and across to its column. + let end_row = if end_col % width == 0 { + end_col / width - 1 + } else { + end_col / width + }; + let up = end_row - target / width; + queue!(out, cursor::Show)?; + if up > 0 { + queue!(out, cursor::MoveUp(up))?; + } + queue!(out, cursor::MoveToColumn(target % width))?; + } } out.flush() } @@ -1301,7 +1349,11 @@ impl Driver for Automatic { } fn depart(&mut self, qualified: &str) -> UserInput { - write_marker_line(&mut self.output, &format!("⇒ {}", display_path(qualified)), self.renderer); + write_marker_line( + &mut self.output, + &format!("⇒ {}", display_path(qualified)), + self.renderer, + ); UserInput::Done(Value::Unitus) } @@ -1310,7 +1362,12 @@ impl Driver for Automatic { } fn command(&mut self, qualified: &str, script: &str) -> UserInput { - render_command(&mut self.output, &display_path(qualified), script, self.renderer); + render_command( + &mut self.output, + &display_path(qualified), + script, + self.renderer, + ); UserInput::Done(Value::Literali(script.to_string())) } diff --git a/src/runner/library.rs b/src/runner/library.rs index 3a38e33..acdd490 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -1,8 +1,11 @@ //! The function table for the evaluator. -use std::io::Read; +use std::io::{self, Read}; +use std::os::fd::AsFd; use std::process::{Command, Stdio}; +use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; + use super::context::Context; use super::runner::RunnerError; use crate::program::ExecutableId; @@ -264,12 +267,24 @@ fn pairs(_context: &Context, args: &[Value]) -> Result { Ok(Value::Arraeum(pairs)) } -/// `exec(script)` — run a shell script, teeing its combined stdout and stderr -/// through the Context to the operator as it streams while accumulating it as -/// the return value. Output is held as bytes until the end so a chunk split -/// mid-UTF-8 is harmless and only one String is allocated. Trailing newlines -/// are trimmed from the captured value (matching the usual experience when -/// doing shell `$(...)` substitution). A non-zero exit is an error. +/// `exec(script)` — run a shell script, teeing its output through the Context +/// to the operator chunk by chunk as it streams while accumulating it as the +/// return value. stdout and stderr are kept on separate pipes and merged here so +/// stderr can be shown in red on a colour terminal; the captured return value is +/// always the plain, uncoloured transcript. Because the two pipes are +/// independent kernel buffers, cross-stream ordering is approximate — bytes stay +/// ordered within each stream but interleaving between them is not guaranteed. +/// Both pipes are drained together under `poll()` so neither can deadlock by +/// filling while the reader is parked on the other. Chunks are written through +/// the instant they are read, so a carriage-return-updated progress meter (as +/// `curl` writes) appears live rather than being held back to a line boundary. +/// Each stderr run is wrapped in red and the colour reset immediately, never +/// held on across the wait for more output, so an interrupt — a real `SIGINT`, +/// since a running command is outside the prompt's raw-mode window — finds the +/// terminal already in its default colour. Bytes are accumulated for the return +/// value and decoded only at the end, so a chunk split mid-UTF-8 is harmless. +/// Trailing newlines are trimmed from the captured value (matching shell +/// `$(...)` substitution). A non-zero exit is an error. fn exec(context: &Context, args: &[Value]) -> Result { let script = match &args[0] { Value::Literali(script) => script, @@ -281,34 +296,119 @@ fn exec(context: &Context, args: &[Value]) -> Result { } }; - // Fold the child's stderr into its stdout (`exec 2>&1`) so the two streams - // are teed and captured together as one ordered transcript. A single pipe - // keeps this to one reader, with no risk of deadlocking on a full stderr - // buffer the way two separately drained pipes would. let mut child = Command::new("bash") .arg("-c") - .arg(format!("exec 2>&1\n{}", script)) + .arg(script) .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .map_err(RunnerError::ExecError)?; - let mut stdout = child + let mut out = child .stdout .take() .expect("child stdout was piped"); + let mut err = child + .stderr + .take() + .expect("child stderr was piped"); + + // The captured return value stays plain; the Context reddens stderr on a + // colour terminal as it writes. Each stream keeps a small buffer holding a + // partial UTF-8 sequence carried over from the previous read, so a colour + // escape is never written into the middle of a character. let mut captured = Vec::new(); + let mut out_pending = Vec::new(); + let mut err_pending = Vec::new(); + let mut out_open = true; + let mut err_open = true; let mut buffer = [0u8; 8192]; - loop { - let count = stdout - .read(&mut buffer) - .map_err(RunnerError::ExecError)?; - if count == 0 { - break; + + while out_open || err_open { + // Ask poll() which still-open stream is readable, copying the readiness + // out into flags so the borrowed fds are released before the readers + // are borrowed mutably below. + let (mut out_ready, mut err_ready) = (false, false); + { + let mut fds = Vec::with_capacity(2); + let mut tags = Vec::with_capacity(2); + if out_open { + fds.push(PollFd::new(out.as_fd(), PollFlags::POLLIN)); + tags.push(true); + } + if err_open { + fds.push(PollFd::new(err.as_fd(), PollFlags::POLLIN)); + tags.push(false); + } + poll(&mut fds, PollTimeout::NONE) + .map_err(|e| RunnerError::ExecError(io::Error::from(e)))?; + for (fd, is_out) in fds + .iter() + .zip(&tags) + { + if !fd + .revents() + .unwrap_or(PollFlags::empty()) + .is_empty() + { + if *is_out { + out_ready = true; + } else { + err_ready = true; + } + } + } } + + // A single read after a readiness signal never blocks: it yields the + // available bytes, or zero once the stream has closed. + if out_ready { + let count = out + .read(&mut buffer) + .map_err(RunnerError::ExecError)?; + if count == 0 { + out_open = false; + } else { + tee( + &buffer[..count], + &mut out_pending, + false, + context, + &mut captured, + )?; + } + } + if err_ready { + let count = err + .read(&mut buffer) + .map_err(RunnerError::ExecError)?; + if count == 0 { + err_open = false; + } else { + tee( + &buffer[..count], + &mut err_pending, + true, + context, + &mut captured, + )?; + } + } + } + + // Flush any bytes still held back (a trailing partial character, decoded + // lossily) now that no continuation can arrive. + if !out_pending.is_empty() { + captured.extend_from_slice(&out_pending); context - .write(&buffer[..count]) + .write_run(&out_pending, false) + .map_err(RunnerError::ExecError)?; + } + if !err_pending.is_empty() { + captured.extend_from_slice(&err_pending); + context + .write_run(&err_pending, true) .map_err(RunnerError::ExecError)?; - captured.extend_from_slice(&buffer[..count]); } let status = child @@ -330,6 +430,44 @@ fn exec(context: &Context, args: &[Value]) -> Result { )) } +/// Tee a chunk from one stream to the operator, recording it plain in +/// `captured` and writing it through the Context (which reddens it when +/// `stderr` and the terminal is colour-capable). The chunk is appended to that +/// stream's `pending` buffer, then every complete UTF-8 character available is +/// written through; a trailing partial character stays in `pending` for the +/// next read, so a colour escape never lands in the middle of a character. +fn tee( + bytes: &[u8], + pending: &mut Vec, + stderr: bool, + context: &Context, + captured: &mut Vec, +) -> Result<(), RunnerError> { + pending.extend_from_slice(bytes); + // How much of `pending` ends on a character boundary: all of it unless the + // tail is an incomplete sequence. + let ready = match std::str::from_utf8(pending) { + Ok(_) => pending.len(), + Err(error) + if error + .error_len() + .is_none() => + { + error.valid_up_to() + } + Err(_) => pending.len(), + }; + if ready == 0 { + return Ok(()); + } + captured.extend_from_slice(&pending[..ready]); + context + .write_run(&pending[..ready], stderr) + .map_err(RunnerError::ExecError)?; + pending.drain(..ready); + Ok(()) +} + /// `now()` — the current wall-clock time as an ISO 8601 string. A read of /// external state, hence part of the system layer rather than `core`. fn now(_context: &Context, _args: &[Value]) -> Result { diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 27de115..eefb944 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -53,9 +53,9 @@ pub fn start<'i>( let label = document_label(document); let outcome = match mode { Mode::Quiet => { - let mut runner = - Runner::new(program, appender, completed, Headless::new(), library) - .with_document(label); + let mut runner = Runner::new(program, appender, completed, Headless::new(), library) + .with_context(Context::native(colour)) + .with_document(label); runner.run(env)? } Mode::Interactive => { @@ -63,6 +63,7 @@ pub fn start<'i>( return Err(RunnerError::TerminalRequired); } let mut runner = Runner::new(program, appender, completed, Console::new(), library) + .with_context(Context::native(colour)) .with_document(label); runner.run(env)? } @@ -74,6 +75,7 @@ pub fn start<'i>( Automatic::new(colour), library, ) + .with_context(Context::native(colour)) .with_document(label); runner.run(env)? } @@ -100,21 +102,24 @@ pub fn inspect<'i>( let appender = Appender::sink(); let completed = HashMap::new(); let driver = Transcript::new(Console::new()); - let mut runner = Runner::new(program, appender, completed, driver, library); + let mut runner = Runner::new(program, appender, completed, driver, library) + .with_context(Context::native(colour)); runner.run(env) } Mode::Automatic => { let appender = Appender::sink(); let completed = HashMap::new(); let driver = Transcript::new(Automatic::new(colour)); - let mut runner = Runner::new(program, appender, completed, driver, library); + let mut runner = Runner::new(program, appender, completed, driver, library) + .with_context(Context::native(colour)); runner.run(env) } Mode::Quiet => { let appender = Appender::sink(); let completed = HashMap::new(); let driver = Transcript::new(Headless::new()); - let mut runner = Runner::new(program, appender, completed, driver, library); + let mut runner = Runner::new(program, appender, completed, driver, library) + .with_context(Context::native(colour)); runner.run(env) } } @@ -152,6 +157,7 @@ pub fn resume<'i>( }; appender.append(&record)?; let mut runner = Runner::new(program, appender, completed, Console::new(), library) + .with_context(Context::native(std::io::stdout().is_terminal())) .with_inputs(inputs) .with_document(document_label(&document)); let env = Environment::new(); diff --git a/src/runner/runner.rs b/src/runner/runner.rs index d6b2673..2fdee09 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -132,7 +132,7 @@ impl<'i, D: Driver> Runner<'i, D> { driver, path: QualifiedPath::new(), library, - context: Context::native(), + context: Context::native(false), document: None, } } From 2fac1cf85a8af0cb2954ea5246ae7d6d6a7d4cb4 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 25 Jun 2026 18:08:30 +1000 Subject: [PATCH 2/6] Refine colouring decision --- src/runner/context.rs | 40 ++++++++++++++++++++--------------- src/runner/library.rs | 49 ++++++++++++++++--------------------------- 2 files changed, 41 insertions(+), 48 deletions(-) diff --git a/src/runner/context.rs b/src/runner/context.rs index ae3d043..6c7e442 100644 --- a/src/runner/context.rs +++ b/src/runner/context.rs @@ -12,10 +12,17 @@ pub struct Context { sink: Sink, } +/// Which of a child's two output streams a run of bytes came from; `write_run` +/// reddens `Stderr` on a colour terminal so the user can tell them apart. +#[derive(Clone, Copy)] +pub enum Stream { + Stdout, + Stderr, +} + enum Sink { // The terminal. `colour` records whether ANSI escapes are wanted, decided - // once by the caller from the same `raw_output || is_terminal()` gate the - // rest of the runner uses, so a redirected stream stays plain. + // by the caller from the `raw_output || is_terminal()` gate. Stdout { colour: bool }, // An in-memory buffer, used by tests. Kept a distinct variant from // `Stdout` so the terminal path carries no interior-mutability wrapper. @@ -25,10 +32,9 @@ enum Sink { } impl Context { - /// The default context of native host capabilities, writing through to - /// standard output. `colour` says whether builtins that tee child output - /// (e.g. `exec`) may tag it with ANSI escapes. Builtins that are pure - /// functions to manipulate Values ignore the context entirely. + /// The default context, writing through to standard output. `colour` says + /// whether builtins that tee child output (e.g. `exec`) may tag it with + /// ANSI escapes; pure builtins ignore the context entirely. pub fn native(colour: bool) -> Self { Context { sink: Sink::Stdout { colour }, @@ -66,14 +72,16 @@ impl Context { } } - /// Pass a run of a child process's output through to the user, wrapping it - /// in red on a colour terminal when it came from `stderr` so the operator - /// can tell the streams apart; anything else passes through unchanged. The - /// colour decision lives here because the sink owns whether it is - /// colour-capable. The reset closes the run immediately, so the terminal is - /// never left reddened between writes — an interrupt finds it already clean. - pub fn write_run(&self, run: &[u8], stderr: bool) -> io::Result<()> { - if stderr && self.colour() { + /// Tee a run of child output to the user, making the foreground colour + /// red for any `Stream::Stderr` output when run in a terminal. The reset + /// every time is necessary in case the user interrupts with Ctrl+C, + /// preventing the subsequent output from incorrectly being in red. + pub fn write_run(&self, run: &[u8], stream: Stream) -> io::Result<()> { + let red = match stream { + Stream::Stderr => self.colour(), + Stream::Stdout => false, + }; + if red { self.write(b"\x1b[31m")?; self.write(run)?; self.write(b"\x1b[0m") @@ -90,9 +98,7 @@ impl Context { self.write(message.as_bytes()) } - /// Whether output written through this context should carry ANSI colour. - /// True only for a terminal sink the caller marked colour-capable; a capture - /// buffer is always plain. Internal to `write_run`'s decision. + /// Whether this sink is a colour-capable terminal; a capture buffer is not. fn colour(&self) -> bool { match &self.sink { Sink::Stdout { colour } => *colour, diff --git a/src/runner/library.rs b/src/runner/library.rs index acdd490..8895ec2 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -6,7 +6,7 @@ use std::process::{Command, Stdio}; use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; -use super::context::Context; +use super::context::{Context, Stream}; use super::runner::RunnerError; use crate::program::ExecutableId; use crate::value::{Numeric, Value}; @@ -267,24 +267,13 @@ fn pairs(_context: &Context, args: &[Value]) -> Result { Ok(Value::Arraeum(pairs)) } -/// `exec(script)` — run a shell script, teeing its output through the Context -/// to the operator chunk by chunk as it streams while accumulating it as the -/// return value. stdout and stderr are kept on separate pipes and merged here so -/// stderr can be shown in red on a colour terminal; the captured return value is -/// always the plain, uncoloured transcript. Because the two pipes are -/// independent kernel buffers, cross-stream ordering is approximate — bytes stay -/// ordered within each stream but interleaving between them is not guaranteed. -/// Both pipes are drained together under `poll()` so neither can deadlock by -/// filling while the reader is parked on the other. Chunks are written through -/// the instant they are read, so a carriage-return-updated progress meter (as -/// `curl` writes) appears live rather than being held back to a line boundary. -/// Each stderr run is wrapped in red and the colour reset immediately, never -/// held on across the wait for more output, so an interrupt — a real `SIGINT`, -/// since a running command is outside the prompt's raw-mode window — finds the -/// terminal already in its default colour. Bytes are accumulated for the return -/// value and decoded only at the end, so a chunk split mid-UTF-8 is harmless. -/// Trailing newlines are trimmed from the captured value (matching shell -/// `$(...)` substitution). A non-zero exit is an error. +/// Run a shell script, teeing its output to the user chunk by chunk as it +/// streams while accumulating the output as the return value. The child's +/// stdout and stderr are kept on separate pipes so stderr can be shown in +/// red; both are drained together using `poll()` so neither deadlocks. Bytes +/// are decoded at the end, so a chunk split mid-UTF-8 is harmless; trailing +/// newlines are trimmed (matching shell substitution). A non-zero exit is an +/// error. fn exec(context: &Context, args: &[Value]) -> Result { let script = match &args[0] { Value::Literali(script) => script, @@ -372,7 +361,7 @@ fn exec(context: &Context, args: &[Value]) -> Result { tee( &buffer[..count], &mut out_pending, - false, + Stream::Stdout, context, &mut captured, )?; @@ -388,7 +377,7 @@ fn exec(context: &Context, args: &[Value]) -> Result { tee( &buffer[..count], &mut err_pending, - true, + Stream::Stderr, context, &mut captured, )?; @@ -401,13 +390,13 @@ fn exec(context: &Context, args: &[Value]) -> Result { if !out_pending.is_empty() { captured.extend_from_slice(&out_pending); context - .write_run(&out_pending, false) + .write_run(&out_pending, Stream::Stdout) .map_err(RunnerError::ExecError)?; } if !err_pending.is_empty() { captured.extend_from_slice(&err_pending); context - .write_run(&err_pending, true) + .write_run(&err_pending, Stream::Stderr) .map_err(RunnerError::ExecError)?; } @@ -430,16 +419,14 @@ fn exec(context: &Context, args: &[Value]) -> Result { )) } -/// Tee a chunk from one stream to the operator, recording it plain in -/// `captured` and writing it through the Context (which reddens it when -/// `stderr` and the terminal is colour-capable). The chunk is appended to that -/// stream's `pending` buffer, then every complete UTF-8 character available is -/// written through; a trailing partial character stays in `pending` for the -/// next read, so a colour escape never lands in the middle of a character. +/// Tee a chunk to the user, recording it plain in `captured` and writing it +/// through the Context. The chunk is appended to `pending`, then every complete +/// UTF-8 character is written through; a trailing partial character stays in +/// `pending` for the next read, so a colour escape never splits a character. fn tee( bytes: &[u8], pending: &mut Vec, - stderr: bool, + stream: Stream, context: &Context, captured: &mut Vec, ) -> Result<(), RunnerError> { @@ -462,7 +449,7 @@ fn tee( } captured.extend_from_slice(&pending[..ready]); context - .write_run(&pending[..ready], stderr) + .write_run(&pending[..ready], stream) .map_err(RunnerError::ExecError)?; pending.drain(..ready); Ok(()) From 7fa5de4d8e8df01e2fa0b983c514156db48c1ce3 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 26 Jun 2026 08:22:33 +1000 Subject: [PATCH 3/6] Move runner tests to golden/ --- tests/{samples => golden}/runner/ConfirmRelease.pfftt | 0 tests/{samples => golden}/runner/ConfirmRelease.tq | 0 tests/{samples => golden}/runner/DemolitionBeams.pfftt | 0 tests/{samples => golden}/runner/DemolitionBeams.tq | 0 tests/{samples => golden}/runner/ExecutionInSteps.pfftt | 0 tests/{samples => golden}/runner/ExecutionInSteps.tq | 0 tests/{samples => golden}/runner/InspectHatches.pfftt | 0 tests/{samples => golden}/runner/InspectHatches.tq | 0 tests/{samples => golden}/runner/PatioCleaning.pfftt | 0 tests/{samples => golden}/runner/PatioCleaning.tq | 0 tests/runner/samples.rs | 2 +- 11 files changed, 1 insertion(+), 1 deletion(-) rename tests/{samples => golden}/runner/ConfirmRelease.pfftt (100%) rename tests/{samples => golden}/runner/ConfirmRelease.tq (100%) rename tests/{samples => golden}/runner/DemolitionBeams.pfftt (100%) rename tests/{samples => golden}/runner/DemolitionBeams.tq (100%) rename tests/{samples => golden}/runner/ExecutionInSteps.pfftt (100%) rename tests/{samples => golden}/runner/ExecutionInSteps.tq (100%) rename tests/{samples => golden}/runner/InspectHatches.pfftt (100%) rename tests/{samples => golden}/runner/InspectHatches.tq (100%) rename tests/{samples => golden}/runner/PatioCleaning.pfftt (100%) rename tests/{samples => golden}/runner/PatioCleaning.tq (100%) diff --git a/tests/samples/runner/ConfirmRelease.pfftt b/tests/golden/runner/ConfirmRelease.pfftt similarity index 100% rename from tests/samples/runner/ConfirmRelease.pfftt rename to tests/golden/runner/ConfirmRelease.pfftt diff --git a/tests/samples/runner/ConfirmRelease.tq b/tests/golden/runner/ConfirmRelease.tq similarity index 100% rename from tests/samples/runner/ConfirmRelease.tq rename to tests/golden/runner/ConfirmRelease.tq diff --git a/tests/samples/runner/DemolitionBeams.pfftt b/tests/golden/runner/DemolitionBeams.pfftt similarity index 100% rename from tests/samples/runner/DemolitionBeams.pfftt rename to tests/golden/runner/DemolitionBeams.pfftt diff --git a/tests/samples/runner/DemolitionBeams.tq b/tests/golden/runner/DemolitionBeams.tq similarity index 100% rename from tests/samples/runner/DemolitionBeams.tq rename to tests/golden/runner/DemolitionBeams.tq diff --git a/tests/samples/runner/ExecutionInSteps.pfftt b/tests/golden/runner/ExecutionInSteps.pfftt similarity index 100% rename from tests/samples/runner/ExecutionInSteps.pfftt rename to tests/golden/runner/ExecutionInSteps.pfftt diff --git a/tests/samples/runner/ExecutionInSteps.tq b/tests/golden/runner/ExecutionInSteps.tq similarity index 100% rename from tests/samples/runner/ExecutionInSteps.tq rename to tests/golden/runner/ExecutionInSteps.tq diff --git a/tests/samples/runner/InspectHatches.pfftt b/tests/golden/runner/InspectHatches.pfftt similarity index 100% rename from tests/samples/runner/InspectHatches.pfftt rename to tests/golden/runner/InspectHatches.pfftt diff --git a/tests/samples/runner/InspectHatches.tq b/tests/golden/runner/InspectHatches.tq similarity index 100% rename from tests/samples/runner/InspectHatches.tq rename to tests/golden/runner/InspectHatches.tq diff --git a/tests/samples/runner/PatioCleaning.pfftt b/tests/golden/runner/PatioCleaning.pfftt similarity index 100% rename from tests/samples/runner/PatioCleaning.pfftt rename to tests/golden/runner/PatioCleaning.pfftt diff --git a/tests/samples/runner/PatioCleaning.tq b/tests/golden/runner/PatioCleaning.tq similarity index 100% rename from tests/samples/runner/PatioCleaning.tq rename to tests/golden/runner/PatioCleaning.tq diff --git a/tests/runner/samples.rs b/tests/runner/samples.rs index 5f6af44..0afbdc4 100644 --- a/tests/runner/samples.rs +++ b/tests/runner/samples.rs @@ -35,7 +35,7 @@ fn strip_timestamp_and_runid(trail: &str) -> Vec { /// matching `.pfftt` also fails. #[test] fn ensure_run() { - let dir = Path::new("tests/samples/runner/"); + let dir = Path::new("tests/golden/runner/"); let files = list_technique_documents(dir); let mut failures = Vec::new(); From fb2c38c9710c3f79f10fa4c1c31c62184664a30c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 26 Jun 2026 08:23:12 +1000 Subject: [PATCH 4/6] Add exec() samples --- tests/parsing/samples.rs | 1 + tests/samples/runner/Curl.tq | 1 + tests/samples/runner/Find.tq | 1 + 3 files changed, 3 insertions(+) create mode 100644 tests/samples/runner/Curl.tq create mode 100644 tests/samples/runner/Find.tq diff --git a/tests/parsing/samples.rs b/tests/parsing/samples.rs index 262aacb..7c5b859 100644 --- a/tests/parsing/samples.rs +++ b/tests/parsing/samples.rs @@ -33,5 +33,6 @@ fn check_directory(dir: &Path) { #[test] fn ensure_parse() { check_directory(Path::new("tests/samples/parsing/")); + check_directory(Path::new("tests/samples/runner/")); check_directory(Path::new("examples/minimal/")); } diff --git a/tests/samples/runner/Curl.tq b/tests/samples/runner/Curl.tq new file mode 100644 index 0000000..b8b6c76 --- /dev/null +++ b/tests/samples/runner/Curl.tq @@ -0,0 +1 @@ +1. { exec("curl -L -o fedora.iso https://download.fedoraproject.org/pub/fedora/linux/releases/44/Workstation/x86_64/iso/Fedora-Workstation-Live-44-1.7.x86_64.iso") } diff --git a/tests/samples/runner/Find.tq b/tests/samples/runner/Find.tq new file mode 100644 index 0000000..e8e4e12 --- /dev/null +++ b/tests/samples/runner/Find.tq @@ -0,0 +1 @@ +1. { exec("find /tmp") } From 934785bba9a93fdb85aac955d5472211a697ff3a Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 26 Jun 2026 08:22:45 +1000 Subject: [PATCH 5/6] Fix interleaving of stderr with stdout --- src/runner/library.rs | 46 ++++++++++++++++--------------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/src/runner/library.rs b/src/runner/library.rs index 8895ec2..66351f2 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -303,15 +303,12 @@ fn exec(context: &Context, args: &[Value]) -> Result { .expect("child stderr was piped"); // The captured return value stays plain; the Context reddens stderr on a - // colour terminal as it writes. Each stream keeps a small buffer holding a - // partial UTF-8 sequence carried over from the previous read, so a colour - // escape is never written into the middle of a character. - let mut captured = Vec::new(); - let mut out_pending = Vec::new(); - let mut err_pending = Vec::new(); - let mut out_open = true; - let mut err_open = true; - let mut buffer = [0u8; 8192]; + // colour terminal as it writes. Each stream keeps a `pending` buffer + // holding partial lines, as bytes are only flushed to terminal at newline + // to avoid the two stream stomping on each other. + let mut captured = Vec::new(); let mut out_pending = Vec::new(); let mut + err_pending = Vec::new(); let mut out_open = true; let mut err_open = + true; let mut buffer = [0u8; 8192]; while out_open || err_open { // Ask poll() which still-open stream is readable, copying the readiness @@ -385,8 +382,7 @@ fn exec(context: &Context, args: &[Value]) -> Result { } } - // Flush any bytes still held back (a trailing partial character, decoded - // lossily) now that no continuation can arrive. + // Flush each stream's final partial content, if any is remaining. if !out_pending.is_empty() { captured.extend_from_slice(&out_pending); context @@ -420,9 +416,10 @@ fn exec(context: &Context, args: &[Value]) -> Result { } /// Tee a chunk to the user, recording it plain in `captured` and writing it -/// through the Context. The chunk is appended to `pending`, then every complete -/// UTF-8 character is written through; a trailing partial character stays in -/// `pending` for the next read, so a colour escape never splits a character. +/// through the Context. Only whole lines are written through; the trailing +/// partial line waits in `pending` for its newline (or the stream's close), so +/// a `Stream::Stderr` run never lands inside a `Stream::Stdout` line. A newline +/// is always a UTF-8 boundary, so this never splits a character either. fn tee( bytes: &[u8], pending: &mut Vec, @@ -431,22 +428,13 @@ fn tee( captured: &mut Vec, ) -> Result<(), RunnerError> { pending.extend_from_slice(bytes); - // How much of `pending` ends on a character boundary: all of it unless the - // tail is an incomplete sequence. - let ready = match std::str::from_utf8(pending) { - Ok(_) => pending.len(), - Err(error) - if error - .error_len() - .is_none() => - { - error.valid_up_to() - } - Err(_) => pending.len(), + let ready = match pending + .iter() + .rposition(|b| *b == b'\n' || *b == b'\r') + { + Some(last) => last + 1, + None => return Ok(()), }; - if ready == 0 { - return Ok(()); - } captured.extend_from_slice(&pending[..ready]); context .write_run(&pending[..ready], stream) From 0ff42bfa589651176af0f22fb84c5dfdb6d14c88 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 26 Jun 2026 22:48:34 +1000 Subject: [PATCH 6/6] Allow override of steps failing due to substep failure --- src/main.rs | 1 + src/runner/checks/driver.rs | 49 ++++++++- src/runner/checks/runner.rs | 76 +++++++++++++- src/runner/driver.rs | 200 +++++++++++++++++++++++++++--------- src/runner/library.rs | 9 +- src/runner/runner.rs | 102 ++++++++++++++++-- 6 files changed, 370 insertions(+), 67 deletions(-) diff --git a/src/main.rs b/src/main.rs index f445153..e8f77ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -967,6 +967,7 @@ fn main() { ); std::process::exit(0); } + Ok((_, Outcome::Failed(_) | Outcome::Throw(_))) => std::process::exit(1), Ok((_, _)) => std::process::exit(0), Err(error) => { eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index f2aa805..5450bd8 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -1,7 +1,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::runner::driver::{ - draw, edit, is_list_forma, Automatic, Console, Driver, Event, Interaction, Mock, UserInput, + draw, edit, is_list_forma, Automatic, Console, Driver, Event, Interaction, Mock, Standing, + UserInput, }; use crate::value::{Numeric, Value}; @@ -160,6 +161,51 @@ fn default_enter_completes_with_produced() { ); } +#[test] +fn overrule_fail_enter_propagates() { + // At a failed sign-off the default is the failure itself: a bare Enter + // settles Fail and never silently lifts it. + let mut it = Interaction::overrule(Standing::Fail); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Fail(String::new())) + ); +} + +#[test] +fn overrule_fail_menu_o_overrides() { + // Override is reachable only deliberately — from the menu — and settles as + // Override, which the runner lifts to a rollup-severing Done. + let mut it = Interaction::overrule(Standing::Fail); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)), + Some(UserInput::Override) + ); +} + +#[test] +fn overrule_skip_enter_propagates() { + // An all-skipped scope defaults to Skip, not Done. + let mut it = Interaction::overrule(Standing::Skip); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Skip) + ); +} + +#[test] +fn override_inert_without_a_failure() { + // A Skip standing has nothing to override, so the menu's `o` is greyed and + // does nothing. + let mut it = Interaction::overrule(Standing::Skip); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)), + None + ); +} + #[test] fn esc_edit_typed_enter_returns_literali() { // Editing is opt-in: Esc -> Edit (the first menu item) opens the buffer @@ -511,6 +557,7 @@ fn list_prompt() -> Interaction { field: edit(String::new(), Value::Literali(String::new()), true), menu: None, reason: None, + standing: Standing::Done, } } diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 253e050..62a32bf 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1254,9 +1254,10 @@ check : } #[test] -fn automatic_failing_exec_fails_step_and_continues() { - // A non-zero exec exit settles its step Fail; the walk continues to the - // sibling below rather than aborting the run. +fn automatic_failing_exec_fails_run_and_continues() { + // A non-zero exec exit settles its step Fail and, with no user present to + // overrule it, rolls the autonomous run up to Failed; the walk still + // continues to the sibling below rather than aborting at the failure. let source = r#" % technique v1 @@ -1286,8 +1287,8 @@ check : .run(Environment::new()) .expect("run"); match outcome { - Outcome::Done(_) => {} - other => panic!("expected Done, got {:?}", other), + Outcome::Failed(_) => {} + other => panic!("expected Failed, got {:?}", other), } let pfftt = fixture.pfftt_contents(); @@ -1310,6 +1311,71 @@ check : .any(|r| r.path == "/check:/2")); } +const ONE_FAILED_STEP: &str = r#" +% technique v1 + +check : + +1. A step the user fails + "#; + +#[test] +fn interactive_override_severs_the_rollup_to_done() { + // The user fails the step, then deliberately Overrides the procedure's + // sign-off: the override settles it Done, severing the rollup so the failed + // child does not propagate. Only an interactive run can do this. + let source = ONE_FAILED_STEP.trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parsed"); + let mut program = translate(&document).expect("translated"); + resolve(&mut program).expect("resolve"); + + let mut fixture = StoreFixture::new("interactive-override"); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashMap::new(), + Mock::with_answers([UserInput::Fail("not done".to_string()), UserInput::Override]), + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + match outcome { + Outcome::Done(_) => {} + other => panic!("expected Done after override, got {:?}", other), + } +} + +#[test] +fn interactive_accepting_a_failure_propagates() { + // Without an Override, the failed step stands: the procedure rolls up to + // Failed even with the user at the controls. + let source = ONE_FAILED_STEP.trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parsed"); + let mut program = translate(&document).expect("translated"); + resolve(&mut program).expect("resolve"); + + let mut fixture = StoreFixture::new("interactive-propagate"); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashMap::new(), + // The step's Fail, then the sign-off accepts the standing failure. + Mock::with_answers([ + UserInput::Fail("not done".to_string()), + UserInput::Fail(String::new()), + ]), + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + match outcome { + Outcome::Failed(_) => {} + other => panic!("expected Failed, got {:?}", other), + } +} + #[test] fn description_instruction_records_under_step_zero() { // An exec in the procedure description runs in the anonymous step-0 diff --git a/src/runner/driver.rs b/src/runner/driver.rs index c64b624..5088457 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -44,9 +44,21 @@ pub enum UserInput { Done(Value), Skip, Fail(String), + /// Deliberately accept a node whose child failed, settling it `Done ()` and + /// severing the rollup so the failure does not propagate above. Reachable + /// only from the `` menu at a failed node, never the Enter default. + Override, Quit, } +/// The default verdict a node's rolled-up body leaves standing at its sign-off. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Standing { + Done, + Skip, + Fail, +} + /// What the walker uses to drive a run. Implementations are the interactive /// console `Console`, the no-operator `Automatic`, the no-output `Headless`, /// the debugging `Transcript`, and the test `Mock`. @@ -139,6 +151,17 @@ pub trait Driver { /// drives unattended `Done`/`Skip` as in `ask`. fn seal(&mut self, qualified: &str, produced: Value, computable: bool) -> UserInput; + /// Sign off a node that rolled up to a failure or skip. When running + /// interactively a failure may be Overridden to Done. + fn overrule(&mut self, qualified: &str, marker: &str, standing: Standing) -> UserInput { + let _ = (qualified, marker); + match standing { + Standing::Done => UserInput::Done(Value::Unitus), + Standing::Skip => UserInput::Skip, + Standing::Fail => UserInput::Fail(String::new()), + } + } + /// Render the settled verdict line for a step or scope close: `marker` /// (`→` step, `↙` scope close), Qualified Name, and the verdict's glyph. /// Quit renders nothing. @@ -266,6 +289,10 @@ impl Driver for Console { prompt(&mut self.output, qualified, "↙", &[], produced) } + fn overrule(&mut self, qualified: &str, marker: &str, standing: Standing) -> UserInput { + prompt_overrule(&mut self.output, qualified, marker, standing) + } + fn settle(&mut self, marker: &str, qualified: &str, verdict: &UserInput) { let qualified = display_path(qualified); let renderer = self.renderer(); @@ -308,6 +335,21 @@ fn prompt( result } +fn prompt_overrule( + out: &mut W, + qualified: &str, + marker: &str, + standing: Standing, +) -> UserInput { + let qualified = display_path(qualified); + let result = interact(out, Interaction::overrule(standing), |o, i| { + draw(o, &qualified, marker, i) + }); + let _ = queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine)); + let _ = out.flush(); + result +} + /// Present a read-only action on the live prompt line — `» {path} {verb} {label} ▶` /// — and settle on the user's verdict. On Done it leaves a compact dark-grey /// trace `» {path} {name}()`; Skip / Fail / Quit clear the line for the step's @@ -351,42 +393,19 @@ fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserI field, menu: None, reason: None, + standing: Standing::Done, }, |o, i| draw(o, &qualified, "→", i), ); - let col = "→" - .chars() - .count() as u16 - + 1 - + qualified - .chars() - .count() as u16 - + 1; - match &result { - UserInput::Done(produced) => { - let ran = if let Value::Literali(text) = produced { - text.trim_end() - } else { - script.trim_end() - }; - let _ = queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine)); - let _ = writeln!(out, "{}", format!("→ {} $ {}", qualified, ran).dark_grey()); - } - UserInput::Quit => { - let _ = writeln!(out); - } - UserInput::Skip => { - let _ = queue!(out, cursor::MoveToColumn(col)); - let _ = write!(out, "{}", "⊘".yellow()); - let _ = queue!(out, Clear(ClearType::UntilNewLine)); - let _ = writeln!(out); - } - UserInput::Fail(_) => { - let _ = queue!(out, cursor::MoveToColumn(col)); - let _ = write!(out, "{}", "✗".red()); - let _ = queue!(out, Clear(ClearType::UntilNewLine)); - let _ = writeln!(out); - } + + let _ = queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine)); + if let UserInput::Done(produced) = &result { + let ran = if let Value::Literali(text) = produced { + text.trim_end() + } else { + script.trim_end() + }; + let _ = writeln!(out, "{}", format!("→ {} $ {}", qualified, ran).dark_grey()); } let _ = out.flush(); result @@ -403,6 +422,7 @@ fn prompt_acquire(out: &mut W, label: &str, list: bool) -> UserInput { field, menu: None, reason: None, + standing: Standing::Done, }, |o, i| draw(o, label, "↘", i), ); @@ -490,7 +510,7 @@ fn render_enter(out: &mut W, qualified: &str, renderer: &dyn Render) { /// renders no glyph). fn verdict_glyph(verdict: &UserInput) -> Option<(&'static str, Syntax)> { match verdict { - UserInput::Done(_) => Some(("✓", Syntax::Done)), + UserInput::Done(_) | UserInput::Override => Some(("✓", Syntax::Done)), UserInput::Skip => Some(("⊘", Syntax::Skip)), UserInput::Fail(_) => Some(("✗", Syntax::Fail)), UserInput::Quit => None, @@ -554,6 +574,7 @@ enum MenuItem { Edit, Skip, Fail, + Override, Quit, } @@ -563,18 +584,21 @@ impl MenuItem { MenuItem::Edit => "Edit", MenuItem::Skip => "Skip", MenuItem::Fail => "Fail", + MenuItem::Override => "Override", MenuItem::Quit => "Quit", } } } -/// The Esc-menu, always shown in full. `Edit` leads but is greyed and -/// unselectable unless the produced value is an editable scalar, so the menu -/// teaches that some steps can be edited while most cannot. -const MENU: [MenuItem; 4] = [ +/// The Esc-menu, always shown in full. `Edit` leads but is greyed unless the +/// produced value is an editable scalar; `Override` is greyed unless a child +/// failed, so the menu teaches both which steps can be edited and that a +/// failure can be deliberately accepted. +const MENU: [MenuItem; 5] = [ MenuItem::Edit, MenuItem::Skip, MenuItem::Fail, + MenuItem::Override, MenuItem::Quit, ]; @@ -670,6 +694,10 @@ struct Interaction { field: Field, menu: Option, reason: Option, + /// The node's rolled-up default — `Done` for an ordinary prompt, `Fail` or + /// `Skip` at an `overrule`. Colours the `▶`, enables `Override` (only on + /// `Fail`), and is where the `` menu opens. + standing: Standing, } impl Interaction { @@ -702,31 +730,64 @@ impl Interaction { field, menu: None, reason: None, + standing: Standing::Done, + } + } + + /// Seed an interaction to sign off a node whose body rolled up to `standing` + /// (`Fail` or `Skip`). No value to accept, so the field is a frozen Unit; + /// the standing colours the `▶` and, on `Fail`, lights up `Override`. + fn overrule(standing: Standing) -> Self { + Interaction { + field: Field::Frozen { + produced: Value::Unitus, + }, + menu: None, + reason: None, + standing, } } - /// Whether a menu item is currently selectable. Only `Edit` is ever - /// disabled — offered when the produced value is an editable scalar, greyed - /// otherwise; the exits are always available. Navigation skips a disabled - /// item and the menu never opens onto one. + /// Whether a menu item is currently selectable. `Edit` is offered only when + /// the produced value is an editable scalar; `Override` only when a child + /// failed (`standing` is `Fail`); the exits are always available. Navigation + /// skips a disabled item and the menu never opens onto one. fn enabled(&self, item: MenuItem) -> bool { match item { MenuItem::Edit => match &self.field { Field::Frozen { produced } => editable_seed(produced).is_some(), _ => false, }, + MenuItem::Override => self.standing == Standing::Fail, _ => true, } } - /// First selectable item, where the menu opens. The exits are always - /// enabled, so the fallback never fires. + /// First selectable item. The exits are always enabled, so the fallback + /// never fires. fn first_enabled(&self) -> usize { (0..MENU.len()) .find(|&i| self.enabled(MENU[i])) .unwrap_or(0) } + /// Where the menu opens: on the node's standing verdict at an overrule (so + /// the cursor rests on what Enter would settle), otherwise the first + /// selectable item. + fn landing(&self) -> usize { + let target = match self.standing { + Standing::Fail => Some(MenuItem::Fail), + Standing::Skip => Some(MenuItem::Skip), + Standing::Done => None, + }; + target + .and_then(|item| { + MENU.iter() + .position(|m| *m == item) + }) + .unwrap_or_else(|| self.first_enabled()) + } + /// Nearest selectable item after / before `from`, or `None` at the edge — /// so navigation stops rather than wrapping, and steps over a greyed item. fn next_enabled(&self, from: usize) -> Option { @@ -796,6 +857,7 @@ impl Interaction { }); None } + MenuItem::Override => Some(UserInput::Override), MenuItem::Quit => Some(UserInput::Quit), } } @@ -829,6 +891,9 @@ impl Interaction { KeyCode::Enter => self.activate(MENU[active]), KeyCode::Char('s') | KeyCode::Char('S') => self.choose(MenuItem::Skip), KeyCode::Char('f') | KeyCode::Char('F') => self.choose(MenuItem::Fail), + KeyCode::Char('o') | KeyCode::Char('O') if self.enabled(MenuItem::Override) => { + self.choose(MenuItem::Override) + } KeyCode::Char('q') | KeyCode::Char('Q') => self.choose(MenuItem::Quit), KeyCode::Esc => { self.menu = None; @@ -861,9 +926,10 @@ impl Interaction { fn field_key(&mut self, code: KeyCode) -> Option { if let KeyCode::Esc = code { - self.menu = Some(self.first_enabled()); + self.menu = Some(self.landing()); return None; } + let standing = self.standing; match &mut self.field { Field::Edit { buffer, @@ -905,7 +971,15 @@ impl Interaction { } }, Field::Frozen { produced } => match code { - KeyCode::Enter => Some(UserInput::Done(std::mem::replace(produced, Value::Unitus))), + // Enter accepts the standing verdict: an ordinary prompt's Done, + // but at an overrule the failure (or skip) the body left — so + // Enter never silently lifts a failure; only the Override menu + // item does. + KeyCode::Enter => Some(match standing { + Standing::Done => UserInput::Done(std::mem::replace(produced, Value::Unitus)), + Standing::Skip => UserInput::Skip, + Standing::Fail => UserInput::Fail(String::new()), + }), _ => None, }, Field::Choose { choices, active } => match code { @@ -943,11 +1017,27 @@ fn draw( queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine))?; let prefix = prompt_prefix_width(qualified, settle); + // The `▶` previews the verdict Enter will settle: blue for an ordinary Done, + // else the failure / skip glyph's own colour so the triangle matches the + // `✗` / `⊘` it foreshadows. + let symbol = match interaction.standing { + Standing::Done => PROMPT_SYMBOL.blue(), + Standing::Fail => PROMPT_SYMBOL.with(Color::Rgb { + r: 0xcc, + g: 0x00, + b: 0x00, + }), + Standing::Skip => PROMPT_SYMBOL.with(Color::Rgb { + r: 0xc4, + g: 0xa0, + b: 0x00, + }), + }; write!( out, "{} {} ", format!("{} {}", settle, qualified).dark_grey(), - PROMPT_SYMBOL.blue(), + symbol, )?; let (cursor_col, end_col) = draw_tail(out, interaction, prefix)?; place_cursor(out, cursor_col, end_col) @@ -1466,7 +1556,7 @@ impl Transcript { fn trace_outcome(&mut self, path: &str, produced: Value, outcome: &UserInput) { let outcome = match outcome { - UserInput::Done(_) => Disposition::Done, + UserInput::Done(_) | UserInput::Override => Disposition::Done, UserInput::Skip => Disposition::Skip, UserInput::Fail(reason) => Disposition::Fail(reason.clone()), UserInput::Quit => Disposition::Stop, @@ -1886,6 +1976,20 @@ impl Driver for Mock { UserInput::Done(Value::Unitus) } + fn overrule(&mut self, qualified: &str, _marker: &str, standing: Standing) -> UserInput { + self.events + .push(Event::Seal { + qualified: qualified.to_string(), + }); + self.answers + .pop_front() + .unwrap_or(match standing { + Standing::Done => UserInput::Done(Value::Unitus), + Standing::Skip => UserInput::Skip, + Standing::Fail => UserInput::Fail(String::new()), + }) + } + fn settle(&mut self, _marker: &str, _qualified: &str, _verdict: &UserInput) {} fn acquire(&mut self, _qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { diff --git a/src/runner/library.rs b/src/runner/library.rs index 66351f2..707ba52 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -306,9 +306,12 @@ fn exec(context: &Context, args: &[Value]) -> Result { // colour terminal as it writes. Each stream keeps a `pending` buffer // holding partial lines, as bytes are only flushed to terminal at newline // to avoid the two stream stomping on each other. - let mut captured = Vec::new(); let mut out_pending = Vec::new(); let mut - err_pending = Vec::new(); let mut out_open = true; let mut err_open = - true; let mut buffer = [0u8; 8192]; + let mut captured = Vec::new(); + let mut out_pending = Vec::new(); + let mut err_pending = Vec::new(); + let mut out_open = true; + let mut err_open = true; + let mut buffer = [0u8; 8192]; while out_open || err_open { // Ask poll() which still-open stream is readable, copying the readiness diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 2fdee09..a95e179 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -5,7 +5,7 @@ use std::io; use std::path::PathBuf; use super::context::Context; -use super::driver::{Driver, UserInput}; +use super::driver::{Driver, Standing, UserInput}; use super::evaluator::Environment; use super::library::{Library, Nature}; use super::path::{PathSegment, QualifiedPath}; @@ -405,6 +405,9 @@ impl<'i, D: Driver> Runner<'i, D> { } UserInput::Skip => Ok(Outcome::Skipped(Value::Unitus)), UserInput::Fail(reason) => Ok(Outcome::Throw(Failure::Aborted(reason))), + UserInput::Override => { + unreachable!("a command prompt never offers Override") + } UserInput::Quit => self.record_stop(), } } @@ -426,6 +429,9 @@ impl<'i, D: Driver> Runner<'i, D> { } UserInput::Skip => Ok(Outcome::Skipped(Value::Unitus)), UserInput::Fail(reason) => Ok(Outcome::Throw(Failure::Aborted(reason))), + UserInput::Override => { + unreachable!("an action prompt never offers Override") + } UserInput::Quit => self.record_stop(), } } @@ -941,6 +947,7 @@ impl<'i, D: Driver> Runner<'i, D> { UserInput::Fail(reason) => { return Ok(Outcome::Failed(Failure::Aborted(reason))) } + UserInput::Override => unreachable!("an acquire prompt never offers Override"), UserInput::Quit => return self.record_stop(), } } @@ -1079,6 +1086,9 @@ impl<'i, D: Driver> Runner<'i, D> { ) -> Result { let mut parallel_idx: usize = 0; let mut last = Value::Unitus; + let mut failed: Option = None; + let mut done_seen = false; + let mut skip_seen = false; for op in ops { let outcome = match op { Operation::Step { ordinal, .. } => { @@ -1094,16 +1104,35 @@ impl<'i, D: Driver> Runner<'i, D> { _ => self.walk(env, op)?, }; match outcome { - Outcome::Done(value) | Outcome::Skipped(value) => last = value, + Outcome::Done(value) => { + done_seen = true; + last = value; + } + Outcome::Skipped(value) => { + skip_seen = true; + last = value; + } Outcome::Stopped => return Ok(Outcome::Stopped), // A Throw is a hard failure mid-body; it propagates up to the - // enclosing step. A plain Fail is a step's recorded verdict and - // the sequence continues to its siblings. + // enclosing step at once. A Fail is a step's recorded verdict; the + // sequence runs on to its siblings but carries the failure so the + // enclosing scope rolls up as failed (its sign-off may overrule). Outcome::Throw(failure) => return Ok(Outcome::Throw(failure)), - Outcome::Failed(_) => {} + Outcome::Failed(failure) => { + if failed.is_none() { + failed = Some(failure); + } + } } } - Ok(Outcome::Done(last)) + // Roll the children up by worst-wins precedence: any failure fails the + // sequence; otherwise a single green makes it Done; only an all-skipped + // (non-empty) sequence rolls up to Skip. + match failed { + Some(failure) => Ok(Outcome::Failed(failure)), + None if !done_seen && skip_seen => Ok(Outcome::Skipped(last)), + None => Ok(Outcome::Done(last)), + } } fn walk_section( @@ -1325,9 +1354,32 @@ impl<'i, D: Driver> Runner<'i, D> { match self.walk(env, body)? { Outcome::Stopped => return Ok(Outcome::Stopped), Outcome::Done(value) => value, - // The body settled itself — a declined command beat (Skip / Fail) - // or a thrown exec failure, which catches here as a Fail. Record - // and show its verdict without an acceptance prompt. + // A rolled-up child failure signs off through `overrule`: the + // failure stands and propagates by default, but an interactive + // run may Override it to Done, severing the rollup. + Outcome::Failed(_) => { + let input = self + .driver + .overrule(qualified, "→", Standing::Fail); + if let UserInput::Quit = input { + return self.record_stop(); + } + self.driver + .settle("→", qualified, &input); + let outcome = outcome_from(input); + let record = Record { + recorded: now_iso8601(), + run_id, + path: qualified.to_string(), + state: record_state(&outcome), + }; + self.appender + .append(&record)?; + return Ok(outcome); + } + // The body settled itself — a declined command beat (Skip) or a + // thrown exec failure (caught here as a Fail). Record and show + // its verdict without an acceptance prompt. settled => { let settled = match settled { Outcome::Throw(failure) => Outcome::Failed(failure), @@ -1559,6 +1611,33 @@ impl<'i, D: Driver> Runner<'i, D> { outcome: Outcome, computable: bool, ) -> Result { + let standing = match &outcome { + Outcome::Failed(_) | Outcome::Throw(_) => Some(Standing::Fail), + Outcome::Skipped(_) => Some(Standing::Skip), + _ => None, + }; + if let Some(standing) = standing { + let input = self + .driver + .overrule(qualified, "↙", standing); + if let UserInput::Quit = input { + return self.record_stop(); + } + self.driver + .settle("↙", qualified, &input); + let settled = outcome_from(input); + let record = Record { + recorded: now_iso8601(), + run_id: self + .appender + .run_id(), + path: qualified.to_string(), + state: record_state(&settled), + }; + self.appender + .append(&record)?; + return Ok(settled); + } let produced = match outcome { Outcome::Done(value) | Outcome::Skipped(value) => value, _ => Value::Unitus, @@ -1662,10 +1741,13 @@ fn computable(op: &Operation) -> bool { } } -/// Lift a `UserInput` from the prompt into the runner's `Outcome`. +/// Lift a `UserInput` from the prompt into the runner's `Outcome`. An Override +/// settles `Done ()`, severing the rollup so the failed child below does not +/// propagate past this node. fn outcome_from(input: UserInput) -> Outcome { match input { UserInput::Done(value) => Outcome::Done(value), + UserInput::Override => Outcome::Done(Value::Unitus), UserInput::Skip => Outcome::Skipped(Value::Unitus), UserInput::Fail(reason) => Outcome::Failed(Failure::Aborted(reason)), UserInput::Quit => Outcome::Stopped,