From 497c846ec432c78012348f02476b3381010c95fd Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 14 Jun 2026 20:07:39 +1000 Subject: [PATCH 1/8] Present procedure description when running --- src/formatting/formatter.rs | 12 ++++++++- src/program/types.rs | 6 ++--- src/runner/checks/runner.rs | 46 +++++++++++++++++++++++++++++++++++ src/runner/driver.rs | 6 ++++- src/runner/runner.rs | 24 ++++++++++++++++++ src/translation/translator.rs | 13 +--------- 6 files changed, 90 insertions(+), 17 deletions(-) diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 56fa078..5507d9b 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -206,6 +206,16 @@ pub fn render_scope<'i>(scope: &'i Scope, renderer: &dyn Render) -> String { .to_string() } +/// Render a procedure's description: its prose paragraphs, blank-line +/// separated, exactly as written in the source. +pub fn render_description<'i>(paragraphs: &'i [Paragraph<'i>], renderer: &dyn Render) -> String { + let mut sub = Formatter::new(78); + sub.append_paragraphs(paragraphs); + render_fragments(&sub.fragments, renderer) + .trim_end() + .to_string() +} + /// Render step's without descending into nested subscopes. pub fn render_step<'i>(scope: &'i Scope, renderer: &dyn Render) -> String { let mut sub = Formatter::new(78); @@ -711,7 +721,7 @@ impl<'i> Formatter<'i> { self.add_fragment_reference(Syntax::Forma, forma.value) } - fn append_paragraphs(&mut self, paragraphs: &'i Vec) { + fn append_paragraphs(&mut self, paragraphs: &'i [Paragraph<'i>]) { for (i, paragraph) in paragraphs .iter() .enumerate() diff --git a/src/program/types.rs b/src/program/types.rs index e1e73bf..c41a74f 100644 --- a/src/program/types.rs +++ b/src/program/types.rs @@ -40,7 +40,7 @@ pub struct Subroutine<'i> { /// then `None`, otherwise all procedures have names. pub name: Option>, pub title: Option<&'i str>, - pub description: Vec>, + pub description: &'i [language::Paragraph<'i>], pub parameters: Option<&'i [language::Identifier<'i>]>, pub signature: Option<&'i language::Signature<'i>>, pub body: Operation<'i>, @@ -55,7 +55,7 @@ impl<'i> Subroutine<'i> { Subroutine { name: Some(name), title: None, - description: Vec::new(), + description: &[], parameters: None, signature: None, body: Operation::Sequence(Vec::new()), @@ -69,7 +69,7 @@ impl<'i> Subroutine<'i> { Subroutine { name: None, title: None, - description: Vec::new(), + description: &[], parameters: None, signature: None, body: Operation::Sequence(Vec::new()), diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 8430eba..91625d9 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1620,6 +1620,52 @@ greet(name) : assert_eq!(descriptions, vec!["1. Hello { name }"]); } +#[test] +fn entry_procedure_description_displayed_intact() { + let source = r#" +% technique v1 + +make_coffee : + +Brew using { 42 ~ water } then serve it hot. + +1. Pour + "# + .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-description-intact"); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + + // The description renders from its source paragraph: the binding hoisted + // into the implicit step 0 still runs, but the prose is shown whole, with + // the binding syntax as-written. + let prompt = runner.into_driver(); + let displayed: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Display(content) = e { + Some(content.as_str()) + } else { + None + } + }) + .collect(); + assert!(displayed.contains(&"Brew using { 42 ~ water } then serve it hot.")); +} + #[test] fn step_with_responses_prompts_choices_and_records() { let source = r#" diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 638e9e1..16659bc 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -1024,6 +1024,7 @@ pub enum Event { Enter { qualified: String, }, + Display(String), Section { qualified: String, numeral: String, @@ -1089,7 +1090,10 @@ impl Driver for Mock { }); } - fn display(&mut self, _content: &str) {} + fn display(&mut self, content: &str) { + self.events + .push(Event::Display(content.to_string())); + } fn section(&mut self, qualified: &str, numeral: &str, title: &str) { self.events diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 02959b8..d707cd5 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -184,6 +184,18 @@ impl<'i, D: Driver> Runner<'i, D> { self.driver .display(&title_text); } + if !entry + .description + .is_empty() + { + let description = crate::formatting::formatter::render_description( + entry.description, + self.driver + .renderer(), + ); + self.driver + .display(&description); + } } let result = self.walk(&mut env, &entry.body); // A named entry procedure is a structural scope: a completed run closes @@ -456,6 +468,18 @@ impl<'i, D: Driver> Runner<'i, D> { self.driver .display(&title_text); } + if !subroutine + .description + .is_empty() + { + let description = crate::formatting::formatter::render_description( + subroutine.description, + self.driver + .renderer(), + ); + self.driver + .display(&description); + } } // Walk the body against the callee's own environment; `local` diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 6385afd..2d3a688 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -203,13 +203,12 @@ impl<'i> Translator<'i> { } } let body = Operation::Sequence(ops); - let description_ops = self.translate_paragraphs(description); let entry = &mut self .program .subroutines[id.0]; entry.title = title; - entry.description = description_ops; + entry.description = description; entry.parameters = procedure .parameters .as_ref() @@ -393,16 +392,6 @@ impl<'i> Translator<'i> { } } - fn translate_paragraphs( - &mut self, - paragraphs: &'i [language::Paragraph<'i>], - ) -> Vec> { - paragraphs - .iter() - .map(|p| self.translate_paragraph(p)) - .collect() - } - // Descriptive paragraphs are whitespace-agnostic and re-wrappable: the // parser strips edge whitespace from each Text fragment, so the original // single-space joins between adjacent tokens are lost. Reinsert one From 1a04ec92a604ad0be9efa10db47e9913c045eab7 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 14 Jun 2026 20:18:14 +1000 Subject: [PATCH 2/8] Output metadata headers if present before run --- src/formatting/formatter.rs | 9 +++++++ src/program/types.rs | 4 ++++ src/runner/checks/runner.rs | 44 +++++++++++++++++++++++++++++++++++ src/runner/runner.rs | 12 ++++++++++ src/translation/translator.rs | 5 ++++ 5 files changed, 74 insertions(+) diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 5507d9b..84cc920 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -133,6 +133,15 @@ pub fn render_descriptive<'i>(descriptive: &'i Descriptive, renderer: &dyn Rende render_fragments(&sub.fragments, renderer) } +/// Render the document's metadata header, coloured, to a String. +pub fn render_header<'i>(metadata: &'i Metadata, renderer: &dyn Render) -> String { + let mut sub = Formatter::new(78); + sub.format_header(metadata); + render_fragments(&sub.fragments, renderer) + .trim_end() + .to_string() +} + /// Render a procedure or section title with its leading `#` marker. pub fn render_title(title: &str, renderer: &dyn Render) -> String { let mut sub = Formatter::new(78); diff --git a/src/program/types.rs b/src/program/types.rs index c41a74f..7bb2a40 100644 --- a/src/program/types.rs +++ b/src/program/types.rs @@ -15,6 +15,9 @@ use crate::language; /// Top-level Technique translated to a runnable program. #[derive(Debug, Eq, PartialEq, Default)] pub struct Program<'i> { + /// The document's metadata header lines, shown as a preface before the + /// steps of the program are run. + pub prelude: Option<&'i language::Metadata<'i>>, /// All procedures declared in the input document, in source order. If an /// anonymous wrapper for a top-level `Technique::Steps`-only document was /// created it will be at index 0. @@ -24,6 +27,7 @@ pub struct Program<'i> { impl<'i> Program<'i> { pub fn new() -> Self { Program { + prelude: None, subroutines: Vec::new(), } } diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 91625d9..1a9b0fe 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1666,6 +1666,50 @@ Brew using { 42 ~ water } then serve it hot. assert!(displayed.contains(&"Brew using { 42 ~ water } then serve it hot.")); } +#[test] +fn metadata_header_displayed_as_prelude() { + let source = r#" +% technique v1 +! MIT; © 2026 ACME +& procedure + +make_coffee : + +1. Pour + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("metadata-prelude"); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + + // The header is shown once, before the entry procedure's declaration. + let prompt = runner.into_driver(); + let first = prompt + .events() + .iter() + .find_map(|e| { + if let Event::Display(content) = e { + Some(content.as_str()) + } else { + None + } + }) + .expect("a Display event"); + assert_eq!(first, "% technique v1\n! MIT; © 2026 ACME\n& procedure"); +} + #[test] fn step_with_responses_prompts_choices_and_records() { let source = r#" diff --git a/src/runner/runner.rs b/src/runner/runner.rs index d707cd5..dffaa1d 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -149,6 +149,18 @@ impl<'i, D: Driver> Runner<'i, D> { /// anonymous wrapper if the document is top-level Steps, otherwise /// the first declared procedure. pub fn run(&mut self, mut env: Environment) -> Result { + if let Some(metadata) = self + .program + .prelude + { + let header = crate::formatting::formatter::render_header( + metadata, + self.driver + .renderer(), + ); + self.driver + .display(&header); + } let entry = self .program .subroutines diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 2d3a688..94ac0c1 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -12,6 +12,11 @@ use crate::program::{ pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { let mut translator = Translator::new(); + translator + .program + .prelude = document + .header + .as_ref(); if let Some(body) = &document.body { if let language::Technique::Steps(scopes) = body { From 946f3c871884e266bc8d2603cb60c1a99a72dcd4 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Mon, 15 Jun 2026 10:50:59 +1000 Subject: [PATCH 3/8] Wrap full error messages --- src/problem/messages.rs | 166 +++++++++++++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 21 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index e997b07..564dca0 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1042,19 +1042,75 @@ pub fn generate_translation_error<'i>( .. } => ( format!("Description out of place in procedure '{}'", name), - "A procedure's free-text description must appear immediately after the title and before any steps or code blocks.".to_string(), + r#" +A procedure's free-text description must appear immediately after the +title and before any steps or code blocks. + "# + .trim_ascii() + .to_string(), ), TranslationError::UnresolvedProcedure(Identifier { value: name, .. }) => ( 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(), + r#" +A `` invocation must refer to a procedure declared in this +document. Built-in functions use the `name(...)` form (without angle +brackets). + "# + .trim_ascii() + .to_string(), + ), + TranslationError::ProcedureArityMismatch { + procedure: Identifier { value: name, .. }, + expected, + actual, + } => ( + format!( + "Wrong number of arguments to <{}>. Expected {}, but called with {}", + name, expected, actual + ), + r#" +A parenthesised argument list must supply one argument for each input the +signature requires (or, absent a signature, each declared parameter). Omit +the parentheses entirely to defer every argument. + "# + .trim_ascii() + .to_string(), + ), + TranslationError::SignatureParameterMismatch { + procedure: Identifier { value: name, .. }, + parameters, + requires, + } => ( + format!( + "Parameter list of '{}' disagrees with its signature. {} names but {} required inputs", + name, parameters, requires + ), + r#" +When a procedure declares both a parameter list and a signature, the named +parameters must correspond one-to-one with the inputs the signature +requires. + "# + .trim_ascii() + .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(), + r#" +A `repeat` runs indefinitely and produces no value, so its result +cannot be bound to a variable. + "# + .trim_ascii() + .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(), + r#" +A `[...]` literal must be either a Tablet (every entry in the list a +`"label" = value` pair) or a List (entries are actual values in +sequence), not a mix of the two. + "# + .trim_ascii() + .to_string(), ), } } @@ -1148,7 +1204,8 @@ pub fn generate_runner_error(error: &RunnerError, _renderer: &dyn Render) -> (St match error { RunnerError::NoSuchRun(run_id) => ( format!("No such run '{:06}'", run_id.0), - "The directory for this run identifier was not found in the local state store.".to_string(), + "The directory for this run identifier was not found in the local state store." + .to_string(), ), RunnerError::StoreError { path, error } => ( format!("I/O error with local state store at {}", path.display()), @@ -1160,15 +1217,30 @@ pub fn generate_runner_error(error: &RunnerError, _renderer: &dyn Render) -> (St ), RunnerError::StartMissing(run_id) => ( format!("Start record missing in run '{:06}'", run_id.0), - "The state file is present but its first record (the Start event) is missing or malformed.".to_string(), + r#" +The state file is present but its first record (the Start event) is +missing or malformed. + "# + .trim_ascii() + .to_string(), ), RunnerError::InvalidRunId(text) => ( format!("Invalid run identifier '{}'", text), - "Run identifiers are integer values, conventionally rendered as six zero-padded digits.".to_string(), + r#" +Run identifiers are integer values, conventionally rendered as six +zero-padded digits. + "# + .trim_ascii() + .to_string(), ), RunnerError::MissingEntryProcedure => ( "No entry procedure".to_string(), - "The document has neither procedure declarations nor top-level steps so the runner can't start its walk.".to_string(), + r#" +The document has neither procedure declarations nor top-level steps so +the runner can't start its walk. + "# + .trim_ascii() + .to_string(), ), RunnerError::UnboundVariable(name) => ( format!("Unbound variable '{}'", name), @@ -1179,14 +1251,24 @@ pub fn generate_runner_error(error: &RunnerError, _renderer: &dyn Render) -> (St "Binding arity mismatch: {} names but {} values", expected, actual ), - "Binding multiple variables requires the procedure being invoked or function being called to return a tuple of the same size.".to_string(), + r#" +Binding multiple variables requires the procedure being invoked or +function being called to return a tuple of the same size. + "# + .trim_ascii() + .to_string(), ), RunnerError::BindNotTuple { expected } => ( format!( "Binding requires a tuple: {} names but the value is not a tuple", expected ), - "Binding multiple variables requires the procedure being invoked or function being called to return a tuple of the same size.".to_string(), + r#" +Binding multiple variables requires the procedure being invoked or +function being called to return a tuple of the same size. + "# + .trim_ascii() + .to_string(), ), RunnerError::ParameterArityMismatch { expected, actual } => ( format!( @@ -1196,7 +1278,9 @@ pub fn generate_runner_error(error: &RunnerError, _renderer: &dyn Render) -> (St 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(), + "# + .trim_ascii() + .to_string(), ), RunnerError::ParameterUnexpected { actual } => ( format!( @@ -1206,7 +1290,9 @@ 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 any parameters. - "#.trim_ascii().to_string(), + "# + .trim_ascii() + .to_string(), ), RunnerError::NotIterable => ( "Iteration requires a list".to_string(), @@ -1217,23 +1303,46 @@ 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(), + "# + .trim_ascii() + .to_string(), ), RunnerError::InvalidArgument { function, expected } => ( format!("Wrong argument type passed to {}()", function), - format!("The {}() function expected {} but was given something else.", function, expected), + format!( + "The {}() function expected {} but was given something else.", + function, expected + ), ), RunnerError::UnknownFunction(function) => ( format!("Unknown function {}()", function), - format!("The function {}() is undefined. This should have been caught during the linking phase!", function), + format!( + r#" +The function {}() is undefined. This should have been caught during the +linking phase! + "#, + function + ) + .trim_ascii() + .to_string(), ), - RunnerError::FunctionArityMismatch { function, expected, actual } => ( + RunnerError::FunctionArityMismatch { + function, + expected, + actual, + } => ( format!("Wrong number of arguments to {}()", function), - format!("The function {}() expects {} but {} given.", function, expected, actual), + format!( + "The function {}() expects {} but {} given.", + function, expected, actual + ), ), RunnerError::ExecError(error) => ( "Could not run external command".to_string(), - format!("Launching or reading from the external command failed: {}.", error), + format!( + "Launching or reading from the external command failed: {}.", + error + ), ), RunnerError::CommandFailed(code) => ( format!("External command exited with status {}", code), @@ -1241,7 +1350,15 @@ you can iterate over. ), 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), + format!( + r#" +Combining Values requires compatible kinds; a {} and a {} can't be added +together. + "#, + left, right + ) + .trim_ascii() + .to_string(), ), RunnerError::TerminalRequired => ( "Running interactively requires a terminal".to_string(), @@ -1250,11 +1367,18 @@ An interactive run writes its prompts to the terminal and reads user input direclty, so its output can't be redirected to a file or pipe. Use `technique run` in a terminal, or use `--mode=automatic` to run on auto; you can then safely redirect the output. - "#.trim_ascii().to_string(), + "# + .trim_ascii() + .to_string(), ), RunnerError::UserQuit => ( "Interrupted".to_string(), - "The user quit before the procedure was completed. Use `technique resume ` to continue.".to_string(), + r#" +The user quit before the procedure was completed. Use `technique resume +` to continue. + "# + .trim_ascii() + .to_string(), ), } } From 4cdd4de8564b919e96b16e8ea3e3eb5d6bad79f0 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Mon, 15 Jun 2026 11:58:16 +1000 Subject: [PATCH 4/8] Introduce hole for defer parameter values --- src/domain/engine.rs | 1 + src/formatting/formatter.rs | 3 +++ src/language/types.rs | 16 ++++++++++++++++ src/linking/linker.rs | 5 ++++- src/parsing/checks/parser.rs | 12 ++++++++++++ src/parsing/parser.rs | 4 ++++ src/program/types.rs | 1 + 7 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/domain/engine.rs b/src/domain/engine.rs index e1a7b2c..d7d9231 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -361,6 +361,7 @@ fn render_expression(expr: &Expression) -> String { .collect(); format!("[{}]", items.join(", ")) } + Expression::Hole(_) => "?".to_string(), Expression::Separator => String::new(), } } diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 84cc920..44becf0 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -1171,6 +1171,9 @@ impl<'i> Formatter<'i> { } Expression::Pair(pair, _) => self.append_pair(pair), Expression::List(elements, _) => self.append_list(elements), + Expression::Hole(_) => { + self.add_fragment_reference(Syntax::Variable, "?"); + } Expression::Separator => {} } } diff --git a/src/language/types.rs b/src/language/types.rs index e46e842..8036bf8 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -188,6 +188,20 @@ pub enum Genus<'i> { List(Forma<'i>), } +impl<'i> Genus<'i> { + /// The number of distinct values this genus carries. As the `requires` + /// of a procedure's signature this is the procedure's arity. + pub fn cardinality(&self) -> usize { + match self { + Genus::Unit => 0, + Genus::Single(_) => 1, + Genus::List(_) => 1, + Genus::Tuple(formas) => formas.len(), + Genus::Naked(formas) => formas.len(), + } + } +} + #[derive(Eq, Debug, PartialEq)] pub struct Signature<'i> { pub requires: Genus<'i>, @@ -418,6 +432,7 @@ pub enum Expression<'i> { Binding(Box>, Vec>, Span), Pair(Box>, Span), List(Vec>, Span), + Hole(Span), Separator, } @@ -441,6 +456,7 @@ impl PartialEq for Expression<'_> { } (Expression::Pair(a, _), Expression::Pair(b, _)) => a == b, (Expression::List(a, _), Expression::List(b, _)) => a == b, + (Expression::Hole(_), Expression::Hole(_)) => true, (Expression::Separator, Expression::Separator) => true, _ => false, } diff --git a/src/linking/linker.rs b/src/linking/linker.rs index 10e11dc..0479cf4 100644 --- a/src/linking/linker.rs +++ b/src/linking/linker.rs @@ -111,7 +111,10 @@ fn link_operation<'i>( link_operation(item, library, problems); } } - Operation::Variable(_) | Operation::Number(_) | Operation::Multiline(_, _) => {} + Operation::Variable(_) + | Operation::Number(_) + | Operation::Multiline(_, _) + | Operation::Hole => {} } } diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index fce7204..d559c3d 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -477,6 +477,18 @@ fn reading_invocations() { }) ); + // A `?` argument is a deferred value: it parses to a Hole in parameter + // position, satisfying the callee's arity without naming a value. + input.initialize("(?)"); + let result = input.read_invocation(); + assert_eq!( + result, + Ok(Invocation { + target: Target::Local(Identifier::new("resume")), + parameters: Some(vec![Expression::Hole(Span::default())]) + }) + ); + // We don't have real support for this yet, but syntactically we will // support the idea of invoking a procedure at an external URL, so we // have this case as a placeholder. diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index 7e1a739..b971a16 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -2627,6 +2627,10 @@ impl<'i> Parser<'i> { let decimal = outer.read_decimal_part()?; let span = outer.span_since(param_start); params.push(Expression::Number(Numeric::Integral(decimal.number), span)); + } else if content.starts_with('?') { + outer.advance(1); + let span = outer.span_since(param_start); + params.push(Expression::Hole(span)); } else { let name = outer.read_identifier()?; let span = name.span; diff --git a/src/program/types.rs b/src/program/types.rs index 7bb2a40..6515830 100644 --- a/src/program/types.rs +++ b/src/program/types.rs @@ -102,6 +102,7 @@ pub enum Operation<'i> { List(Vec>), Invoke(Invocable<'i>), Execute(Executable<'i>), + Hole, Sequence(Vec>), Section { numeral: &'i str, From 44c4e90969c96f8be8ca88d0e976b39853aedb1b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Mon, 15 Jun 2026 18:42:50 +1000 Subject: [PATCH 5/8] Acquire values for explicit or implied holes --- src/language/types.rs | 14 ++++ src/program/types.rs | 20 +++++ src/runner/checks/runner.rs | 102 +++++++++++++++--------- src/runner/driver.rs | 54 +++++++++++++ src/runner/evaluator.rs | 14 ++-- src/runner/runner.rs | 82 +++++++++++++++----- src/translation/checks/errors.rs | 72 +++++++++++++++++ src/translation/checks/translate.rs | 116 ++++++++++++++++++++++++++-- src/translation/translator.rs | 115 +++++++++++++++++++++++---- 9 files changed, 505 insertions(+), 84 deletions(-) diff --git a/src/language/types.rs b/src/language/types.rs index 8036bf8..b02d5a9 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -200,6 +200,20 @@ impl<'i> Genus<'i> { Genus::Naked(formas) => formas.len(), } } + + pub fn formae(&self) -> Vec<&Forma<'i>> { + match self { + Genus::Unit => Vec::new(), + Genus::Single(forma) => vec![forma], + Genus::List(forma) => vec![forma], + Genus::Tuple(formas) => formas + .iter() + .collect(), + Genus::Naked(formas) => formas + .iter() + .collect(), + } + } } #[derive(Eq, Debug, PartialEq)] diff --git a/src/program/types.rs b/src/program/types.rs index 6515830..81801da 100644 --- a/src/program/types.rs +++ b/src/program/types.rs @@ -67,6 +67,20 @@ impl<'i> Subroutine<'i> { } } + /// The number of arguments an invocation must supply. The signature's + /// `requires` is authoritative when present; otherwise the count falls + /// back to the named parameter list, or zero when neither is declared. + /// Translation guarantees the two agree when both are present. + pub fn arity(&self) -> usize { + match (self.signature, self.parameters) { + (Some(signature), _) => signature + .requires + .cardinality(), + (None, Some(parameters)) => parameters.len(), + (None, None) => 0, + } + } + /// Synthetic anonymous-wrapper procedure used when a document has no /// procedure shell (a top-level `Technique::Steps`-only document). pub fn anonymous() -> Self { @@ -143,6 +157,12 @@ pub enum Ordinal<'i> { pub struct Invocable<'i> { pub target: SubroutineRef<'i>, pub arguments: Vec>, + /// Elided is `true` when the invocation is make with no argument list at + /// all (the author wrote `` rather than `(x, y, z)`). A + /// bare invocation is the equivalent to an all-`?` list of the + /// target's arity — and so is passes the arity check. A parenthesised + /// list, including an empty `()`, must match the arity of the target. + pub elided: bool, } /// Reference to a subroutine. The collect pass registers every declared diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 1a9b0fe..5f05e39 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -643,6 +643,72 @@ test : ); } +#[test] +fn hole_argument_acquired_at_entry() { + // main invokes cycle with `?`, declining to supply the Situation. The + // operator is asked for `s` once, when cycle is entered, and the bound + // value serves both reads in the body. + let source = r#" +% technique v1 + +main : + +{ + (?) +} + +cycle(s) : Situation -> Done + +1. First { s ~ x } +2. Second { s ~ y } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("hole-at-entry"); + // acquire (for `?`) pops first at entry, then the two step completions. + let prompt = Mock::with_answers([ + UserInput::Done(Value::Literali("the situation".to_string())), + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + + let prompt = runner.into_driver(); + let acquired: Vec<(Option<&str>, Option<&str>)> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Acquire { name, forma } = e { + Some(( + name.as_ref() + .map(String::as_str), + forma + .as_ref() + .map(String::as_str), + )) + } else { + None + } + }) + .collect(); + assert_eq!( + acquired, + vec![(Some("s"), Some("Situation"))], + "asked once, at entry, for s : Situation" + ); +} + #[test] fn resolved_invoke_descends_into_subroutine() { let source = r#" @@ -829,41 +895,6 @@ peek : 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, - Library::stub(), - ); - let env = Environment::new(); - let Err(RunnerError::ParameterArityMismatch { expected, actual }) = runner.run(env) else { - panic!("expected ParameterArityMismatch"); - }; - assert_eq!(expected, 1); - assert_eq!(actual, 2); -} - #[test] fn execute_announces_function_call() { let source = r#" @@ -1938,6 +1969,7 @@ fn deferred_invoke_is_prompted_and_recorded() { let invoke = Operation::Invoke(Invocable { target: SubroutineRef::Deferred(external), arguments: Vec::new(), + elided: true, }); anonymous_with_body(Operation::Sequence(vec![invoke])) } diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 16659bc..a472a8e 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -96,6 +96,9 @@ pub trait Driver { /// `produced` is the scope's value, offered for acceptance. fn seal(&mut self, qualified: &str, produced: Value) -> UserInput; + /// Obtain a value for a deferred input when entering a procedure + fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> Value; + /// The syntax renderer for highlighting source fragments shown to the /// user. `Console` returns the ANSI `Terminal` renderer; non-interactive /// drivers return `Identity` (no markup). @@ -174,6 +177,30 @@ impl Driver for Console { prompt(&mut self.output, qualified, "↙", &[], produced) } + fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> Value { + let prompt = format!( + "{} ({} : {}) ", + qualified, + name.unwrap_or("?"), + forma.unwrap_or("?") + ); + let _ = write!(self.output, "{}", prompt.dark_grey()); + let _ = self + .output + .flush(); + let mut line = String::new(); + if io::stdin() + .read_line(&mut line) + .is_err() + { + return Value::Unitus; + } + Value::Literali( + line.trim_end() + .to_string(), + ) + } + fn renderer(&self) -> &'static dyn Render { &Terminal } @@ -944,6 +971,10 @@ impl Driver for Automatic { UserInput::Done(produced) } + fn acquire(&mut self, _qualified: &str, _name: Option<&str>, _forma: Option<&str>) -> Value { + Value::Unitus + } + fn renderer(&self) -> &'static dyn Render { &Identity } @@ -997,6 +1028,10 @@ impl Driver for Headless { UserInput::Done(produced) } + fn acquire(&mut self, _qualified: &str, _name: Option<&str>, _forma: Option<&str>) -> Value { + Value::Unitus + } + fn renderer(&self) -> &'static dyn Render { &Identity } @@ -1045,6 +1080,10 @@ pub enum Event { Seal { qualified: String, }, + Acquire { + name: Option, + forma: Option, + }, } #[cfg(test)] @@ -1158,6 +1197,21 @@ impl Driver for Mock { UserInput::Done(Value::Unitus) } + fn acquire(&mut self, _qualified: &str, name: Option<&str>, forma: Option<&str>) -> Value { + self.events + .push(Event::Acquire { + name: name.map(|n| n.to_string()), + forma: forma.map(|f| f.to_string()), + }); + match self + .answers + .pop_front() + { + Some(UserInput::Done(value)) => value, + _ => Value::Unitus, + } + } + fn renderer(&self) -> &'static dyn Render { &Identity } diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index 3fa1a82..3850088 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -122,11 +122,12 @@ pub fn evaluate<'i>( for fragment in fragments { match fragment { Fragment::Text(t) => text.push_str(t), - Fragment::Interpolation(inner) => match evaluate(library, context, env, inner)? - { - Value::Literali(s) => text.push_str(&s), - other => text.push_str(&other.to_string()), - }, + Fragment::Interpolation(inner) => { + match evaluate(library, context, env, inner)? { + Value::Literali(s) => text.push_str(&s), + other => text.push_str(&other.to_string()), + } + } } } Ok(Value::Literali(text)) @@ -165,6 +166,9 @@ pub fn evaluate<'i>( Ok(last) } Operation::Execute(executable) => dispatch(library, context, env, executable, None), + // A `?` reached outside a procedure invocation has no parameter name + // to defer against; it stands for an as-yet-unsupplied value. + Operation::Hole => Ok(Value::Futurae(String::new())), Operation::Section { .. } | Operation::Step { .. } | Operation::Loop { .. } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index dffaa1d..3e817df 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -338,7 +338,8 @@ impl<'i, D: Driver> Runner<'i, D> { | Operation::String(_) | Operation::Multiline(_, _) | Operation::Tablet(_) - | Operation::List(_) => { + | Operation::List(_) + | Operation::Hole => { let value = super::evaluator::evaluate(&self.library, &self.context, env, op)?; Ok(Outcome::Done(value)) } @@ -362,7 +363,7 @@ impl<'i, D: Driver> Runner<'i, D> { /// The shell script an `exec` will run, rendered for the operator to see /// before they command it. The command's first argument is the script. fn script_text( - &self, + &mut self, env: &mut Environment, executable: &'i Executable<'i>, ) -> Result { @@ -398,30 +399,21 @@ impl<'i, D: Driver> Runner<'i, D> { let params = subroutine .parameters .unwrap_or(&[]); - let expected = params.len(); + let expected = subroutine.arity(); let actual = invocable .arguments .len(); - if expected == 0 && actual > 0 { - return Err(RunnerError::ParameterUnexpected { actual }); - } - if expected != actual { - return Err(RunnerError::ParameterArityMismatch { expected, actual }); + // A bare call defers every argument and is exempt; a written + // argument list must match arity exactly. + if !invocable.elided { + 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(&self.library, &self.context, env, arg)?; - local.extend( - param - .value - .to_string(), - value, - ); - } - let name = subroutine .name .as_ref() @@ -460,6 +452,53 @@ impl<'i, D: Driver> Runner<'i, D> { self.path .push(PathSegment::Procedure(name)); + + let formae = subroutine + .signature + .map(|s| { + s.requires + .formae() + }) + .unwrap_or_default(); + if invocable.elided { + for i in 0..subroutine.arity() { + let bind = params + .get(i) + .map(|p| p.value); + let forma = formae + .get(i) + .map(|f| f.value); + let value = self + .driver + .acquire(&descended, bind, forma); + if let Some(bind) = bind { + local.extend(bind.to_string(), value); + } + } + } else { + for (i, arg) in invocable + .arguments + .iter() + .enumerate() + { + let bind = params + .get(i) + .map(|p| p.value); + let value = if let Operation::Hole = arg { + let forma = formae + .get(i) + .map(|f| f.value); + self.driver + .acquire(&descended, bind, forma) + } else { + super::evaluator::evaluate(&self.library, &self.context, env, arg)? + }; + if let Some(bind) = bind { + local.extend(bind.to_string(), value); + } + } + } + self.driver .enter(&descended); let declaration = crate::formatting::formatter::render_declaration( @@ -471,6 +510,7 @@ impl<'i, D: Driver> Runner<'i, D> { ); self.driver .display(&declaration); + if let Some(t) = subroutine.title { let title_text = crate::formatting::formatter::render_title( t, diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 707c176..57fc107 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -36,6 +36,78 @@ make_coffee : ); } +#[test] +fn procedure_arity_mismatch() { + // observe's arity is 1 (one signature input). A bare `` would + // defer it, but a written argument list must match exactly — two arguments + // is a mismatch. + let source = r#" +% technique v1 + +main : + +{ + (a, b) +} + +observe : Situation -> Context + "# + .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::ProcedureArityMismatch { + procedure, + expected, + actual, + } = &errors[0] + else { + panic!("expected ProcedureArityMismatch, got {:?}", errors[0]); + }; + assert_eq!(procedure.value, "observe"); + assert_eq!(*expected, 1); + assert_eq!(*actual, 2); + assert_eq!( + procedure + .span + .offset, + source + .find("observe") + .expect("observe in source"), + "span points at the invocation" + ); +} + +#[test] +fn signature_parameter_mismatch() { + // The parameter list names two inputs but the signature requires only + // one, so the declaration disagrees with itself. + let source = r#" +% technique v1 + +brew(beans, water) : Beans -> Coffee + "# + .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::SignatureParameterMismatch { + procedure, + parameters, + requires, + } = &errors[0] + else { + panic!("expected SignatureParameterMismatch, got {:?}", errors[0]); + }; + assert_eq!(procedure.value, "brew"); + assert_eq!(*parameters, 2); + assert_eq!(*requires, 1); +} + #[test] fn bound_repeat() { let source = r#" diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index d078f47..363bbce 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -8,7 +8,7 @@ use std::path::Path; use crate::language; use crate::parsing; use crate::program::{ExecutableRef, Fragment, Operation, Ordinal, SubroutineId, SubroutineRef}; -use crate::translation::translate; +use crate::translation::{translate, TranslationError}; #[test] fn empty_input_yields_empty_program() { @@ -752,6 +752,105 @@ other : X -> Y ); } +#[test] +fn hole_argument_satisfies_arity_and_translates() { + // `?` fills the single argument slot the signature requires, so the call + // type-checks, and the argument lowers to Operation::Hole. + let source = r#" +% technique v1 + +run : + +{ + (?) +} + +other : X -> Y + "# + .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::Invoke(invocable) = &ops[0] else { + panic!("expected Invoke, got {:?}", ops[0]); + }; + let [Operation::Hole] = invocable + .arguments + .as_slice() + else { + panic!( + "expected a single Hole argument, got {:?}", + invocable.arguments + ); + }; +} + +#[test] +fn bare_call_is_exempt_from_arity() { + // A bare `` (no argument list) defers all arguments, so it passes + // against an arity-1 procedure and is marked elided with no arguments. + let source = r#" +% technique v1 + +run : + +{ + +} + +other : X -> Y + "# + .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::Invoke(invocable) = &ops[0] else { + panic!("expected Invoke, got {:?}", ops[0]); + }; + assert!(invocable.elided, "a bare call is elided"); + assert!(invocable + .arguments + .is_empty()); +} + +#[test] +fn empty_parens_must_match_arity() { + // `()` writes an explicit (empty) list, so it must match arity — a + // mismatch against arity 1. + let source = r#" +% technique v1 + +run : + +{ + () +} + +other : X -> Y + "# + .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"); + + let TranslationError::ProcedureArityMismatch { + expected, actual, .. + } = &errors[0] + else { + panic!("expected ProcedureArityMismatch, got {:?}", errors[0]); + }; + assert_eq!(*expected, 1); + assert_eq!(*actual, 0); +} + #[test] fn expression_binding_translates() { let source = r#" @@ -1447,9 +1546,9 @@ run : #[test] fn expression_repeat_translates() { - // A bare `{ repeat }` with no subscopes to supply its body translates to a - // Loop with names=[], over=None, and an empty Sequence body — the body is - // provided by the code block's subscopes, as for `foreach` above. + // `repeat ` loops over ``: the inline expression is the loop + // body. Here `repeat 5` translates to a Loop with names=[], over=None, and + // a body holding the repeated expression. let source = r#" % technique v1 @@ -1480,9 +1579,14 @@ run : assert!(over.is_none(), "repeat has no `over` source"); assert!(responses.is_empty()); let Operation::Sequence(inner) = body.as_ref() else { - panic!("expected empty Sequence body"); + panic!("expected Sequence body"); + }; + let [Operation::Number(_)] = inner.as_slice() else { + panic!( + "expected the repeated expression in the body, got {:?}", + inner + ); }; - assert!(inner.is_empty()); } #[test] diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 94ac0c1..7e3aa52 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -64,6 +64,21 @@ 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>), + /// A procedure invocation `(...)` whose argument count doesn't match + /// the arity of the procedure it resolves to. + ProcedureArityMismatch { + procedure: language::Identifier<'i>, + expected: usize, + actual: usize, + }, + /// A procedure declaring both a parameter list and a signature whose + /// required inputs disagree in count. The names must correspond one-to-one + /// with the signature's inputs. + SignatureParameterMismatch { + procedure: language::Identifier<'i>, + parameters: usize, + requires: usize, + }, /// 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 @@ -86,6 +101,8 @@ impl<'i> TranslationError<'i> { TranslationError::DuplicateTitle { at, .. } => *at, TranslationError::InterleavedDescription { at, .. } => *at, TranslationError::UnresolvedProcedure(id) => id.span, + TranslationError::ProcedureArityMismatch { procedure, .. } => procedure.span, + TranslationError::SignatureParameterMismatch { procedure, .. } => procedure.span, TranslationError::BoundRepeat { at } => *at, TranslationError::HeterogenousList { at } => *at, } @@ -223,6 +240,20 @@ impl<'i> Translator<'i> { .as_ref(); entry.body = body; entry.responses = responses; + + if let (Some(parameters), Some(signature)) = (&procedure.parameters, &procedure.signature) { + let requires = signature + .requires + .cardinality(); + if parameters.len() != requires { + self.problems + .push(TranslationError::SignatureParameterMismatch { + procedure: procedure.name, + parameters: parameters.len(), + requires, + }); + } + } } fn translate_scope( @@ -257,6 +288,7 @@ impl<'i> Translator<'i> { body_ops.push(Operation::Invoke(Invocable { target: SubroutineRef::Unresolved(procedure.name), arguments: Vec::new(), + elided: true, })); } } @@ -363,8 +395,10 @@ impl<'i> Translator<'i> { responses, }) } - language::Expression::Repeat(_, _) => { - let mut body_ops = Vec::new(); + language::Expression::Repeat(inner, _) => { + // `repeat ` does `` over and over: the inline + // expression is the loop body. Any subscopes follow it. + let mut body_ops = vec![self.translate_expression(inner)]; let mut responses = Vec::new(); for sub in subscopes { self.append_attributes(&mut body_ops, &mut responses, sub, attrs); @@ -447,6 +481,7 @@ impl<'i> Translator<'i> { | language::Expression::Application(..) | language::Expression::Execution(..) | language::Expression::Binding(..) + | language::Expression::Hole(..) | language::Expression::Separator => None, }, language::Descriptive::Application(_) => None, @@ -582,68 +617,106 @@ impl<'i> Translator<'i> { // Pass 1 become Resolved(SubroutineId); unmatched local references // are errors. fn resolve_references(&mut self) { + // Arity of every declared subroutine, indexed by SubroutineId, so an + // Invoke's argument count can be checked against the procedure it + // resolves to. + let arities: Vec = self + .program + .subroutines + .iter() + .map(Subroutine::arity) + .collect(); for subroutine in &mut self .program .subroutines { - Self::resolve_operation(&mut subroutine.body, &self.known, &mut self.problems); + Self::resolve_operation( + &mut subroutine.body, + &self.known, + &arities, + &mut self.problems, + ); } } fn resolve_operation( op: &mut Operation<'i>, known: &HashMap<&'i str, SubroutineId>, + arities: &[usize], problems: &mut Vec>, ) { match op { Operation::Invoke(invocable) => { if let SubroutineRef::Unresolved(id) = &invocable.target { match known.get(id.value) { - Some(sub_id) => invocable.target = SubroutineRef::Resolved(*sub_id), + Some(sub_id) => { + // A bare call (``) defers all arguments, so + // it is exempt; a parenthesised list must match the + // procedure's arity exactly. + let expected = arities[sub_id.0]; + let actual = invocable + .arguments + .len(); + if !invocable.elided && expected != actual { + problems.push(TranslationError::ProcedureArityMismatch { + procedure: *id, + expected, + actual, + }); + } + invocable.target = SubroutineRef::Resolved(*sub_id); + } None => problems.push(TranslationError::UnresolvedProcedure(*id)), } } for arg in &mut invocable.arguments { - Self::resolve_operation(arg, known, problems); + Self::resolve_operation(arg, known, arities, problems); } } Operation::Sequence(ops) => { for op in ops { - Self::resolve_operation(op, known, problems); + Self::resolve_operation(op, known, arities, problems); } } - Operation::Section { body, .. } => Self::resolve_operation(body, known, problems), - Operation::Step { body, .. } => Self::resolve_operation(body, known, problems), + Operation::Section { body, .. } => { + Self::resolve_operation(body, known, arities, problems) + } + Operation::Step { body, .. } => Self::resolve_operation(body, known, arities, problems), Operation::Loop { over, body, .. } => { if let Some(over) = over { - Self::resolve_operation(over, known, problems); + Self::resolve_operation(over, known, arities, problems); } - Self::resolve_operation(body, known, problems); + Self::resolve_operation(body, known, arities, problems); + } + Operation::Bind { value, .. } => { + Self::resolve_operation(value, known, arities, problems) } - Operation::Bind { value, .. } => Self::resolve_operation(value, known, problems), Operation::Execute(executable) => { for arg in &mut executable.arguments { - Self::resolve_operation(arg, known, problems); + Self::resolve_operation(arg, known, arities, problems); } } Operation::String(fragments) => { for fragment in fragments { if let Fragment::Interpolation(op) = fragment { - Self::resolve_operation(op, known, problems); + Self::resolve_operation(op, known, arities, problems); } } } Operation::Tablet(entries) => { for entry in entries { - Self::resolve_operation(&mut entry.value, known, problems); + Self::resolve_operation(&mut entry.value, known, arities, problems); } } Operation::List(items) => { for item in items { - Self::resolve_operation(item, known, problems); + Self::resolve_operation(item, known, arities, problems); } } - Operation::Variable(_) | Operation::Number(_) | Operation::Multiline(_, _) => {} + Operation::Variable(_) + | Operation::Number(_) + | Operation::Multiline(_, _) + | Operation::Hole => {} } } @@ -762,6 +835,7 @@ impl<'i> Translator<'i> { value: Box::new(self.translate_expression(value)), } } + language::Expression::Hole(_) => Operation::Hole, language::Expression::Separator => Operation::Sequence(Vec::new()), } } @@ -771,6 +845,9 @@ impl<'i> Translator<'i> { language::Target::Local(id) => SubroutineRef::Unresolved(*id), language::Target::Remote(external) => SubroutineRef::Deferred(*external), }; + let elided = invocation + .parameters + .is_none(); let arguments = match &invocation.parameters { Some(params) => params .iter() @@ -778,6 +855,10 @@ impl<'i> Translator<'i> { .collect(), None => Vec::new(), }; - Invocable { target, arguments } + Invocable { + target, + arguments, + elided, + } } } From 9b06e595ed5668d98e836d1d0a87b8baeb6b5be9 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Mon, 15 Jun 2026 23:21:46 +1000 Subject: [PATCH 6/8] Prompt for implicit arguments on invoke --- src/program/types.rs | 11 +++++ src/runner/checks/runner.rs | 13 +++--- src/runner/driver.rs | 42 ++++++++++++-------- src/runner/path.rs | 27 ++++++++----- src/runner/runner.rs | 75 ++++++++++++++++++----------------- src/translation/translator.rs | 37 +++++++++++++---- 6 files changed, 128 insertions(+), 77 deletions(-) diff --git a/src/program/types.rs b/src/program/types.rs index 81801da..b6258cb 100644 --- a/src/program/types.rs +++ b/src/program/types.rs @@ -38,6 +38,13 @@ impl<'i> Program<'i> { #[derive(Debug, Eq, PartialEq, Clone, Copy)] pub struct SubroutineId(pub usize); +/// One link in a subroutine's lexical address: an enclosing procedure or section. +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum Locale<'i> { + Procedure(&'i str), + Section(&'i str), +} + #[derive(Debug, Eq, PartialEq)] pub struct Subroutine<'i> { /// If this is a synthetic wrapper around a top-level `Technique::Steps` @@ -49,6 +56,8 @@ pub struct Subroutine<'i> { pub signature: Option<&'i language::Signature<'i>>, pub body: Operation<'i>, pub responses: Vec<&'i language::Response<'i>>, + /// Where this procedure is declared, root outward. + pub locale: Vec>, } impl<'i> Subroutine<'i> { @@ -64,6 +73,7 @@ impl<'i> Subroutine<'i> { signature: None, body: Operation::Sequence(Vec::new()), responses: Vec::new(), + locale: Vec::new(), } } @@ -92,6 +102,7 @@ impl<'i> Subroutine<'i> { signature: None, body: Operation::Sequence(Vec::new()), responses: Vec::new(), + locale: Vec::new(), } } } diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 5f05e39..db0cc44 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -308,7 +308,7 @@ helper : .count(); let begins = pfftt .lines() - .filter(|line| line.contains("/main:/helper:/1 Begin")) + .filter(|line| line.contains("/helper:/1 Begin")) .count(); assert_eq!(invokes, 2, "both call sites should invoke helper"); assert_eq!(begins, 2, "helper's body should run on both calls"); @@ -754,10 +754,10 @@ helper : } }) .collect(); - // The Step inside `helper` was reached and prompted, with the full - // enclosing hierarchy in the FQN: the entry `main` frame, the - // invoked `helper` frame, then the step. - assert_eq!(step_fqns, vec!["/main:/helper:/1"]); + // The Step inside `helper` was reached and prompted. `helper` is a + // top-level procedure, so its lexical address is its own root — the + // call from `main` does not nest it under the call site. + assert_eq!(step_fqns, vec!["/helper:/1"]); } #[test] @@ -855,7 +855,8 @@ greet(name) : }) .collect(); // The step renders from its source paragraph, showing the template. - assert_eq!(steps, vec![("/main:/greet:/1", "1. Hello { name }")]); + // `greet` is a top-level procedure, addressed at its own root. + assert_eq!(steps, vec![("/greet:/1", "1. Hello { name }")]); } #[test] diff --git a/src/runner/driver.rs b/src/runner/driver.rs index a472a8e..4c08a55 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -178,27 +178,13 @@ impl Driver for Console { } fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> Value { - let prompt = format!( - "{} ({} : {}) ", + let label = format!( + "{}({} : {})", qualified, name.unwrap_or("?"), forma.unwrap_or("?") ); - let _ = write!(self.output, "{}", prompt.dark_grey()); - let _ = self - .output - .flush(); - let mut line = String::new(); - if io::stdin() - .read_line(&mut line) - .is_err() - { - return Value::Unitus; - } - Value::Literali( - line.trim_end() - .to_string(), - ) + prompt_acquire(&mut self.output, &label) } fn renderer(&self) -> &'static dyn Render { @@ -306,6 +292,28 @@ fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserI result } +/// Solicit a deferred input on the `▶` prompt line. The field starts empty: +/// `` accepts the empty default, typing overrides it. +fn prompt_acquire(out: &mut W, label: &str) -> Value { + let field = edit(String::new(), Value::Literali(String::new())); + let result = interact( + out, + label, + "↘", + Interaction { + field, + menu: None, + reason: None, + }, + ); + let _ = queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine)); + let _ = out.flush(); + match result { + UserInput::Done(value) => value, + _ => Value::Unitus, + } +} + /// Drive one raw-mode interaction to a settled `UserInput`, leaving the prompt /// row cleared. Shared by the step/scope prompt and the exec command gate; the /// caller writes whatever record line it wants afterward. diff --git a/src/runner/path.rs b/src/runner/path.rs index b4fced7..cdecad7 100644 --- a/src/runner/path.rs +++ b/src/runner/path.rs @@ -47,6 +47,11 @@ impl<'i> QualifiedPath<'i> { .pop() } + /// Swap in a fresh set of segments, returning the displaced ones. + pub fn replace(&mut self, segments: Vec>) -> Vec> { + std::mem::replace(&mut self.segments, segments) + } + /// Render the current path as a PFFTT absolute path string, always /// rooted at `/`. The full enclosing hierarchy is shown: every scope /// level is its own `/`-delimited component, with a `Procedure` @@ -58,18 +63,22 @@ impl<'i> QualifiedPath<'i> { /// 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 pieces: Vec = self - .segments - .iter() - .filter_map(render_segment) - .collect(); - - let mut text = String::from("/"); - text.push_str(&pieces.join("/")); - text + render_path(&self.segments) } } +/// Render a segment slice as a PFFTT absolute path, always rooted at `/`. +pub fn render_path(segments: &[PathSegment]) -> String { + let pieces: Vec = segments + .iter() + .filter_map(render_segment) + .collect(); + + let mut text = String::from("/"); + text.push_str(&pieces.join("/")); + text +} + fn render_segment(segment: &PathSegment) -> Option { match segment { PathSegment::Section(numeral) => Some(numeral.to_string()), diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 3e817df..39e9c6c 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -14,7 +14,7 @@ use super::state::{ }; use crate::language; use crate::program::{ - Executable, ExecutableRef, Invocable, Operation, Ordinal, Program, SubroutineRef, + Executable, ExecutableRef, Invocable, Locale, Operation, Ordinal, Program, SubroutineRef, }; use crate::value::Value; @@ -419,23 +419,25 @@ impl<'i, D: Driver> Runner<'i, D> { .as_ref() .map(|n| n.value); if let Some(name) = name { - self.path - .push(PathSegment::Procedure(name)); - let descended = self - .path - .render(); + // Steps record under the callee's lexical address, not the + // call site they were reached from. + let lexical_segments: Vec = subroutine + .locale + .iter() + .map(|locale| match *locale { + Locale::Procedure(n) => PathSegment::Procedure(n), + Locale::Section(n) => PathSegment::Section(n), + }) + .collect(); + let lexical = super::path::render_path(&lexical_segments); if self .completed - .contains(&descended) + .contains(&lexical) { - self.path - .pop(); return Ok(Outcome::Done(Value::Unitus)); } - self.path - .pop(); - let qualified = self + let caller = self .path .render(); let run_id = self @@ -444,14 +446,15 @@ impl<'i, D: Driver> Runner<'i, D> { let record = Record { recorded: now_iso8601(), run_id, - path: qualified, + path: caller.clone(), state: State::Invoke(InvokeTarget::Procedure(name.to_string())), }; self.appender .append(&record)?; - self.path - .push(PathSegment::Procedure(name)); + // Deferred arguments are acquired at the call site, in the + // invocation's source `` form, before descending. + let invoked = format!("{} <{}>", caller, name); let formae = subroutine .signature @@ -470,7 +473,7 @@ impl<'i, D: Driver> Runner<'i, D> { .map(|f| f.value); let value = self .driver - .acquire(&descended, bind, forma); + .acquire(&invoked, bind, forma); if let Some(bind) = bind { local.extend(bind.to_string(), value); } @@ -489,7 +492,7 @@ impl<'i, D: Driver> Runner<'i, D> { .get(i) .map(|f| f.value); self.driver - .acquire(&descended, bind, forma) + .acquire(&invoked, bind, forma) } else { super::evaluator::evaluate(&self.library, &self.context, env, arg)? }; @@ -499,8 +502,14 @@ impl<'i, D: Driver> Runner<'i, D> { } } + // Descend onto the callee's lexical address, restoring the + // call site on return. + let caller = self + .path + .replace(lexical_segments); self.driver - .enter(&descended); + .enter(&lexical); + let declaration = crate::formatting::formatter::render_declaration( name, subroutine.parameters, @@ -532,29 +541,21 @@ impl<'i, D: Driver> Runner<'i, D> { self.driver .display(&description); } - } - - // 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); - // An invoked procedure is a structural scope: the operator signs - // it off at its close, like a Section, before control returns to - // the caller. A Quit or error walk skips the prompt — the - // procedure did not complete. - if name.is_some() { - let qualified = self - .path - .render(); - self.path - .pop(); - match result { + // Walk the callee's body in its own `local` environment, + // then sign off its scope; a Quit or error skips the + // sign-off, leaving the procedure unfinished. + let result = self.walk(&mut local, &subroutine.body); + let sealed = match result { Ok(Outcome::Stopped) => Ok(Outcome::Stopped), - Ok(outcome) => self.seal_scope(&qualified, outcome), + Ok(outcome) => self.seal_scope(&lexical, outcome), Err(error) => Err(error), - } + }; + self.path + .replace(caller); + sealed } else { - result + self.walk(&mut local, &subroutine.body) } } SubroutineRef::Unresolved(id) => { diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 7e3aa52..d92bfda 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -6,8 +6,8 @@ use crate::language; use crate::language::{Document, Span}; use crate::program::{ - Entry, Executable, ExecutableRef, Fragment, Invocable, Operation, Ordinal, Program, Subroutine, - SubroutineId, SubroutineRef, + Entry, Executable, ExecutableRef, Fragment, Invocable, Locale, Operation, Ordinal, Program, + Subroutine, SubroutineId, SubroutineRef, }; pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { @@ -113,6 +113,7 @@ struct Translator<'i> { program: Program<'i>, problems: Vec>, known: HashMap<&'i str, SubroutineId>, + locus: Vec>, } impl<'i> Translator<'i> { @@ -121,6 +122,7 @@ impl<'i> Translator<'i> { program: Program::new(), problems: Vec::new(), known: HashMap::new(), + locus: Vec::new(), } } @@ -139,10 +141,16 @@ impl<'i> Translator<'i> { } // Element::Steps scopes may contain Sections, whose - // bodies can in turn declare further procedures. Walk - // the procedure's scopes so those nested declarations - // are discovered; the walk is independent of whether - // this procedure itself was a duplicate. + // bodies can in turn declare further procedures. Walk the + // procedure's scopes so those nested declarations are + // discovered, with this procedure on the lexical prefix so + // their addresses descend from it. + self.locus + .push(Locale::Procedure( + procedure + .name + .value, + )); for element in &procedure.elements { if let language::Element::Steps(scopes, _) = element { for scope in scopes { @@ -150,6 +158,8 @@ impl<'i> Translator<'i> { } } } + self.locus + .pop(); } } language::Technique::Steps(scopes) => { @@ -187,9 +197,16 @@ impl<'i> Translator<'i> { ); self.known .insert(name, id); + let mut subroutine = Subroutine::new(procedure.name); + subroutine.locale = self + .locus + .iter() + .cloned() + .chain(std::iter::once(Locale::Procedure(name))) + .collect(); self.program .subroutines - .push(Subroutine::new(procedure.name)); + .push(subroutine); Some(id) } @@ -597,8 +614,12 @@ impl<'i> Translator<'i> { fn collect_scope(&mut self, scope: &'i language::Scope<'i>) { match scope { - language::Scope::SectionChunk { body, .. } => { + language::Scope::SectionChunk { numeral, body, .. } => { + self.locus + .push(Locale::Section(numeral)); self.collect_technique(body); + self.locus + .pop(); } language::Scope::DependentBlock { subscopes, .. } | language::Scope::ParallelBlock { subscopes, .. } From e0bebd0aaf07486871771459f8486aa35dd357e3 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Jun 2026 00:07:34 +1000 Subject: [PATCH 7/8] Correct Quit and interrupt behaviour --- src/runner/checks/runner.rs | 113 ++++++++++++++++++++++++++++++ src/runner/driver.rs | 47 +++++++------ src/runner/runner.rs | 133 +++++++++++++++++++----------------- 3 files changed, 210 insertions(+), 83 deletions(-) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index db0cc44..c5a41bc 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -709,6 +709,119 @@ cycle(s) : Situation -> Done ); } +#[test] +fn quit_while_acquiring_stops_the_run() { + // Ctrl-C at the implicit-argument prompt quits the run rather than + // accepting an empty value: the walk stops and a Stop event is recorded, + // and the callee's body never runs. + let source = r#" +% technique v1 + +main : + +{ + (?) +} + +cycle(s) : Situation -> Done + +1. First { s } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("quit-acquire"); + let prompt = Mock::with_answers([UserInput::Quit]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + assert_eq!(outcome, Outcome::Stopped); + + let pfftt = fixture.pfftt_contents(); + assert!( + pfftt + .lines() + .any(|line| line.contains(" / Stop")), + "a Stop lifecycle event is recorded at the root" + ); + assert!( + !pfftt + .lines() + .any(|line| line.contains("/cycle:/1 Begin")), + "the callee's body must not run after the quit" + ); + assert!( + !pfftt + .lines() + .any(|line| line.contains("Invoke")), + "declining at the prompt records no Invoke — the call never began" + ); +} + +#[test] +fn skip_while_acquiring_records_the_skipped_invocation() { + // Skip at the implicit-argument prompt skips the invocation: a Skip is + // recorded at the callee's path (not silently swallowed) with no Invoke, + // and the callee's body never runs. + let source = r#" +% technique v1 + +main : + +{ + (?) +} + +cycle(s) : Situation -> Done + +1. First { s } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("skip-acquire"); + let prompt = Mock::with_answers([UserInput::Skip]); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + + let pfftt = fixture.pfftt_contents(); + assert!( + pfftt + .lines() + .any(|line| line.contains("/cycle: Skip")), + "the skipped invocation is recorded at the callee's path" + ); + assert!( + !pfftt + .lines() + .any(|line| line.contains("Invoke")), + "a declined call records no Invoke" + ); + assert!( + !pfftt + .lines() + .any(|line| line.contains("/cycle:/1 Begin")), + "the callee's body never runs" + ); +} + #[test] fn resolved_invoke_descends_into_subroutine() { let source = r#" diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 4c08a55..f5f5af4 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -96,8 +96,9 @@ pub trait Driver { /// `produced` is the scope's value, offered for acceptance. fn seal(&mut self, qualified: &str, produced: Value) -> UserInput; - /// Obtain a value for a deferred input when entering a procedure - fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> Value; + /// Obtain a value for a deferred input: `Done` supplies it, Skip / Fail + /// abandon the call, Quit stops the run. + fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput; /// The syntax renderer for highlighting source fragments shown to the /// user. `Console` returns the ANSI `Terminal` renderer; non-interactive @@ -177,7 +178,7 @@ impl Driver for Console { prompt(&mut self.output, qualified, "↙", &[], produced) } - fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> Value { + fn acquire(&mut self, qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { let label = format!( "{}({} : {})", qualified, @@ -292,9 +293,10 @@ fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserI result } -/// Solicit a deferred input on the `▶` prompt line. The field starts empty: -/// `` accepts the empty default, typing overrides it. -fn prompt_acquire(out: &mut W, label: &str) -> Value { +/// Solicit a deferred input on the `▶` prompt line: `` accepts the +/// empty default, typing overrides it; the `` menu and `` abandon +/// the call. +fn prompt_acquire(out: &mut W, label: &str) -> UserInput { let field = edit(String::new(), Value::Literali(String::new())); let result = interact( out, @@ -308,10 +310,7 @@ fn prompt_acquire(out: &mut W, label: &str) -> Value { ); let _ = queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine)); let _ = out.flush(); - match result { - UserInput::Done(value) => value, - _ => Value::Unitus, - } + result } /// Drive one raw-mode interaction to a settled `UserInput`, leaving the prompt @@ -979,8 +978,13 @@ impl Driver for Automatic { UserInput::Done(produced) } - fn acquire(&mut self, _qualified: &str, _name: Option<&str>, _forma: Option<&str>) -> Value { - Value::Unitus + fn acquire( + &mut self, + _qualified: &str, + _name: Option<&str>, + _forma: Option<&str>, + ) -> UserInput { + UserInput::Done(Value::Unitus) } fn renderer(&self) -> &'static dyn Render { @@ -1036,8 +1040,13 @@ impl Driver for Headless { UserInput::Done(produced) } - fn acquire(&mut self, _qualified: &str, _name: Option<&str>, _forma: Option<&str>) -> Value { - Value::Unitus + fn acquire( + &mut self, + _qualified: &str, + _name: Option<&str>, + _forma: Option<&str>, + ) -> UserInput { + UserInput::Done(Value::Unitus) } fn renderer(&self) -> &'static dyn Render { @@ -1205,19 +1214,15 @@ impl Driver for Mock { UserInput::Done(Value::Unitus) } - fn acquire(&mut self, _qualified: &str, name: Option<&str>, forma: Option<&str>) -> Value { + fn acquire(&mut self, _qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { self.events .push(Event::Acquire { name: name.map(|n| n.to_string()), forma: forma.map(|f| f.to_string()), }); - match self - .answers + self.answers .pop_front() - { - Some(UserInput::Done(value)) => value, - _ => Value::Unitus, - } + .unwrap_or(UserInput::Done(Value::Unitus)) } fn renderer(&self) -> &'static dyn Render { diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 39e9c6c..912b381 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -307,17 +307,7 @@ impl<'i, D: Driver> Runner<'i, D> { } UserInput::Skip => Ok(Outcome::Skipped), UserInput::Fail(reason) => Ok(Outcome::Failed(Failure::Aborted(reason))), - UserInput::Quit => { - let suspend = Record { - recorded: now_iso8601(), - run_id, - path: "/".to_string(), - state: State::Stop, - }; - self.appender - .append(&suspend)?; - Ok(Outcome::Stopped) - } + UserInput::Quit => self.record_stop(), } } else { self.driver @@ -437,23 +427,11 @@ impl<'i, D: Driver> Runner<'i, D> { return Ok(Outcome::Done(Value::Unitus)); } + // Acquire deferred arguments at the call site, in the + // invocation's `` form, before any Invoke is recorded. let caller = self .path .render(); - let run_id = self - .appender - .run_id(); - let record = Record { - recorded: now_iso8601(), - run_id, - path: caller.clone(), - state: State::Invoke(InvokeTarget::Procedure(name.to_string())), - }; - self.appender - .append(&record)?; - - // Deferred arguments are acquired at the call site, in the - // invocation's source `` form, before descending. let invoked = format!("{} <{}>", caller, name); let formae = subroutine @@ -471,9 +449,13 @@ impl<'i, D: Driver> Runner<'i, D> { let forma = formae .get(i) .map(|f| f.value); - let value = self + let value = match self .driver - .acquire(&invoked, bind, forma); + .acquire(&invoked, bind, forma) + { + UserInput::Done(value) => value, + other => return self.abandon(&lexical, other), + }; if let Some(bind) = bind { local.extend(bind.to_string(), value); } @@ -491,8 +473,13 @@ impl<'i, D: Driver> Runner<'i, D> { let forma = formae .get(i) .map(|f| f.value); - self.driver + match self + .driver .acquire(&invoked, bind, forma) + { + UserInput::Done(value) => value, + other => return self.abandon(&lexical, other), + } } else { super::evaluator::evaluate(&self.library, &self.context, env, arg)? }; @@ -502,9 +489,20 @@ impl<'i, D: Driver> Runner<'i, D> { } } - // Descend onto the callee's lexical address, restoring the - // call site on return. - let caller = self + // Record the Invoke at the call site, then descend onto the + // callee's lexical address, restored on return. + let run_id = self + .appender + .run_id(); + self.appender + .append(&Record { + recorded: now_iso8601(), + run_id, + path: caller, + state: State::Invoke(InvokeTarget::Procedure(name.to_string())), + })?; + + let saved = self .path .replace(lexical_segments); self.driver @@ -552,7 +550,7 @@ impl<'i, D: Driver> Runner<'i, D> { Err(error) => Err(error), }; self.path - .replace(caller); + .replace(saved); sealed } else { self.walk(&mut local, &subroutine.body) @@ -608,16 +606,9 @@ impl<'i, D: Driver> Runner<'i, D> { .driver .external(&qualified); if let UserInput::Quit = input { - self.appender - .append(&Record { - recorded: now_iso8601(), - run_id, - path: "/".to_string(), - state: State::Stop, - })?; self.path .pop(); - return Ok(Outcome::Stopped); + return self.record_stop(); } let outcome = outcome_from(input); @@ -900,20 +891,10 @@ impl<'i, D: Driver> Runner<'i, D> { .driver .ask(qualified, &choices, produced); - // Quit halts the walk and is recorded as a Stop lifecycle event at the - // root path, distinguishing a deliberate stop from a crash (which records - // nothing). This step's Begin stands without a matching outcome, so - // resume re-runs it. + // Quit halts the walk; this step's Begin stands without a matching + // outcome, so resume re-runs it. if let UserInput::Quit = input { - let suspend = Record { - recorded: now_iso8601(), - run_id, - path: "/".to_string(), - state: State::Stop, - }; - self.appender - .append(&suspend)?; - return Ok(Outcome::Stopped); + return self.record_stop(); } let outcome = outcome_from(input); @@ -942,15 +923,7 @@ impl<'i, D: Driver> Runner<'i, D> { .driver .seal(qualified, produced); if let UserInput::Quit = input { - let suspend = Record { - recorded: now_iso8601(), - run_id, - path: "/".to_string(), - state: State::Stop, - }; - self.appender - .append(&suspend)?; - return Ok(Outcome::Stopped); + return self.record_stop(); } let outcome = outcome_from(input); let record = Record { @@ -963,6 +936,42 @@ impl<'i, D: Driver> Runner<'i, D> { .append(&record)?; Ok(outcome) } + + /// Settle an invocation declined at its acquire prompt: Skip and Fail + /// record the call's outcome at `qualified`; Quit stops the run. + fn abandon(&mut self, qualified: &str, input: UserInput) -> Result { + if let UserInput::Quit = input { + return self.record_stop(); + } + let outcome = outcome_from(input); + let run_id = self + .appender + .run_id(); + self.appender + .append(&Record { + recorded: now_iso8601(), + run_id, + path: qualified.to_string(), + state: record_state(&outcome), + })?; + Ok(outcome) + } + + /// Record a deliberate Stop at the root path and unwind the walk. + fn record_stop(&mut self) -> Result { + let run_id = self + .appender + .run_id(); + let suspend = Record { + recorded: now_iso8601(), + run_id, + path: "/".to_string(), + state: State::Stop, + }; + self.appender + .append(&suspend)?; + Ok(Outcome::Stopped) + } } fn describe_loop( From 12500b9f612289f21ede9e4dbf824ef7da3306ff Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 16 Jun 2026 07:58:58 +1000 Subject: [PATCH 8/8] Parse variable holes and type wildcards --- src/language/types.rs | 5 +++++ src/parsing/checks/parser.rs | 34 ++++++++++++++++++++++++++++++++++ src/parsing/parser.rs | 4 ++++ 3 files changed, 43 insertions(+) diff --git a/src/language/types.rs b/src/language/types.rs index b02d5a9..6a5c193 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -536,6 +536,11 @@ pub(crate) fn validate_forma(input: &str, span: Span) -> Option> { return None; } + // wildcard, represents "any" type. + if input == "*" { + return Some(Forma { value: input, span }); + } + let mut cs = input.chars(); if !cs diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index d559c3d..f395bd4 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -200,6 +200,40 @@ fn signatures() { ); } +#[test] +fn signature_wildcards() { + let mut input = Parser::new(); + input.initialize("* -> *"); + assert_eq!( + input.read_signature(), + Ok(Signature { + requires: Genus::Single(Forma::new("*")), + provides: Genus::Single(Forma::new("*")) + }) + ); + + input.initialize("(A, *) -> [*]"); + assert_eq!( + input.read_signature(), + Ok(Signature { + requires: Genus::Tuple(vec![Forma::new("A"), Forma::new("*")]), + provides: Genus::List(Forma::new("*")) + }) + ); +} + +#[test] +fn hole_as_expression() { + // A bare `?` parses as a Hole outside argument position too — in a code + // block or as a binding value, not only inside an invocation's parens. + let mut input = Parser::new(); + input.initialize("?"); + assert_eq!( + input.read_expression(), + Ok(Expression::Hole(Span::default())) + ); +} + #[test] fn declaration_simple() { let mut input = Parser::new(); diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index b971a16..6107a4c 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -1561,6 +1561,10 @@ impl<'i> Parser<'i> { return Err(ParsingError::InvalidForeach(Span::new(self.offset, 0))); } else if content.starts_with('[') { self.read_bracket_expression() + } else if content.starts_with('?') { + self.advance(1); + let span = self.span_since(start); + Ok(Expression::Hole(span)) } else if is_numeric(content) { let numeric = self.read_numeric()?; let span = self.span_since(start);