From 22f7f7e2d90393d0e6c1023f7425843bf3b18f28 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 28 May 2026 22:18:17 +1000 Subject: [PATCH 1/9] Pass arguments on command-line to entry procedure --- src/main.rs | 13 ++- src/problem/messages.rs | 20 ++++ src/runner/checks/runner.rs | 220 +++++++++++++++++++++++++++++++++--- src/runner/mod.rs | 18 ++- src/runner/runner.rs | 45 +++++++- 5 files changed, 295 insertions(+), 21 deletions(-) diff --git a/src/main.rs b/src/main.rs index a2242c54..9b44d360 100644 --- a/src/main.rs +++ b/src/main.rs @@ -507,6 +507,17 @@ fn main() { debug!(filename); + let arguments: Vec = submatches + .get_many::("arguments") + .map(|values| { + values + .cloned() + .collect() + }) + .unwrap_or_default(); + + debug!(?arguments); + let filename = Path::new(filename); let content = match parsing::load(&filename) { Ok(data) => data, @@ -561,7 +572,7 @@ fn main() { } }; - match runner::start(filename, &program) { + match runner::start(filename, &program, &arguments) { Ok((run_id, Outcome::Quit)) => { eprintln!("paused; resume with `technique resume {}`", run_id.render()); std::process::exit(0); diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 4a6363cf..b043636e 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1097,6 +1097,26 @@ pub fn generate_runner_error(error: &RunnerError, _renderer: &dyn Render) -> (St ), "Binding multiple variables requires the procedure being invoked or function being called to return a tuple of the same size.".to_string(), ), + RunnerError::ParameterArityMismatch { expected, actual } => ( + format!( + "Wrong number of arguments: procedure expects {} but {} given", + expected, actual + ), + r#" +Arguments after the filename are passed as the parameters for the entry +procedure at the top of the Technique document. + "#.trim_ascii().to_string(), + ), + RunnerError::ParameterUnexpected { actual } => ( + format!( + "Unexpected arguments: procedure takes no parameters but {} given", + actual + ), + r#" +Arguments were supplied on the command-line but the entry procedure at the top +of the document doesn't take ant parameters. + "#.trim_ascii().to_string(), + ), RunnerError::UserQuit => ( "Interrupted".to_string(), "The user quit before the procedure was completed. Use `technique resume ` to continue.".to_string(), diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 4857e7fa..6c39393d 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -3,8 +3,9 @@ use std::path::{Path, PathBuf}; use crate::parsing; use crate::program::{Operation, Ordinal, Program, Subroutine}; +use crate::runner::evaluator::Environment; use crate::runner::prompt::{Event, Mock, UserInput}; -use crate::runner::runner::{Outcome, Runner}; +use crate::runner::runner::{bind_parameters, Outcome, Runner, RunnerError}; use crate::runner::state::{parse_record, Appender, State, Store, Value as RecordValue}; use crate::translation::translate; use crate::value::Value; @@ -96,7 +97,13 @@ fn step_outcomes_recorded() { )]); let program = anonymous_with_body(body); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); let outcome = runner .run() .expect("run"); @@ -132,7 +139,13 @@ fn step_outcomes_recorded() { )]); let program = anonymous_with_body(body); let prompt = Mock::with_answers([UserInput::Skip]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -156,7 +169,13 @@ fn step_outcomes_recorded() { )]); let program = anonymous_with_body(body); let prompt = Mock::with_answers([UserInput::Fail]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -192,7 +211,13 @@ fn two_steps_prompted_in_source_order() { UserInput::Done(Value::Unitus), UserInput::Done(Value::Unitus), ]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -227,7 +252,13 @@ fn pre_completed_step_short_circuits() { completed.insert("/1".to_string()); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), completed, prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + completed, + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -257,7 +288,13 @@ fn quit_propagates_and_stops_walking() { let program = anonymous_with_body(body); let prompt = Mock::with_answers([UserInput::Quit]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); let outcome = runner .run() .expect("run"); @@ -309,7 +346,13 @@ fn section_walking() { }]); let program = anonymous_with_body(body); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -349,7 +392,13 @@ fn section_walking() { }]); let program = anonymous_with_body(body); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -381,7 +430,13 @@ fn parallel_step_index_starts_at_one() { UserInput::Done(Value::Unitus), UserInput::Done(Value::Unitus), ]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -420,7 +475,13 @@ test : UserInput::Done(Value::Unitus), UserInput::Done(Value::Unitus), ]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -463,7 +524,13 @@ helper : let mut fixture = StoreFixture::new("invoke-descent"); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -501,7 +568,13 @@ test : let mut fixture = StoreFixture::new("execute-announce"); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -544,7 +617,13 @@ fn loop_inside_step_produces_one_result() { let program = anonymous_with_body(body); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); - let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); runner .run() .expect("run"); @@ -564,3 +643,116 @@ fn loop_inside_step_produces_one_result() { assert!(lines[1].ends_with(" Begin")); assert!(lines[2].contains(" Done")); } + +#[test] +fn bind_parameters_arity_and_errors() { + // Procedure with two parameters and matching arity: the returned + // Environment contains both parameter bindings in `Value::Literali` + // form. + let source = r#" +% technique v1 + +connectivity_check(e, s) : + +1. step + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + let args = ["foo".to_string(), "192.168.1.5".to_string()]; + let env = bind_parameters(&program, &args).expect("bind"); + assert_eq!(env.lookup("e"), Some(&Value::Literali("foo".to_string()))); + assert_eq!( + env.lookup("s"), + Some(&Value::Literali("192.168.1.5".to_string())) + ); + + // Too few arguments: ParameterArityMismatch. + let args = ["foo".to_string()]; + let error = bind_parameters(&program, &args).expect_err("expected arity error"); + let RunnerError::ParameterArityMismatch { expected, actual } = error else { + panic!("expected ParameterArityMismatch, got {:?}", error); + }; + assert_eq!(expected, 2); + assert_eq!(actual, 1); + + // Too many arguments: also ParameterArityMismatch. + let args = [ + "foo".to_string(), + "192.168.1.5".to_string(), + "extra".to_string(), + ]; + let error = bind_parameters(&program, &args).expect_err("expected arity error"); + let RunnerError::ParameterArityMismatch { expected, actual } = error else { + panic!("expected ParameterArityMismatch, got {:?}", error); + }; + assert_eq!(expected, 2); + assert_eq!(actual, 3); + + // Procedure declares no parameters but args supplied: ParameterUnexpected. + let source = r#" +% technique v1 + +test : + +1. step + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + let args = ["unwanted".to_string()]; + let error = bind_parameters(&program, &args).expect_err("expected unexpected error"); + let RunnerError::ParameterUnexpected { actual } = error else { + panic!("expected ParameterUnexpected, got {:?}", error); + }; + assert_eq!(actual, 1); + + // No parameters and no args: empty environment, no error. + let env = bind_parameters(&program, &[]).expect("bind"); + assert!(env + .lookup("anything") + .is_none()); +} + +#[test] +fn entry_procedure_parameters_visible_in_descriptions() { + let source = r#" +% technique v1 + +greet(name) : + +1. Hello { name } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("entry-param-interpolate"); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut env = Environment::new(); + env.extend("name".to_string(), Value::Literali("world".to_string())); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + env, + ); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let descriptions: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { description, .. } = e { + Some(description.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(descriptions, vec!["Hello world"]); +} diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 0b705605..514327f6 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -16,24 +16,28 @@ mod state; pub use runner::{Outcome, RunnerError}; pub use state::{RecordError, RunId}; +use evaluator::Environment; use prompt::Console; -use runner::{now_iso8601, Runner}; +use runner::{bind_parameters, now_iso8601, Runner}; use state::{construct_state_path, Appender, Record, State, Store}; 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. +/// or quitting. Command-line arguments are bound to the entry procedure's +/// parameters before the beginning the walk. pub fn start<'i>( document: &Path, program: &'i Program<'i>, + arguments: &[String], ) -> Result<(RunId, Outcome), RunnerError> { + let env = bind_parameters(program, arguments)?; let store = Store::new(PathBuf::from(STORE_ROOT)); let (run_id, run_dir) = store.create(document, now_iso8601())?; let pfftt = construct_state_path(&run_dir, document); let appender = Appender::open(pfftt, run_id)?; - let mut runner = Runner::with_pieces(program, appender, HashSet::new(), Console::new()); + let mut runner = Runner::new(program, appender, HashSet::new(), Console::new(), env); let outcome = runner.run()?; Ok((run_id, outcome)) } @@ -62,6 +66,12 @@ pub fn resume<'i>(run_id: RunId, program: &'i Program<'i>) -> Result { } impl<'i, P: Prompt> Runner<'i, P> { - pub fn with_pieces( + pub fn new( program: &'i Program<'i>, appender: Appender, completed: HashSet, prompt: P, + env: Environment, ) -> Self { Runner { program, appender, completed, prompt, - env: Environment::new(), + env, path: QualifiedPath::new(), } } @@ -440,6 +443,44 @@ fn record_state(outcome: &Outcome) -> State { } } +/// Build an `Environment` seeded with the entry procedure's parameters +/// bound to the supplied CLI arguments. Each argument is bound as +/// `Value::Literali` for now; when the value-literal grammar settles, +/// parse the strings into the typed Value the parameter declares. +pub(super) fn bind_parameters( + program: &Program<'_>, + arguments: &[String], +) -> Result { + let entry = program + .subroutines + .first() + .ok_or(RunnerError::MissingEntryProcedure)?; + let params = entry + .parameters + .unwrap_or(&[]); + let expected = params.len(); + let actual = arguments.len(); + if expected == 0 && actual > 0 { + return Err(RunnerError::ParameterUnexpected { actual }); + } + if expected != actual { + return Err(RunnerError::ParameterArityMismatch { expected, actual }); + } + let mut env = Environment::new(); + for (param, argument) in params + .iter() + .zip(arguments) + { + env.extend( + param + .value + .to_string(), + Value::Literali(argument.clone()), + ); + } + Ok(env) +} + /// Current UTC time as an RFC3339 millisecond-precision string, used /// for the `recorded` field of every Result tablet. The fraction is /// truncated (not rounded) — sub-millisecond resolution is dropped — From b2f2ea6baeff9dce63bd3f2c2251d6b1eaf5b08f Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 28 May 2026 23:25:08 +1000 Subject: [PATCH 2/9] Use Response text as literal values --- src/runner/checks/prompt.rs | 69 +++++++++++++++++++++++++++++-------- src/runner/checks/runner.rs | 59 +++++++++++++++++++++++++++++++ src/runner/checks/state.rs | 17 +++++++++ src/runner/prompt.rs | 51 ++++++++++++++++++++++----- src/runner/runner.rs | 22 ++++++++---- src/runner/state.rs | 16 +++++++-- 6 files changed, 201 insertions(+), 33 deletions(-) diff --git a/src/runner/checks/prompt.rs b/src/runner/checks/prompt.rs index 2f37bdb7..74907fc8 100644 --- a/src/runner/checks/prompt.rs +++ b/src/runner/checks/prompt.rs @@ -10,16 +10,16 @@ fn mock_returns_canned_answers_in_order() { UserInput::Skip, UserInput::Quit, ]); - assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); - assert_eq!(p.ask(), UserInput::Skip); - assert_eq!(p.ask(), UserInput::Quit); + assert_eq!(p.ask(&[]), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(&[]), UserInput::Skip); + assert_eq!(p.ask(&[]), UserInput::Quit); } #[test] fn mock_records_step_and_ask_events() { let mut p = Mock::with_answers([UserInput::Done(Value::Unitus)]); p.step("local_network:I/1", "Check the cable."); - let _ = p.ask(); + let _ = p.ask(&[]); assert_eq!( p.events(), &[ @@ -27,11 +27,52 @@ fn mock_records_step_and_ask_events() { qualified: "local_network:I/1".to_string(), description: "Check the cable.".to_string(), }, - Event::Ask, + Event::Ask { choices: vec![] }, ] ); } +#[test] +fn mock_records_offered_choices() { + let mut p = Mock::with_answers([UserInput::Done(Value::Literali("Yes".to_string()))]); + let _ = p.ask(&["Yes", "No"]); + assert_eq!( + p.events(), + &[Event::Ask { + choices: vec!["Yes".to_string(), "No".to_string()], + }] + ); +} + +#[test] +fn console_response_choices() { + // A numbered selection returns the chosen response value, and the + // choices are listed in the output. + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b"2\n"), &mut output); + assert_eq!( + p.ask(&["Yes", "No"]), + UserInput::Done(Value::Literali("No".to_string())) + ); + let written = String::from_utf8(output).expect("utf8"); + assert!(written.contains("1) Yes")); + assert!(written.contains("2) No")); + + // Skip / fail / quit stay available when choices are offered. + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b"s\n"), &mut output); + assert_eq!(p.ask(&["Yes", "No"]), UserInput::Skip); + + // An out-of-range number and a bare `d` both re-prompt; the valid + // pick that follows is accepted. + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b"9\nd\n1\n"), &mut output); + assert_eq!( + p.ask(&["Yes", "No"]), + UserInput::Done(Value::Literali("Yes".to_string())) + ); +} + #[test] fn mock_records_section_and_announce() { let mut p = Mock::new(); @@ -53,36 +94,36 @@ fn mock_records_section_and_announce() { #[should_panic(expected = "Mock::ask called with no canned answers remaining")] fn mock_ask_without_answers_panics() { let mut p = Mock::new(); - let _ = p.ask(); + let _ = p.ask(&[]); } #[test] fn console_input() { let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"d\n"), &mut output); - assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(&[]), UserInput::Done(Value::Unitus)); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"s\n"), &mut output); - assert_eq!(p.ask(), UserInput::Skip); + assert_eq!(p.ask(&[]), UserInput::Skip); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"f\n"), &mut output); - assert_eq!(p.ask(), UserInput::Fail); + assert_eq!(p.ask(&[]), UserInput::Fail); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"q\n"), &mut output); - assert_eq!(p.ask(), UserInput::Quit); + assert_eq!(p.ask(&[]), UserInput::Quit); // Case-insensitive on the first character. let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"DONE\n"), &mut output); - assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(&[]), UserInput::Done(Value::Unitus)); // Leading whitespace is tolerated. let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b" q\n"), &mut output); - assert_eq!(p.ask(), UserInput::Quit); + assert_eq!(p.ask(&[]), UserInput::Quit); } #[test] @@ -90,7 +131,7 @@ fn console_unrecognized_input_reprompts() { let input = Cursor::new(b"x\nd\n"); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(input, &mut output); - assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(&[]), UserInput::Done(Value::Unitus)); // Two prompts written: one for the rejected `x`, one for the // accepted `d`. The prompt text contains "[d]one". let written = String::from_utf8(output).expect("utf8"); @@ -107,7 +148,7 @@ fn console_eof_returns_quit() { let input = Cursor::new(b""); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(input, &mut output); - assert_eq!(p.ask(), UserInput::Quit); + assert_eq!(p.ask(&[]), UserInput::Quit); } #[test] diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 6c39393d..720ef0dc 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -756,3 +756,62 @@ greet(name) : .collect(); assert_eq!(descriptions, vec!["Hello world"]); } + +#[test] +fn step_with_responses_prompts_choices_and_records() { + let source = r#" +% technique v1 + +test : + +1. Is the site marked? + 'Yes' | 'No' + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("step-responses"); + let prompt = Mock::with_answers([UserInput::Done(Value::Literali("Yes".to_string()))]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); + runner + .run() + .expect("run"); + + // The prompt offered the two declared responses as choices. + let prompt = runner.into_prompt(); + let asked: Vec<&Vec> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Ask { choices } = e { + Some(choices) + } else { + None + } + }) + .collect(); + assert_eq!(asked, vec![&vec!["Yes".to_string(), "No".to_string()]]); + + // The chosen response is recorded as a quoted literal in the PFFTT. + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + let record = parse_record(lines[2]).expect("parse record"); + assert_eq!( + record.state, + State::Done(Some(RecordValue::Literal("Yes".to_string()))) + ); +} diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index ec50c68c..ed985659 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -380,6 +380,17 @@ fn format_record_pins_on_disk_text() { "2026-05-17T00:29:15Z 015003 /local_network:3 Done [ address = \"192.168.1.1\" ]\n" ); + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/before_anesthesia:2".to_string(), + state: State::Done(Some(Value::Literal("Not Applicable".to_string()))), + }; + assert_eq!( + format_record(&record), + "2026-05-14T12:00:00Z 000001 /before_anesthesia:2 Done \"Not Applicable\"\n" + ); + let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), run_id: RunId(1), @@ -485,6 +496,12 @@ fn record_round_trips_through_format_and_parse() { "[ address = \"10.0.0.1\" ]".to_string(), ))), }, + Record { + recorded: "2026-05-14T12:00:02Z".to_string(), + run_id: RunId(1), + path: "/a:7".to_string(), + state: State::Done(Some(Value::Literal("Not Applicable".to_string()))), + }, Record { recorded: "2026-05-14T12:00:03Z".to_string(), run_id: RunId(1), diff --git a/src/runner/prompt.rs b/src/runner/prompt.rs index d354d8e7..ef0d594d 100644 --- a/src/runner/prompt.rs +++ b/src/runner/prompt.rs @@ -39,7 +39,11 @@ pub trait Prompt { fn announce(&mut self, message: &str); /// Block until the operator answers the most recent `step` prompt. - fn ask(&mut self) -> UserInput; + /// When `choices` is non-empty the operator selects one of those + /// response values, yielding `Done(Literali(choice))`; an empty slice + /// presents the plain done/skip/fail/quit verdict, yielding + /// `Done(Unitus)`. Skip, fail, and quit remain available either way. + fn ask(&mut self, choices: &[&str]) -> UserInput; } /// Interactive console prompt. Writes to stdout, reads line-buffered @@ -92,9 +96,23 @@ impl Prompt for Console { let _ = writeln!(self.output, "{}", message); } - fn ask(&mut self) -> UserInput { + fn ask(&mut self, choices: &[&str]) -> UserInput { loop { - let _ = write!(self.output, "[d]one / [s]kip / [f]ail / [q]uit ? "); + if choices.is_empty() { + let _ = write!(self.output, "[d]one / [s]kip / [f]ail / [q]uit ? "); + } else { + for (i, choice) in choices + .iter() + .enumerate() + { + let _ = writeln!(self.output, " {}) {}", i + 1, choice); + } + let _ = write!( + self.output, + "[1-{}] / [s]kip / [f]ail / [q]uit ? ", + choices.len() + ); + } let _ = self .output .flush(); @@ -107,13 +125,21 @@ impl Prompt for Console { Ok(_) => {} Err(_) => return UserInput::Quit, } - match line - .trim_start() + let trimmed = line.trim(); + // A numbered selection picks the corresponding response value. + if !choices.is_empty() { + if let Ok(n) = trimmed.parse::() { + if (1..=choices.len()).contains(&n) { + return UserInput::Done(Value::Literali(choices[n - 1].to_string())); + } + } + } + match trimmed .chars() .next() .map(|c| c.to_ascii_lowercase()) { - Some('d') => return UserInput::Done(Value::Unitus), + Some('d') if choices.is_empty() => return UserInput::Done(Value::Unitus), Some('s') => return UserInput::Skip, Some('f') => return UserInput::Fail, Some('q') => return UserInput::Quit, @@ -147,7 +173,9 @@ pub enum Event { title: String, }, Announce(String), - Ask, + Ask { + choices: Vec, + }, } #[allow(dead_code)] @@ -198,9 +226,14 @@ impl Prompt for Mock { .push(Event::Announce(message.to_string())); } - fn ask(&mut self) -> UserInput { + fn ask(&mut self, choices: &[&str]) -> UserInput { self.events - .push(Event::Ask); + .push(Event::Ask { + choices: choices + .iter() + .map(|c| c.to_string()) + .collect(), + }); self.answers .pop_front() .expect("Mock::ask called with no canned answers remaining") diff --git a/src/runner/runner.rs b/src/runner/runner.rs index fa723827..e41a95a4 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -10,6 +10,7 @@ use super::prompt::{Prompt, UserInput}; use super::state::{ Appender, InvokeTarget, Record, RecordError, RunId, State, Value as RecordValue, }; +use crate::language; use crate::program::{Executable, Invocable, Operation, Ordinal, Program, SubroutineRef}; use crate::value::Value; @@ -293,7 +294,7 @@ impl<'i, P: Prompt> Runner<'i, P> { attributes, description, body, - .. + responses, } = op else { unreachable!("walk_step called with non-Step operation"); @@ -313,7 +314,7 @@ impl<'i, P: Prompt> Runner<'i, P> { .path .render(); - let result = self.perform_step(&qualified, body, description); + let result = self.perform_step(&qualified, body, description, responses); self.path .pop(); @@ -330,6 +331,7 @@ impl<'i, P: Prompt> Runner<'i, P> { qualified: &str, body: &'i Operation<'i>, description: &'i [Operation<'i>], + responses: &[&'i language::Response<'i>], ) -> Result { if self .completed @@ -371,9 +373,13 @@ impl<'i, P: Prompt> Runner<'i, P> { self.prompt .step(qualified, &description_text); + let choices: Vec<&str> = responses + .iter() + .map(|r| r.value) + .collect(); let outcome = outcome_from( self.prompt - .ask(), + .ask(&choices), ); if let Outcome::Quit = outcome { return Ok(Outcome::Quit); @@ -428,12 +434,14 @@ fn outcome_from(input: UserInput) -> Outcome { } /// Project the runner's in-memory `Outcome` into the on-disk `State` -/// the PFFTT writer expects. Done renders with an explicit unit -/// placeholder for now — capturing the operator's actual value into a -/// tablet is future work. Quit is unreachable here: the caller filters -/// it out before recording. +/// the PFFTT writer expects. A chosen response records as a quoted +/// literal; any other Done (the plain confirmation) records as unit. +/// Quit is unreachable here: the caller filters it out before recording. fn record_state(outcome: &Outcome) -> State { match outcome { + Outcome::Done(Value::Literali(text)) => { + State::Done(Some(RecordValue::Literal(text.clone()))) + } Outcome::Done(_) => State::Done(Some(RecordValue::Unit)), Outcome::Skipped => State::Skip, Outcome::Failed(Failure::Aborted(reason)) => State::Fail(Some(RecordValue::Tablet( diff --git a/src/runner/state.rs b/src/runner/state.rs index d4f554fd..60ffc9ff 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -44,8 +44,8 @@ pub struct Record { pub state: State, } -/// A lifecycle or step-outcome event, mirroring the PFFTT BNF's `State` -/// production. `Start`, `Pause`, and `Resume` are run-lifecycle events +/// A lifecycle or step-outcome event; the keyword written into each PFFTT +/// record line. `Start`, `Pause`, and `Resume` are run-lifecycle events /// emitted at the root path `/`; `Begin` marks the moment work starts /// on a step (paired with the eventual `Done`, `Skip`, or `Fail`). /// `Invoke` records dispatch into another procedure (the return is @@ -74,13 +74,16 @@ pub enum InvokeTarget { Uri(String), } -/// A `Value` carried by a Done or Fail state. The BNF admits `unit` or +/// A `Value` carried by a Done or Fail state. Three on-disk forms, +/// handled by `format_value` / `parse_value` below: `unit` (`()`), a +/// double-quoted `literal` (the form a chosen response records as), and a /// `tablet`; tablets currently round-trip as opaque text until tablet /// typing in the runner lands. #[allow(dead_code)] #[derive(Debug, Clone, Eq, PartialEq)] pub enum Value { Unit, + Literal(String), Tablet(String), } @@ -408,6 +411,11 @@ fn format_state(out: &mut String, state: &State) { fn format_value(out: &mut String, value: &Value) { match value { Value::Unit => out.push_str("()"), + Value::Literal(text) => { + out.push('"'); + out.push_str(text); + out.push('"'); + } Value::Tablet(text) => out.push_str(text), } } @@ -527,6 +535,8 @@ fn parse_optional_value(rest: Option<&str>) -> Result, RecordError fn parse_value(text: &str) -> Result { if text == "()" { Ok(Value::Unit) + } else if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') { + Ok(Value::Literal(text[1..text.len() - 1].to_string())) } else if text.starts_with('[') && text.ends_with(']') { Ok(Value::Tablet(text.to_string())) } else { From 0691c0f965ff728c357de341c4ff984979c96724 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 31 May 2026 20:53:07 +1000 Subject: [PATCH 3/9] Implement evaluation of foreach loops --- src/problem/messages.rs | 4 + src/runner/checks/path.rs | 11 ++ src/runner/checks/runner.rs | 204 +++++++++++++++++++++++++++++++++++- src/runner/evaluator.rs | 71 ++++++++----- src/runner/path.rs | 9 +- src/runner/runner.rs | 49 +++++++-- src/value/checks/types.rs | 8 +- src/value/types.rs | 14 +++ 8 files changed, 330 insertions(+), 40 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index b043636e..7b8a7d03 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1117,6 +1117,10 @@ Arguments were supplied on the command-line but the entry procedure at the top of the document doesn't take ant parameters. "#.trim_ascii().to_string(), ), + RunnerError::NotIterable => ( + "Value is not a list".to_string(), + "The foreach keyword requires a list to iterate over, but the value suppliedisn't one.".to_string(), + ), RunnerError::UserQuit => ( "Interrupted".to_string(), "The user quit before the procedure was completed. Use `technique resume ` to continue.".to_string(), diff --git a/src/runner/checks/path.rs b/src/runner/checks/path.rs index b1c21ed5..5193bb53 100644 --- a/src/runner/checks/path.rs +++ b/src/runner/checks/path.rs @@ -37,6 +37,17 @@ fn parallel_step_uses_dash_prefix() { assert_eq!(stack.render(), "/-3"); } +// Walk a step with a foreach scope as its second element which in turn has a +// single nested substep. +#[test] +fn iteration_segment_renders_bracketed_index() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("5")); + stack.push(PathSegment::Iteration(2)); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "/5/[2]/a"); +} + #[test] fn attribute_frame_composes_role_and_place() { let frame = vec![ diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 720ef0dc..1c100bf4 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1,8 +1,9 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; +use crate::language::Identifier; use crate::parsing; -use crate::program::{Operation, Ordinal, Program, Subroutine}; +use crate::program::{Fragment, Operation, Ordinal, Program, Subroutine}; use crate::runner::evaluator::Environment; use crate::runner::prompt::{Event, Mock, UserInput}; use crate::runner::runner::{bind_parameters, Outcome, Runner, RunnerError}; @@ -644,6 +645,207 @@ fn loop_inside_step_produces_one_result() { assert!(lines[2].contains(" Done")); } +#[test] +fn foreach_walks_body_once_per_list_element() { + let mut fixture = StoreFixture::new("foreach-list"); + + // foreach item in items: a substep whose description interpolates the + // iteration variable, so each walk reveals which element it saw. + let description = Operation::String(vec![Fragment::Interpolation(Operation::Variable( + Identifier::new("item"), + ))]); + let substep = Operation::Step { + ordinal: Ordinal::Dependent("a"), + attributes: Vec::new(), + description: vec![description], + body: Box::new(Operation::Sequence(Vec::new())), + responses: Vec::new(), + }; + // `names` borrows from the IR, so the array must outlive the program. + let names = [Identifier::new("item")]; + let loop_op = Operation::Loop { + names: &names, + over: Some(Box::new(Operation::Variable(Identifier::new("items")))), + body: Box::new(Operation::Sequence(vec![substep])), + responses: Vec::new(), + }; + let mut sub = Subroutine::anonymous(); + sub.body = loop_op; + let mut program = Program::new(); + program + .subroutines + .push(sub); + + let mut env = Environment::new(); + env.extend( + "items".to_string(), + Value::Arraeum(vec![ + Value::Literali("first".to_string()), + Value::Literali("second".to_string()), + ]), + ); + + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + env, + ); + runner + .run() + .expect("run"); + + // The body is walked once per element. Each Step event carries an + // `[n]` iteration segment in its path and the description it saw, + // confirming the iteration variable was bound to that element. + let prompt = runner.into_prompt(); + let steps: Vec<(&str, &str)> = prompt + .events() + .iter() + .filter_map(|event| match event { + Event::Step { + qualified, + description, + } => Some((qualified.as_str(), description.as_str())), + _ => None, + }) + .collect(); + assert_eq!(steps, vec![("/[1]/a", "first"), ("/[2]/a", "second")]); +} + +#[test] +fn foreach_destructures_tuple_elements() { + let mut fixture = StoreFixture::new("foreach-destructure"); + + // foreach (first, second) in pairs: two names destructure each + // tuple-shaped element positionally. + let description = Operation::String(vec![ + Fragment::Interpolation(Operation::Variable(Identifier::new("first"))), + Fragment::Text("/"), + Fragment::Interpolation(Operation::Variable(Identifier::new("second"))), + ]); + let substep = Operation::Step { + ordinal: Ordinal::Dependent("a"), + attributes: Vec::new(), + description: vec![description], + body: Box::new(Operation::Sequence(Vec::new())), + responses: Vec::new(), + }; + // `names` borrows from the IR, so the array must outlive the program. + let names = [Identifier::new("first"), Identifier::new("second")]; + let loop_op = Operation::Loop { + names: &names, + over: Some(Box::new(Operation::Variable(Identifier::new("pairs")))), + body: Box::new(Operation::Sequence(vec![substep])), + responses: Vec::new(), + }; + let mut sub = Subroutine::anonymous(); + sub.body = loop_op; + let mut program = Program::new(); + program + .subroutines + .push(sub); + + let mut env = Environment::new(); + env.extend( + "pairs".to_string(), + Value::Arraeum(vec![ + Value::Parametriq(vec![ + Value::Literali("a".to_string()), + Value::Literali("b".to_string()), + ]), + Value::Parametriq(vec![ + Value::Literali("c".to_string()), + Value::Literali("d".to_string()), + ]), + ]), + ); + + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + env, + ); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let steps: Vec<&str> = prompt + .events() + .iter() + .filter_map(|event| match event { + Event::Step { description, .. } => Some(description.as_str()), + _ => None, + }) + .collect(); + assert_eq!(steps, vec!["a/b", "c/d"]); +} + +#[test] +fn foreach_over_non_list_or_unbound_errors() { + // foreach item in source, where `source` is supplied by the caller's + // environment. A scalar source is `NotIterable`; an unbound source + // propagates `UnboundVariable` rather than being swallowed. + let names = [Identifier::new("item")]; + let loop_op = Operation::Loop { + names: &names, + over: Some(Box::new(Operation::Variable(Identifier::new("source")))), + body: Box::new(Operation::Sequence(Vec::new())), + responses: Vec::new(), + }; + let mut sub = Subroutine::anonymous(); + sub.body = loop_op; + let mut program = Program::new(); + program + .subroutines + .push(sub); + + // A scalar bound to `source` is not a list. + let mut scalar_fixture = StoreFixture::new("foreach-scalar"); + let mut env = Environment::new(); + env.extend( + "source".to_string(), + Value::Literali("not a list".to_string()), + ); + let mut runner = Runner::new( + &program, + scalar_fixture.take_appender(), + HashSet::new(), + Mock::new(), + env, + ); + match runner.run() { + Err(RunnerError::NotIterable) => {} + other => panic!("expected NotIterable, got {:?}", other), + } + + // An unbound `source` propagates the evaluation error. + let mut unbound_fixture = StoreFixture::new("foreach-unbound"); + let mut runner = Runner::new( + &program, + unbound_fixture.take_appender(), + HashSet::new(), + Mock::new(), + Environment::new(), + ); + match runner.run() { + Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "source"), + other => panic!("expected UnboundVariable, got {:?}", other), + } +} + #[test] fn bind_parameters_arity_and_errors() { // Procedure with two parameters and matching arity: the returned diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index 460475f5..c33ac505 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -85,35 +85,7 @@ pub fn evaluate<'i>(env: &mut Environment, op: &Operation<'i>) -> Result { let v = evaluate(env, value)?; - match names.len() { - 1 => env.extend( - names[0] - .value - .to_string(), - v, - ), - n => { - let Value::Parametriq(values) = v else { - return Err(RunnerError::BindNotTuple { expected: n }); - }; - if values.len() != n { - return Err(RunnerError::BindArityMismatch { - expected: n, - actual: values.len(), - }); - } - for (name, value) in names - .iter() - .zip(values) - { - env.extend( - name.value - .to_string(), - value, - ); - } - } - } + bind_names(env, names, v)?; Ok(Value::Unitus) } Operation::Sequence(ops) => { @@ -131,6 +103,47 @@ pub fn evaluate<'i>(env: &mut Environment, op: &Operation<'i>) -> Result], + value: Value, +) -> Result<(), RunnerError> { + match names.len() { + 0 => unreachable!(), // bind_names requires at least one name + 1 => env.extend( + names[0] + .value + .to_string(), + value, + ), + n => { + let Value::Parametriq(values) = value else { + return Err(RunnerError::BindNotTuple { expected: n }); + }; + if values.len() != n { + return Err(RunnerError::BindArityMismatch { + expected: n, + actual: values.len(), + }); + } + for (name, value) in names + .iter() + .zip(values) + { + env.extend( + name.value + .to_string(), + value, + ); + } + } + } + Ok(()) +} + #[cfg(test)] #[path = "checks/evaluator.rs"] mod check; diff --git a/src/runner/path.rs b/src/runner/path.rs index d75029fe..5cbce9c1 100644 --- a/src/runner/path.rs +++ b/src/runner/path.rs @@ -12,6 +12,7 @@ pub enum PathSegment<'i> { Section(&'i str), DependentStep(&'i str), ParallelStep(usize), + Iteration(usize), Attributes(&'i [language::Attribute<'i>]), Procedure(&'i str), } @@ -52,6 +53,11 @@ impl<'i> QualifiedPath<'i> { /// root. Other segments are `/`-joined after the procedure prefix. /// An attribute frame containing the `@*` reset role contributes /// nothing. + /// + /// A `foreach` or `repeat` keyword creates a scope as well. When + /// iterating it is recorded using a path segment of the form `[n]`, + /// one-origin. Thus `/5/[2]/a` would be the first substep within the + /// second iteration of a scope within the 5th step of a Technique. pub fn render(&self) -> String { let mut prefix: Option<&str> = None; let mut start: usize = 0; @@ -85,7 +91,8 @@ fn render_segment(segment: &PathSegment) -> Option { match segment { PathSegment::Section(numeral) => Some(numeral.to_string()), PathSegment::DependentStep(ordinal) => Some(ordinal.to_string()), - PathSegment::ParallelStep(idx) => Some(format!("-{}", idx)), + PathSegment::ParallelStep(index) => Some(format!("-{}", index)), + PathSegment::Iteration(number) => Some(format!("[{}]", number)), // you can't "index" into it! PathSegment::Attributes(frame) => render_attributes(frame), PathSegment::Procedure(_) => None, } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index e41a95a4..c7b24f91 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -53,6 +53,7 @@ pub enum RunnerError { UnboundVariable(String), BindArityMismatch { expected: usize, actual: usize }, BindNotTuple { expected: usize }, + NotIterable, ParameterArityMismatch { expected: usize, actual: usize }, ParameterUnexpected { actual: usize }, UserQuit, @@ -142,11 +143,7 @@ impl<'i, P: Prompt> Runner<'i, P> { } Operation::Loop { names, over, body, .. - } => { - self.prompt - .announce(&describe_loop(names, over.as_deref())); - self.walk(body) - } + } => self.walk_loop(names, over.as_deref(), body), Operation::Invoke(invocable) => self.walk_invoke(invocable), Operation::Execute(executable) => { let qualified = self @@ -227,6 +224,44 @@ impl<'i, P: Prompt> Runner<'i, P> { } } + /// 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 + /// collection must evaluate to a list otherwise it's a runtime error. A + /// `repeat` keyword (an iterable with `over: None`) has no collection and + /// walks its body once. + fn walk_loop( + &mut self, + names: &'i [language::Identifier<'i>], + over: Option<&'i Operation<'i>>, + body: &'i Operation<'i>, + ) -> Result { + self.prompt + .announce(&describe_loop(names, over)); + let items = match over { + None => return self.walk(body), + Some(expr) => match super::evaluator::evaluate(&mut self.env, expr)? { + Value::Arraeum(items) => items, + _ => return Err(RunnerError::NotIterable), + }, + }; + for (i, item) in items + .into_iter() + .enumerate() + { + super::evaluator::bind_names(&mut self.env, names, item)?; + self.path + .push(PathSegment::Iteration(i + 1)); + let result = self.walk(body); + self.path + .pop(); + if let Outcome::Quit = result? { + return Ok(Outcome::Quit); + } + } + Ok(Outcome::Done(Value::Unitus)) + } + fn walk_sequence(&mut self, ops: &'i [Operation<'i>]) -> Result { let mut parallel_idx: usize = 0; for op in ops { @@ -452,9 +487,7 @@ fn record_state(outcome: &Outcome) -> State { } /// Build an `Environment` seeded with the entry procedure's parameters -/// bound to the supplied CLI arguments. Each argument is bound as -/// `Value::Literali` for now; when the value-literal grammar settles, -/// parse the strings into the typed Value the parameter declares. +/// bound to the supplied CLI arguments. pub(super) fn bind_parameters( program: &Program<'_>, arguments: &[String], diff --git a/src/value/checks/types.rs b/src/value/checks/types.rs index cf6f2b0a..9a249b7f 100644 --- a/src/value/checks/types.rs +++ b/src/value/checks/types.rs @@ -21,7 +21,13 @@ fn value_display() { Value::Literali("b".to_string()), Value::Quanticle(Numeric::Integral(3)), ]); - assert_eq!(v.to_string(), "[\"a\", \"b\", 3]"); + assert_eq!(v.to_string(), "(\"a\", \"b\", 3)"); + + let v = Value::Arraeum(vec![ + Value::Quanticle(Numeric::Integral(1)), + Value::Quanticle(Numeric::Integral(2)), + ]); + assert_eq!(v.to_string(), "[1, 2]"); assert_eq!(Value::Futurae("name".to_string()).to_string(), "{name}"); } diff --git a/src/value/types.rs b/src/value/types.rs index 29f936d9..44fd4a33 100644 --- a/src/value/types.rs +++ b/src/value/types.rs @@ -21,6 +21,7 @@ pub enum Value { Literali(String), Quanticle(Numeric), Tabularum(Vec<(String, Value)>), + Arraeum(Vec), Parametriq(Vec), Futurae(String), } @@ -90,6 +91,19 @@ impl Display for Value { f.write_str("]") } Value::Parametriq(values) => { + f.write_str("(")?; + for (i, value) in values + .iter() + .enumerate() + { + if i > 0 { + f.write_str(", ")?; + } + write!(f, "{}", value)?; + } + f.write_str(")") + } + Value::Arraeum(values) => { f.write_str("[")?; for (i, value) in values .iter() From 162b8de0fb99590008d65f3e7abbaebc5052bcd5 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 31 May 2026 21:11:39 +1000 Subject: [PATCH 4/9] Implement evaluation of repeat loops --- src/runner/runner.rs | 65 +++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/src/runner/runner.rs b/src/runner/runner.rs index c7b24f91..0bec6c0f 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -228,8 +228,10 @@ impl<'i, P: Prompt> Runner<'i, P> { /// each element of the input collection, binding the loop name(s) to each /// element in turn and pushing an `Iteration` scope segment. The /// collection must evaluate to a list otherwise it's a runtime error. A - /// `repeat` keyword (an iterable with `over: None`) has no collection and - /// walks its body once. + /// `repeat` keyword (an iterable with `over: None`) is unbounded: it + /// evaluates its body over and over, each pass an iteration scope, and in + /// theory never returns though in practice, stops if a Quit or Abort is + /// registered. fn walk_loop( &mut self, names: &'i [language::Identifier<'i>], @@ -238,28 +240,47 @@ impl<'i, P: Prompt> Runner<'i, P> { ) -> Result { self.prompt .announce(&describe_loop(names, over)); - let items = match over { - None => return self.walk(body), - Some(expr) => match super::evaluator::evaluate(&mut self.env, expr)? { - Value::Arraeum(items) => items, - _ => return Err(RunnerError::NotIterable), - }, - }; - for (i, item) in items - .into_iter() - .enumerate() - { - super::evaluator::bind_names(&mut self.env, names, item)?; - self.path - .push(PathSegment::Iteration(i + 1)); - let result = self.walk(body); - self.path - .pop(); - if let Outcome::Quit = result? { - return Ok(Outcome::Quit); + match over { + None => { + let mut number = 1; + loop { + self.path + .push(PathSegment::Iteration(number)); + let result = self.walk(body); + self.path + .pop(); + + if let Outcome::Quit = result? { + return Ok(Outcome::Quit); + } + number += 1; + } + } + Some(expr) => { + let items = match super::evaluator::evaluate(&mut self.env, expr)? { + Value::Arraeum(items) => items, + _ => return Err(RunnerError::NotIterable), + }; + for (i, item) in items + .into_iter() + .enumerate() + { + super::evaluator::bind_names(&mut self.env, names, item)?; + + let number = i + 1; + self.path + .push(PathSegment::Iteration(number)); + let result = self.walk(body); + self.path + .pop(); + + if let Outcome::Quit = result? { + return Ok(Outcome::Quit); + } + } + Ok(Outcome::Done(Value::Unitus)) } } - Ok(Outcome::Done(Value::Unitus)) } fn walk_sequence(&mut self, ops: &'i [Operation<'i>]) -> Result { From 4e5bcbf089f3456eb2c05f89fa51240ad0026e82 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 31 May 2026 21:39:58 +1000 Subject: [PATCH 5/9] Fix hoisting of operations from descriptives --- src/translation/translator.rs | 66 +++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 67248c90..409a0021 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -59,6 +59,13 @@ pub enum TranslationError<'i> { /// A local procedure invocation `(...)` whose `name` doesn't /// match any procedure declared in this document is an error. UnresolvedProcedure(language::Identifier<'i>), + /// Binding the result of a `repeat` to a variable is an error; the + /// `repeat` keyword does not terminate naturally and does not produces a + /// value so cannot be bound. Note: we could reconsider this in the fugure + /// if we implement a `break` or `return` keyword. + BoundRepeat { + at: Span, + }, } impl<'i> TranslationError<'i> { @@ -68,6 +75,7 @@ impl<'i> TranslationError<'i> { TranslationError::DuplicateTitle { at, .. } => *at, TranslationError::InterleavedDescription { at, .. } => *at, TranslationError::UnresolvedProcedure(id) => id.span, + TranslationError::BoundRepeat { at } => *at, } } } @@ -370,30 +378,44 @@ impl<'i> Translator<'i> { .len() .saturating_mul(2), ); - for (i, descriptive) in descriptives - .iter() - .enumerate() - { - if i > 0 { - fragments.push(Fragment::Text(" ")); + for descriptive in descriptives { + if let Some(fragment) = self.fragment_from_descriptive(descriptive) { + if !fragments.is_empty() { + fragments.push(Fragment::Text(" ")); + } + fragments.push(fragment); } - fragments.push(self.fragment_from_descriptive(descriptive)); } Operation::String(fragments) } + // The display fragment for a descriptive, or `None` when it renders no + // text. Only a value read or literal shows inline; everything executable + // (a bare invocation, inline `exec`/`repeat`/`foreach`, a binding) is + // hoisted into the enclosing body (a step's body, or the procedure's + // step-0 prefix) and contributes no fragment here. fn fragment_from_descriptive( &mut self, descriptive: &'i language::Descriptive<'i>, - ) -> Fragment<'i> { + ) -> Option> { match descriptive { - language::Descriptive::Text(text) => Fragment::Text(text), - language::Descriptive::CodeInline(expr) => { - Fragment::Interpolation(self.translate_expression(expr)) - } - language::Descriptive::Application(invocation) => { - Fragment::Interpolation(Operation::Invoke(self.translate_invocation(invocation))) - } + language::Descriptive::Text(text) => Some(Fragment::Text(text)), + language::Descriptive::CodeInline(expr) => match expr { + language::Expression::Variable(..) + | language::Expression::Number(..) + | language::Expression::String(..) + | language::Expression::Multiline(..) + | language::Expression::Tablet(..) => { + Some(Fragment::Interpolation(self.translate_expression(expr))) + } + language::Expression::Repeat(..) + | language::Expression::Foreach(..) + | language::Expression::Application(..) + | language::Expression::Execution(..) + | language::Expression::Binding(..) + | language::Expression::Separator => None, + }, + language::Descriptive::Application(_) => None, language::Descriptive::Binding(inner, _) => self.fragment_from_descriptive(inner), } } @@ -642,10 +664,16 @@ impl<'i> Translator<'i> { body: Box::new(Operation::Sequence(Vec::new())), responses: Vec::new(), }, - language::Expression::Binding(value, names, _) => Operation::Bind { - names, - value: Box::new(self.translate_expression(value)), - }, + language::Expression::Binding(value, names, span) => { + if let language::Expression::Repeat(_, _) = value.as_ref() { + self.problems + .push(TranslationError::BoundRepeat { at: *span }); + } + Operation::Bind { + names, + value: Box::new(self.translate_expression(value)), + } + } language::Expression::Separator => Operation::Sequence(Vec::new()), } } From 97b1826b8c1aab820f91951d846c111f84df3707 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 31 May 2026 21:40:27 +1000 Subject: [PATCH 6/9] Error if binding a repeat keyword result --- examples/minimal/ExampleOfEverything.tq | 3 +- src/problem/messages.rs | 4 ++ src/runner/checks/runner.rs | 61 +++++++++++++++++++++++-- src/translation/checks/errors.rs | 29 ++++++++++++ 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/examples/minimal/ExampleOfEverything.tq b/examples/minimal/ExampleOfEverything.tq index 871f9072..369c117a 100644 --- a/examples/minimal/ExampleOfEverything.tq +++ b/examples/minimal/ExampleOfEverything.tq @@ -10,7 +10,7 @@ Ask these questions: are you really sure you want a coffee? Assuming you do, then: - 1. { repeat (e) ~ cups } + 1. { repeat (e) } a. First task b. Second another task 'Yes' | 'No' @@ -19,6 +19,7 @@ Assuming you do, then: ./stuff ``` ) } + 3. Write everything down ~ paper another_example(e) : Input -> Output { diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 7b8a7d03..fdac10e3 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1048,6 +1048,10 @@ pub fn generate_translation_error<'i>( format!("Unresolved procedure '{}'", name), "A `` invocation must refer to a procedure declared in this document. Built-in functions use the `name(...)` form (without angle brackets).".to_string(), ), + TranslationError::BoundRepeat { .. } => ( + "Cannot use the result of `repeat`".to_string(), + "A `repeat` runs indefinitely and produces no value, so its result cannot be bound to a variable.".to_string(), + ), } } diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 1c100bf4..7b6a6e66 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -599,11 +599,12 @@ test : fn loop_inside_step_produces_one_result() { let mut fixture = StoreFixture::new("loop-in-step"); - // A Step whose body contains a Loop. The Loop announces but does - // not record a Result; the enclosing Step records exactly one. + // A Step whose body contains a Loop over an empty list. The Loop + // announces and walks its body zero times, recording nothing; the + // enclosing Step records exactly one Result. let loop_op = Operation::Loop { names: &[], - over: None, + over: Some(Box::new(Operation::Variable(Identifier::new("empty")))), body: Box::new(Operation::Sequence(vec![])), responses: Vec::new(), }; @@ -617,13 +618,15 @@ fn loop_inside_step_produces_one_result() { let body = Operation::Sequence(vec![the_step]); let program = anonymous_with_body(body); + let mut env = Environment::new(); + env.extend("empty".to_string(), Value::Arraeum(Vec::new())); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); let mut runner = Runner::new( &program, fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), + env, ); runner .run() @@ -645,6 +648,56 @@ fn loop_inside_step_produces_one_result() { assert!(lines[2].contains(" Done")); } +#[test] +fn repeat_loops_until_quit() { + let mut fixture = StoreFixture::new("repeat-until-quit"); + + // A `repeat` whose body is a single step. Each pass walks the step with + // a distinct `[n]` iteration segment; the operator quits on the third + // pass, ending the loop. + let inner = Operation::Step { + ordinal: Ordinal::Dependent("1"), + attributes: Vec::new(), + description: Vec::new(), + body: Box::new(Operation::Sequence(Vec::new())), + responses: Vec::new(), + }; + let loop_op = Operation::Loop { + names: &[], + over: None, + body: Box::new(Operation::Sequence(vec![inner])), + responses: Vec::new(), + }; + let program = anonymous_with_body(loop_op); + + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + UserInput::Quit, + ]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + ); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let steps: Vec<&str> = prompt + .events() + .iter() + .filter_map(|event| match event { + Event::Step { qualified, .. } => Some(qualified.as_str()), + _ => None, + }) + .collect(); + assert_eq!(steps, vec!["/[1]/1", "/[2]/1", "/[3]/1"]); +} + #[test] fn foreach_walks_body_once_per_list_element() { let mut fixture = StoreFixture::new("foreach-list"); diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 0b5fc8dc..1cd03971 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -36,6 +36,35 @@ make_coffee : ); } +#[test] +fn bound_repeat() { + let source = r#" +% technique v1 + +making : + + 1. { repeat (e) ~ cups } + +coffee : E -> C + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let errors = translate(&document).expect_err("translate should fail"); + + assert_eq!(errors.len(), 1); + let TranslationError::BoundRepeat { at } = &errors[0] else { + panic!("expected BoundRepeat, got {:?}", errors[0]); + }; + assert_eq!( + at.offset, + source + .find("repeat") + .expect("repeat in source"), + "span points at the bound repeat expression" + ); +} + #[test] fn duplicate_title() { let source = r#" From da87667d446af4b6ffc2147f87133a2ab899eb38 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 31 May 2026 23:34:45 +1000 Subject: [PATCH 7/9] Widen singletons to single element lists --- src/runner/checks/runner.rs | 98 ++++++++++++++++++++++++++++++++++--- src/runner/runner.rs | 5 +- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 7b6a6e66..79915db8 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -846,11 +846,72 @@ fn foreach_destructures_tuple_elements() { assert_eq!(steps, vec!["a/b", "c/d"]); } +#[test] +fn foreach_widens_primitive_to_singleton() { + let mut fixture = StoreFixture::new("foreach-widen"); + + // foreach item in source, where `source` is a bare scalar: it widens + // to a one-element list and the body walks exactly once. + let description = Operation::String(vec![Fragment::Interpolation(Operation::Variable( + Identifier::new("item"), + ))]); + let substep = Operation::Step { + ordinal: Ordinal::Dependent("a"), + attributes: Vec::new(), + description: vec![description], + body: Box::new(Operation::Sequence(Vec::new())), + responses: Vec::new(), + }; + let names = [Identifier::new("item")]; + let loop_op = Operation::Loop { + names: &names, + over: Some(Box::new(Operation::Variable(Identifier::new("source")))), + body: Box::new(Operation::Sequence(vec![substep])), + responses: Vec::new(), + }; + let mut sub = Subroutine::anonymous(); + sub.body = loop_op; + let mut program = Program::new(); + program + .subroutines + .push(sub); + + let mut env = Environment::new(); + env.extend("source".to_string(), Value::Literali("lonely".to_string())); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + env, + ); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let steps: Vec<(&str, &str)> = prompt + .events() + .iter() + .filter_map(|event| match event { + Event::Step { + qualified, + description, + } => Some((qualified.as_str(), description.as_str())), + _ => None, + }) + .collect(); + assert_eq!(steps, vec![("/[1]/a", "lonely")]); +} + #[test] fn foreach_over_non_list_or_unbound_errors() { // foreach item in source, where `source` is supplied by the caller's - // environment. A scalar source is `NotIterable`; an unbound source - // propagates `UnboundVariable` rather than being swallowed. + // environment. A tuple or tablet source is `NotIterable` (only lists + // iterate and only scalars widen); an unbound source propagates + // `UnboundVariable` rather than being swallowed. let names = [Identifier::new("item")]; let loop_op = Operation::Loop { names: &names, @@ -865,16 +926,41 @@ fn foreach_over_non_list_or_unbound_errors() { .subroutines .push(sub); - // A scalar bound to `source` is not a list. - let mut scalar_fixture = StoreFixture::new("foreach-scalar"); + // A tuple bound to `source` is not a list and does not widen. + let mut tuple_fixture = StoreFixture::new("foreach-tuple"); + let mut env = Environment::new(); + env.extend( + "source".to_string(), + Value::Parametriq(vec![ + Value::Literali("a".to_string()), + Value::Literali("b".to_string()), + ]), + ); + let mut runner = Runner::new( + &program, + tuple_fixture.take_appender(), + HashSet::new(), + Mock::new(), + env, + ); + match runner.run() { + Err(RunnerError::NotIterable) => {} + other => panic!("expected NotIterable, got {:?}", other), + } + + // A tablet bound to `source` is not a list either. + let mut tablet_fixture = StoreFixture::new("foreach-tablet"); let mut env = Environment::new(); env.extend( "source".to_string(), - Value::Literali("not a list".to_string()), + Value::Tabularum(vec![( + "label".to_string(), + Value::Literali("v".to_string()), + )]), ); let mut runner = Runner::new( &program, - scalar_fixture.take_appender(), + tablet_fixture.take_appender(), HashSet::new(), Mock::new(), env, diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 0bec6c0f..110a42bb 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -227,7 +227,8 @@ impl<'i, P: Prompt> Runner<'i, P> { /// 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 - /// collection must evaluate to a list otherwise it's a runtime error. A + /// collection must evaluate to a list; a bare primitive widens to a + /// one-element list, but a tuple or tablet is a runtime error. A /// `repeat` keyword (an iterable with `over: None`) is unbounded: it /// evaluates its body over and over, each pass an iteration scope, and in /// theory never returns though in practice, stops if a Quit or Abort is @@ -259,6 +260,8 @@ impl<'i, P: Prompt> Runner<'i, P> { Some(expr) => { let items = match super::evaluator::evaluate(&mut self.env, expr)? { Value::Arraeum(items) => items, + // A scalar in list context is a singleton list. + value @ (Value::Literali(_) | Value::Quanticle(_)) => vec![value], _ => return Err(RunnerError::NotIterable), }; for (i, item) in items From 10f1740c19f9484df3209149897e82c317997bf0 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Mon, 1 Jun 2026 19:18:01 +1000 Subject: [PATCH 8/9] Parse lists and tablets as bracket delimited elements --- src/domain/engine.rs | 32 +++- src/formatting/formatter.rs | 100 ++++++++++--- src/language/types.rs | 6 +- src/parsing/checks/parser.rs | 221 ++++++++++++++++++++++------ src/parsing/parser.rs | 151 +++++++++++-------- src/problem/messages.rs | 4 + src/program/types.rs | 1 + src/runner/checks/evaluator.rs | 19 +++ src/runner/evaluator.rs | 7 + src/runner/runner.rs | 3 +- src/translation/checks/errors.rs | 24 +++ src/translation/checks/translate.rs | 30 ++++ src/translation/translator.rs | 80 ++++++++-- tests/formatting/formatter.rs | 42 +++--- 14 files changed, 553 insertions(+), 167 deletions(-) diff --git a/src/domain/engine.rs b/src/domain/engine.rs index 76e6b0b5..e1a7b2c4 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -146,13 +146,26 @@ impl<'i> Scope<'i> { } } - /// Returns the tablet pairs if this is a CodeBlock containing a Tablet. - pub fn tablet(&self) -> Option<&[Pair<'i>]> { + /// Returns the tablet pairs if this is a CodeBlock containing a single + /// list whose elements are all labelled values. + pub fn tablet(&self) -> Option>> { match self { Scope::CodeBlock { expressions, .. } => { if expressions.len() == 1 { - if let Expression::Tablet(pairs, _) = &expressions[0] { - return Some(pairs); + if let Expression::List(elements, _) = &expressions[0] { + 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 @@ -338,7 +351,16 @@ fn render_expression(expr: &Expression) -> String { } Expression::Number(Numeric::Scientific(q), _) => q.to_string(), Expression::Number(Numeric::Integral(n), _) => n.to_string(), - Expression::Tablet(_, _) => String::new(), + Expression::Pair(pair, _) => { + format!("\"{}\" = {}", pair.label, render_expression(&pair.value)) + } + Expression::List(elements, _) => { + let items: Vec<_> = elements + .iter() + .map(render_expression) + .collect(); + format!("[{}]", items.join(", ")) + } Expression::Separator => String::new(), } } diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 749a9524..0ad32138 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -188,6 +188,31 @@ fn render_fragments<'i>(fragments: &[(Syntax, Cow<'i, str>)], renderer: &dyn Ren result } +/// A list reads as a tablet when it is non-empty and every element is a +/// labelled value. Such lists are laid out and treated as blocks rather than +/// inline. +fn is_tablet_list(elements: &[Expression]) -> bool { + !elements.is_empty() + && elements + .iter() + .all(|element| { + if let Expression::Pair(_, _) = element { + true + } else { + false + } + }) +} + +/// True when an expression is a tablet-shaped list (see `is_tablet_list`). +fn is_tablet_list_expr(expr: &Expression) -> bool { + if let Expression::List(elements, _) = expr { + is_tablet_list(elements) + } else { + false + } +} + struct Formatter<'i> { fragments: Vec<(Syntax, Cow<'i, str>)>, nesting: u8, @@ -329,8 +354,12 @@ impl<'i> Formatter<'i> { } fn render_inline_code(&self, expr: &'i Expression) -> Vec<(Syntax, Cow<'i, str>)> { + if is_tablet_list_expr(expr) { + // Not inline; caller handles the block layout specially. + return Vec::new(); + } match expr { - Expression::Tablet(_, _) | Expression::Multiline(_, _, _) => { + Expression::Multiline(_, _, _) => { // These are not inline, caller should handle specially Vec::new() } @@ -657,7 +686,7 @@ impl<'i> Formatter<'i> { line.add_breakable(syntax, text); } Descriptive::CodeInline(expr) => match expr { - Expression::Tablet(_, _) => { + _ if is_tablet_list_expr(expr) => { line.flush(); self.add_fragment_reference(Syntax::Structure, "{"); self.append_char('\n'); @@ -884,11 +913,7 @@ impl<'i> Formatter<'i> { let inline = if has_separator { true } else if expressions.len() == 1 { - if let Expression::Tablet(_, _) = &expressions[0] { - false - } else { - true - } + !is_tablet_list_expr(&expressions[0]) } else { false }; @@ -1075,7 +1100,8 @@ impl<'i> Formatter<'i> { self.add_fragment_reference(Syntax::Neutral, " "); self.append_variables(variables); } - Expression::Tablet(pairs, _) => self.append_tablet(pairs), + Expression::Pair(pair, _) => self.append_pair(pair), + Expression::List(elements, _) => self.append_list(elements), Expression::Separator => {} } } @@ -1205,25 +1231,53 @@ impl<'i> Formatter<'i> { self.add_fragment_reference(Syntax::Structure, ")"); } - fn append_tablet(&mut self, pairs: &'i Vec) { - self.add_fragment_reference(Syntax::Structure, "["); - self.append_char('\n'); + fn append_pair(&mut self, pair: &'i Pair) { + self.add_fragment_reference(Syntax::Quote, "\""); + self.add_fragment_reference(Syntax::Label, pair.label); + self.add_fragment_reference(Syntax::Quote, "\""); + self.add_fragment_reference(Syntax::Neutral, " "); + self.add_fragment_reference(Syntax::Structure, "="); + self.add_fragment_reference(Syntax::Neutral, " "); + self.append_expression(&pair.value); + } - self.increase(4); - for pair in pairs { - self.indent(); - self.add_fragment_reference(Syntax::Quote, "\""); - self.add_fragment_reference(Syntax::Label, pair.label); - self.add_fragment_reference(Syntax::Quote, "\""); - self.add_fragment_reference(Syntax::Neutral, " "); - self.add_fragment_reference(Syntax::Structure, "="); - self.add_fragment_reference(Syntax::Neutral, " "); - self.append_expression(&pair.value); + /// A list whose elements are all labelled (a tablet) is laid out one + /// element per line; any other list, and the empty list, is inline. + fn append_list(&mut self, elements: &'i Vec) { + if elements.is_empty() { + self.add_fragment_reference(Syntax::Structure, "[]"); + return; + } + + if is_tablet_list(elements) { + self.add_fragment_reference(Syntax::Structure, "["); self.append_char('\n'); + + self.increase(4); + for element in elements { + self.indent(); + self.append_expression(element); + self.append_char('\n'); + } + self.decrease(4); + + self.indent(); + self.add_fragment_reference(Syntax::Structure, "]"); + return; } - self.decrease(4); - self.indent(); + self.add_fragment_reference(Syntax::Structure, "["); + for (i, element) in elements + .iter() + .enumerate() + { + if i > 0 { + self.add_fragment_reference(Syntax::Structure, ","); + } + self.add_fragment_reference(Syntax::Neutral, " "); + self.append_expression(element); + } + self.add_fragment_reference(Syntax::Neutral, " "); self.add_fragment_reference(Syntax::Structure, "]"); } } diff --git a/src/language/types.rs b/src/language/types.rs index 89a91f9d..a1f68dd1 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -416,7 +416,8 @@ pub enum Expression<'i> { Application(Invocation<'i>, Span), Execution(Function<'i>, Span), Binding(Box>, Vec>, Span), - Tablet(Vec>, Span), + Pair(Box>, Span), + List(Vec>, Span), Separator, } @@ -438,7 +439,8 @@ impl PartialEq for Expression<'_> { (Expression::Binding(a1, a2, _), Expression::Binding(b1, b2, _)) => { a1 == b1 && a2 == b2 } - (Expression::Tablet(a, _), Expression::Tablet(b, _)) => a == b, + (Expression::Pair(a, _), Expression::Pair(b, _)) => a == b, + (Expression::List(a, _), Expression::List(b, _)) => a == b, (Expression::Separator, Expression::Separator) => true, _ => false, } diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index b6bcc040..fce72049 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -1,5 +1,10 @@ use super::*; +/// Test helper: a labelled value (`"label" = value`) with a default span. +fn pair<'i>(label: &'i str, value: Expression<'i>) -> Expression<'i> { + Expression::Pair(Box::new(Pair { label, value }), Span::default()) +} + #[test] fn magic_line() { let mut input = Parser::new(); @@ -1468,16 +1473,18 @@ echo test fn tablets() { let mut input = Parser::new(); + // Tablets are lists whose elements are all labelled values. + // Test simple single-entry tablet input.initialize(r#"{ ["name" = "Johannes Grammerly"] }"#); let result = input.read_code_block(); assert_eq!( result, - Ok(vec![Expression::Tablet( - vec![Pair { - label: "name", - value: Expression::String(vec![Piece::Text("Johannes Grammerly")], Span::default()) - }], + Ok(vec![Expression::List( + vec![pair( + "name", + Expression::String(vec![Piece::Text("Johannes Grammerly")], Span::default()) + )], Span::default() )]) ); @@ -1492,19 +1499,16 @@ fn tablets() { let result = input.read_code_block(); assert_eq!( result, - Ok(vec![Expression::Tablet( + Ok(vec![Expression::List( vec![ - Pair { - label: "name", - value: Expression::String( - vec![Piece::Text("Alice of Chains")], - Span::default() - ) - }, - Pair { - label: "age", - value: Expression::String(vec![Piece::Text("29")], Span::default()) - } + pair( + "name", + Expression::String(vec![Piece::Text("Alice of Chains")], Span::default()) + ), + pair( + "age", + Expression::String(vec![Piece::Text("29")], Span::default()) + ) ], Span::default() )]) @@ -1521,38 +1525,35 @@ fn tablets() { let result = input.read_code_block(); assert_eq!( result, - Ok(vec![Expression::Tablet( + Ok(vec![Expression::List( vec![ - Pair { - label: "answer", - value: Expression::Number(Numeric::Integral(42), Span::default()) - }, - Pair { - label: "message", - value: Expression::Variable(Identifier::new("msg"), Span::default()) - }, - Pair { - label: "timestamp", - value: Expression::Execution( + pair( + "answer", + Expression::Number(Numeric::Integral(42), Span::default()) + ), + pair( + "message", + Expression::Variable(Identifier::new("msg"), Span::default()) + ), + pair( + "timestamp", + Expression::Execution( Function { target: Identifier::new("now"), parameters: vec![] }, Span::default() ) - } + ) ], Span::default() )]) ); - // Test empty tablet - input.initialize("{ [ ] }"); + // Bare `[]` is the empty list + input.initialize("{ [] }"); let result = input.read_code_block(); - assert_eq!( - result, - Ok(vec![Expression::Tablet(vec![], Span::default())]) - ); + assert_eq!(result, Ok(vec![Expression::List(vec![], Span::default())])); // Test tablet with interpolated string values input.initialize( @@ -1564,19 +1565,149 @@ fn tablets() { let result = input.read_code_block(); assert_eq!( result, - Ok(vec![Expression::Tablet( + Ok(vec![Expression::List( vec![ - Pair { - label: "context", - value: Expression::String( + pair( + "context", + Expression::String( vec![Piece::Text("Details about the thing")], Span::default() ) - }, - Pair { - label: "status", - value: Expression::Variable(Identifier::new("active"), Span::default()) - } + ), + pair( + "status", + Expression::Variable(Identifier::new("active"), Span::default()) + ) + ], + Span::default() + )]) + ); +} + +#[test] +fn lists() { + let mut input = Parser::new(); + + // Comma-separated list of numbers + input.initialize("{ [ 1, 4, 9 ] }"); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(vec![Expression::List( + vec![ + Expression::Number(Numeric::Integral(1), Span::default()), + Expression::Number(Numeric::Integral(4), Span::default()), + Expression::Number(Numeric::Integral(9), Span::default()) + ], + Span::default() + )]) + ); + + // List of string literals, one containing a comma + input.initialize(r#"{ [ "a, b", "c" ] }"#); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(vec![Expression::List( + vec![ + Expression::String(vec![Piece::Text("a, b")], Span::default()), + Expression::String(vec![Piece::Text("c")], Span::default()) + ], + Span::default() + )]) + ); + + // Newline-separated list parses the same as the comma form + input.initialize( + r#"{ [ + 10 + 20 + 30 +] }"#, + ); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(vec![Expression::List( + vec![ + Expression::Number(Numeric::Integral(10), Span::default()), + Expression::Number(Numeric::Integral(20), Span::default()), + Expression::Number(Numeric::Integral(30), Span::default()) + ], + Span::default() + )]) + ); + + // Nested lists: a top-level comma inside brackets does not split + input.initialize("{ [ [1, 2], [3, 4] ] }"); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(vec![Expression::List( + vec![ + Expression::List( + vec![ + Expression::Number(Numeric::Integral(1), Span::default()), + Expression::Number(Numeric::Integral(2), Span::default()) + ], + Span::default() + ), + Expression::List( + vec![ + Expression::Number(Numeric::Integral(3), Span::default()), + Expression::Number(Numeric::Integral(4), Span::default()) + ], + Span::default() + ) + ], + Span::default() + )]) + ); +} + +#[test] +fn tablet_inline_commas() { + let mut input = Parser::new(); + + // Pairs sharing a line, separated by commas + input.initialize(r#"{ [ "answer" = 42, "truth" = "yes" ] }"#); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(vec![Expression::List( + vec![ + pair( + "answer", + Expression::Number(Numeric::Integral(42), Span::default()) + ), + pair( + "truth", + Expression::String(vec![Piece::Text("yes")], Span::default()) + ) + ], + Span::default() + )]) + ); +} + +#[test] +fn bracket_mixed_pairs_and_values_parses() { + let mut input = Parser::new(); + + // The parser makes no tablet/list judgement: a bracket mixing a labelled + // value with a bare value parses as a list with mixed elements. Rejecting + // it is a translation-stage concern. + input.initialize(r#"{ [ "answer" = 42, 99 ] }"#); + let result = input.read_code_block(); + assert_eq!( + result, + Ok(vec![Expression::List( + vec![ + pair( + "answer", + Expression::Number(Numeric::Integral(42), Span::default()) + ), + Expression::Number(Numeric::Integral(99), Span::default()) ], Span::default() )]) diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index 8db7587f..0b349840 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -642,6 +642,57 @@ impl<'i> Parser<'i> { Ok(results) } + /// Split the current content (assumed to be inside bracket delimiters + /// representing a list) interior into elements, breaking on each + /// top-level comma or newline. Separators nested within parentheses, + /// brackets, or string literals do not split. Empty chunks (a trailing + /// comma, a blank line) are skipped. + fn take_elements(&mut self, function: F) -> Result, ParsingError> + where + F: Fn(&mut Parser<'i>) -> Result, + { + let content = self.source; + let base = content.as_ptr() as usize; + let mut results = Vec::new(); + + let mut start = 0; + let mut depth = 0i32; + let mut in_string = false; + + let mut cut = |outer: &mut Parser<'i>, chunk: &'i str| -> Result<(), ParsingError> { + let trimmed = chunk.trim_ascii(); + if trimmed.is_empty() { + return Ok(()); + } + let indent = trimmed.as_ptr() as usize - base; + let mut parser = outer.subparser(indent, trimmed); + results.push(function(&mut parser)?); + outer + .problems + .extend(parser.problems); + Ok(()) + }; + + for (i, c) in content.char_indices() { + match c { + '"' => in_string = !in_string, + _ if in_string => {} + '(' | '[' => depth += 1, + ')' | ']' => depth -= 1, + ',' | '\n' if depth == 0 => { + cut(self, &content[start..i])?; + start = i + c.len_utf8(); + } + _ => {} + } + } + cut(self, &content[start..])?; + + self.advance(content.len()); + + Ok(results) + } + fn take_paragraph(&mut self, function: F) -> Result where F: Fn(&mut Parser<'i>) -> Result, @@ -1491,7 +1542,7 @@ impl<'i> Parser<'i> { // Malformed foreach expression return Err(ParsingError::InvalidForeach(Span::new(self.offset, 0))); } else if content.starts_with('[') { - self.read_tablet_expression() + self.read_bracket_expression() } else if is_numeric(content) { let numeric = self.read_numeric()?; let span = self.span_since(start); @@ -1685,74 +1736,31 @@ impl<'i> Parser<'i> { Ok(Expression::Binding(Box::new(expression), identifiers, span)) } - fn read_tablet_expression(&mut self) -> Result, ParsingError> { + /// Read a list. Elements are comma or newline-separated expressions. An + /// element is of the form `"label" = value` for a labelled tablet value + /// (an `Expression::Pair`) or without a label as an indexed list element + /// (an `Expression:List`). + fn read_bracket_expression(&mut self) -> Result, ParsingError> { let start = self.offset; - let pairs = self.take_block_chars("a tablet", '[', ']', true, |outer| { - let mut pairs = Vec::new(); - - loop { - outer.trim_whitespace(); - - if outer - .source - .is_empty() - { - break; - } - - // Parse quoted key - if !outer - .source - .starts_with('"') - { - return Err(ParsingError::Expected( - Span::new(outer.offset, 0), - "a string label for the field, in double-quotes", - )); - } - - let label = - outer.take_block_chars("a label", '"', '"', false, |inner| Ok(inner.source))?; - - // Skip whitespace and expect '=' - outer.trim_whitespace(); - if !outer - .source - .starts_with('=') - { - return Err(ParsingError::Expected( - Span::new(outer.offset, 0), - "a '=' after the field name to indicate what value is to be assigned to it", - )); - } - outer.advance(1); // consume '=' - outer.trim_whitespace(); - - // Parse value - take everything up to newline or end - let value = outer.take_line(|inner| { + let elements = self.take_block_chars("a list", '[', ']', true, |outer| { + outer.take_elements(|inner| { + if is_pair(inner.source) { + let pair_start = inner.offset; + let label = inner + .take_block_chars("a label", '"', '"', false, |label| Ok(label.source))?; inner.trim_whitespace(); - - let content = inner.source; - if content.is_empty() { - return Err(ParsingError::Expected( - Span::new(inner.offset, 0), - "value expression", - )); - }; - + inner.advance(1); // consume '=' (is_pair guarantees it) + inner.trim_whitespace(); + let value = inner.read_expression()?; + let span = inner.span_since(pair_start); + Ok(Expression::Pair(Box::new(Pair { label, value }), span)) + } else { inner.read_expression() - })?; - - pairs.push(Pair { label, value }); - - // Skip any remaining whitespace/newlines - outer.trim_whitespace(); - } - - Ok(pairs) + } + }) })?; let span = self.span_since(start); - Ok(Expression::Tablet(pairs, span)) + Ok(Expression::List(elements, span)) } fn parse_string_pieces(&mut self, raw: &'i str) -> Result>, ParsingError> { @@ -3270,6 +3278,21 @@ fn is_string_literal(content: &str) -> bool { re.is_match(content) } +/// Detect a tablet pair being a quoted label followed by `=` (which would +/// then be followed by an expression). +fn is_pair(content: &str) -> bool { + let content = content.trim_ascii_start(); + let Some(rest) = content.strip_prefix('"') else { + return false; + }; + match rest.split_once('"') { + Some((_label, after)) => after + .trim_ascii_start() + .starts_with('='), + None => false, + } +} + fn is_attribute_assignment(input: &str) -> bool { // Matches any combination of @ and ^ attributes separated by + // Also matches the special @* "reset to all" role diff --git a/src/problem/messages.rs b/src/problem/messages.rs index fdac10e3..6bcc480d 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1052,6 +1052,10 @@ pub fn generate_translation_error<'i>( "Cannot use the result of `repeat`".to_string(), "A `repeat` runs indefinitely and produces no value, so its result cannot be bound to a variable.".to_string(), ), + TranslationError::HeterogenousList { .. } => ( + "Mixed List and Tablet syntax".to_string(), + "A `[...]` literal must be either a Tablet (every entry in the list a `\"label\" = value` pair) or a lLst (entries are actual values in sequence), not a mix of the two.".to_string(), + ), } } diff --git a/src/program/types.rs b/src/program/types.rs index 401a28fe..4eb89283 100644 --- a/src/program/types.rs +++ b/src/program/types.rs @@ -89,6 +89,7 @@ pub enum Operation<'i> { String(Vec>), Multiline(Option<&'i str>, Vec<&'i str>), Tablet(Vec>), + List(Vec>), Invoke(Invocable<'i>), Execute(Executable<'i>), Sequence(Vec>), diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index 6455dbe6..f923732e 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -94,6 +94,25 @@ fn tablet_entries_evaluate() { ); } +#[test] +fn list_elements_evaluate() { + let op = Operation::List(vec![ + Operation::Number(LangNumeric::Integral(1)), + Operation::Number(LangNumeric::Integral(4)), + Operation::Number(LangNumeric::Integral(9)), + ]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!( + v, + value::Value::Arraeum(vec![ + value::Value::Quanticle(value::Numeric::Integral(1)), + value::Value::Quanticle(value::Numeric::Integral(4)), + value::Value::Quanticle(value::Numeric::Integral(9)), + ]) + ); +} + #[test] fn bind_extends_env_for_subsequent_lookup() { let names = [Identifier::new("greeting")]; diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index c33ac505..10096301 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -83,6 +83,13 @@ pub fn evaluate<'i>(env: &mut Environment, op: &Operation<'i>) -> Result { + let mut values = Vec::with_capacity(items.len()); + for item in items { + values.push(evaluate(env, item)?); + } + Ok(Value::Arraeum(values)) + } Operation::Bind { names, value } => { let v = evaluate(env, value)?; bind_names(env, names, v)?; diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 110a42bb..8080886c 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -174,7 +174,8 @@ impl<'i, P: Prompt> Runner<'i, P> { | Operation::Number(_) | Operation::String(_) | Operation::Multiline(_, _) - | Operation::Tablet(_) => { + | Operation::Tablet(_) + | Operation::List(_) => { let value = super::evaluator::evaluate(&mut self.env, op)?; Ok(Outcome::Done(value)) } diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 1cd03971..57614708 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -253,3 +253,27 @@ I. Lead with }; assert_eq!(id.value, "does_not_exist"); } + +#[test] +fn mixed_bracket_entries() { + // A bracket mixing a labelled value with a bare value is neither a + // tablet nor a list; the parser accepts it but translation rejects it. + let source = r#" +% technique v1 + +run : + +{ + [ "answer" = 42, 99 ] +} + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let errors = translate(&document).expect_err("translate should fail"); + + assert_eq!(errors.len(), 1); + let TranslationError::HeterogenousList { .. } = &errors[0] else { + panic!("expected MixedBracket, got {:?}", errors[0]); + }; +} diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index f855abd4..0d5d2c83 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -833,6 +833,36 @@ run : assert_eq!(entries[1].label, "weight"); } +#[test] +fn expression_list_translates() { + let source = r#" +% technique v1 + +run : + +{ + [ 1, 4, 9 ] +} + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + let Operation::Sequence(ops) = &program.subroutines[0].body else { + panic!("expected Sequence"); + }; + let Operation::List(items) = &ops[0] else { + panic!("expected List, got {:?}", ops[0]); + }; + assert_eq!(items.len(), 3); + for item in items { + let Operation::Number(_) = item else { + panic!("expected Number element, got {:?}", item); + }; + } +} + #[test] fn foreach_codeblock_becomes_loop_with_subscopes_as_body() { let source = r#" diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 409a0021..1d260950 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -66,6 +66,12 @@ pub enum TranslationError<'i> { BoundRepeat { at: Span, }, + /// A list mixing labelled pairs (`"label" = value`) with bare values is + /// neither a tablet nor a plain list. The two forms can't be combined in + /// one set of brackets. + HeterogenousList { + at: Span, + }, } impl<'i> TranslationError<'i> { @@ -76,6 +82,7 @@ impl<'i> TranslationError<'i> { TranslationError::InterleavedDescription { at, .. } => *at, TranslationError::UnresolvedProcedure(id) => id.span, TranslationError::BoundRepeat { at } => *at, + TranslationError::HeterogenousList { at } => *at, } } } @@ -405,7 +412,8 @@ impl<'i> Translator<'i> { | language::Expression::Number(..) | language::Expression::String(..) | language::Expression::Multiline(..) - | language::Expression::Tablet(..) => { + | language::Expression::Pair(..) + | language::Expression::List(..) => { Some(Fragment::Interpolation(self.translate_expression(expr))) } language::Expression::Repeat(..) @@ -604,6 +612,11 @@ impl<'i> Translator<'i> { Self::resolve_operation(&mut entry.value, known, problems); } } + Operation::List(items) => { + for item in items { + Self::resolve_operation(item, known, problems); + } + } Operation::Variable(_) | Operation::Number(_) | Operation::Multiline(_, _) => {} } } @@ -627,15 +640,64 @@ impl<'i> Translator<'i> { language::Expression::Multiline(lang, lines, _) => { Operation::Multiline(*lang, lines.clone()) } - language::Expression::Tablet(pairs, _) => { - let entries = pairs + language::Expression::Pair(pair, _) => { + // A standalone labelled value widens to a single-entry + // tablet, mirroring the way a bare value widens to a + // single-element list. + Operation::Tablet(vec![Entry { + label: pair.label, + value: self.translate_expression(&pair.value), + }]) + } + language::Expression::List(elements, span) => { + let labelled = elements .iter() - .map(|pair| Entry { - label: pair.label, - value: self.translate_expression(&pair.value), - }) - .collect(); - Operation::Tablet(entries) + .any(|element| { + if let language::Expression::Pair(..) = element { + true + } else { + false + } + }); + let unlabelled = elements + .iter() + .any(|element| { + if let language::Expression::Pair(..) = element { + false + } else { + true + } + }); + + if labelled && unlabelled { + self.problems + .push(TranslationError::HeterogenousList { at: *span }); + } + + // All elements labelled: a tablet. Otherwise (including the + // empty list and the mixed-content recovery case) a list. + if labelled && !unlabelled { + let entries = elements + .iter() + .filter_map(|element| { + if let language::Expression::Pair(pair, _) = element { + Some(Entry { + label: pair.label, + value: self.translate_expression(&pair.value), + }) + } else { + None + } + }) + .collect(); + Operation::Tablet(entries) + } else { + let items = elements + .iter() + .map(|element| self.translate_expression(element)) + .collect(); + Operation::List(items) + } } language::Expression::Application(invocation, _) => { Operation::Invoke(self.translate_invocation(invocation)) diff --git a/tests/formatting/formatter.rs b/tests/formatting/formatter.rs index 15656f00..7e887968 100644 --- a/tests/formatting/formatter.rs +++ b/tests/formatting/formatter.rs @@ -335,25 +335,31 @@ We must take action! Span::default(), )], subscopes: vec![Scope::CodeBlock { - expressions: vec![Expression::Tablet( + expressions: vec![Expression::List( vec![ - Pair { - label: "timestamp", - value: Expression::Execution( - Function { - target: Identifier::new("now"), - parameters: vec![], - }, - Span::default(), - ), - }, - Pair { - label: "message", - value: Expression::Variable( - Identifier::new("msg"), - Span::default(), - ), - }, + Expression::Pair( + Box::new(Pair { + label: "timestamp", + value: Expression::Execution( + Function { + target: Identifier::new("now"), + parameters: vec![], + }, + Span::default(), + ), + }), + Span::default(), + ), + Expression::Pair( + Box::new(Pair { + label: "message", + value: Expression::Variable( + Identifier::new("msg"), + Span::default(), + ), + }), + Span::default(), + ), ], Span::default(), )], From 73e729375e30a9934ad5e7aded985a4f5f1f75c5 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Mon, 1 Jun 2026 20:55:22 +1000 Subject: [PATCH 9/9] Require iteratable to be passed to foreach --- src/problem/messages.rs | 11 +++++++++-- src/runner/checks/runner.rs | 10 ++++++---- src/runner/runner.rs | 2 ++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 6bcc480d..d03e478a 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1126,8 +1126,15 @@ of the document doesn't take ant parameters. "#.trim_ascii().to_string(), ), RunnerError::NotIterable => ( - "Value is not a list".to_string(), - "The foreach keyword requires a list to iterate over, but the value suppliedisn't one.".to_string(), + "Iteration requires a list".to_string(), + r#" +The foreach loop control structure requires a list to iterate over, but the +value supplied isn't one. A tablet is a dictonary, not a sequence. If you want +to use the values from a tablet convert them into a list first with the +values() function. There is also a labels() function to get each of the +tablet's labels, and pairs() to get a sequence of tuples of labels and values +you can iterate over. + "#.trim_ascii().to_string(), ), RunnerError::UserQuit => ( "Interrupted".to_string(), diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 79915db8..62c49e32 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -909,9 +909,10 @@ fn foreach_widens_primitive_to_singleton() { #[test] fn foreach_over_non_list_or_unbound_errors() { // foreach item in source, where `source` is supplied by the caller's - // environment. A tuple or tablet source is `NotIterable` (only lists - // iterate and only scalars widen); an unbound source propagates - // `UnboundVariable` rather than being swallowed. + // environment. A tuple or tablet source is `NotIterable` (lists iterate + // and scalars widen, but a tablet is a record that must be projected via + // values()/labels()/pairs() first, and a tuple does neither); an unbound + // source propagates `UnboundVariable` rather than being swallowed. let names = [Identifier::new("item")]; let loop_op = Operation::Loop { names: &names, @@ -948,7 +949,8 @@ fn foreach_over_non_list_or_unbound_errors() { other => panic!("expected NotIterable, got {:?}", other), } - // A tablet bound to `source` is not a list either. + // A tablet bound to `source` is a record, not a sequence: it must be + // projected with values()/labels()/pairs() rather than iterated directly. let mut tablet_fixture = StoreFixture::new("foreach-tablet"); let mut env = Environment::new(); env.extend( diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 8080886c..610ea153 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -263,6 +263,8 @@ impl<'i, P: Prompt> Runner<'i, P> { Value::Arraeum(items) => items, // A scalar in list context is a singleton list. value @ (Value::Literali(_) | Value::Quanticle(_)) => vec![value], + // A tablet is a record, not a sequence, so it does not + // iterate directly. _ => return Err(RunnerError::NotIterable), }; for (i, item) in items