diff --git a/src/main.rs b/src/main.rs index 1f0e659..26cf923 100644 --- a/src/main.rs +++ b/src/main.rs @@ -344,12 +344,33 @@ fn main() { ) .arg( Arg::new("mode") - .short('m') .long("mode") - .value_parser(["interactive", "automatic"]) + .value_parser(["interactive", "automatic", "quiet"]) .default_value("interactive") .action(ArgAction::Set) - .help("Whether to walk the procedure interactively, prompting the operator at each step, or automatically, taking each step's computed value and running to completion or first failure."), + .conflicts_with_all(["interactive", "automatic", "quiet"]) + .help("How to walk the procedure: interactively, prompting the operator at each step; automatically, taking each step's computed value and running to completion or first failure; or quietly, also running automatically but suppressing all progress trace output, so that only the output of external commands is printed to the terminal."), + ) + .arg( + Arg::new("interactive") + .short('i') + .action(ArgAction::SetTrue) + .conflicts_with_all(["automatic", "quiet"]) + .help("Short form for --mode=interactive."), + ) + .arg( + Arg::new("automatic") + .short('a') + .action(ArgAction::SetTrue) + .conflicts_with_all(["interactive", "quiet"]) + .help("Short form for --mode=automatic."), + ) + .arg( + Arg::new("quiet") + .short('q') + .action(ArgAction::SetTrue) + .conflicts_with_all(["interactive", "automatic"]) + .help("Short form for --mode=quiet."), ) .arg( Arg::new("output") @@ -793,14 +814,37 @@ fn main() { debug!(?output); - let mode = match submatches - .get_one::("mode") - .map(String::as_str) + // The short flags -i / -a / -q are shorthand overrides for --mode; + // clap has already enforced they are mutually exclusive. + let mode = if *submatches + .get_one::("quiet") + .unwrap() + { + Mode::Quiet + } else if *submatches + .get_one::("automatic") + .unwrap() { - Some("automatic") => Mode::Automatic, - _ => Mode::Interactive, + Mode::Automatic + } else if *submatches + .get_one::("interactive") + .unwrap() + { + Mode::Interactive + } else { + match submatches + .get_one::("mode") + .map(String::as_str) + { + Some("automatic") => Mode::Automatic, + Some("quiet") => Mode::Quiet, + Some("interactive") => Mode::Interactive, + _ => unreachable!() + } }; + debug!(?mode); + let raw_output = *submatches .get_one::("raw-control-chars") .unwrap(); // flags are always present since SetTrue implies default_value diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 9004d5d..253e050 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -2412,10 +2412,14 @@ fn deferred_invoke_is_prompted_and_recorded() { anonymous_with_body(Operation::Sequence(vec![invoke])) } - // The operator marks the external procedure Done. + // The operator confirms the departure, then marks the external procedure + // Done at the return. let mut fixture = StoreFixture::new("deferred-done"); let program = deferred_program(); - let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); let mut runner = Runner::new( &program, fixture.take_appender(), @@ -2463,7 +2467,7 @@ fn deferred_invoke_is_prompted_and_recorded() { // concern, tested elsewhere; what matters here is the recorded Skip.) let mut fixture = StoreFixture::new("deferred-skip"); let program = deferred_program(); - let prompt = Mock::with_answers([UserInput::Skip]); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus), UserInput::Skip]); let mut runner = Runner::new( &program, fixture.take_appender(), @@ -2527,13 +2531,13 @@ cleanup : resolve(&mut program).expect("resolve"); let mut fixture = StoreFixture::new("descriptive-binding-acquire"); - // The acquire for `items` pops first, then the step and substep verdicts. + // The acquire for `items` doubles as step 1's completion; step 2 and its + // two substeps then take their verdicts. let prompt = Mock::with_answers([ UserInput::Done(Value::Literali(r#"["east", "west"]"#.to_string())), UserInput::Done(Value::Unitus), UserInput::Done(Value::Unitus), UserInput::Done(Value::Unitus), - UserInput::Done(Value::Unitus), ]); let mut runner = Runner::new( &program, @@ -2596,13 +2600,12 @@ task : resolve(&mut program).expect("resolve"); let mut fixture = StoreFixture::new("descriptive-tuple-acquire"); - // Two acquires (account_number, then account_name), then the two step - // verdicts. + // Two acquires (account_number, then account_name); the second doubles as + // step 1's completion, then step 2 takes its verdict. let prompt = Mock::with_answers([ UserInput::Done(Value::Literali("12345".to_string())), UserInput::Done(Value::Literali("Acme".to_string())), UserInput::Done(Value::Unitus), - UserInput::Done(Value::Unitus), ]); let mut runner = Runner::new( &program, @@ -2633,6 +2636,159 @@ task : assert_eq!(acquired, vec![Some("account_number"), Some("account_name")]); } +#[test] +fn descriptive_binding_settles_without_a_second_prompt() { + // `Do something ~ answer` solicits the value once at its acquire prompt; + // that input is the step's verdict, so the step settles Done with the + // acquired value rather than asking a redundant second time. The plain step + // that follows is the only one to take an `ask`. + let source = r#" +% technique v1 + +task : + + 1. Do something ~ answer + 2. Then check the result + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let mut program = translate(&document).expect("translate"); + resolve(&mut program).expect("resolve"); + + let mut fixture = StoreFixture::new("descriptive-binding-settles"); + let prompt = Mock::with_answers([ + UserInput::Done(Value::Literali("42".to_string())), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashMap::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + + let driver = runner.into_driver(); + let acquires = driver + .events() + .iter() + .filter(|event| { + if let Event::Acquire { .. } = event { + true + } else { + false + } + }) + .count(); + let asks = driver + .events() + .iter() + .filter(|event| { + if let Event::Ask { .. } = event { + true + } else { + false + } + }) + .count(); + // One acquire (for `answer`) and one ask (for step 2 only): the binding + // step never reaches an `ask`. + assert_eq!(acquires, 1); + assert_eq!(asks, 1); + + let pfftt = fixture.pfftt_contents(); + let record = pfftt + .lines() + .filter_map(|line| parse_record(line).ok()) + .find(|record| { + record + .path + .ends_with("/1") + && if let State::Done(_) = record.state { + true + } else { + false + } + }) + .expect("step 1 recorded Done"); + let State::Done(Some(value)) = record.state else { + panic!("expected step 1 Done carrying the acquired value"); + }; + assert_eq!(value, Value::Literali("42".to_string())); +} + +#[test] +fn response_choice_binds_to_the_step_variable() { + // A step carrying both a descriptive binding and response choices takes its + // value from the chosen response: the menu is the only prompt (no separate + // acquire), and the choice binds to the variable for later steps to read. + let source = r#" +% technique v1 + +task : + + 1. Is it working ~ answer + 'Yes' | 'No' + 2. You said { answer } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let mut program = translate(&document).expect("translate"); + resolve(&mut program).expect("resolve"); + + let mut fixture = StoreFixture::new("response-binds-variable"); + let prompt = Mock::with_answers([ + UserInput::Done(Value::Literali("Yes".to_string())), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashMap::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + + let driver = runner.into_driver(); + // The response menu replaces the acquire: the binding step never prompts to + // type a value. + let acquires = driver + .events() + .iter() + .filter(|event| { + if let Event::Acquire { .. } = event { + true + } else { + false + } + }) + .count(); + assert_eq!(acquires, 0); + + // Step 2 interpolates `answer`, proving the chosen response bound to it. + let descriptions: Vec<&str> = driver + .events() + .iter() + .filter_map(|event| { + if let Event::Step { description, .. } = event { + Some(description.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!( + descriptions, + vec!["1. Is it working ~ answer", "2. You said \"Yes\""] + ); +} + #[test] fn resume_rehydrates_binding_made_inside_a_completed_loop() { // The DeleteAccount resume bug: a completed `foreach` step binds a variable @@ -2824,11 +2980,11 @@ sweep : resolve(&mut program).expect("resolve"); let mut fixture = StoreFixture::new("iterated-binding"); + // The acquire for `regions` doubles as step 1's completion; step 2's + // foreach iterates zero times, so only its own verdict follows. let prompt = Mock::with_answers([ UserInput::Done(Value::Literali("[]".to_string())), UserInput::Done(Value::Unitus), - UserInput::Done(Value::Unitus), - UserInput::Done(Value::Unitus), ]); let mut runner = Runner::new( &program, diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 0aa6700..9b357f1 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -25,11 +25,14 @@ use crate::highlighting::Terminal; use crate::value::Value; /// Which driver walks a run: `Interactive` prompts the user, `Automatic` runs -/// to completion, taking each step's body value as the result. +/// to completion taking each step's body value as the result, `Quiet` does the +/// same but with the no-output `Headless` driver, leaving only executed +/// commands' output on the terminal. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Mode { Interactive, Automatic, + Quiet, } /// The person executing each step indicates a verdict on each prompt as @@ -64,8 +67,10 @@ pub trait Driver { fn commence(&mut self, label: &str); /// Cross out of this document on the way out: the `⇐` boundary line carrying - /// the run identifier (`/ 000096`). - fn conclude(&mut self, label: &str); + /// the document name, version, and run identifier (`/ NetworkProbe,1 000096`), + /// closed by the run's rolled-up verdict glyph just as a scope's `↙` sign-off + /// is. Quit renders no glyph. + fn conclude(&mut self, label: &str, verdict: &UserInput); /// Display a line of formatted content at the left margin. fn display(&mut self, content: &str); @@ -91,6 +96,14 @@ pub trait Driver { computable: bool, ) -> UserInput; + /// Cross out into an external `` procedure: prompt at the `⇒` departure + /// — the place arguments would be solicited — then leave the glyph-less `⇒` + /// depart line, paired with the `⇐` return that `external` prompts and + /// `settle` closes with the verdict. The same document-boundary crossing the + /// run's own `commence`/`conclude` makes, one level down. `Quit` abandons + /// before departing; the unattended drivers proceed with `Done`. + fn depart(&mut self, qualified: &str) -> UserInput; + /// Settle an external invocation this run cannot perform (a `` call /// into another document or system). `Console` prompts the operator to /// attest it — `Done` if it was performed or recorded elsewhere, otherwise @@ -190,9 +203,9 @@ impl Driver for Console { let _ = writeln!(self.output); } - fn conclude(&mut self, label: &str) { + fn conclude(&mut self, label: &str, verdict: &UserInput) { let renderer = self.renderer(); - write_marker_line(&mut self.output, &format!("⇐ {}", label), renderer); + render_conclude(&mut self.output, label, verdict, renderer); } fn display(&mut self, content: &str) { @@ -223,8 +236,18 @@ impl Driver for Console { prompt(&mut self.output, qualified, "→", choices, produced) } + fn depart(&mut self, qualified: &str) -> UserInput { + let input = prompt(&mut self.output, qualified, "⇒", &[], Value::Unitus); + if let UserInput::Quit = input { + } else { + let renderer = self.renderer(); + write_marker_line(&mut self.output, &format!("⇒ {}", display_path(qualified)), renderer); + } + input + } + fn external(&mut self, qualified: &str) -> UserInput { - prompt(&mut self.output, qualified, "⇒", &[], Value::Unitus) + prompt(&mut self.output, qualified, "⇐", &[], Value::Unitus) } fn command(&mut self, qualified: &str, script: &str) -> UserInput { @@ -305,10 +328,10 @@ fn prompt_action( } /// Solicit the user's approval to run a shell command. The script appears -/// pre-filled on the `▶` prompt line as if already typed — Enter runs it, +/// pre-filled on the '▶' prompt line as if already typed — Enter runs it, /// typing edits it in place, Esc opens the menu (Skip / Fail / Quit). On -/// `Done` no settle line is printed — the command's output follows immediately -/// and the step's own verdict prompt judges the result. +/// `Done` the live line is redrawn in grey with the interactive prompt marker +/// becoming the '$', reminiscent of a shell. fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserInput { let qualified = display_path(qualified); let field = edit( @@ -334,7 +357,16 @@ fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserI .count() as u16 + 1; match &result { - UserInput::Done(_) | UserInput::Quit => { + 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 => { @@ -448,6 +480,17 @@ fn render_enter(out: &mut W, qualified: &str, renderer: &dyn Render) { write_marker_line(out, &format!("↘ {}", qualified), renderer); } +/// The glyph and styling for a settled verdict, or `None` for Quit (which +/// renders no glyph). +fn verdict_glyph(verdict: &UserInput) -> Option<(&'static str, Syntax)> { + match verdict { + UserInput::Done(_) => Some(("✓", Syntax::Done)), + UserInput::Skip => Some(("⊘", Syntax::Skip)), + UserInput::Fail(_) => Some(("✗", Syntax::Fail)), + UserInput::Quit => None, + } +} + fn render_settle( out: &mut W, marker: &str, @@ -455,16 +498,31 @@ fn render_settle( verdict: &UserInput, renderer: &dyn Render, ) { - let (glyph, syntax) = match verdict { - UserInput::Done(_) => ("✓", Syntax::Done), - UserInput::Skip => ("⊘", Syntax::Skip), - UserInput::Fail(_) => ("✗", Syntax::Fail), - UserInput::Quit => return, + let (glyph, syntax) = match verdict_glyph(verdict) { + Some(pair) => pair, + None => return, }; let path = renderer.style(Syntax::Marker, &format!("{} {}", marker, qualified)); let _ = writeln!(out, "{} {}", path, renderer.style(syntax, glyph)); } +/// 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, +) { + let (glyph, syntax) = match verdict_glyph(verdict) { + Some(pair) => pair, + None => return, + }; + let line = renderer.style(Syntax::Marker, &format!("⇐ {}", label)); + let _ = writeln!(out, "{} {}", line, renderer.style(syntax, glyph)); +} + /// Render an automatically-run shell command on one line: `{path} $ {script}`, /// the whole line dark grey like the trace chrome so it reads as announce /// rather than as the command's own output that follows. The `$` stands in for @@ -1207,8 +1265,8 @@ impl Driver for Automatic { let _ = writeln!(self.output); } - fn conclude(&mut self, label: &str) { - write_marker_line(&mut self.output, &format!("⇐ {}", label), self.renderer); + fn conclude(&mut self, label: &str, verdict: &UserInput) { + render_conclude(&mut self.output, label, verdict, self.renderer); } fn display(&mut self, content: &str) { @@ -1242,6 +1300,11 @@ impl Driver for Automatic { } } + fn depart(&mut self, qualified: &str) -> UserInput { + write_marker_line(&mut self.output, &format!("⇒ {}", display_path(qualified)), self.renderer); + UserInput::Done(Value::Unitus) + } + fn external(&mut self, _qualified: &str) -> UserInput { UserInput::Skip } @@ -1381,9 +1444,9 @@ impl Driver for Transcript { .commence(label); } - fn conclude(&mut self, label: &str) { + fn conclude(&mut self, label: &str, verdict: &UserInput) { self.inner - .conclude(label); + .conclude(label, verdict); } fn display(&mut self, content: &str) { @@ -1410,6 +1473,11 @@ impl Driver for Transcript { outcome } + fn depart(&mut self, qualified: &str) -> UserInput { + self.inner + .depart(qualified) + } + fn external(&mut self, qualified: &str) -> UserInput { self.emit(Trace::External { path: qualified.to_string(), @@ -1508,7 +1576,7 @@ impl Driver for Headless { fn commence(&mut self, _label: &str) {} - fn conclude(&mut self, _label: &str) {} + fn conclude(&mut self, _label: &str, _verdict: &UserInput) {} fn display(&mut self, _content: &str) {} @@ -1525,6 +1593,10 @@ impl Driver for Headless { UserInput::Done(produced) } + fn depart(&mut self, _qualified: &str) -> UserInput { + UserInput::Done(Value::Unitus) + } + fn external(&mut self, _qualified: &str) -> UserInput { self.results += 1; UserInput::Skip @@ -1661,7 +1733,7 @@ impl Driver for Mock { fn commence(&mut self, _label: &str) {} - fn conclude(&mut self, _label: &str) {} + fn conclude(&mut self, _label: &str, _verdict: &UserInput) {} fn display(&mut self, content: &str) { self.events @@ -1702,6 +1774,12 @@ impl Driver for Mock { .expect("Mock::ask called with no canned answers remaining") } + fn depart(&mut self, _qualified: &str) -> UserInput { + self.answers + .pop_front() + .expect("Mock::depart called with no canned answers remaining") + } + fn external(&mut self, qualified: &str) -> UserInput { self.events .push(Event::External { diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 6d84332..27de115 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -32,7 +32,9 @@ const STORE_ROOT: &str = ".store"; /// Allocate a new run, write the opening `Start` record, and walk the program /// to completion or until the user interrupts by signalling they are pausing /// or quitting. Command-line arguments are bound to the entry procedure's -/// parameters before the beginning the walk. +/// parameters before the beginning the walk. `Mode::Quiet` runs +/// non-interactively with the `Headless` driver, suppressing all chrome so only +/// executed commands' output reaches the terminal. pub fn start<'i>( mode: Mode, colour: bool, @@ -50,6 +52,12 @@ pub fn start<'i>( let completed = HashMap::new(); 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); + runner.run(env)? + } Mode::Interactive => { if !std::io::stdout().is_terminal() { return Err(RunnerError::TerminalRequired); @@ -102,6 +110,13 @@ pub fn inspect<'i>( let mut runner = Runner::new(program, appender, completed, driver, library); 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); + runner.run(env) + } } } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 3fd4a8f..d6b2673 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -285,18 +285,17 @@ impl<'i, D: Driver> Runner<'i, D> { if let Outcome::Stopped = outcome { } else { self.record_finish()?; - if self - .document - .is_some() - { + if let Some(document) = &self.document { let label = format!( - "/ #{}", + "/ {},1 #{}", + document, self.appender .run_id() .render() ); + let verdict = verdict_from(outcome); self.driver - .conclude(&label); + .conclude(&label, &verdict); } } } @@ -520,6 +519,32 @@ impl<'i, D: Driver> Runner<'i, D> { } } + /// Echo a deferred external's arguments after its `` path in the + /// `value ~ name` binding form. A bare variable shows its binding; any + /// other expression shows its evaluated value. Not shown if ther eare no + /// arguments. + fn render_deferred_echo( + &self, + env: &mut Environment, + qualified: &str, + arguments: &[Operation<'i>], + ) -> Result { + if arguments.is_empty() { + return Ok(qualified.to_string()); + } + let mut parts = Vec::new(); + for arg in arguments { + let value = super::evaluator::evaluate(&self.library, &self.context, env, arg)?; + let part = if let Operation::Variable(id) = arg { + format!("{} ~ {}", value, id.value) + } else { + value.to_string() + }; + parts.push(part); + } + Ok(format!("{} ({})", qualified, parts.join(", "))) + } + /// An action's parts for the user to confirm: its imperative verb (the /// library's `display` name, e.g. `Click`) and the bare label its single /// argument evaluates to, with string literals shown unquoted. @@ -814,11 +839,23 @@ impl<'i, D: Driver> Runner<'i, D> { } self.begin_scope(&qualified)?; - self.driver - .announce(&format!("<{}>", ext.value)); - let input = self + // Prompt at the departure, echoing the arguments flowing into + // the external Technque. + let echo = self.render_deferred_echo(env, &qualified, &invocable.arguments)?; + let embarked = self .driver - .external(&qualified); + .depart(&echo); + let input = match embarked { + UserInput::Quit => { + self.path + .pop(); + return self.record_stop(); + } + UserInput::Done(_) => self + .driver + .external(&qualified), + declined => declined, + }; if let UserInput::Quit = input { self.path .pop(); @@ -826,7 +863,7 @@ impl<'i, D: Driver> Runner<'i, D> { } self.driver - .settle("⇒", &qualified, &input); + .settle("⇐", &qualified, &input); let outcome = outcome_from(input); self.appender .append(&Record { @@ -1277,36 +1314,65 @@ impl<'i, D: Driver> Runner<'i, D> { self.driver .step(qualified, &step_text, depth); - let produced = 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. - settled => { - let settled = match settled { - Outcome::Throw(failure) => Outcome::Failed(failure), - other => other, - }; - let record = Record { - recorded: now_iso8601(), - run_id, - path: qualified.to_string(), - state: record_state(&settled), - }; - self.appender - .append(&record)?; - let verdict = match &settled { - Outcome::Skipped(_) => UserInput::Skip, - Outcome::Failed(Failure::Aborted(reason)) => UserInput::Fail(reason.clone()), - _ => unreachable!("only Skip and Fail reach the settled branch"), - }; - self.driver - .settle("→", qualified, &verdict); - return Ok(settled); + // A descriptive binding on a step with response choices takes its value + // from the chosen response, not a separate acquire: skip the body walk + // and bind the choice (taken below) to the step's name(s). + let binding_via_response = !responses.is_empty() && binds_descriptively(body); + + let produced = if binding_via_response { + Value::Unitus + } else { + 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. + settled => { + let settled = match settled { + Outcome::Throw(failure) => Outcome::Failed(failure), + other => other, + }; + let record = Record { + recorded: now_iso8601(), + run_id, + path: qualified.to_string(), + state: record_state(&settled), + }; + self.appender + .append(&record)?; + let verdict = match &settled { + Outcome::Skipped(_) => UserInput::Skip, + Outcome::Failed(Failure::Aborted(reason)) => { + UserInput::Fail(reason.clone()) + } + _ => unreachable!("only Skip and Fail reach the settled branch"), + }; + self.driver + .settle("→", qualified, &verdict); + return Ok(settled); + } } }; + // A descriptive binding already took the user's input at its acquire + // prompt; that value (or a bare ) is the step's verdict, so + // settle Done without a redundant acceptance prompt. + if responses.is_empty() && binds_descriptively(body) { + let outcome = Outcome::Done(produced); + self.driver + .settle("→", qualified, &verdict_from(&outcome)); + let record = Record { + recorded: now_iso8601(), + run_id, + path: qualified.to_string(), + state: record_state(&outcome), + }; + self.appender + .append(&record)?; + return Ok(outcome); + } + let choices: Vec<&str> = responses .iter() .map(|r| r.value) @@ -1330,6 +1396,19 @@ impl<'i, D: Driver> Runner<'i, D> { UserInput::Skip => Outcome::Skipped(propagate), other => outcome_from(other), }; + // Bind the chosen response to the step's name(s); a skip binds Unitus + // so a later reference resolves, mirroring a descriptive acquire. + if binding_via_response { + if let Some(names) = binding_names(body) { + match &outcome { + Outcome::Done(value) => { + super::evaluator::bind_names(env, names, value.clone())? + } + Outcome::Skipped(_) => super::evaluator::bind_names(env, names, Value::Unitus)?, + _ => {} + } + } + } let record = Record { recorded: now_iso8601(), run_id, @@ -1593,6 +1672,18 @@ fn outcome_from(input: UserInput) -> Outcome { } } +/// The closing line's verdict, rolling the run's final `Outcome` back into the +/// `UserInput` glyph the driver renders — mirroring the entry procedure's +/// sign-off. A `Done` shows `✓`, a `Skipped` `⊘`, a failure `✗`; the value and +/// reason are immaterial to the glyph. +fn verdict_from(outcome: &Outcome) -> UserInput { + match outcome { + Outcome::Done(_) => UserInput::Done(Value::Unitus), + Outcome::Skipped(_) => UserInput::Skip, + _ => UserInput::Fail(String::new()), + } +} + /// Project the runner's in-memory `Outcome` into the on-disk `State` for the /// PFFTT file. A `Done` records its full value (serialized by the state codec), /// so a value bound with `~` rehydrates on resume. Quit is unreachable here: @@ -1629,6 +1720,38 @@ fn binding_names<'i>(op: &Operation<'i>) -> Option<&'i [language::Identifier<'i> } } +/// Whether walking a step body amounts to nothing more than acquiring one or +/// more descriptive `~` bindings — prose interleaved with bindings that carry no +/// expression to compute their value. Such a step takes the user's input at its +/// acquire prompt(s); the last doubles as the step's completion, so there is no +/// separate verdict left to take. +fn binds_descriptively(op: &Operation) -> bool { + match op { + Operation::Bind { value, .. } => { + if let Operation::Sequence(ops) = value.as_ref() { + ops.is_empty() + } else { + false + } + } + Operation::Sequence(ops) => { + let mut bound = false; + for op in ops { + if let Operation::Prose(_) = op { + continue; + } + if binds_descriptively(op) { + bound = true; + } else { + return false; + } + } + bound + } + _ => false, + } +} + /// Whether a step body nests further executable scopes — a `foreach`/`repeat` /// loop or substeps — as opposed to being a leaf of prose, a binding, or a /// call. A completed step that nests work is re-walked on resume so bindings diff --git a/tests/samples/parsing/OpenDoor.tq b/tests/samples/parsing/OpenDoor.tq index 3c3e8d5..778a1a5 100644 --- a/tests/samples/parsing/OpenDoor.tq +++ b/tests/samples/parsing/OpenDoor.tq @@ -1,4 +1,4 @@ - 1. Open door - 2. Through door + 1. Open door ~ number + 2. Through door (number) 3. Close door, accepting that it will make a smug and self-satisified sigh with the knowledge of a job well done. diff --git a/tests/samples/runner/DemolitionBeams.pfftt b/tests/samples/runner/DemolitionBeams.pfftt new file mode 100644 index 0000000..a5c3a11 --- /dev/null +++ b/tests/samples/runner/DemolitionBeams.pfftt @@ -0,0 +1,10 @@ +2026-06-14T05:58:08.700Z 000001 / Start file://tests/samples/runner/DemolitionBeams.tq +2026-06-14T05:58:08.700Z 000001 /remove_planet: Begin +2026-06-14T05:58:08.700Z 000001 /remove_planet:/1 Begin +2026-06-14T05:58:08.700Z 000001 /remove_planet:/1 Done () +2026-06-14T05:58:08.700Z 000001 /remove_planet:/2 Begin +2026-06-14T05:58:08.700Z 000001 /remove_planet:/2 Done () +2026-06-14T05:58:08.700Z 000001 /remove_planet:/3 Begin +2026-06-14T05:58:08.700Z 000001 /remove_planet:/3 Done () +2026-06-14T05:58:08.700Z 000001 /remove_planet: Done () +2026-06-14T05:58:08.700Z 000001 / Finish diff --git a/tests/samples/runner/DemolitionBeams.tq b/tests/samples/runner/DemolitionBeams.tq new file mode 100644 index 0000000..4149d98 --- /dev/null +++ b/tests/samples/runner/DemolitionBeams.tq @@ -0,0 +1,10 @@ +% technique v1 + +remove_planet : + +# Destroy Obstacles in way of Hyperspace Bypass + + 1. Are all the hatches open? ~ hatches + 'Open' | 'Closed' + 2. Record { hatches } in the flight log + 3. Energize the demolition beams.