From df72353d3d814347efcadd433b022c76165795e2 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 18 Jun 2026 00:34:46 +1000 Subject: [PATCH 1/5] Add Transcript driver for debugging --- src/main.rs | 54 ++++++++++--- src/runner/driver.rs | 180 ++++++++++++++++++++++++++++++++++++++++++- src/runner/mod.rs | 32 +++++++- 3 files changed, 250 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index caf1bfb..6373df7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ enum Output { Terminal, Native, Silent, + Store, } #[derive(Eq, Debug, PartialEq)] @@ -173,7 +174,6 @@ fn main() { ) .arg( Arg::new("output") - .short('o') .long("output") .value_name("type") .value_parser(["native", "none"]) @@ -232,16 +232,6 @@ fn main() { PDF using a template. This allows you to transform the code of \ the procedure into the intended layout suitable to the \ domain of your application.") - .arg( - Arg::new("output") - .short('o') - .long("output") - .value_name("type") - .value_parser(["pdf", "typst"]) - .default_value("pdf") - .action(ArgAction::Set) - .help("Whether to write PDF to a file on disk, or print the Typst markup that would be used to create that PDF (for debugging)."), - ) .arg( Arg::new("domain") .short('d') @@ -269,11 +259,19 @@ fn main() { ) .arg( Arg::new("keep") - .short('k') .long("keep") .action(ArgAction::SetTrue) .help("Keep the generated intermediate files in place after rendering. This allows you to do iterative development of the template and styling with the Typst compiler without having to regenerate the input document every time. The intermediate pieces are written as hidden files in the same directory as the source document."), ) + .arg( + Arg::new("output") + .long("output") + .value_name("type") + .value_parser(["pdf", "typst"]) + .default_value("pdf") + .action(ArgAction::Set) + .help("Whether to write PDF to a file on disk, or print the Typst markup that would be used to create that PDF (for debugging)."), + ) .arg( Arg::new("filename") .required(true) @@ -310,6 +308,14 @@ fn main() { .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."), ) + .arg( + Arg::new("output") + .long("output") + .value_parser(["pfftt", "native"]) + .default_value("pfftt") + .action(ArgAction::Set) + .help("Whether to write the recorded trace to disk in PFFTT format, as is the default, or to instead print a diagnostic trace of the steps as the are completed (for debugging)."), + ) .arg( Arg::new("raw-control-chars") .short('R') @@ -418,6 +424,7 @@ fn main() { println!("{:#?}", technique); } Output::Silent => {} + _ => {} } std::process::exit(0); } @@ -450,6 +457,7 @@ fn main() { println!("{:#?}", program); } Output::Silent => {} + _ => {} } std::process::exit(0); } @@ -490,6 +498,7 @@ fn main() { println!("{:#?}", program); } Output::Silent => {} + _ => {} } std::process::exit(0); } @@ -705,6 +714,17 @@ fn main() { debug!(?arguments); + let output = submatches + .get_one::("output") + .unwrap(); + let output = match output.as_str() { + "native" => Output::Native, + "pfftt" => Output::Store, + _ => panic!("Unrecognized --output value"), + }; + + debug!(?output); + let mode = match submatches .get_one::("mode") .map(String::as_str) @@ -811,6 +831,16 @@ fn main() { std::process::exit(1); } + if let Output::Native = output { + match runner::inspect(mode, colour, &program, &arguments, library) { + Ok(_) => std::process::exit(0), + Err(error) => { + eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); + std::process::exit(1); + } + } + } + match runner::start(mode, colour, filename, &program, &arguments, library) { Ok((run_id, Outcome::Stopped)) => { eprintln!( diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 764e885..b653133 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -46,7 +46,7 @@ pub enum UserInput { /// What the walker uses to drive a run. Implementations are the interactive /// console `Console`, the no-operator `Automatic`, the no-output `Headless`, -/// and the test `Mock`. +/// the debugging `Transcript`, and the test `Mock`. pub trait Driver { /// Show the step's Qualified Name and rendered description. /// The implementation displays them; it does not block waiting for @@ -1083,6 +1083,180 @@ impl Driver for Automatic { } } +#[derive(Debug)] +#[allow(dead_code)] // fields are read only via Debug +enum Trace { + Enter { + path: String, + }, + Leave { + path: String, + outcome: Disposition, + result: Value, + }, + Execute { + path: String, + script: String, + }, + Acquire { + path: String, + name: Option, + forma: Option, + supplied: Value, + }, + External { + path: String, + }, +} + +#[derive(Debug)] +#[allow(dead_code)] // read only via Debug +enum Disposition { + Done, + Skip, + Fail(String), + Stop, +} + +/// A driver for debugging that prints each value-bearing callback as a +/// `Trace`, delegating decisions about outcomes to the inner wrapped driver. +pub struct Transcript { + inner: D, + output: W, +} + +impl Transcript { + pub fn new(inner: D) -> Self { + Transcript { + inner, + output: io::stdout(), + } + } +} + +impl Transcript { + fn emit(&mut self, trace: Trace) { + let _ = writeln!(self.output, "{:#?}", trace); + } + + fn trace_outcome(&mut self, path: &str, produced: Value, outcome: &UserInput) { + let outcome = match outcome { + UserInput::Done(_) => Disposition::Done, + UserInput::Skip => Disposition::Skip, + UserInput::Fail(reason) => Disposition::Fail(reason.clone()), + UserInput::Quit => Disposition::Stop, + }; + self.emit(Trace::Leave { + path: path.to_string(), + outcome, + result: produced, + }); + } +} + +impl Driver for Transcript { + fn step(&mut self, qualified: &str, description: &str) { + self.emit(Trace::Enter { + path: qualified.to_string(), + }); + self.inner + .step(qualified, description); + } + + fn enter(&mut self, qualified: &str) { + self.emit(Trace::Enter { + path: qualified.to_string(), + }); + self.inner + .enter(qualified); + } + + fn display(&mut self, content: &str) { + self.inner + .display(content); + } + + fn announce(&mut self, message: &str) { + self.inner + .announce(message); + } + + fn ask( + &mut self, + qualified: &str, + choices: &[&str], + produced: Value, + effectful: bool, + ) -> UserInput { + let outcome = self + .inner + .ask(qualified, choices, produced.clone(), effectful); + self.trace_outcome(qualified, produced, &outcome); + outcome + } + + fn external(&mut self, qualified: &str) -> UserInput { + self.emit(Trace::External { + path: qualified.to_string(), + }); + self.inner + .external(qualified) + } + + fn command(&mut self, qualified: &str, script: &str) -> UserInput { + self.emit(Trace::Execute { + path: qualified.to_string(), + script: script.to_string(), + }); + self.inner + .command(qualified, script) + } + + fn section(&mut self, qualified: &str, numeral: &str, title: &str) { + self.emit(Trace::Enter { + path: qualified.to_string(), + }); + self.inner + .section(qualified, numeral, title); + } + + fn seal(&mut self, qualified: &str, produced: Value, effectful: bool) -> UserInput { + let outcome = self + .inner + .seal(qualified, produced.clone(), effectful); + self.trace_outcome(qualified, produced, &outcome); + outcome + } + + fn settle(&mut self, marker: &str, qualified: &str, verdict: &UserInput) { + self.inner + .settle(marker, qualified, verdict); + } + + fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { + let outcome = self + .inner + .acquire(qualified, name, forma); + let supplied = if let UserInput::Done(value) = &outcome { + value.clone() + } else { + Value::Unitus + }; + self.emit(Trace::Acquire { + path: qualified.to_string(), + name: name.map(|n| n.to_string()), + forma: forma.map(|f| f.to_string()), + supplied, + }); + outcome + } + + fn renderer(&self) -> &'static dyn Render { + self.inner + .renderer() + } +} + /// No-operator, no-output driver: takes each step and scope's computed value as /// its result, emitting nothing, and counts the results it settles — one per /// step and per structural-scope close. Lets a Technique be run without a @@ -1163,8 +1337,8 @@ pub struct Mock { events: Vec, } -/// One thing the walker showed (or attempted to show). Tests use this -/// to inspect ordering and content of the walker's user-facing output. +/// What the walker showed (or attempted to show). Tests use this to inspect +/// ordering and content of the walker's user-facing output. #[cfg(test)] #[derive(Debug, Clone, PartialEq)] pub enum Event { diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 236a5fc..e5cba90 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -23,7 +23,7 @@ pub use library::{Builtin, Library, Native}; pub use runner::{Outcome, Runner, RunnerError}; pub use state::{Appender, RecordError, RunId}; -use driver::{Automatic, Console}; +use driver::{Automatic, Console, Transcript}; use runner::{bind_parameters, now_iso8601}; use state::{construct_state_path, Record, State, Store}; @@ -69,6 +69,36 @@ pub fn start<'i>( Ok((run_id, outcome)) } +/// Walk the program with the mode's driver wrapped in a `Transcript`, which +/// streams the value trace to stderr while the wrapped driver runs as usual. +/// Records nothing. Backs `run --output=native`, orthogonal to `--mode`. +pub fn inspect<'i>( + mode: Mode, + colour: bool, + program: &'i Program<'i>, + arguments: &[String], + library: Library, +) -> Result { + let env = bind_parameters(program, arguments)?; + match mode { + Mode::Interactive => { + if !std::io::stdout().is_terminal() { + return Err(RunnerError::TerminalRequired); + } + let driver = Transcript::new(Console::new()); + let mut runner = + Runner::new(program, Appender::sink(), HashSet::new(), driver, library); + runner.run(env) + } + Mode::Automatic => { + let driver = Transcript::new(Automatic::new(colour)); + let mut runner = + Runner::new(program, Appender::sink(), HashSet::new(), driver, library); + runner.run(env) + } + } +} + /// Read the opening `Start` record of an existing run, returning the /// source document path so the caller can load and re-translate it /// before resuming. From 0ba17a849d3d2282f47424f3d07e5f922639c307 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 19 Jun 2026 17:17:37 +1000 Subject: [PATCH 2/5] Step value recorded if computable --- src/runner/checks/driver.rs | 2 +- src/runner/checks/runner.rs | 48 ++++++++++++++++++++----------------- src/runner/driver.rs | 41 ++++++++++++++++--------------- src/runner/runner.rs | 46 +++++++++++++++++++---------------- 4 files changed, 73 insertions(+), 64 deletions(-) diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index ba5dc05..e581e01 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -87,7 +87,7 @@ fn console_step_writes_fqn_and_description() { } #[test] -fn automatic_settles_done_when_effectful_skip_otherwise() { +fn automatic_settles_done_when_computable_skip_otherwise() { let mut p = Automatic::with_handle(Vec::new()); assert_eq!( p.ask("/I/1", &[], Value::Literali("ran".to_string()), true), diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 934aeb4..311097c 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1109,9 +1109,9 @@ test : } #[test] -fn automatic_substantiates_only_effectful_steps() { - // An exec step settles Done; a pure-prose sibling Skip; the procedure - // seals Done since one step beneath it was effectful. +fn automatic_settles_computable_steps_done_prose_skip() { + // An exec step settles Done; a pure-prose sibling Skips; the procedure + // seals Done since one step beneath it was computable. let source = r#" % technique v1 @@ -2020,10 +2020,8 @@ test : } #[test] -fn automatic_propagates_body_value_but_records_skip() { - // Under the automatic driver the body value propagates as the outcome, - // but with no effectful work the step records Skip. - fn skip_of(label: &str, body: Operation<'static>) -> (Outcome, State) { +fn automatic_records_done_for_computable_step_skip_for_prose() { + fn record_of(label: &str, body: Operation<'static>) -> (Outcome, State) { let mut fixture = StoreFixture::new(label); let program = anonymous_with_body(Operation::Sequence(vec![step( Ordinal::Dependent("1"), @@ -2054,8 +2052,8 @@ fn automatic_propagates_body_value_but_records_skip() { (outcome, state) } - // A single-line value propagates as the outcome; the step records Skip. - let (outcome, state) = skip_of( + // A single-line value computes and records Done with the literal. + let (outcome, state) = record_of( "automatic-records-value", Operation::String(vec![Fragment::Text("probe output")]), ); @@ -2063,10 +2061,13 @@ fn automatic_propagates_body_value_but_records_skip() { outcome, Outcome::Done(Value::Literali("probe output".to_string())) ); - assert_eq!(state, State::Skip); + assert_eq!( + state, + State::Done(Some(RecordValue::Literal("probe output".to_string()))) + ); - // Multi-line text propagates intact and still records Skip. - let (outcome, state) = skip_of( + // Multi-line text propagates intact; the record projects it to Unit. + let (outcome, state) = record_of( "multiline-records-unit", Operation::String(vec![Fragment::Text("1: lo\n2: eth0\n3: wlan0")]), ); @@ -2074,18 +2075,16 @@ fn automatic_propagates_body_value_but_records_skip() { outcome, Outcome::Done(Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string())) ); - assert_eq!(state, State::Skip); + assert_eq!(state, State::Done(Some(RecordValue::Unit))); - // A pure-prose step (empty body) also records Skip — nothing effectful ran. - let (_, state) = skip_of("automatic-empty-body", Operation::Sequence(vec![])); + // A pure-prose step (empty body) has nothing to compute and records Skip. + let (_, state) = record_of("automatic-empty-body", Operation::Sequence(vec![])); assert_eq!(state, State::Skip); } #[test] fn sequence_value_is_last_member() { - // Block semantics: a multi-member body sequence runs each step in order - // and takes the LAST member's value, not the first and not a fold. Both - // steps run (each records its own value), but the run returns "second". + // A body sequence takes the last member's value, not the first or a fold. let mut fixture = StoreFixture::new("sequence-last-member"); let body = Operation::Sequence(vec![ step( @@ -2113,14 +2112,19 @@ fn sequence_value_is_last_member() { Outcome::Done(Value::Literali("second".to_string())) ); - // Neither step is effectful, so each records Skip. let pfftt = fixture.pfftt_contents(); - let skips = pfftt + let dones = pfftt .lines() .filter_map(|line| parse_record(line).ok()) - .filter(|record| record.state == State::Skip) + .filter(|record| { + if let State::Done(_) = record.state { + true + } else { + false + } + }) .count(); - assert_eq!(skips, 2); + assert_eq!(dones, 2); } #[test] diff --git a/src/runner/driver.rs b/src/runner/driver.rs index b653133..a222354 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -71,15 +71,14 @@ pub trait Driver { /// response values, yielding `Done(Literali(choice))`. Skip, fail, and /// quit are available either way. `produced` is consumed: the driver /// either moves it into the returned `Done` or discards it. `qualified` is - /// the step's Qualified Name, repeated on the live prompt line. `effectful` - /// is whether the body ran an `exec`; an unattended driver settles `Done` - /// only when it did, otherwise `Skip`. + /// the step's Qualified Name, repeated on the live prompt line. `computable` + /// is whether the step has a body; an unattended driver `Done`s it, else `Skip`. fn ask( &mut self, qualified: &str, choices: &[&str], produced: Value, - effectful: bool, + computable: bool, ) -> UserInput; /// Settle an external invocation this run cannot perform (a `` call @@ -104,9 +103,9 @@ pub trait Driver { /// Prompt the operator to sign off a completed structural scope — a Section /// at its close, or the whole run at the entry procedure's close. Like /// `ask` but with no response choices, settling to the `↙` close marker; - /// `produced` is the scope's value, offered for acceptance; `effectful` - /// governs an unattended driver's `Done`/`Skip` sign-off as in `ask`. - fn seal(&mut self, qualified: &str, produced: Value, effectful: bool) -> UserInput; + /// `produced` is the scope's value, offered for acceptance; `computable` + /// drives unattended `Done`/`Skip` as in `ask`. + fn seal(&mut self, qualified: &str, produced: Value, computable: bool) -> UserInput; /// Render the settled verdict line for a step or scope close: `marker` /// (`→` step, `↙` scope close), Qualified Name, and the verdict's glyph. @@ -183,7 +182,7 @@ impl Driver for Console { qualified: &str, choices: &[&str], produced: Value, - _effectful: bool, + _computable: bool, ) -> UserInput { prompt(&mut self.output, qualified, "→", choices, produced) } @@ -196,7 +195,7 @@ impl Driver for Console { prompt_command(&mut self.output, qualified, script) } - fn seal(&mut self, qualified: &str, produced: Value, _effectful: bool) -> UserInput { + fn seal(&mut self, qualified: &str, produced: Value, _computable: bool) -> UserInput { prompt(&mut self.output, qualified, "↙", &[], produced) } @@ -1038,9 +1037,9 @@ impl Driver for Automatic { _qualified: &str, _choices: &[&str], produced: Value, - effectful: bool, + computable: bool, ) -> UserInput { - if effectful { + if computable { UserInput::Done(produced) } else { UserInput::Skip @@ -1056,8 +1055,8 @@ impl Driver for Automatic { UserInput::Done(Value::Literali(script.to_string())) } - fn seal(&mut self, _qualified: &str, produced: Value, effectful: bool) -> UserInput { - if effectful { + fn seal(&mut self, _qualified: &str, produced: Value, computable: bool) -> UserInput { + if computable { UserInput::Done(produced) } else { UserInput::Skip @@ -1186,11 +1185,11 @@ impl Driver for Transcript { qualified: &str, choices: &[&str], produced: Value, - effectful: bool, + computable: bool, ) -> UserInput { let outcome = self .inner - .ask(qualified, choices, produced.clone(), effectful); + .ask(qualified, choices, produced.clone(), computable); self.trace_outcome(qualified, produced, &outcome); outcome } @@ -1220,10 +1219,10 @@ impl Driver for Transcript { .section(qualified, numeral, title); } - fn seal(&mut self, qualified: &str, produced: Value, effectful: bool) -> UserInput { + fn seal(&mut self, qualified: &str, produced: Value, computable: bool) -> UserInput { let outcome = self .inner - .seal(qualified, produced.clone(), effectful); + .seal(qualified, produced.clone(), computable); self.trace_outcome(qualified, produced, &outcome); outcome } @@ -1289,7 +1288,7 @@ impl Driver for Headless { _qualified: &str, _choices: &[&str], produced: Value, - _effectful: bool, + _computable: bool, ) -> UserInput { self.results += 1; UserInput::Done(produced) @@ -1306,7 +1305,7 @@ impl Driver for Headless { fn section(&mut self, _qualified: &str, _numeral: &str, _title: &str) {} - fn seal(&mut self, _qualified: &str, produced: Value, _effectful: bool) -> UserInput { + fn seal(&mut self, _qualified: &str, produced: Value, _computable: bool) -> UserInput { self.results += 1; UserInput::Done(produced) } @@ -1443,7 +1442,7 @@ impl Driver for Mock { qualified: &str, choices: &[&str], _produced: Value, - _effectful: bool, + _computable: bool, ) -> UserInput { self.events .push(Event::Ask { @@ -1485,7 +1484,7 @@ impl Driver for Mock { /// rather than draining the `ask` answer queue — the structural-scope /// close is orthogonal to the step verdicts a test drives. A test /// asserting sign-off behaviour inspects the recorded `Seal` event. - fn seal(&mut self, qualified: &str, _produced: Value, _effectful: bool) -> UserInput { + fn seal(&mut self, qualified: &str, _produced: Value, _computable: bool) -> UserInput { self.events .push(Event::Seal { qualified: qualified.to_string(), diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 64a7ab3..9f4bc5b 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -114,9 +114,6 @@ pub struct Runner<'i, D: Driver> { path: QualifiedPath<'i>, library: Library, context: Context, - /// Count of `exec` actions run; a rise across a step or scope's body - /// substantiates it for an unattended driver. - actions: usize, } impl<'i, D: Driver> Runner<'i, D> { @@ -135,7 +132,6 @@ impl<'i, D: Driver> Runner<'i, D> { path: QualifiedPath::new(), library, context: Context::native(), - actions: 0, } } @@ -227,7 +223,6 @@ impl<'i, D: Driver> Runner<'i, D> { .display(&description); } } - let actions_before = self.actions; let result = self.walk(&mut env, &entry.body); // A named entry procedure is a structural scope: a completed run closes // with a final sign-off prompt at its path. A Quit or error walk skips @@ -237,10 +232,10 @@ impl<'i, D: Driver> Runner<'i, D> { let qualified = self .path .render(); - let effectful = self.actions > actions_before; + let computable = computable(&entry.body); let sealed = match result { Ok(Outcome::Stopped) => Ok(Outcome::Stopped), - Ok(outcome) => self.seal_scope(&qualified, outcome, effectful), + Ok(outcome) => self.seal_scope(&qualified, outcome, computable), Err(error) => Err(error), }; self.path @@ -323,7 +318,6 @@ impl<'i, D: Driver> Runner<'i, D> { executable, Some(&[chosen]), )?; - self.actions += 1; Ok(Outcome::Done(value)) } UserInput::Skip => Ok(Outcome::Skipped(Value::Unitus)), @@ -543,12 +537,11 @@ impl<'i, D: Driver> Runner<'i, D> { // Walk the callee's body in its own `local` environment, // then sign off its scope; a Quit or error skips the // sign-off, leaving the procedure unfinished. - let actions_before = self.actions; let result = self.walk(&mut local, &subroutine.body); - let effectful = self.actions > actions_before; + let computable = computable(&subroutine.body); let sealed = match result { Ok(Outcome::Stopped) => Ok(Outcome::Stopped), - Ok(outcome) => self.seal_scope(&lexical, outcome, effectful), + Ok(outcome) => self.seal_scope(&lexical, outcome, computable), Err(error) => Err(error), }; self.path @@ -767,18 +760,17 @@ impl<'i, D: Driver> Runner<'i, D> { .pop(); return Ok(Outcome::Done(Value::Unitus)); } - let actions_before = self.actions; self.begin_scope(&qualified)?; let result = self.perform_section(env, numeral, title, body); self.path .pop(); - let effectful = self.actions > actions_before; - // A section is a structural scope: the operator signs it off at its + let computable = computable(body); + // A section is a structural scope: the user signs it off at its // close before the next sibling runs. A Quit or error walk skips the // prompt — the section did not complete. match result { Ok(Outcome::Stopped) => Ok(Outcome::Stopped), - Ok(outcome) => self.seal_scope(&qualified, outcome, effectful), + Ok(outcome) => self.seal_scope(&qualified, outcome, computable), Err(error) => Err(error), } } @@ -889,7 +881,6 @@ impl<'i, D: Driver> Runner<'i, D> { self.driver .step(qualified, &step_text); - let actions_before = self.actions; let produced = match self.walk(env, body)? { Outcome::Stopped => return Ok(Outcome::Stopped), Outcome::Done(value) => value, @@ -913,12 +904,12 @@ impl<'i, D: Driver> Runner<'i, D> { .iter() .map(|r| r.value) .collect(); - let effectful = self.actions > actions_before; + let computable = computable(body); // `ask` consumes `produced`; keep a copy for a Skip to propagate. let propagate = produced.clone(); let input = self .driver - .ask(qualified, &choices, produced, effectful); + .ask(qualified, &choices, produced, computable); // Quit halts the walk; this step's Begin stands without a matching // outcome, so resume re-runs it. @@ -1009,7 +1000,7 @@ impl<'i, D: Driver> Runner<'i, D> { &mut self, qualified: &str, outcome: Outcome, - effectful: bool, + computable: bool, ) -> Result { let produced = match outcome { Outcome::Done(value) | Outcome::Skipped(value) => value, @@ -1021,7 +1012,7 @@ impl<'i, D: Driver> Runner<'i, D> { .run_id(); let input = self .driver - .seal(qualified, produced, effectful); + .seal(qualified, produced, computable); if let UserInput::Quit = input { return self.record_stop(); } @@ -1083,6 +1074,21 @@ fn describe_execute(function: &str) -> String { format!("{}()", function) } +/// This is true when a body holds work to perform (that can be evaluated); a +/// step whose definition is purely descriptive prose can and will have a +/// Result, but is not computable. +fn computable(op: &Operation) -> bool { + match op { + // A pure-prose body is not computable (this is fine!). + Operation::Sequence(ops) if ops.is_empty() => false, + Operation::Sequence(ops) => ops + .iter() + .any(computable), + Operation::Step { body, .. } | Operation::Section { body, .. } => computable(body), + _ => true, + } +} + /// Lift a `UserInput` from the prompt into the runner's `Outcome`. fn outcome_from(input: UserInput) -> Outcome { match input { From 800a1f47d14a7858450e6a8944b462b65e4f9c61 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 19 Jun 2026 17:47:16 +1000 Subject: [PATCH 3/5] Introduce Throw to propagate failures --- src/runner/checks/runner.rs | 56 +++++++++++++++++++++++ src/runner/runner.rs | 48 +++++++++++++++---- tests/samples/parsing/Choices.tq | 7 +++ tests/samples/runner/ConfirmRelease.pfftt | 7 +++ tests/samples/runner/ConfirmRelease.tq | 8 ++++ 5 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 tests/samples/parsing/Choices.tq create mode 100644 tests/samples/runner/ConfirmRelease.pfftt create mode 100644 tests/samples/runner/ConfirmRelease.tq diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 311097c..15067eb 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1154,6 +1154,62 @@ check : assert!(trace.contains("↙ 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. + let source = r#" +% technique v1 + +check : + +1. Run a failing command { exec("exit 3") } + +2. This step is still reached + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parsed"); + let mut program = translate(&document).expect("translated"); + let mut library = Library::core(); + library.extend(Library::system()); + crate::linking::link(&mut program, &library).expect("linked"); + + let mut fixture = StoreFixture::new("automatic-failing-exec"); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Automatic::with_handle(Vec::new()), + library, + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + match outcome { + Outcome::Done(_) => {} + other => panic!("expected Done, got {:?}", other), + } + + let pfftt = fixture.pfftt_contents(); + let records: Vec<_> = pfftt + .lines() + .filter_map(|line| parse_record(line).ok()) + .collect(); + let step_one_failed = records + .iter() + .any(|r| { + if let State::Fail(_) = r.state { + r.path == "/check:/1" + } else { + false + } + }); + assert!(step_one_failed, "step 1 should record Fail"); + assert!(records + .iter() + .any(|r| r.path == "/check:/2")); +} + #[test] fn loop_inside_step_produces_one_result() { let mut fixture = StoreFixture::new("loop-in-step"); diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 9f4bc5b..914d37a 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -35,6 +35,9 @@ pub enum Outcome { /// Carries the body's computed value for block semantics; recorded as no value. Skipped(Value), Failed(Failure), + /// A failure thrown mid-body (a failed exec); propagates up to the + /// enclosing step, which catches it and settles as Fail. + Throw(Failure), Stopped, } @@ -311,17 +314,27 @@ impl<'i, D: Driver> Runner<'i, D> { .command(&qualified, &script) { UserInput::Done(chosen) => { - let value = super::evaluator::dispatch( + match super::evaluator::dispatch( &self.library, &self.context, env, executable, Some(&[chosen]), - )?; - Ok(Outcome::Done(value)) + ) { + Ok(value) => Ok(Outcome::Done(value)), + // A non-zero exit throws to fail the step rather + // than aborting the run; the walk continues. + Err(RunnerError::CommandFailed(code)) => { + Ok(Outcome::Throw(Failure::Aborted(format!( + "External command exited with status {}", + code + )))) + } + Err(other) => Err(other), + } } UserInput::Skip => Ok(Outcome::Skipped(Value::Unitus)), - UserInput::Fail(reason) => Ok(Outcome::Failed(Failure::Aborted(reason))), + UserInput::Fail(reason) => Ok(Outcome::Throw(Failure::Aborted(reason))), UserInput::Quit => self.record_stop(), } } else { @@ -734,6 +747,10 @@ impl<'i, D: Driver> Runner<'i, D> { match outcome { Outcome::Done(value) | Outcome::Skipped(value) => 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. + Outcome::Throw(failure) => return Ok(Outcome::Throw(failure)), Outcome::Failed(_) => {} } } @@ -884,10 +901,14 @@ impl<'i, D: Driver> Runner<'i, D> { let produced = match self.walk(env, body)? { Outcome::Stopped => return Ok(Outcome::Stopped), Outcome::Done(value) => value, - // The body declined its exec command beat (Skip / Fail), which - // settles the step: there is no result to judge, so record the - // outcome and return without the verdict prompt. + // 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, @@ -896,6 +917,13 @@ impl<'i, D: Driver> Runner<'i, D> { }; 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); } }; @@ -1112,10 +1140,10 @@ fn record_state(outcome: &Outcome) -> State { } Outcome::Done(_) => State::Done(Some(RecordValue::Unit)), Outcome::Skipped(_) => State::Skip, - Outcome::Failed(Failure::Aborted(reason)) => { + Outcome::Failed(Failure::Aborted(reason)) | Outcome::Throw(Failure::Aborted(reason)) => { if reason.is_empty() { - // The operator failed the step without giving a reason; record - // the failure with no reason rather than an empty-string one. + // The user failed the step without giving a reason; record the + // failure with no reason rather than an empty-string one. State::Fail(None) } else { State::Fail(Some(super::state::fail_reason(reason))) diff --git a/tests/samples/parsing/Choices.tq b/tests/samples/parsing/Choices.tq new file mode 100644 index 0000000..79e8e3d --- /dev/null +++ b/tests/samples/parsing/Choices.tq @@ -0,0 +1,7 @@ +one_of_many : + +Pick one! + + 1. From these + + 'One' | 'Two' | 'Three' diff --git a/tests/samples/runner/ConfirmRelease.pfftt b/tests/samples/runner/ConfirmRelease.pfftt new file mode 100644 index 0000000..02271e1 --- /dev/null +++ b/tests/samples/runner/ConfirmRelease.pfftt @@ -0,0 +1,7 @@ +2026-06-14T05:58:08.700Z 000001 / Start file://tests/samples/runner/ConfirmRelease.tq +2026-06-14T05:58:08.700Z 000001 /confirm_release: Begin +2026-06-14T05:58:08.700Z 000001 /confirm_release:/1 Begin +2026-06-14T05:58:08.700Z 000001 /confirm_release:/1 Done "v1.0" +2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Begin +2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Done () +2026-06-14T05:58:08.700Z 000001 /confirm_release: Done () diff --git a/tests/samples/runner/ConfirmRelease.tq b/tests/samples/runner/ConfirmRelease.tq new file mode 100644 index 0000000..93d65c6 --- /dev/null +++ b/tests/samples/runner/ConfirmRelease.tq @@ -0,0 +1,8 @@ +% technique v1 + +confirm_release : + +# Confirm the release tag + + 1. Suggested release tag { "v1.0" } + 2. Proceed once the tag above is correct From 04d22212cd26d20b91344e5d2141ad2c4e7f4a45 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 19 Jun 2026 22:14:02 +1000 Subject: [PATCH 4/5] Differentiante code blocks with scopes from expressions --- src/domain/engine.rs | 30 +++++++++++++++++++++++ src/domain/recipe/adapter.rs | 22 ++++++++--------- src/formatting/formatter.rs | 5 +++- src/parsing/parser.rs | 17 ++++++++++++- src/translation/checks/translate.rs | 8 +++--- tests/runner/samples.rs | 7 +++++- tests/samples/runner/ConfirmRelease.pfftt | 4 ++- tests/samples/runner/ConfirmRelease.tq | 5 ++-- 8 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/domain/engine.rs b/src/domain/engine.rs index d7d9231..070901f 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -174,6 +174,13 @@ impl<'i> Scope<'i> { } } + /// Returns tablet pairs from an inline code block in a step's description, + /// the folded-inline counterpart of `tablet()`. + pub fn inline_tablet(&self) -> Option>> { + self.description() + .find_map(|paragraph| paragraph.tablet()) + } + /// Returns true if this scope represents a step (dependent or parallel). pub fn is_step(&self) -> bool { match self { @@ -385,6 +392,29 @@ impl<'i> Paragraph<'i> { targets } + /// Returns tablet pairs if a `CodeInline` in this paragraph is a single + /// list whose elements are all labelled values. + pub fn tablet(&self) -> Option>> { + for d in &self.0 { + if let Descriptive::CodeInline(Expression::List(elements, _)) = d { + let pairs: Vec<&Pair<'i>> = elements + .iter() + .filter_map(|element| { + if let Expression::Pair(pair, _) = element { + Some(pair.as_ref()) + } else { + None + } + }) + .collect(); + if !pairs.is_empty() && pairs.len() == elements.len() { + return Some(pairs); + } + } + } + None + } + /// Returns rendered code inline expressions from this paragraph. /// Each entry is (expression, body_lines) where body_lines captures /// multiline content for separate styling. diff --git a/src/domain/recipe/adapter.rs b/src/domain/recipe/adapter.rs index 042b836..0b1c51d 100644 --- a/src/domain/recipe/adapter.rs +++ b/src/domain/recipe/adapter.rs @@ -138,19 +138,17 @@ fn collect_ingredients(items: &mut Vec, scope: &language::Scope, pla return; } - // Steps may contain tablet children + // A step's tablet folds into its description as inline code if scope.is_step() { - for child in scope.children() { - if let Some(pairs) = child.tablet() { - for pair in pairs { - items.push(Ingredient { - label: pair - .label - .to_string(), - quantity: format_value(&pair.value), - source: place.map(String::from), - }); - } + if let Some(pairs) = scope.inline_tablet() { + for pair in pairs { + items.push(Ingredient { + label: pair + .label + .to_string(), + quantity: format_value(&pair.value), + source: place.map(String::from), + }); } } } diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 44becf0..106e4a9 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -765,6 +765,8 @@ impl<'i> Formatter<'i> { Descriptive::CodeInline(expr) => match expr { _ if is_tablet_list_expr(expr) => { line.flush(); + self.append_char('\n'); + self.indent(); self.add_fragment_reference(Syntax::Structure, "{"); self.append_char('\n'); self.increase(4); @@ -772,8 +774,9 @@ impl<'i> Formatter<'i> { self.append_expression(expr); self.append_char('\n'); self.decrease(4); + self.indent(); + self.add_fragment_reference(Syntax::Structure, "}"); line = self.builder(); - line.add_word(Syntax::Structure, "}"); } Expression::Multiline(_, _, _) => { line.flush(); diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index 6107a4c..8bd6625 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -2364,7 +2364,7 @@ impl<'i> Parser<'i> { || is_enum_response(line) || malformed_step_pattern(line) || malformed_response_pattern(line) - || is_code_block(line) + || is_loop_block_line(line) }, |outer| { let mut results = vec![]; @@ -3185,6 +3185,21 @@ fn is_code_block(content: &str) -> bool { re.is_match(content) } +/// Does this line open a control-structure code block (`{ foreach ... }` or +/// `{ repeat ... }`)? Only a control structure owns the substeps below it and +/// so opens a scope; otherwise a plain expression block (literal value, +/// function call, tablet etc) is inline. +fn is_loop_block_line(content: &str) -> bool { + if let Some(rest) = content + .trim_ascii_start() + .strip_prefix('{') + { + is_foreach_keyword(rest) || is_repeat_keyword(rest) + } else { + false + } +} + /// Is this code block is a control structure (`foreach` or `repeat`) which /// owns the steps below it as its body. A plain code does not. fn is_loop_block(expressions: &[Expression]) -> bool { diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 4fd45f6..93f9e80 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -1665,11 +1665,9 @@ delete_rds_instance : let Operation::Sequence(step_body) = body.as_ref() else { panic!("expected Step body Sequence"); }; - // The CodeBlock translates to a Sequence pushed into the Step body. - let Operation::Sequence(block_ops) = &step_body[0] else { - panic!("expected CodeBlock Sequence, got {:?}", step_body[0]); - }; - let names: Vec<&str> = block_ops + // A plain code block is inline: its expressions hoist directly into the + // Step body, one Execute per call. + let names: Vec<&str> = step_body .iter() .map(|op| match op { Operation::Execute(executable) => { diff --git a/tests/runner/samples.rs b/tests/runner/samples.rs index cb0f869..7abb142 100644 --- a/tests/runner/samples.rs +++ b/tests/runner/samples.rs @@ -52,7 +52,7 @@ fn ensure_run() { } }; - let program = match translation::translate(&document) { + let mut program = match translation::translate(&document) { Ok(program) => program, Err(e) => { println!("File {:?} failed to translate: {:?}", file, e); @@ -63,6 +63,11 @@ fn ensure_run() { let mut library = Library::core(); library.extend(Library::system()); + if let Err(e) = technique::linking::link(&mut program, &library) { + println!("File {:?} failed to link: {:?}", file, e); + failures.push(file.clone()); + continue; + } let mut runner = Runner::new( &program, Appender::memory(), diff --git a/tests/samples/runner/ConfirmRelease.pfftt b/tests/samples/runner/ConfirmRelease.pfftt index 02271e1..b3be2b6 100644 --- a/tests/samples/runner/ConfirmRelease.pfftt +++ b/tests/samples/runner/ConfirmRelease.pfftt @@ -1,7 +1,9 @@ 2026-06-14T05:58:08.700Z 000001 / Start file://tests/samples/runner/ConfirmRelease.tq 2026-06-14T05:58:08.700Z 000001 /confirm_release: Begin 2026-06-14T05:58:08.700Z 000001 /confirm_release:/1 Begin -2026-06-14T05:58:08.700Z 000001 /confirm_release:/1 Done "v1.0" +2026-06-14T05:58:08.700Z 000001 /confirm_release:/1 Done "v2.0" 2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Begin +2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Execute exec() +2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Execute exec() 2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Done () 2026-06-14T05:58:08.700Z 000001 /confirm_release: Done () diff --git a/tests/samples/runner/ConfirmRelease.tq b/tests/samples/runner/ConfirmRelease.tq index 93d65c6..9ef3088 100644 --- a/tests/samples/runner/ConfirmRelease.tq +++ b/tests/samples/runner/ConfirmRelease.tq @@ -4,5 +4,6 @@ confirm_release : # Confirm the release tag - 1. Suggested release tag { "v1.0" } - 2. Proceed once the tag above is correct + 1. Suggested release tag { "v1.0" } and then { "v2.0" } + 2. Proceed once the tag above is correct with + { exec("echo 1") } then { exec("echo 2") } From e052a8c9a6f19bb67dbbe02960beb4f9c3e6b93d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 19 Jun 2026 22:43:18 +1000 Subject: [PATCH 5/5] Parse list values when accepting input --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/runner/checks/evaluator.rs | 67 +++++++++++++++++++++++++++++++- src/runner/checks/runner.rs | 69 +++++++++++++++++++++++++++++++++ src/runner/evaluator.rs | 46 ++++++++++++++++++++++ src/runner/runner.rs | 70 +++++++++++++++++++++++++++------- src/runner/state.rs | 2 +- 7 files changed, 240 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ae431d..ee60967 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,7 +668,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.6.1" +version = "0.6.2" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index e193183..6b1d9e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.6.1" +version = "0.6.2" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index 0d7fa8e..efe79e8 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -1,7 +1,7 @@ use crate::language::{Identifier, Numeric as LangNumeric}; use crate::program::{Entry, Executable, ExecutableRef, Fragment, Operation}; use crate::runner::context::Context; -use crate::runner::evaluator::{combine, evaluate, Environment}; +use crate::runner::evaluator::{coerce_to_list, combine, evaluate, Environment}; use crate::runner::library::Library; use crate::runner::runner::RunnerError; use crate::value; @@ -379,3 +379,68 @@ fn combine_cross_kind_errors() { assert_eq!(left, "quantity"); assert_eq!(right, "string"); } + +#[test] +fn coerce_list_passthrough_and_widening() { + // A list yields its members unchanged. + let list = value::Value::Arraeum(vec![ + value::Value::Literali("a".to_string()), + value::Value::Literali("b".to_string()), + ]); + assert_eq!( + coerce_to_list(list).expect("coerced"), + vec![ + value::Value::Literali("a".to_string()), + value::Value::Literali("b".to_string()), + ] + ); + + // Unit is the empty list; a bare scalar widens to a singleton. + assert_eq!( + coerce_to_list(value::Value::Unitus).expect("coerced"), + Vec::::new() + ); + assert_eq!( + coerce_to_list(value::Value::Literali("solo".to_string())).expect("coerced"), + vec![value::Value::Literali("solo".to_string())] + ); +} + +#[test] +fn coerce_parses_bracketed_literal() { + // A `[ … ]` literal acquired at a prompt parses into its elements, quoted + // or bare; empty brackets are the empty list. + let quoted = + coerce_to_list(value::Value::Literali(r#"["east", "west"]"#.to_string())).expect("coerced"); + assert_eq!( + quoted, + vec![ + value::Value::Literali("east".to_string()), + value::Value::Literali("west".to_string()), + ] + ); + + let bare = coerce_to_list(value::Value::Literali("[east, west]".to_string())).expect("coerced"); + assert_eq!( + bare, + vec![ + value::Value::Literali("east".to_string()), + value::Value::Literali("west".to_string()), + ] + ); + + let empty = coerce_to_list(value::Value::Literali("[]".to_string())).expect("coerced"); + assert_eq!(empty, Vec::::new()); +} + +#[test] +fn coerce_rejects_tablet() { + let tablet = value::Value::Tabularum(vec![( + "k".to_string(), + value::Value::Literali("v".to_string()), + )]); + match coerce_to_list(tablet) { + Err(RunnerError::NotIterable) => {} + other => panic!("expected NotIterable, got {:?}", other), + } +} diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index e48e712..d7e1605 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -2237,3 +2237,72 @@ fn deferred_invoke_is_prompted_and_recorded() { .collect(); assert_eq!(settled, vec![State::Begin, State::Skip]); } + +#[test] +fn descriptive_binding_acquires_list_for_foreach() { + // A descriptive `~ items` binding has no executable value, so the operator + // is asked to supply it. They enter a `[ … ]` literal, which coerces to a + // list, and the following foreach walks its body once per element. + let source = r#" +% technique v1 + +cleanup : + + 1. enumerate things ~ items + 2. { foreach item in items } + - handle { item } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("descriptive-binding-acquire"); + // The acquire for `items` pops first, then the step and substep 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, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + + let prompt = runner.into_driver(); + let acquired: Vec> = prompt + .events() + .iter() + .filter_map(|event| { + if let Event::Acquire { name, .. } = event { + Some( + name.as_ref() + .map(String::as_str), + ) + } else { + None + } + }) + .collect(); + assert_eq!(acquired, vec![Some("items")]); + + let substeps = prompt + .events() + .iter() + .filter(|event| { + if let Event::Step { description, .. } = event { + description.contains("handle") + } else { + false + } + }) + .count(); + assert_eq!(substeps, 2); +} diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index 3850088..6790285 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -176,6 +176,52 @@ pub fn evaluate<'i>( } } +/// Reduce a value to the elements a `foreach` iterates. A list yields its +/// members; `Unit` (the absence of a value) is empty; a string acquired at a +/// prompt may be a `[a, b]` literal, which parses into its elements, else it +/// is a one-element list; a bare quantity widens likewise. A tablet, tuple, +/// or future is not iterable. +pub(super) fn coerce_to_list(value: Value) -> Result, RunnerError> { + match value { + Value::Arraeum(items) => Ok(items), + Value::Unitus => Ok(Vec::new()), + Value::Literali(text) => match parse_list_literal(&text) { + Some(items) => Ok(items), + None => Ok(vec![Value::Literali(text)]), + }, + value @ Value::Quanticle(_) => Ok(vec![value]), + _ => Err(RunnerError::NotIterable), + } +} + +/// Parse a `[ "a", b, ... ]` literal into its elements, each a string with +/// any surrounding quotes stripped. Returns `None` for text that is not +/// bracketed. Commas inside element text are not supported. +fn parse_list_literal(text: &str) -> Option> { + let inner = text + .trim() + .strip_prefix('[')? + .strip_suffix(']')?; + if inner + .trim() + .is_empty() + { + return Some(Vec::new()); + } + let items = inner + .split(',') + .map(|element| { + let element = element.trim(); + let unquoted = element + .strip_prefix('"') + .and_then(|e| e.strip_suffix('"')) + .unwrap_or(element); + Value::Literali(unquoted.to_string()) + }) + .collect(); + Some(items) +} + /// Bind names to a value, shared by `Bind` evaluation and `foreach` /// iteration. One name takes the whole value; multiple names destructure a /// `Parametriq` of matching arity. diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 64a7ab3..997884d 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -343,8 +343,8 @@ impl<'i, D: Driver> Runner<'i, D> { Ok(Outcome::Done(value)) } } - Operation::Bind { .. } - | Operation::Variable(_) + Operation::Bind { names, value } => self.walk_bind(env, names, value), + Operation::Variable(_) | Operation::Number(_) | Operation::String(_) | Operation::Multiline(_, _) @@ -631,6 +631,58 @@ impl<'i, D: Driver> Runner<'i, D> { } } + /// Establish a binding. A descriptive binding of an action in a + /// prose-only paragraph, for example + /// + /// ```technique + /// 4. Enumerate all the geographies ~ regions + /// ``` + /// + /// carries no computable; the value of regions will be the result the + /// user enters, acquired from the driver. + /// + /// A binding whose value is an invocation or inline code block is + /// computable and is invoked or evaluated first. + fn walk_bind( + &mut self, + env: &mut Environment, + names: &'i [language::Identifier<'i>], + value: &'i Operation<'i>, + ) -> Result { + let descriptive = if let Operation::Sequence(ops) = value { + ops.is_empty() + } else { + false + }; + if descriptive { + let qualified = self + .path + .render(); + let name = names + .first() + .map(|n| n.value); + match self + .driver + .acquire(&qualified, name, None) + { + UserInput::Done(value) => { + super::evaluator::bind_names(env, names, value)?; + Ok(Outcome::Done(Value::Unitus)) + } + UserInput::Skip => { + super::evaluator::bind_names(env, names, Value::Unitus)?; + Ok(Outcome::Skipped(Value::Unitus)) + } + UserInput::Fail(reason) => Ok(Outcome::Failed(Failure::Aborted(reason))), + UserInput::Quit => self.record_stop(), + } + } else { + let value = super::evaluator::evaluate(&self.library, &self.context, env, value)?; + super::evaluator::bind_names(env, names, value)?; + Ok(Outcome::Done(Value::Unitus)) + } + } + /// Evaluate a control structure. A `foreach` evalutates its body once for /// each element of the input collection, binding the loop name(s) to each /// element in turn and pushing an `Iteration` scope segment. The @@ -658,18 +710,8 @@ impl<'i, D: Driver> Runner<'i, D> { } } Some(expr) => { - let items = - match super::evaluator::evaluate(&self.library, &self.context, env, expr)? { - Value::Arraeum(items) => items, - // A scalar in list context is a singleton list. - value @ (Value::Literali(_) | Value::Quanticle(_)) => vec![value], - // Unit is the absence of a value, so there is nothing - // to iterate: the body runs zero times. - Value::Unitus => Vec::new(), - // A tablet is a record, not a sequence, so it does not - // iterate directly. - _ => return Err(RunnerError::NotIterable), - }; + let value = super::evaluator::evaluate(&self.library, &self.context, env, expr)?; + let items = super::evaluator::coerce_to_list(value)?; for (i, item) in items .into_iter() .enumerate() diff --git a/src/runner/state.rs b/src/runner/state.rs index 5a700c5..82f8a12 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -270,7 +270,7 @@ impl Store { // Recover the source document's file path and the libraries that were // selected from a Start URI of the form -// +// // file://{path}?library=a,b // written by `create` records. The query string parameters are optional.