From c8d92f2fee679fb93eca8340f1d3d989d1ce74b9 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 3 Jun 2026 10:54:38 +1000 Subject: [PATCH 01/10] Fix typos --- src/problem/messages.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 0b647fc5..42a94319 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1199,14 +1199,14 @@ procedure at the top of the Technique document. ), r#" Arguments were supplied on the command-line but the entry procedure at the top -of the document doesn't take ant parameters. +of the document doesn't take any parameters. "#.trim_ascii().to_string(), ), RunnerError::NotIterable => ( "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 +value supplied isn't one. A tablet is a dictionary, 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 From 2a3cbb8905e4d6b9d4f3f1dc56420056b1f7f2be Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 3 Jun 2026 10:55:23 +1000 Subject: [PATCH 02/10] Pass parameters to invocations when evaluating --- src/runner/checks/runner.rs | 123 ++++++++++++++++++++++++++++++++++++ src/runner/runner.rs | 38 +++++++++++ 2 files changed, 161 insertions(+) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index e43b4bc8..72e01d54 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -568,6 +568,129 @@ helper : assert_eq!(step_fqns, vec!["/helper:1"]); } +#[test] +fn invoke_binds_arguments_to_parameters() { + let source = r#" +% technique v1 + +main : + +{ + ("World") +} + +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("invoke-args"); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + Library::stub(), + ); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let steps: Vec<(&str, &str)> = prompt + .events() + .iter() + .filter_map(|e| match e { + Event::Step { + qualified, + description, + } => Some((qualified.as_str(), description.as_str())), + _ => None, + }) + .collect(); + // The argument "World" is bound to greet's `name` parameter and + // interpolated into the step description. + assert_eq!(steps, vec![("/greet:1", "Hello World")]); +} + +#[test] +fn invoke_does_not_leak_caller_bindings() { + let source = r#" +% technique v1 + +main : +{ + () +} + +peek : + +1. Value is { secret } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("invoke-isolation"); + // `secret` lives in the caller's (entry) frame; the callee `peek` runs + // in a fresh frame and must not see it. + let mut env = Environment::new(); + env.extend("secret".to_string(), Value::Literali("99".to_string())); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + env, + Library::stub(), + ); + let Err(RunnerError::UnboundVariable(name)) = runner.run() else { + panic!("expected UnboundVariable from the isolated frame"); + }; + assert_eq!(name, "secret"); +} + +#[test] +fn invoke_arity_mismatch_errors() { + let source = r#" +% technique v1 + +main : +{ + ("a", "b") +} + +greet(name) : + +1. Hi + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("invoke-arity"); + let prompt = Mock::with_answers([]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Environment::new(), + Library::stub(), + ); + let Err(RunnerError::ParameterArityMismatch { expected, actual }) = runner.run() else { + panic!("expected ParameterArityMismatch"); + }; + assert_eq!(expected, 1); + assert_eq!(actual, 2); +} + #[test] fn execute_announces_function_call() { let source = r#" diff --git a/src/runner/runner.rs b/src/runner/runner.rs index c0eb0b2d..c9f5d879 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -225,6 +225,38 @@ impl<'i, P: Prompt> Runner<'i, P> { let subroutine = &self .program .subroutines[id.0]; + + // Evaluate the call arguments in the caller's environment, then + // bind them positionally into a fresh environment for the + // callee. The callee sees only its parameters, not the caller's + // bindings. + let params = subroutine + .parameters + .unwrap_or(&[]); + let expected = params.len(); + let actual = invocable + .arguments + .len(); + if expected == 0 && actual > 0 { + return Err(RunnerError::ParameterUnexpected { actual }); + } + if expected != actual { + return Err(RunnerError::ParameterArityMismatch { expected, actual }); + } + let mut local = Environment::new(); + for (param, arg) in params + .iter() + .zip(&invocable.arguments) + { + let value = super::evaluator::evaluate(&mut self.env, &self.library, arg)?; + local.extend( + param + .value + .to_string(), + value, + ); + } + let name = subroutine .name .as_ref() @@ -247,7 +279,13 @@ impl<'i, P: Prompt> Runner<'i, P> { self.path .push(PathSegment::Procedure(name)); } + + // Swap the callee's environment in for the body walk, + // restoring the caller's afterwards (even on error). + let caller = std::mem::replace(&mut self.env, local); let result = self.walk(&subroutine.body); + self.env = caller; + if name.is_some() { self.path .pop(); From d5809381e965d0c5799d93ba30cae60db574cc52 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 3 Jun 2026 11:18:15 +1000 Subject: [PATCH 03/10] Thread environment through walk methods --- src/runner/checks/evaluator.rs | 32 ++++++------ src/runner/checks/runner.rs | 96 ++++++++++++++++------------------ src/runner/evaluator.rs | 14 ++--- src/runner/mod.rs | 23 ++------ src/runner/runner.rs | 76 +++++++++++++++------------ 5 files changed, 116 insertions(+), 125 deletions(-) diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index 2de1d570..4188dfb9 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -10,7 +10,7 @@ fn variable_lookup() { let library = Library::core(); let op = Operation::Variable(Identifier::new("missing")); let mut env = Environment::new(); - match evaluate(&mut env, &library, &op) { + match evaluate(&library, &mut env, &op) { Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "missing"), other => panic!("expected UnboundVariable, got {:?}", other), } @@ -21,7 +21,7 @@ fn variable_lookup() { value::Value::Literali("World".to_string()), ); let op = Operation::Variable(Identifier::new("name")); - let v = evaluate(&mut env, &library, &op).expect("evaluated"); + let v = evaluate(&library, &mut env, &op).expect("evaluated"); assert_eq!(v, value::Value::Literali("World".to_string())); } @@ -30,7 +30,7 @@ fn number_evaluates_to_quanticle() { let library = Library::core(); let op = Operation::Number(LangNumeric::Integral(42)); let mut env = Environment::new(); - let v = evaluate(&mut env, &library, &op).expect("evaluated"); + let v = evaluate(&library, &mut env, &op).expect("evaluated"); assert_eq!(v, value::Value::Quanticle(value::Numeric::Integral(42))); } @@ -47,7 +47,7 @@ fn string_interpolation() { Fragment::Interpolation(Operation::Variable(Identifier::new("name"))), Fragment::Text("!"), ]); - let v = evaluate(&mut env, &library, &op).expect("evaluated"); + let v = evaluate(&library, &mut env, &op).expect("evaluated"); assert_eq!(v, value::Value::Literali("Hello, World!".to_string())); let op = Operation::String(vec![ @@ -55,7 +55,7 @@ fn string_interpolation() { Fragment::Interpolation(Operation::Variable(Identifier::new("nope"))), ]); let mut env = Environment::new(); - match evaluate(&mut env, &library, &op) { + match evaluate(&library, &mut env, &op) { Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "nope"), other => panic!("expected UnboundVariable, got {:?}", other), } @@ -66,7 +66,7 @@ fn multiline_joins_with_newlines() { let library = Library::core(); let op = Operation::Multiline(None, vec!["foo", "bar", "baz"]); let mut env = Environment::new(); - let v = evaluate(&mut env, &library, &op).expect("evaluated"); + let v = evaluate(&library, &mut env, &op).expect("evaluated"); assert_eq!(v, value::Value::Literali("foo\nbar\nbaz".to_string())); } @@ -84,7 +84,7 @@ fn tablet_entries_evaluate() { }, ]); let mut env = Environment::new(); - let v = evaluate(&mut env, &library, &op).expect("evaluated"); + let v = evaluate(&library, &mut env, &op).expect("evaluated"); assert_eq!( v, value::Value::Tabularum(vec![ @@ -109,7 +109,7 @@ fn list_elements_evaluate() { Operation::Number(LangNumeric::Integral(9)), ]); let mut env = Environment::new(); - let v = evaluate(&mut env, &library, &op).expect("evaluated"); + let v = evaluate(&library, &mut env, &op).expect("evaluated"); assert_eq!( v, value::Value::Arraeum(vec![ @@ -131,7 +131,7 @@ fn bind_extends_env_for_subsequent_lookup() { let lookup = Operation::Variable(Identifier::new("greeting")); let seq = Operation::Sequence(vec![bind, lookup]); let mut env = Environment::new(); - let v = evaluate(&mut env, &library, &seq).expect("evaluated"); + let v = evaluate(&library, &mut env, &seq).expect("evaluated"); assert_eq!(v, value::Value::Literali("Hello".to_string())); } @@ -144,12 +144,12 @@ fn sequence_evaluation() { Operation::Number(LangNumeric::Integral(3)), ]); let mut env = Environment::new(); - let v = evaluate(&mut env, &library, &seq).expect("evaluated"); + let v = evaluate(&library, &mut env, &seq).expect("evaluated"); assert_eq!(v, value::Value::Quanticle(value::Numeric::Integral(3))); let seq = Operation::Sequence(vec![]); let mut env = Environment::new(); - let v = evaluate(&mut env, &library, &seq).expect("evaluated"); + let v = evaluate(&library, &mut env, &seq).expect("evaluated"); assert_eq!(v, value::Value::Unitus); } @@ -177,7 +177,7 @@ fn multi_name_bind_destructures_parametriq() { names: &names, value: Box::new(Operation::Variable(Identifier::new("triple"))), }; - let result = evaluate(&mut env, &library, &bind).expect("evaluated"); + let result = evaluate(&library, &mut env, &bind).expect("evaluated"); assert_eq!(result, value::Value::Unitus); assert_eq!( env.lookup("a"), @@ -213,7 +213,7 @@ fn multi_name_bind_wrong_arity_errors() { names: &names, value: Box::new(Operation::Variable(Identifier::new("pair"))), }; - match evaluate(&mut env, &library, &bind) { + match evaluate(&library, &mut env, &bind) { Err(RunnerError::BindArityMismatch { expected, actual }) => { assert_eq!(expected, 3); assert_eq!(actual, 2); @@ -235,7 +235,7 @@ fn multi_name_bind_against_scalar_errors_as_not_tuple() { names: &names, value: Box::new(Operation::Variable(Identifier::new("scalar"))), }; - match evaluate(&mut env, &library, &bind) { + match evaluate(&library, &mut env, &bind) { Err(RunnerError::BindNotTuple { expected }) => { assert_eq!(expected, 2); } @@ -257,7 +257,7 @@ fn execute_dispatches_resolved_builtin() { ], }); let mut env = Environment::new(); - let v = evaluate(&mut env, &library, &op).expect("evaluated"); + let v = evaluate(&library, &mut env, &op).expect("evaluated"); assert_eq!( v, value::Value::Arraeum(vec![ @@ -276,7 +276,7 @@ fn execute_unresolved_function_errors() { arguments: Vec::new(), }); let mut env = Environment::new(); - let Err(RunnerError::UnresolvedFunction(name)) = evaluate(&mut env, &library, &op) else { + let Err(RunnerError::UnresolvedFunction(name)) = evaluate(&library, &mut env, &op) else { panic!("expected UnresolvedFunction"); }; assert_eq!(name, "click"); diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 72e01d54..91c283bd 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -106,11 +106,11 @@ fn step_outcomes_recorded() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); let outcome = runner - .run() + .run(env) .expect("run"); assert_eq!(outcome, Outcome::Done(Value::Unitus)); let pfftt = fixture.pfftt_contents(); @@ -149,11 +149,11 @@ fn step_outcomes_recorded() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let pfftt = fixture.pfftt_contents(); let lines: Vec<&str> = pfftt @@ -180,11 +180,11 @@ fn step_outcomes_recorded() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let pfftt = fixture.pfftt_contents(); let lines: Vec<&str> = pfftt @@ -223,11 +223,11 @@ fn two_steps_prompted_in_source_order() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -265,11 +265,11 @@ fn pre_completed_step_short_circuits() { fixture.take_appender(), completed, prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -302,11 +302,11 @@ fn quit_propagates_and_stops_walking() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); let outcome = runner - .run() + .run(env) .expect("run"); assert_eq!(outcome, Outcome::Quit); @@ -361,11 +361,11 @@ fn section_walking() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); let events = prompt.events(); @@ -408,11 +408,11 @@ fn section_walking() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); let section_title = prompt @@ -447,11 +447,11 @@ fn parallel_step_index_starts_at_one() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -493,11 +493,11 @@ test : fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -543,11 +543,11 @@ helper : fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -594,11 +594,11 @@ greet(name) : fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -647,10 +647,9 @@ peek : fixture.take_appender(), HashSet::new(), prompt, - env, Library::stub(), ); - let Err(RunnerError::UnboundVariable(name)) = runner.run() else { + let Err(RunnerError::UnboundVariable(name)) = runner.run(env) else { panic!("expected UnboundVariable from the isolated frame"); }; assert_eq!(name, "secret"); @@ -681,10 +680,10 @@ greet(name) : fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); - let Err(RunnerError::ParameterArityMismatch { expected, actual }) = runner.run() else { + let env = Environment::new(); + let Err(RunnerError::ParameterArityMismatch { expected, actual }) = runner.run(env) else { panic!("expected ParameterArityMismatch"); }; assert_eq!(expected, 1); @@ -711,11 +710,11 @@ test : fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -764,11 +763,10 @@ fn loop_inside_step_produces_one_result() { fixture.take_appender(), HashSet::new(), prompt, - env, Library::stub(), ); runner - .run() + .run(env) .expect("run"); // One Start record, then the enclosing step's Begin and Done — the @@ -819,11 +817,11 @@ fn repeat_loops_until_quit() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -887,11 +885,10 @@ fn foreach_walks_body_once_per_list_element() { fixture.take_appender(), HashSet::new(), prompt, - env, Library::stub(), ); runner - .run() + .run(env) .expect("run"); // The body is walked once per element. Each Step event carries an @@ -964,11 +961,11 @@ fn foreach_over_seq_builtin_runs() { fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), library, ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -1048,11 +1045,10 @@ fn foreach_destructures_tuple_elements() { fixture.take_appender(), HashSet::new(), prompt, - env, Library::stub(), ); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -1106,11 +1102,10 @@ fn foreach_widens_primitive_to_singleton() { fixture.take_appender(), HashSet::new(), prompt, - env, Library::stub(), ); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -1164,10 +1159,9 @@ fn foreach_over_non_list_or_unbound_errors() { tuple_fixture.take_appender(), HashSet::new(), Mock::new(), - env, Library::stub(), ); - match runner.run() { + match runner.run(env) { Err(RunnerError::NotIterable) => {} other => panic!("expected NotIterable, got {:?}", other), } @@ -1188,10 +1182,9 @@ fn foreach_over_non_list_or_unbound_errors() { tablet_fixture.take_appender(), HashSet::new(), Mock::new(), - env, Library::stub(), ); - match runner.run() { + match runner.run(env) { Err(RunnerError::NotIterable) => {} other => panic!("expected NotIterable, got {:?}", other), } @@ -1203,10 +1196,10 @@ fn foreach_over_non_list_or_unbound_errors() { unbound_fixture.take_appender(), HashSet::new(), Mock::new(), - Environment::new(), Library::stub(), ); - match runner.run() { + let env = Environment::new(); + match runner.run(env) { Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "source"), other => panic!("expected UnboundVariable, got {:?}", other), } @@ -1304,11 +1297,10 @@ greet(name) : fixture.take_appender(), HashSet::new(), prompt, - env, Library::stub(), ); runner - .run() + .run(env) .expect("run"); let prompt = runner.into_prompt(); @@ -1347,11 +1339,11 @@ test : fixture.take_appender(), HashSet::new(), prompt, - Environment::new(), Library::stub(), ); + let env = Environment::new(); runner - .run() + .run(env) .expect("run"); // The prompt offered the two declared responses as choices. diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index e0857b7e..50097f5a 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -45,8 +45,8 @@ impl Environment { /// builtin, evaluating its arguments before doing so. #[allow(dead_code)] pub fn evaluate<'i>( - env: &mut Environment, library: &Library, + env: &mut Environment, op: &Operation<'i>, ) -> Result { match op { @@ -65,7 +65,7 @@ pub fn evaluate<'i>( for fragment in fragments { match fragment { Fragment::Text(t) => text.push_str(t), - Fragment::Interpolation(inner) => match evaluate(env, library, inner)? { + Fragment::Interpolation(inner) => match evaluate(library, env, inner)? { Value::Literali(s) => text.push_str(&s), other => text.push_str(&other.to_string()), }, @@ -77,7 +77,7 @@ pub fn evaluate<'i>( Operation::Tablet(entries) => { let mut pairs = Vec::with_capacity(entries.len()); for entry in entries { - let v = evaluate(env, library, &entry.value)?; + let v = evaluate(library, env, &entry.value)?; pairs.push(( entry .label @@ -90,19 +90,19 @@ pub fn evaluate<'i>( Operation::List(items) => { let mut values = Vec::with_capacity(items.len()); for item in items { - values.push(evaluate(env, library, item)?); + values.push(evaluate(library, env,item)?); } Ok(Value::Arraeum(values)) } Operation::Bind { names, value } => { - let v = evaluate(env, library, value)?; + let v = evaluate(library, env,value)?; bind_names(env, names, v)?; Ok(Value::Unitus) } Operation::Sequence(ops) => { let mut last = Value::Unitus; for child in ops { - last = evaluate(env, library, child)?; + last = evaluate(library, env,child)?; } Ok(last) } @@ -114,7 +114,7 @@ pub fn evaluate<'i>( .len(), ); for arg in &executable.arguments { - args.push(evaluate(env, library, arg)?); + args.push(evaluate(library, env,arg)?); } library.call(*id, &args) } diff --git a/src/runner/mod.rs b/src/runner/mod.rs index ce2dee25..baf235a2 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -40,15 +40,8 @@ pub fn start<'i>( 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::new( - program, - appender, - HashSet::new(), - Console::new(), - env, - library, - ); - let outcome = runner.run()?; + let mut runner = Runner::new(program, appender, HashSet::new(), Console::new(), library); + let outcome = runner.run(env)?; Ok((run_id, outcome)) } @@ -80,13 +73,7 @@ pub fn resume<'i>( state: State::Resume, }; appender.append(&record)?; - let mut runner = Runner::new( - program, - appender, - completed, - Console::new(), - Environment::new(), - library, - ); - runner.run() + let mut runner = Runner::new(program, appender, completed, Console::new(), library); + let env = Environment::new(); + runner.run(env) } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index c9f5d879..60120f5e 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -92,7 +92,6 @@ pub struct Runner<'i, P: Prompt> { appender: Appender, completed: HashSet, prompt: P, - env: Environment, path: QualifiedPath<'i>, library: Library, } @@ -103,7 +102,6 @@ impl<'i, P: Prompt> Runner<'i, P> { appender: Appender, completed: HashSet, prompt: P, - env: Environment, library: Library, ) -> Self { Runner { @@ -111,7 +109,6 @@ impl<'i, P: Prompt> Runner<'i, P> { appender, completed, prompt, - env, path: QualifiedPath::new(), library, } @@ -128,7 +125,7 @@ impl<'i, P: Prompt> Runner<'i, P> { /// selection here is `program.subroutines[0]` — the synthetic /// anonymous wrapper if the document is top-level Steps, otherwise /// the first declared procedure. - pub fn run(&mut self) -> Result { + pub fn run(&mut self, mut env: Environment) -> Result { let entry = self .program .subroutines @@ -142,7 +139,7 @@ impl<'i, P: Prompt> Runner<'i, P> { self.path .push(PathSegment::Procedure(name)); } - let result = self.walk(&entry.body); + let result = self.walk(&mut env, &entry.body); if name.is_some() { self.path .pop(); @@ -150,26 +147,30 @@ impl<'i, P: Prompt> Runner<'i, P> { result } - fn walk(&mut self, op: &'i Operation<'i>) -> Result { + fn walk( + &mut self, + env: &mut Environment, + op: &'i Operation<'i>, + ) -> Result { match op { - Operation::Sequence(ops) => self.walk_sequence(ops), + Operation::Sequence(ops) => self.walk_sequence(env, ops), Operation::Section { numeral, title, body, .. - } => self.walk_section(numeral, title.as_deref(), body), + } => self.walk_section(env, numeral, title.as_deref(), body), Operation::Step { .. } => { // Dependent vs Parallel ordinal index needs the // surrounding Sequence's parallel counter; a Step // encountered outside a Sequence (i.e. as the entire // body of a procedure) is treated as Dependent. - self.walk_step(op, 0) + self.walk_step(env, op, 0) } Operation::Loop { names, over, body, .. - } => self.walk_loop(names, over.as_deref(), body), - Operation::Invoke(invocable) => self.walk_invoke(invocable), + } => self.walk_loop(env, names, over.as_deref(), body), + Operation::Invoke(invocable) => self.walk_invoke(env, invocable), Operation::Execute(executable) => { let function = self.executable_name(&executable.target); let qualified = self @@ -199,7 +200,7 @@ impl<'i, P: Prompt> Runner<'i, P> { | Operation::Multiline(_, _) | Operation::Tablet(_) | Operation::List(_) => { - let value = super::evaluator::evaluate(&mut self.env, &self.library, op)?; + let value = super::evaluator::evaluate(&self.library, env, op)?; Ok(Outcome::Done(value)) } } @@ -219,7 +220,11 @@ impl<'i, P: Prompt> Runner<'i, P> { } } - fn walk_invoke(&mut self, invocable: &'i Invocable<'i>) -> Result { + fn walk_invoke( + &mut self, + env: &mut Environment, + invocable: &'i Invocable<'i>, + ) -> Result { match &invocable.target { SubroutineRef::Resolved(id) => { let subroutine = &self @@ -248,7 +253,7 @@ impl<'i, P: Prompt> Runner<'i, P> { .iter() .zip(&invocable.arguments) { - let value = super::evaluator::evaluate(&mut self.env, &self.library, arg)?; + let value = super::evaluator::evaluate(&self.library, env, arg)?; local.extend( param .value @@ -280,11 +285,9 @@ impl<'i, P: Prompt> Runner<'i, P> { .push(PathSegment::Procedure(name)); } - // Swap the callee's environment in for the body walk, - // restoring the caller's afterwards (even on error). - let caller = std::mem::replace(&mut self.env, local); - let result = self.walk(&subroutine.body); - self.env = caller; + // Walk the body against the callee's own environment; `local` + // is dropped on return, leaving the caller's `env` untouched. + let result = self.walk(&mut local, &subroutine.body); if name.is_some() { self.path @@ -311,6 +314,7 @@ impl<'i, P: Prompt> Runner<'i, P> { /// registered. fn walk_loop( &mut self, + env: &mut Environment, names: &'i [language::Identifier<'i>], over: Option<&'i Operation<'i>>, body: &'i Operation<'i>, @@ -323,7 +327,7 @@ impl<'i, P: Prompt> Runner<'i, P> { loop { self.path .push(PathSegment::Iteration(number)); - let result = self.walk(body); + let result = self.walk(env, body); self.path .pop(); @@ -334,7 +338,7 @@ impl<'i, P: Prompt> Runner<'i, P> { } } Some(expr) => { - let items = match super::evaluator::evaluate(&mut self.env, &self.library, expr)? { + let items = match super::evaluator::evaluate(&self.library, env, expr)? { Value::Arraeum(items) => items, // A scalar in list context is a singleton list. value @ (Value::Literali(_) | Value::Quanticle(_)) => vec![value], @@ -346,12 +350,12 @@ impl<'i, P: Prompt> Runner<'i, P> { .into_iter() .enumerate() { - super::evaluator::bind_names(&mut self.env, names, item)?; + super::evaluator::bind_names(env, names, item)?; let number = i + 1; self.path .push(PathSegment::Iteration(number)); - let result = self.walk(body); + let result = self.walk(env, body); self.path .pop(); @@ -364,7 +368,11 @@ impl<'i, P: Prompt> Runner<'i, P> { } } - fn walk_sequence(&mut self, ops: &'i [Operation<'i>]) -> Result { + fn walk_sequence( + &mut self, + env: &mut Environment, + ops: &'i [Operation<'i>], + ) -> Result { let mut parallel_idx: usize = 0; for op in ops { let outcome = match op { @@ -376,9 +384,9 @@ impl<'i, P: Prompt> Runner<'i, P> { } Ordinal::Dependent(_) => 0, }; - self.walk_step(op, index)? + self.walk_step(env, op, index)? } - _ => self.walk(op)?, + _ => self.walk(env, op)?, }; if let Outcome::Quit = outcome { return Ok(Outcome::Quit); @@ -389,13 +397,14 @@ impl<'i, P: Prompt> Runner<'i, P> { fn walk_section( &mut self, + env: &mut Environment, numeral: &'i str, title: Option<&'i Operation<'i>>, body: &'i Operation<'i>, ) -> Result { self.path .push(PathSegment::Section(numeral)); - let result = self.perform_section(title, body); + let result = self.perform_section(env, title, body); self.path .pop(); result @@ -403,6 +412,7 @@ impl<'i, P: Prompt> Runner<'i, P> { fn perform_section( &mut self, + env: &mut Environment, title: Option<&'i Operation<'i>>, body: &'i Operation<'i>, ) -> Result { @@ -410,7 +420,7 @@ impl<'i, P: Prompt> Runner<'i, P> { .path .render(); let title_text = match title { - Some(op) => match super::evaluator::evaluate(&mut self.env, &self.library, op)? { + Some(op) => match super::evaluator::evaluate(&self.library, env, op)? { Value::Literali(s) => s, other => other.to_string(), }, @@ -418,11 +428,12 @@ impl<'i, P: Prompt> Runner<'i, P> { }; self.prompt .section(&qualified, &title_text); - self.walk(body) + self.walk(env, body) } fn walk_step( &mut self, + env: &mut Environment, op: &'i Operation<'i>, parallel_index: usize, ) -> Result { @@ -451,7 +462,7 @@ impl<'i, P: Prompt> Runner<'i, P> { .path .render(); - let result = self.perform_step(&qualified, body, description, responses); + let result = self.perform_step(env, &qualified, body, description, responses); self.path .pop(); @@ -465,6 +476,7 @@ impl<'i, P: Prompt> Runner<'i, P> { fn perform_step( &mut self, + env: &mut Environment, qualified: &str, body: &'i Operation<'i>, description: &'i [Operation<'i>], @@ -492,7 +504,7 @@ impl<'i, P: Prompt> Runner<'i, P> { self.appender .append(&begin)?; - if let Outcome::Quit = self.walk(body)? { + if let Outcome::Quit = self.walk(env, body)? { return Ok(Outcome::Quit); } @@ -501,7 +513,7 @@ impl<'i, P: Prompt> Runner<'i, P> { if !description_text.is_empty() { description_text.push('\n'); } - match super::evaluator::evaluate(&mut self.env, &self.library, op)? { + match super::evaluator::evaluate(&self.library, env, op)? { Value::Literali(s) => description_text.push_str(&s), other => description_text.push_str(&other.to_string()), } From 7182745e93d9ebc8d74508316ce3d06ac8c353fd Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 3 Jun 2026 13:42:37 +1000 Subject: [PATCH 04/10] Preliminary combine() implementation --- src/problem/messages.rs | 4 ++ src/runner/checks/evaluator.rs | 91 ++++++++++++++++++++++++++++++++-- src/runner/evaluator.rs | 63 +++++++++++++++++++++-- src/runner/runner.rs | 15 ++++-- 4 files changed, 161 insertions(+), 12 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 42a94319..363548c6 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1221,6 +1221,10 @@ you can iterate over. format!("Unresolved function {}()", function), format!("The function {}() is not a builtin and is not provided by the domain.", function), ), + RunnerError::IncompatibleCombination { left, right } => ( + format!("Cannot combine {} with {}", left, right), + format!("Combining Values requires compatible kinds; a {} and a {} can't be added together.", left, right), + ), 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/evaluator.rs b/src/runner/checks/evaluator.rs index 4188dfb9..4d3cfb5b 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -1,6 +1,6 @@ use crate::language::{Identifier, Numeric as LangNumeric}; use crate::program::{Entry, Executable, ExecutableRef, Fragment, Operation}; -use crate::runner::evaluator::{evaluate, Environment}; +use crate::runner::evaluator::{combine, evaluate, Environment}; use crate::runner::library::Library; use crate::runner::runner::RunnerError; use crate::value; @@ -135,6 +135,9 @@ fn bind_extends_env_for_subsequent_lookup() { assert_eq!(v, value::Value::Literali("Hello".to_string())); } +// A sequence is statement composition: its value is the last member's value +// (not a ⊕-fold — that will be the `+` operator's job). + #[test] fn sequence_evaluation() { let library = Library::core(); @@ -153,11 +156,12 @@ fn sequence_evaluation() { assert_eq!(v, value::Value::Unitus); } +// Build a Parametriq of three values by reducing a wrapped construction. +// Simplest path: pre-stuff env with a Parametriq, then bind a tuple of names +// to a Variable that looks it up. + #[test] fn multi_name_bind_destructures_parametriq() { - // Build a Parametriq of three values by reducing a wrapped construction. - // Simplest path: pre-stuff env with a Parametriq, then bind a tuple of - // names to a Variable that looks it up. let library = Library::core(); let mut env = Environment::new(); env.extend( @@ -281,3 +285,82 @@ fn execute_unresolved_function_errors() { }; assert_eq!(name, "click"); } + +// NOTE these tests of combine() document its behaviour when it was first +// crafted, but the logic inherent in these rules has not been established as +// being actually appropriate. + +#[test] +fn combine_unit_is_identity() { + let s = value::Value::Literali("x".to_string()); + let left = combine(value::Value::Unitus, s.clone()).expect("combined"); + let right = combine(s.clone(), value::Value::Unitus).expect("combined"); + assert_eq!(left, s); + assert_eq!(right, s); +} + +#[test] +fn combine_strings_concatenate() { + let a = value::Value::Literali("foo".to_string()); + let b = value::Value::Literali("bar".to_string()); + let v = combine(a, b).expect("combined"); + assert_eq!(v, value::Value::Literali("foobar".to_string())); +} + +#[test] +fn combine_lists_append() { + let a = value::Value::Arraeum(vec![value::Value::Literali("a".to_string())]); + let b = value::Value::Arraeum(vec![value::Value::Literali("b".to_string())]); + let v = combine(a, b).expect("combined"); + assert_eq!( + v, + value::Value::Arraeum(vec![ + value::Value::Literali("a".to_string()), + value::Value::Literali("b".to_string()), + ]) + ); +} + +#[test] +fn combine_tablets_merge_last_write_wins() { + let a = value::Value::Tabularum(vec![ + ( + "host".to_string(), + value::Value::Literali("one".to_string()), + ), + ("port".to_string(), value::Value::Literali("80".to_string())), + ]); + let b = value::Value::Tabularum(vec![ + ( + "port".to_string(), + value::Value::Literali("443".to_string()), + ), + ("tls".to_string(), value::Value::Literali("yes".to_string())), + ]); + let v = combine(a, b).expect("combined"); + assert_eq!( + v, + value::Value::Tabularum(vec![ + ( + "host".to_string(), + value::Value::Literali("one".to_string()) + ), + ( + "port".to_string(), + value::Value::Literali("443".to_string()) + ), + ("tls".to_string(), value::Value::Literali("yes".to_string())), + ]) + ); +} + +#[test] +fn combine_cross_kind_errors() { + let a = value::Value::Quanticle(value::Numeric::Integral(42)); + let b = value::Value::Literali("x".to_string()); + let Err(RunnerError::IncompatibleCombination { left, right }) = combine(a, b) else { + panic!("expected IncompatibleCombination"); + }; + assert_eq!(left, "quantity"); + assert_eq!(right, "string"); +} diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index 50097f5a..09ba86d1 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -35,6 +35,61 @@ impl Environment { } } +/// The monoidal append operation for the Value type. +/// +/// Combine two Values into one, with `Unitus` as the identity. Within-kind +/// pairings combine (strings concatenate, lists append, tablets merge with +/// last-write-wins on duplicate keys); cross-kind and not-yet-defined +/// within-kind pairings are a hard error. +/// +/// Note that this is deliberately *not* the value of a `Sequence`: a +/// sequence is statement composition and takes its last member's value, +/// while `+` accumulates — `{ "a"; "b" }` is `"b"`, but `"a" + "b"` is +/// `"ab"`. +#[allow(dead_code)] +pub fn combine(left: Value, right: Value) -> Result { + match (left, right) { + (Value::Unitus, other) | (other, Value::Unitus) => Ok(other), + (Value::Literali(mut a), Value::Literali(b)) => { + a.push_str(&b); + Ok(Value::Literali(a)) + } + (Value::Arraeum(mut a), Value::Arraeum(b)) => { + a.extend(b); + Ok(Value::Arraeum(a)) + } + (Value::Tabularum(mut a), Value::Tabularum(b)) => { + for (key, value) in b { + match a + .iter_mut() + .find(|(existing, _)| *existing == key) + { + Some(entry) => entry.1 = value, + None => a.push((key, value)), + } + } + Ok(Value::Tabularum(a)) + } + (left, right) => Err(RunnerError::IncompatibleCombination { + left: kind(&left), + right: kind(&right), + }), + } +} + +/// Human-facing kind name of a Value, for combination error messages. +fn kind(value: &Value) -> &'static str { + match value { + Value::Unitus => "unit", + Value::Literali(_) => "string", + Value::Quanticle(_) => "quantity", + Value::Tabularum(_) => "tablet", + Value::Arraeum(_) => "list", + Value::Parametriq(_) => "tuple", + Value::Futurae(_) => "future", + } +} + /// Evaluate an `Operation` to a `Value`. /// /// Fails with `UnboundVariable` etc if the operation cannot be resolved; @@ -90,19 +145,19 @@ pub fn evaluate<'i>( Operation::List(items) => { let mut values = Vec::with_capacity(items.len()); for item in items { - values.push(evaluate(library, env,item)?); + values.push(evaluate(library, env, item)?); } Ok(Value::Arraeum(values)) } Operation::Bind { names, value } => { - let v = evaluate(library, env,value)?; + let v = evaluate(library, env, value)?; bind_names(env, names, v)?; Ok(Value::Unitus) } Operation::Sequence(ops) => { let mut last = Value::Unitus; for child in ops { - last = evaluate(library, env,child)?; + last = evaluate(library, env, child)?; } Ok(last) } @@ -114,7 +169,7 @@ pub fn evaluate<'i>( .len(), ); for arg in &executable.arguments { - args.push(evaluate(library, env,arg)?); + args.push(evaluate(library, env, arg)?); } library.call(*id, &args) } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 60120f5e..cdf1ba32 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -71,6 +71,10 @@ pub enum RunnerError { expected: &'static str, }, UnresolvedFunction(String), + IncompatibleCombination { + left: &'static str, + right: &'static str, + }, ParameterArityMismatch { expected: usize, actual: usize, @@ -82,7 +86,7 @@ pub enum RunnerError { } /// Execute a Technique interactively by walking the `Program` tree. Tracks -/// the position in the document via a `QaulifiedPath` stack, carries an +/// the position in the document via a `QualifiedPath` stack, carries an /// `Environment` with known result values. Maintains a set of /// already-completed step FQNs, an append handle to write results, and the /// prompt the operator interacts through. @@ -374,6 +378,7 @@ impl<'i, P: Prompt> Runner<'i, P> { ops: &'i [Operation<'i>], ) -> Result { let mut parallel_idx: usize = 0; + let mut last = Value::Unitus; for op in ops { let outcome = match op { Operation::Step { ordinal, .. } => { @@ -388,11 +393,13 @@ impl<'i, P: Prompt> Runner<'i, P> { } _ => self.walk(env, op)?, }; - if let Outcome::Quit = outcome { - return Ok(Outcome::Quit); + match outcome { + Outcome::Done(value) => last = value, + Outcome::Quit => return Ok(Outcome::Quit), + Outcome::Skipped | Outcome::Failed(_) => {} } } - Ok(Outcome::Done(Value::Unitus)) + Ok(Outcome::Done(last)) } fn walk_section( From 725780f5589d68dc6dcc80a1bcec0685eca22316 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 3 Jun 2026 15:09:07 +1000 Subject: [PATCH 05/10] Add Context to function dispatch --- src/runner/checks/evaluator.rs | 47 ++++++++++++++++++++++------------ src/runner/checks/library.rs | 4 ++- src/runner/context.rs | 39 ++++++++++++++++++++++++++++ src/runner/evaluator.rs | 17 +++++++----- src/runner/library.rs | 33 +++++++++++++++--------- src/runner/mod.rs | 1 + src/runner/runner.rs | 28 +++++++++++--------- 7 files changed, 121 insertions(+), 48 deletions(-) create mode 100644 src/runner/context.rs diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index 4d3cfb5b..ef0e8949 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -1,5 +1,6 @@ use crate::language::{Identifier, Numeric as LangNumeric}; use crate::program::{Entry, Executable, ExecutableRef, Fragment, Operation}; +use crate::runner::context::Context; use crate::runner::evaluator::{combine, evaluate, Environment}; use crate::runner::library::Library; use crate::runner::runner::RunnerError; @@ -8,9 +9,10 @@ use crate::value; #[test] fn variable_lookup() { let library = Library::core(); + let context = Context::native(); let op = Operation::Variable(Identifier::new("missing")); let mut env = Environment::new(); - match evaluate(&library, &mut env, &op) { + match evaluate(&library, &context, &mut env, &op) { Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "missing"), other => panic!("expected UnboundVariable, got {:?}", other), } @@ -21,22 +23,24 @@ fn variable_lookup() { value::Value::Literali("World".to_string()), ); let op = Operation::Variable(Identifier::new("name")); - let v = evaluate(&library, &mut env, &op).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); assert_eq!(v, value::Value::Literali("World".to_string())); } #[test] fn number_evaluates_to_quanticle() { let library = Library::core(); + let context = Context::native(); let op = Operation::Number(LangNumeric::Integral(42)); let mut env = Environment::new(); - let v = evaluate(&library, &mut env, &op).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); assert_eq!(v, value::Value::Quanticle(value::Numeric::Integral(42))); } #[test] fn string_interpolation() { let library = Library::core(); + let context = Context::native(); let mut env = Environment::new(); env.extend( "name".to_string(), @@ -47,7 +51,7 @@ fn string_interpolation() { Fragment::Interpolation(Operation::Variable(Identifier::new("name"))), Fragment::Text("!"), ]); - let v = evaluate(&library, &mut env, &op).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); assert_eq!(v, value::Value::Literali("Hello, World!".to_string())); let op = Operation::String(vec![ @@ -55,7 +59,7 @@ fn string_interpolation() { Fragment::Interpolation(Operation::Variable(Identifier::new("nope"))), ]); let mut env = Environment::new(); - match evaluate(&library, &mut env, &op) { + match evaluate(&library, &context, &mut env, &op) { Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "nope"), other => panic!("expected UnboundVariable, got {:?}", other), } @@ -64,15 +68,17 @@ fn string_interpolation() { #[test] fn multiline_joins_with_newlines() { let library = Library::core(); + let context = Context::native(); let op = Operation::Multiline(None, vec!["foo", "bar", "baz"]); let mut env = Environment::new(); - let v = evaluate(&library, &mut env, &op).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); assert_eq!(v, value::Value::Literali("foo\nbar\nbaz".to_string())); } #[test] fn tablet_entries_evaluate() { let library = Library::core(); + let context = Context::native(); let op = Operation::Tablet(vec![ Entry { label: "name", @@ -84,7 +90,7 @@ fn tablet_entries_evaluate() { }, ]); let mut env = Environment::new(); - let v = evaluate(&library, &mut env, &op).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); assert_eq!( v, value::Value::Tabularum(vec![ @@ -103,13 +109,14 @@ fn tablet_entries_evaluate() { #[test] fn list_elements_evaluate() { let library = Library::core(); + let context = Context::native(); 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(&library, &mut env, &op).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); assert_eq!( v, value::Value::Arraeum(vec![ @@ -123,6 +130,7 @@ fn list_elements_evaluate() { #[test] fn bind_extends_env_for_subsequent_lookup() { let library = Library::core(); + let context = Context::native(); let names = [Identifier::new("greeting")]; let bind = Operation::Bind { names: &names, @@ -131,7 +139,7 @@ fn bind_extends_env_for_subsequent_lookup() { let lookup = Operation::Variable(Identifier::new("greeting")); let seq = Operation::Sequence(vec![bind, lookup]); let mut env = Environment::new(); - let v = evaluate(&library, &mut env, &seq).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &seq).expect("evaluated"); assert_eq!(v, value::Value::Literali("Hello".to_string())); } @@ -141,18 +149,19 @@ fn bind_extends_env_for_subsequent_lookup() { #[test] fn sequence_evaluation() { let library = Library::core(); + let context = Context::native(); let seq = Operation::Sequence(vec![ Operation::Number(LangNumeric::Integral(1)), Operation::Number(LangNumeric::Integral(2)), Operation::Number(LangNumeric::Integral(3)), ]); let mut env = Environment::new(); - let v = evaluate(&library, &mut env, &seq).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &seq).expect("evaluated"); assert_eq!(v, value::Value::Quanticle(value::Numeric::Integral(3))); let seq = Operation::Sequence(vec![]); let mut env = Environment::new(); - let v = evaluate(&library, &mut env, &seq).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &seq).expect("evaluated"); assert_eq!(v, value::Value::Unitus); } @@ -163,6 +172,7 @@ fn sequence_evaluation() { #[test] fn multi_name_bind_destructures_parametriq() { let library = Library::core(); + let context = Context::native(); let mut env = Environment::new(); env.extend( "triple".to_string(), @@ -181,7 +191,7 @@ fn multi_name_bind_destructures_parametriq() { names: &names, value: Box::new(Operation::Variable(Identifier::new("triple"))), }; - let result = evaluate(&library, &mut env, &bind).expect("evaluated"); + let result = evaluate(&library, &context, &mut env, &bind).expect("evaluated"); assert_eq!(result, value::Value::Unitus); assert_eq!( env.lookup("a"), @@ -200,6 +210,7 @@ fn multi_name_bind_destructures_parametriq() { #[test] fn multi_name_bind_wrong_arity_errors() { let library = Library::core(); + let context = Context::native(); let mut env = Environment::new(); env.extend( "pair".to_string(), @@ -217,7 +228,7 @@ fn multi_name_bind_wrong_arity_errors() { names: &names, value: Box::new(Operation::Variable(Identifier::new("pair"))), }; - match evaluate(&library, &mut env, &bind) { + match evaluate(&library, &context, &mut env, &bind) { Err(RunnerError::BindArityMismatch { expected, actual }) => { assert_eq!(expected, 3); assert_eq!(actual, 2); @@ -229,6 +240,7 @@ fn multi_name_bind_wrong_arity_errors() { #[test] fn multi_name_bind_against_scalar_errors_as_not_tuple() { let library = Library::core(); + let context = Context::native(); let mut env = Environment::new(); env.extend( "scalar".to_string(), @@ -239,7 +251,7 @@ fn multi_name_bind_against_scalar_errors_as_not_tuple() { names: &names, value: Box::new(Operation::Variable(Identifier::new("scalar"))), }; - match evaluate(&library, &mut env, &bind) { + match evaluate(&library, &context, &mut env, &bind) { Err(RunnerError::BindNotTuple { expected }) => { assert_eq!(expected, 2); } @@ -250,6 +262,7 @@ fn multi_name_bind_against_scalar_errors_as_not_tuple() { #[test] fn execute_dispatches_resolved_builtin() { let library = Library::core(); + let context = Context::native(); let id = library .resolve("seq") .expect("seq registered"); @@ -261,7 +274,7 @@ fn execute_dispatches_resolved_builtin() { ], }); let mut env = Environment::new(); - let v = evaluate(&library, &mut env, &op).expect("evaluated"); + let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); assert_eq!( v, value::Value::Arraeum(vec![ @@ -275,12 +288,14 @@ fn execute_dispatches_resolved_builtin() { #[test] fn execute_unresolved_function_errors() { let library = Library::core(); + let context = Context::native(); let op = Operation::Execute(Executable { target: ExecutableRef::Unresolved(Identifier::new("click")), arguments: Vec::new(), }); let mut env = Environment::new(); - let Err(RunnerError::UnresolvedFunction(name)) = evaluate(&library, &mut env, &op) else { + let Err(RunnerError::UnresolvedFunction(name)) = evaluate(&library, &context, &mut env, &op) + else { panic!("expected UnresolvedFunction"); }; assert_eq!(name, "click"); diff --git a/src/runner/checks/library.rs b/src/runner/checks/library.rs index 9e622925..ff647bfe 100644 --- a/src/runner/checks/library.rs +++ b/src/runner/checks/library.rs @@ -1,3 +1,4 @@ +use crate::runner::context::Context; use crate::runner::library::Library; use crate::runner::runner::RunnerError; use crate::value::{Numeric, Value}; @@ -14,10 +15,11 @@ fn text(s: &str) -> Value { // evaluator takes once a call is resolved. fn call(name: &str, args: &[Value]) -> Result { let library = Library::core(); + let context = Context::native(); let id = library .resolve(name) .expect("builtin registered"); - library.call(id, args) + library.call(id, &context, args) } #[test] diff --git a/src/runner/context.rs b/src/runner/context.rs new file mode 100644 index 00000000..c1e22093 --- /dev/null +++ b/src/runner/context.rs @@ -0,0 +1,39 @@ +//! Host capabilities available to native functions when they execute. For now +//! the only capability is passing output through to the operator, and there is +//! no state to carry — output goes straight to standard output. A future GUI +//! or web frontend would hold a sink here and route through it, at which point +//! `native()` stays the empty/default context and a separate constructor +//! carries the real one. + +use std::io::{self, Write}; + +pub struct Context; + +impl Context { + /// The default context of native host capabilities. Builtins that are + /// pure functions to manipulate Values ignore it. + pub fn native() -> Self { + Context + } + + /// Pass a slice of bytes through to the user immediately. This is the + /// streaming primitive: a function teeing a child process's stdout reads + /// it in chunks and writes each chunk here (while separately accumulating + /// those bytes for its return value). No intermediate `String` is + /// allocated and a chunk split mid-UTF-8 is harmless. This calls + /// `flush()` so output appears to the user live. + #[allow(dead_code)] + pub fn write(&self, bytes: &[u8]) -> io::Result<()> { + let mut out = io::stdout(); + out.write_all(bytes)?; + out.flush() + } + + /// Pass a complete, already known, text string through to the user. This + /// is just a convenience over `write()` for whole messages such as status + /// lines or announcements. + #[allow(dead_code)] + pub fn emit(&self, message: &str) -> io::Result<()> { + self.write(message.as_bytes()) + } +} diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index 09ba86d1..c87c6a95 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; +use super::context::Context; use super::library::Library; use super::runner::RunnerError; use crate::program::{ExecutableRef, Fragment, Operation}; @@ -101,6 +102,7 @@ fn kind(value: &Value) -> &'static str { #[allow(dead_code)] pub fn evaluate<'i>( library: &Library, + context: &Context, env: &mut Environment, op: &Operation<'i>, ) -> Result { @@ -120,7 +122,8 @@ pub fn evaluate<'i>( for fragment in fragments { match fragment { Fragment::Text(t) => text.push_str(t), - Fragment::Interpolation(inner) => match evaluate(library, env, inner)? { + Fragment::Interpolation(inner) => match evaluate(library, context, env, inner)? + { Value::Literali(s) => text.push_str(&s), other => text.push_str(&other.to_string()), }, @@ -132,7 +135,7 @@ pub fn evaluate<'i>( Operation::Tablet(entries) => { let mut pairs = Vec::with_capacity(entries.len()); for entry in entries { - let v = evaluate(library, env, &entry.value)?; + let v = evaluate(library, context, env, &entry.value)?; pairs.push(( entry .label @@ -145,19 +148,19 @@ pub fn evaluate<'i>( Operation::List(items) => { let mut values = Vec::with_capacity(items.len()); for item in items { - values.push(evaluate(library, env, item)?); + values.push(evaluate(library, context, env, item)?); } Ok(Value::Arraeum(values)) } Operation::Bind { names, value } => { - let v = evaluate(library, env, value)?; + let v = evaluate(library, context, env, value)?; bind_names(env, names, v)?; Ok(Value::Unitus) } Operation::Sequence(ops) => { let mut last = Value::Unitus; for child in ops { - last = evaluate(library, env, child)?; + last = evaluate(library, context, env, child)?; } Ok(last) } @@ -169,9 +172,9 @@ pub fn evaluate<'i>( .len(), ); for arg in &executable.arguments { - args.push(evaluate(library, env, arg)?); + args.push(evaluate(library, context, env, arg)?); } - library.call(*id, &args) + library.call(*id, context, &args) } ExecutableRef::Unresolved(target) => Err(RunnerError::UnresolvedFunction( target diff --git a/src/runner/library.rs b/src/runner/library.rs index b2cadb9e..bf12e790 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -1,12 +1,15 @@ //! The function table for the evaluator. +use super::context::Context; use super::runner::RunnerError; use crate::program::ExecutableId; use crate::value::{Numeric, Value}; -/// A native function: implemented in Rust, taking already-evaluated -/// arguments. -pub type Native = fn(&[Value]) -> Result; +/// A native function: implemented in Rust, taking an execution Context (host +/// capabilities) and the already-evaluated arguments. Pure builtins disregard +/// the Context; effectful functions from the host domain (e.g. `exec`) use +/// it. +pub type Native = fn(&Context, &[Value]) -> Result; /// A function in the Library's table struct Entry { @@ -63,15 +66,21 @@ impl Library { self.functions[id.0].name } - /// Call the function at `id` with (already evaluated) arguments. - pub fn call(&self, id: ExecutableId, args: &[Value]) -> Result { - (self.functions[id.0].pointer)(args) + /// Call the function at `id` with the execution context and (already + /// evaluated) arguments. + pub fn call( + &self, + id: ExecutableId, + context: &Context, + args: &[Value], + ) -> Result { + (self.functions[id.0].pointer)(context, args) } } /// `seq(a, b)` — the inclusive integer range from `a` to `b` as a list, /// empty when `a > b`. -fn seq(args: &[Value]) -> Result { +fn seq(_context: &Context, args: &[Value]) -> Result { let a = as_integer("seq", &args[0])?; let b = as_integer("seq", &args[1])?; let range = (a..=b) @@ -82,7 +91,7 @@ fn seq(args: &[Value]) -> Result { /// `zip(xs, ys)` — a list of `(x, y)` pairs, one per position, truncated to /// the shorter input. -fn zip(args: &[Value]) -> Result { +fn zip(_context: &Context, args: &[Value]) -> Result { let xs = as_list("zip", &args[0])?; let ys = as_list("zip", &args[1])?; let pairs = xs @@ -94,7 +103,7 @@ fn zip(args: &[Value]) -> Result { } /// `values(form)` — the values of a tablet's entries, in order, as a list. -fn values(args: &[Value]) -> Result { +fn values(_context: &Context, args: &[Value]) -> Result { let entries = as_tablet("values", &args[0])?; let values = entries .iter() @@ -105,7 +114,7 @@ fn values(args: &[Value]) -> Result { /// `labels(form)` — the labels of a tablet's entries, in order, as a list of /// text values. -fn labels(args: &[Value]) -> Result { +fn labels(_context: &Context, args: &[Value]) -> Result { let entries = as_tablet("labels", &args[0])?; let labels = entries .iter() @@ -116,7 +125,7 @@ fn labels(args: &[Value]) -> Result { /// `pairs(form)` — a tablet's entries as a list of `(label, value)` pairs, /// so `foreach (k, v) in pairs(form)` destructures through the usual rule. -fn pairs(args: &[Value]) -> Result { +fn pairs(_context: &Context, args: &[Value]) -> Result { let entries = as_tablet("pairs", &args[0])?; let pairs = entries .iter() @@ -166,7 +175,7 @@ fn as_tablet<'a>( #[cfg(test)] impl Library { pub fn stub() -> Self { - fn unit(_: &[Value]) -> Result { + fn unit(_: &Context, _: &[Value]) -> Result { Ok(Value::Unitus) } let entry = |name, arity| Entry { diff --git a/src/runner/mod.rs b/src/runner/mod.rs index baf235a2..ff7b0541 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use crate::program::Program; +mod context; mod evaluator; mod library; mod path; diff --git a/src/runner/runner.rs b/src/runner/runner.rs index cdf1ba32..f5488c51 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -4,6 +4,7 @@ use std::collections::HashSet; use std::io; use std::path::PathBuf; +use super::context::Context; use super::evaluator::Environment; use super::library::Library; use super::path::{PathSegment, QualifiedPath}; @@ -98,6 +99,7 @@ pub struct Runner<'i, P: Prompt> { prompt: P, path: QualifiedPath<'i>, library: Library, + context: Context, } impl<'i, P: Prompt> Runner<'i, P> { @@ -115,6 +117,7 @@ impl<'i, P: Prompt> Runner<'i, P> { prompt, path: QualifiedPath::new(), library, + context: Context::native(), } } @@ -204,7 +207,7 @@ impl<'i, P: Prompt> Runner<'i, P> { | Operation::Multiline(_, _) | Operation::Tablet(_) | Operation::List(_) => { - let value = super::evaluator::evaluate(&self.library, env, op)?; + let value = super::evaluator::evaluate(&self.library, &self.context, env, op)?; Ok(Outcome::Done(value)) } } @@ -257,7 +260,7 @@ impl<'i, P: Prompt> Runner<'i, P> { .iter() .zip(&invocable.arguments) { - let value = super::evaluator::evaluate(&self.library, env, arg)?; + let value = super::evaluator::evaluate(&self.library, &self.context, env, arg)?; local.extend( param .value @@ -342,14 +345,15 @@ impl<'i, P: Prompt> Runner<'i, P> { } } Some(expr) => { - let items = match super::evaluator::evaluate(&self.library, env, expr)? { - 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), - }; + let items = + match super::evaluator::evaluate(&self.library, &self.context, env, expr)? { + Value::Arraeum(items) => items, + // A scalar in list context is a singleton list. + value @ (Value::Literali(_) | Value::Quanticle(_)) => vec![value], + // A tablet is a record, not a sequence, so it does not + // iterate directly. + _ => return Err(RunnerError::NotIterable), + }; for (i, item) in items .into_iter() .enumerate() @@ -427,7 +431,7 @@ impl<'i, P: Prompt> Runner<'i, P> { .path .render(); let title_text = match title { - Some(op) => match super::evaluator::evaluate(&self.library, env, op)? { + Some(op) => match super::evaluator::evaluate(&self.library, &self.context, env, op)? { Value::Literali(s) => s, other => other.to_string(), }, @@ -520,7 +524,7 @@ impl<'i, P: Prompt> Runner<'i, P> { if !description_text.is_empty() { description_text.push('\n'); } - match super::evaluator::evaluate(&self.library, env, op)? { + match super::evaluator::evaluate(&self.library, &self.context, env, op)? { Value::Literali(s) => description_text.push_str(&s), other => description_text.push_str(&other.to_string()), } From dcb10a3ea2d80c3f1b3d52c2a726696bdcb30ab6 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 3 Jun 2026 21:28:12 +1000 Subject: [PATCH 06/10] Establish Domain trait and move implementors to src/domain/ tree --- src/domain/checklist/mod.rs | 5 +++++ src/domain/mod.rs | 17 +++++++++++++++++ src/domain/nasa_esa_iss/mod.rs | 5 +++++ src/domain/procedure/mod.rs | 5 +++++ src/domain/recipe/mod.rs | 5 +++++ src/domain/source/mod.rs | 5 +++++ src/main.rs | 9 +++++++++ src/templating/checklist.rs | 2 +- src/templating/nasa_esa_iss.rs | 2 +- src/templating/procedure.rs | 2 +- src/templating/recipe.rs | 2 +- src/templating/source.rs | 2 +- 12 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/domain/checklist/mod.rs b/src/domain/checklist/mod.rs index 93a61742..9595062e 100644 --- a/src/domain/checklist/mod.rs +++ b/src/domain/checklist/mod.rs @@ -1,3 +1,8 @@ pub mod adapter; pub mod types; mod typst; + +/// The checklist domain: flattens procedures into printable checklists. +pub struct Checklist; + +impl crate::domain::Domain for Checklist {} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 3e5170ff..db4e1e39 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -19,3 +19,20 @@ pub(crate) mod serialize; pub mod source; pub use adapter::Adapter; +pub use checklist::Checklist; +pub use nasa_esa_iss::NasaEsaIss; +pub use procedure::Procedure; +pub use recipe::Recipe; +pub use source::Source; + +use crate::runner::Builtin; + +/// The runtime facet of a domain: the domain-specific host functions it +/// contributes to the interpreter's function table, on top of `Library::core` +/// and the `Library::system` layer. Orthogonal to a domain's rendering +/// projection (the `Template` trait). +pub trait Domain { + fn functions(&self) -> Vec { + Vec::new() + } +} diff --git a/src/domain/nasa_esa_iss/mod.rs b/src/domain/nasa_esa_iss/mod.rs index 54f0d460..9b64f0b8 100644 --- a/src/domain/nasa_esa_iss/mod.rs +++ b/src/domain/nasa_esa_iss/mod.rs @@ -1 +1,6 @@ pub mod adapter; + +/// The NASA/ESA ISS domain. +pub struct NasaEsaIss; + +impl crate::domain::Domain for NasaEsaIss {} diff --git a/src/domain/procedure/mod.rs b/src/domain/procedure/mod.rs index 93a61742..783faacb 100644 --- a/src/domain/procedure/mod.rs +++ b/src/domain/procedure/mod.rs @@ -1,3 +1,8 @@ pub mod adapter; pub mod types; mod typst; + +/// The procedure domain: preserves the full procedure hierarchy when rendering. +pub struct Procedure; + +impl crate::domain::Domain for Procedure {} diff --git a/src/domain/recipe/mod.rs b/src/domain/recipe/mod.rs index 93a61742..68559f6f 100644 --- a/src/domain/recipe/mod.rs +++ b/src/domain/recipe/mod.rs @@ -1,3 +1,8 @@ pub mod adapter; pub mod types; mod typst; + +/// The recipe domain. +pub struct Recipe; + +impl crate::domain::Domain for Recipe {} diff --git a/src/domain/source/mod.rs b/src/domain/source/mod.rs index 93a61742..ba37c787 100644 --- a/src/domain/source/mod.rs +++ b/src/domain/source/mod.rs @@ -1,3 +1,8 @@ pub mod adapter; pub mod types; mod typst; + +/// The source domain: renders Technique source with syntax highlighting. +pub struct Source; + +impl crate::domain::Domain for Source {} diff --git a/src/main.rs b/src/main.rs index d2e35048..a130cf90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use std::str::FromStr; use tracing::debug; use tracing_subscriber::{self, EnvFilter}; +use technique::domain::Domain; use technique::formatting::{self, Identity}; use technique::highlighting::{self, Terminal}; use technique::linking; @@ -281,6 +282,14 @@ fn main() { .required(true) .help("The file containing the Technique document to run."), ) + .arg( + Arg::new("domain") + .short('d') + .long("domain") + .value_parser(["checklist", "nasa-esa-iss", "procedure", "recipe", "source"]) + .action(ArgAction::Set) + .help("The kind of procedure this Technique document represents. By default the value specified in the input document's metadata will be used, falling back to source if unspecified."), + ) .arg( Arg::new("arguments") .num_args(0..) diff --git a/src/templating/checklist.rs b/src/templating/checklist.rs index d7398aaa..739c7b0f 100644 --- a/src/templating/checklist.rs +++ b/src/templating/checklist.rs @@ -13,7 +13,7 @@ use crate::templating::template::Template; pub static TEMPLATE: &str = include_str!("checklist.typ"); -pub struct Checklist; +pub use crate::domain::Checklist; impl Template for Checklist { fn markup(&self, document: &language::Document) -> String { diff --git a/src/templating/nasa_esa_iss.rs b/src/templating/nasa_esa_iss.rs index 78027cbd..a0cbf2d3 100644 --- a/src/templating/nasa_esa_iss.rs +++ b/src/templating/nasa_esa_iss.rs @@ -10,7 +10,7 @@ use crate::templating::template::Template; pub static TEMPLATE: &str = include_str!("nasa_esa_iss.typ"); -pub struct NasaEsaIss; +pub use crate::domain::NasaEsaIss; impl Template for NasaEsaIss { fn markup(&self, document: &language::Document) -> String { diff --git a/src/templating/procedure.rs b/src/templating/procedure.rs index 030b3c3f..d4bb606f 100644 --- a/src/templating/procedure.rs +++ b/src/templating/procedure.rs @@ -13,7 +13,7 @@ use crate::templating::template::Template; pub static TEMPLATE: &str = include_str!("procedure.typ"); -pub struct Procedure; +pub use crate::domain::Procedure; impl Template for Procedure { fn markup(&self, document: &language::Document) -> String { diff --git a/src/templating/recipe.rs b/src/templating/recipe.rs index f924d7d2..c91fd0f1 100644 --- a/src/templating/recipe.rs +++ b/src/templating/recipe.rs @@ -9,7 +9,7 @@ use crate::templating::template::Template; pub static TEMPLATE: &str = include_str!("recipe.typ"); -pub struct Recipe; +pub use crate::domain::Recipe; impl Template for Recipe { fn markup(&self, document: &language::Document) -> String { diff --git a/src/templating/source.rs b/src/templating/source.rs index 1e030c27..2e34fb01 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -12,7 +12,7 @@ use crate::templating::template::Template; pub static TEMPLATE: &str = include_str!("source.typ"); -pub struct Source; +pub use crate::domain::Source; impl Template for Source { fn markup(&self, document: &language::Document) -> String { From 447894582ae7346ee2286ca6baed13ef629748a0 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 3 Jun 2026 21:36:13 +1000 Subject: [PATCH 07/10] Define builtin core and system level functions --- src/main.rs | 28 +++++++++++++-- src/problem/messages.rs | 8 +++++ src/runner/library.rs | 80 +++++++++++++++++++++++++++++++---------- 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index a130cf90..761a5260 100644 --- a/src/main.rs +++ b/src/main.rs @@ -722,7 +722,26 @@ fn main() { } }; - let library = Library::core(); + // Add domain-specific host functions to the Library based on + // whether the document or command-line indicate the domain being + // used. FUTURE the `core` and `system` functions are added here + // regardless, in time we should make that more configurable. + + let domain = submatches + .get_one::("domain") + .map(String::as_str) + .unwrap_or("source"); + let domain: &dyn Domain = match domain { + "checklist" => &Checklist, + "nasa-esa-iss" => &NasaEsaIss, + "procedure" => &Procedure, + "recipe" => &Recipe, + _ => &Source, + }; + + let mut library = Library::core(); + library.extend(Library::system()); + library.extend(domain.functions()); if let Err(errors) = linking::link(&mut program, &library) { for (i, error) in errors .iter() @@ -822,7 +841,12 @@ fn main() { } }; - let library = Library::core(); + // TODO it is slightly problematic that we have to reconstruct the + // Library here and at present are hard-coding the functions being + // brought into scope. + + let mut library = Library::core(); + library.extend(Library::system()); if let Err(errors) = linking::link(&mut program, &library) { for (i, error) in errors .iter() diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 363548c6..47e17424 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1221,6 +1221,14 @@ you can iterate over. format!("Unresolved function {}()", function), format!("The function {}() is not a builtin and is not provided by the domain.", function), ), + RunnerError::ExecError(error) => ( + "Could not run external command".to_string(), + format!("Launching or reading from the external command failed: {}.", error), + ), + RunnerError::CommandFailed(code) => ( + format!("External command exited with status {}", code), + "The shell command run by exec() finished with a non-zero exit status.".to_string(), + ), RunnerError::IncompatibleCombination { left, right } => ( format!("Cannot combine {} with {}", left, right), format!("Combining Values requires compatible kinds; a {} and a {} can't be added together.", left, right), diff --git a/src/runner/library.rs b/src/runner/library.rs index bf12e790..2fe849d6 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -1,5 +1,8 @@ //! The function table for the evaluator. +use std::io::Read; +use std::process::{Command, Stdio}; + use super::context::Context; use super::runner::RunnerError; use crate::program::ExecutableId; @@ -12,10 +15,10 @@ use crate::value::{Numeric, Value}; pub type Native = fn(&Context, &[Value]) -> Result; /// A function in the Library's table -struct Entry { - name: &'static str, - arity: usize, - pointer: Native, +pub struct Builtin { + pub name: &'static str, + pub arity: usize, + pub function: Native, } /// The set of functions available to a program, indexed by `ExecutableId`. A @@ -23,7 +26,7 @@ struct Entry { /// domain contributes. It is used by the linking phase to perform lookups of /// function pointers, then ownership is passed to the runner. pub struct Library { - functions: Vec, + functions: Vec, } impl Library { @@ -32,22 +35,63 @@ impl Library { /// functions (functions supplied by the host environment) are declared /// and implemented by the relevant domain the Technique is executing in. pub fn core() -> Self { - let entry = |name, arity, pointer| Entry { - name, - arity, - pointer, - }; Library { functions: vec![ - entry("seq", 2, seq as Native), - entry("zip", 2, zip as Native), - entry("values", 1, values as Native), - entry("labels", 1, labels as Native), - entry("pairs", 1, pairs as Native), + Builtin { + name: "seq", + arity: 2, + function: seq, + }, + Builtin { + name: "zip", + arity: 2, + function: zip, + }, + Builtin { + name: "values", + arity: 1, + function: values, + }, + Builtin { + name: "labels", + arity: 1, + function: labels, + }, + Builtin { + name: "pairs", + arity: 1, + function: pairs, + }, ], } } + /// The system layer: effectful, world-touching functions (process + /// execution and the clock). Kept out of `core` so a pure, isolated, + /// deterministic Technique can be run without them; an interactive run + /// adds this layer on top of `core`. + pub fn system() -> Vec { + vec![ + Builtin { + name: "exec", + arity: 1, + function: exec, + }, + Builtin { + name: "now", + arity: 0, + function: now, + }, + ] + } + + /// Add functions to the table, after the core builtins — the system layer + /// or a domain's own host functions. + pub fn extend(&mut self, builtins: impl IntoIterator) { + self.functions + .extend(builtins); + } + /// Resolve a function name to its index, or `None` if no entry matches. pub fn resolve(&self, name: &str) -> Option { self.functions @@ -74,7 +118,7 @@ impl Library { context: &Context, args: &[Value], ) -> Result { - (self.functions[id.0].pointer)(context, args) + (self.functions[id.0].function)(context, args) } } @@ -178,10 +222,10 @@ impl Library { fn unit(_: &Context, _: &[Value]) -> Result { Ok(Value::Unitus) } - let entry = |name, arity| Entry { + let entry = |name, arity| Builtin { name, arity, - pointer: unit as Native, + function: unit as Native, }; Library { functions: vec![ From 61ff81a87dad05843fd5f13738be2e7cf8e44eaa Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 3 Jun 2026 21:49:33 +1000 Subject: [PATCH 08/10] Implement exec() function --- src/runner/library.rs | 140 +++++++++++++++++++++++++++++++++++++----- src/runner/mod.rs | 3 +- src/runner/runner.rs | 14 ++++- 3 files changed, 138 insertions(+), 19 deletions(-) diff --git a/src/runner/library.rs b/src/runner/library.rs index 2fe849d6..feca467e 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -180,6 +180,69 @@ fn pairs(_context: &Context, args: &[Value]) -> Result { Ok(Value::Arraeum(pairs)) } +/// `exec(script)` — run a shell script, teeing its stdout through the Context +/// to the operator as it streams while accumulating it as the return value. +/// Output is held as bytes until the end so a chunk split mid-UTF-8 is +/// harmless and only one String is allocated. A non-zero exit is an error. +fn exec(context: &Context, args: &[Value]) -> Result { + let script = match &args[0] { + Value::Literali(script) => script, + _ => { + return Err(RunnerError::InvalidArgument { + function: "exec", + expected: "a shell script string", + }) + } + }; + + let mut child = Command::new("bash") + .arg("-c") + .arg(script) + .stdout(Stdio::piped()) + .spawn() + .map_err(RunnerError::ExecError)?; + + let mut stdout = child + .stdout + .take() + .expect("child stdout was piped"); + let mut captured = Vec::new(); + let mut buffer = [0u8; 8192]; + loop { + let count = stdout + .read(&mut buffer) + .map_err(RunnerError::ExecError)?; + if count == 0 { + break; + } + context + .write(&buffer[..count]) + .map_err(RunnerError::ExecError)?; + captured.extend_from_slice(&buffer[..count]); + } + + let status = child + .wait() + .map_err(RunnerError::ExecError)?; + if !status.success() { + return Err(RunnerError::CommandFailed( + status + .code() + .unwrap_or(-1), + )); + } + + Ok(Value::Literali( + String::from_utf8_lossy(&captured).into_owned(), + )) +} + +/// `now()` — the current wall-clock time as an ISO 8601 string. A read of +/// external state, hence part of the system layer rather than `core`. +fn now(_context: &Context, _args: &[Value]) -> Result { + Ok(Value::Literali(super::runner::now_iso8601())) +} + fn as_integer(function: &'static str, value: &Value) -> Result { if let Value::Quanticle(Numeric::Integral(n)) = value { Ok(*n) @@ -222,25 +285,68 @@ impl Library { fn unit(_: &Context, _: &[Value]) -> Result { Ok(Value::Unitus) } - let entry = |name, arity| Builtin { - name, - arity, - function: unit as Native, - }; Library { functions: vec![ - entry("seq", 2), - entry("zip", 2), - entry("exec", 1), - entry("cmd", 1), - entry("now", 0), - entry("uuid", 0), - entry("timer", 1), - entry("journal", 1), - entry("click", 1), - entry("navigate", 1), - entry("select", 1), - entry("deselect", 1), + Builtin { + name: "seq", + arity: 2, + function: unit, + }, + Builtin { + name: "zip", + arity: 2, + function: unit, + }, + Builtin { + name: "exec", + arity: 1, + function: unit, + }, + Builtin { + name: "cmd", + arity: 1, + function: unit, + }, + Builtin { + name: "now", + arity: 0, + function: unit, + }, + Builtin { + name: "uuid", + arity: 0, + function: unit, + }, + Builtin { + name: "timer", + arity: 1, + function: unit, + }, + Builtin { + name: "journal", + arity: 1, + function: unit, + }, + Builtin { + name: "click", + arity: 1, + function: unit, + }, + Builtin { + name: "navigate", + arity: 1, + function: unit, + }, + Builtin { + name: "select", + arity: 1, + function: unit, + }, + Builtin { + name: "deselect", + arity: 1, + function: unit, + }, ], } } diff --git a/src/runner/mod.rs b/src/runner/mod.rs index ff7b0541..40e86b35 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -15,7 +15,8 @@ mod prompt; mod runner; mod state; -pub use library::Library; +pub use context::Context; +pub use library::{Builtin, Library, Native}; pub use runner::{Outcome, RunnerError}; pub use state::{RecordError, RunId}; diff --git a/src/runner/runner.rs b/src/runner/runner.rs index f5488c51..2090f737 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -72,6 +72,8 @@ pub enum RunnerError { expected: &'static str, }, UnresolvedFunction(String), + ExecError(io::Error), + CommandFailed(i32), IncompatibleCombination { left: &'static str, right: &'static str, @@ -198,7 +200,17 @@ impl<'i, P: Prompt> Runner<'i, P> { .append(&record)?; self.prompt .announce(&describe_execute(&function)); - Ok(Outcome::Done(Value::Unitus)) + // A resolved target runs through the evaluator, which calls + // the function with the live Context, teeing any output; an + // Unresolved target is announced only, not run. + match &executable.target { + ExecutableRef::Resolved(_) => { + let value = + super::evaluator::evaluate(&self.library, &self.context, env, op)?; + Ok(Outcome::Done(value)) + } + ExecutableRef::Unresolved(_) => Ok(Outcome::Done(Value::Unitus)), + } } Operation::Bind { .. } | Operation::Variable(_) From 1e331722f43d46f08c58a48c48a28017d25d568d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 3 Jun 2026 22:14:03 +1000 Subject: [PATCH 09/10] Resolve builtin functions in linking phase --- src/domain/mod.rs | 14 ++++++++++++ src/linking/checks/linker.rs | 18 ++++++++------- src/linking/linker.rs | 33 ++++++++++++++++----------- src/main.rs | 41 +++++++++++++++++++++++++--------- src/problem/messages.rs | 15 ++++++++++--- src/runner/checks/evaluator.rs | 2 +- src/runner/checks/runner.rs | 5 +++-- src/runner/evaluator.rs | 2 +- src/runner/runner.rs | 19 ++++++---------- 9 files changed, 99 insertions(+), 50 deletions(-) diff --git a/src/domain/mod.rs b/src/domain/mod.rs index db4e1e39..80827f48 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -36,3 +36,17 @@ pub trait Domain { Vec::new() } } + +/// Select a domain by name, for both the renderer and the runtime. +/// `None` if the name matches no known domain — the caller reports that as an +/// error rather than silently substituting a default. +pub fn domain_for(name: &str) -> Option<&'static dyn Domain> { + match name { + "checklist" => Some(&Checklist), + "nasa-esa-iss" => Some(&NasaEsaIss), + "procedure" => Some(&Procedure), + "recipe" => Some(&Recipe), + "source" => Some(&Source), + _ => None, + } +} diff --git a/src/linking/checks/linker.rs b/src/linking/checks/linker.rs index d862a699..846afc9d 100644 --- a/src/linking/checks/linker.rs +++ b/src/linking/checks/linker.rs @@ -55,7 +55,7 @@ powerdown : } #[test] -fn unknown_function_left_unresolved() { +fn unknown_function_is_an_error() { let source = r#" % technique v1 @@ -67,13 +67,12 @@ probe : let document = parsing::parse(path, source).expect("parse"); let mut program = translate(&document).expect("translate"); - link(&mut program, &Library::stub()).expect("link"); - - let executable = first_execute(&program.subroutines[0].body).expect("an Execute in the body"); - let ExecutableRef::Unresolved(target) = &executable.target else { - panic!("expected Unresolved, got {:?}", executable.target); + let errors = link(&mut program, &Library::stub()).expect_err("unknown function"); + assert_eq!(errors.len(), 1); + let LinkingError::UnresolvedFunction { function } = &errors[0] else { + panic!("expected UnresolvedFunction, got {:?}", errors[0]); }; - assert_eq!(target.value, "mystery"); + assert_eq!(function.value, "mystery"); } #[test] @@ -95,7 +94,10 @@ powerdown : function, expected, actual, - } = &errors[0]; + } = &errors[0] + else { + panic!("expected ArityMismatch, got {:?}", errors[0]); + }; assert_eq!(function.value, "cmd"); assert_eq!(*expected, 1); assert_eq!(*actual, 2); diff --git a/src/linking/linker.rs b/src/linking/linker.rs index abb6496f..10e11dca 100644 --- a/src/linking/linker.rs +++ b/src/linking/linker.rs @@ -11,12 +11,16 @@ pub enum LinkingError<'i> { expected: usize, actual: usize, }, + /// A function call naming nothing in the function table — neither a core + /// nor system builtin nor a function the selected domain provides. + UnresolvedFunction { function: language::Identifier<'i> }, } impl<'i> LinkingError<'i> { pub fn span(&self) -> Span { match self { LinkingError::ArityMismatch { function, .. } => function.span, + LinkingError::UnresolvedFunction { function } => function.span, } } } @@ -48,20 +52,23 @@ fn link_operation<'i>( match op { Operation::Execute(executable) => { if let ExecutableRef::Unresolved(id) = &executable.target { - if let Some(exec_id) = library.resolve(id.value) { - let expected = library.arity(exec_id); - let actual = executable - .arguments - .len(); - if actual == expected { - executable.target = ExecutableRef::Resolved(exec_id); - } else { - problems.push(LinkingError::ArityMismatch { - function: *id, - expected, - actual, - }); + match library.resolve(id.value) { + Some(exec_id) => { + let expected = library.arity(exec_id); + let actual = executable + .arguments + .len(); + if actual == expected { + executable.target = ExecutableRef::Resolved(exec_id); + } else { + problems.push(LinkingError::ArityMismatch { + function: *id, + expected, + actual, + }); + } } + None => problems.push(LinkingError::UnresolvedFunction { function: *id }), } } for arg in &mut executable.arguments { diff --git a/src/main.rs b/src/main.rs index 761a5260..e31481f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use std::str::FromStr; use tracing::debug; use tracing_subscriber::{self, EnvFilter}; -use technique::domain::Domain; +use technique::domain::{self, Domain}; use technique::formatting::{self, Identity}; use technique::highlighting::{self, Terminal}; use technique::linking; @@ -119,6 +119,17 @@ impl TypedValueParser for PaperSizeParser { } } +/// Resolve a domain name to its handle. +fn select_domain(name: &str) -> &'static dyn Domain { + match domain::domain_for(name) { + Some(domain) => domain, + None => { + eprintln!("{}: unrecognized domain \"{}\"", "error".bright_red(), name); + std::process::exit(1); + } + } +} + fn main() { const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); @@ -427,7 +438,17 @@ fn main() { std::process::exit(0); } - let library = Library::core(); + // Check validates against the functions a run would resolve + // against: core and system, plus the document's declared domain. + let name = technique + .header + .as_ref() + .and_then(|m| m.domain) + .unwrap_or("source"); + let domain = select_domain(name); + let mut library = Library::core(); + library.extend(Library::system()); + library.extend(domain.functions()); if let Err(errors) = linking::link(&mut program, &library) { for (i, error) in errors .iter() @@ -727,17 +748,17 @@ fn main() { // used. FUTURE the `core` and `system` functions are added here // regardless, in time we should make that more configurable. - let domain = submatches + let name = submatches .get_one::("domain") .map(String::as_str) + .or_else(|| { + technique + .header + .as_ref() + .and_then(|m| m.domain) + }) .unwrap_or("source"); - let domain: &dyn Domain = match domain { - "checklist" => &Checklist, - "nasa-esa-iss" => &NasaEsaIss, - "procedure" => &Procedure, - "recipe" => &Recipe, - _ => &Source, - }; + let domain = select_domain(name); let mut library = Library::core(); library.extend(Library::system()); diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 47e17424..c5496131 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1133,6 +1133,15 @@ functions calls: .trim_ascii() .to_string(), ), + LinkingError::UnresolvedFunction { + function: Identifier { value: name, .. }, + } => ( + format!("Unknown function {}()", name), + format!( + "The function {}() is neither builtin nor provided by the selected domain.", + name + ), + ), } } @@ -1217,9 +1226,9 @@ you can iterate over. format!("Wrong argument type passed to {}()", function), format!("The {}() function expected {} but was given something else.", function, expected), ), - RunnerError::UnresolvedFunction(function) => ( - format!("Unresolved function {}()", function), - format!("The function {}() is not a builtin and is not provided by the domain.", function), + RunnerError::UnknownFunction(function) => ( + format!("Unknown function {}()", function), + format!("The function {}() is undefined. This should have been caught during the linking phase!", function), ), RunnerError::ExecError(error) => ( "Could not run external command".to_string(), diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index ef0e8949..9d01f868 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -294,7 +294,7 @@ fn execute_unresolved_function_errors() { arguments: Vec::new(), }); let mut env = Environment::new(); - let Err(RunnerError::UnresolvedFunction(name)) = evaluate(&library, &context, &mut env, &op) + let Err(RunnerError::UnknownFunction(name)) = evaluate(&library, &context, &mut env, &op) else { panic!("expected UnresolvedFunction"); }; diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 91c283bd..1c21ca69 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -700,8 +700,9 @@ test : 1. Do this { journal("hello") } "# .trim_ascii(); - let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); - let program = translate(&document).expect("translate"); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parsed"); + let mut program = translate(&document).expect("translated"); + crate::linking::link(&mut program, &Library::stub()).expect("linked"); let mut fixture = StoreFixture::new("execute-announce"); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index c87c6a95..3e6342f4 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -176,7 +176,7 @@ pub fn evaluate<'i>( } library.call(*id, context, &args) } - ExecutableRef::Unresolved(target) => Err(RunnerError::UnresolvedFunction( + ExecutableRef::Unresolved(target) => Err(RunnerError::UnknownFunction( target .value .to_string(), diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 2090f737..293d9d9c 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -71,7 +71,7 @@ pub enum RunnerError { function: &'static str, expected: &'static str, }, - UnresolvedFunction(String), + UnknownFunction(String), ExecError(io::Error), CommandFailed(i32), IncompatibleCombination { @@ -200,17 +200,12 @@ impl<'i, P: Prompt> Runner<'i, P> { .append(&record)?; self.prompt .announce(&describe_execute(&function)); - // A resolved target runs through the evaluator, which calls - // the function with the live Context, teeing any output; an - // Unresolved target is announced only, not run. - match &executable.target { - ExecutableRef::Resolved(_) => { - let value = - super::evaluator::evaluate(&self.library, &self.context, env, op)?; - Ok(Outcome::Done(value)) - } - ExecutableRef::Unresolved(_) => Ok(Outcome::Done(Value::Unitus)), - } + // Linking resolves every Execute against the library, so a + // target still Unresolved here means a resume-time runtime + // missing a builtin the run started with; the evaluator + // surfaces that as an error rather than running anything. + let value = super::evaluator::evaluate(&self.library, &self.context, env, op)?; + Ok(Outcome::Done(value)) } Operation::Bind { .. } | Operation::Variable(_) From 25d683ad49b8abbff7f90dc5d971c663a595f0d5 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 4 Jun 2026 00:10:42 +1000 Subject: [PATCH 10/10] Tidy and de-duplicate run-phase tests --- src/runner/checks/evaluator.rs | 2 +- src/translation/checks/errors.rs | 2 +- src/translation/checks/translate.rs | 46 +++++++---------------------- 3 files changed, 13 insertions(+), 37 deletions(-) diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index 9d01f868..0d7fa8e6 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -296,7 +296,7 @@ fn execute_unresolved_function_errors() { let mut env = Environment::new(); let Err(RunnerError::UnknownFunction(name)) = evaluate(&library, &context, &mut env, &op) else { - panic!("expected UnresolvedFunction"); + panic!("expected UnknownFunction"); }; assert_eq!(name, "click"); } diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 57614708..707c1762 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -274,6 +274,6 @@ run : assert_eq!(errors.len(), 1); let TranslationError::HeterogenousList { .. } = &errors[0] else { - panic!("expected MixedBracket, got {:?}", errors[0]); + panic!("expected HeterogenousList, got {:?}", errors[0]); }; } diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 4fa6a22f..f2c834d7 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -73,34 +73,6 @@ third : assert_eq!(names, vec![Some("first"), Some("second"), Some("third")]); } -#[test] -fn procedure_inside_section_registered() { - let source = r#" -% technique v1 - -outer : - -I. Section One - -inner : () -> () - "# - .trim_ascii(); - let path = Path::new("Test.tq"); - let document = parsing::parse(path, source).expect("parse"); - let program = translate(&document).expect("translate"); - - let names: Vec<_> = program - .subroutines - .iter() - .map(|p| { - p.name - .as_ref() - .map(|id| id.value) - }) - .collect(); - assert_eq!(names, vec![Some("outer"), Some("inner")]); -} - #[test] fn procedure_title_extracted() { let source = r#" @@ -260,13 +232,17 @@ inner : () -> () let document = parsing::parse(path, source).expect("parse"); let program = translate(&document).expect("translate"); - // The inner procedure was hoisted into the flat list. - assert_eq!( - program - .subroutines - .len(), - 2 - ); + // The inner procedure was hoisted into the flat list, after the outer. + let names: Vec<_> = program + .subroutines + .iter() + .map(|p| { + p.name + .as_ref() + .map(|id| id.value) + }) + .collect(); + assert_eq!(names, vec![Some("outer"), Some("inner")]); let Operation::Sequence(ops) = &program.subroutines[0].body else { panic!("expected Sequence");