From 047091c2578ebcd0749f1e9c3dfb99aacf60943b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 5 May 2026 17:36:45 +1000 Subject: [PATCH 01/30] Add --until option to check command --- src/main.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/main.rs b/src/main.rs index ee6b3aa..66fa7fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,12 @@ enum Output { Silent, } +#[derive(Eq, Debug, PartialEq)] +enum Phase { + Parsing, + Translation, +} + fn main() { const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); @@ -72,6 +78,18 @@ fn main() { .action(ArgAction::Set) .help("Which kind of diagnostic output to print when checking.") ) + .arg( + Arg::new("until") + .long("until") + .value_name("phase") + .value_parser(["parsing", "translation"]) + .default_value("parsing") + .action(ArgAction::Set) + .help("Stop compilation after the given phase is complete so that the result can be inspected. \ + Use this in conjunction with the --output option. The phases are: \ + parsing, where the input is parsed from the surface language to an internal abstract syntax tree; then \ + translation, which resolves names, checks references, and ensures the input is valid Technique.") + ) .arg( Arg::new("filename") .required(true) @@ -179,6 +197,17 @@ fn main() { debug!(?output); + let until = submatches + .get_one::("until") + .unwrap(); + let until = match until.as_str() { + "parsing" => Phase::Parsing, + "translation" => Phase::Translation, + _ => panic!("Unrecognized --until value"), + }; + + debug!(?until); + let filename = submatches .get_one::("filename") .unwrap(); // argument are required by definition so always present From 90a71644fa1e91d107e46fc629f9defb2de5f593 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 5 May 2026 17:51:35 +1000 Subject: [PATCH 02/30] Output native, normal to "terminal" or silent --- src/main.rs | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 66fa7fc..3e7bbab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use technique::formatting::{self, Identity}; use technique::highlighting::{self, Terminal}; use technique::parsing; use technique::templating::{self, Checklist, NasaEsaIss, Procedure, Recipe, Source}; +use technique::translation; mod editor; mod output; @@ -17,6 +18,7 @@ mod problem; #[derive(Eq, Debug, PartialEq)] enum Output { + Terminal, Native, Silent, } @@ -191,7 +193,7 @@ fn main() { .unwrap(); let output = match output.as_str() { "native" => Output::Native, - "none" => Output::Silent, + "none" => Output::Terminal, _ => panic!("Unrecognized --output value"), }; @@ -242,12 +244,39 @@ fn main() { } }; - // TODO continue with validation of the returned technique + if let Phase::Parsing = until { + match output { + Output::Terminal => { + eprintln!("{}", "ok".bright_green()); + } + Output::Native => { + println!("{:#?}", technique); + } + Output::Silent => {} + } + std::process::exit(0); + } - eprintln!("{}", "ok".bright_green()); + let program = match translation::translate(&technique) { + Ok(program) => program, + Err(_errors) => { + // Translation error rendering is a later step. + eprintln!("{}", "translation failed".bright_red()); + std::process::exit(1); + } + }; - if let Output::Native = output { - println!("{:#?}", technique); + if let Phase::Translation = until { + match output { + Output::Terminal => { + eprintln!("{}", "ok".bright_green()); + } + Output::Native => { + println!("{:#?}", program); + } + Output::Silent => {} + } + std::process::exit(0); } } Some(("format", submatches)) => { From 8e1c228081c5f459020692b6e5048372377d977c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 5 May 2026 17:53:19 +1000 Subject: [PATCH 03/30] Apply code formatter --- src/domain/nasa_esa_iss/adapter.rs | 17 ++++++++++++++--- src/domain/source/adapter.rs | 11 +++++++---- src/domain/source/typst.rs | 6 +----- src/parsing/parser.rs | 14 +++++++++++--- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/domain/nasa_esa_iss/adapter.rs b/src/domain/nasa_esa_iss/adapter.rs index 630cecc..3880643 100644 --- a/src/domain/nasa_esa_iss/adapter.rs +++ b/src/domain/nasa_esa_iss/adapter.rs @@ -113,7 +113,9 @@ fn rewrite_expression(expr: &str) -> String { if let Some(rest) = expr.strip_prefix("foreach ") { if let Some((var, seq_expr)) = rest.split_once(" in ") { if let Some((start, end)) = parse_seq(seq_expr) { - let values: Vec = (start..=end).map(|n| n.to_string()).collect(); + let values: Vec = (start..=end) + .map(|n| n.to_string()) + .collect(); return format!("foreach {} {}", var.trim(), values.join(" ")); } } @@ -123,7 +125,16 @@ fn rewrite_expression(expr: &str) -> String { /// Parse `seq(A, B)` into a (start, end) pair. fn parse_seq(s: &str) -> Option<(i64, i64)> { - let inner = s.strip_prefix("seq(")?.strip_suffix(')')?; + let inner = s + .strip_prefix("seq(")? + .strip_suffix(')')?; let (a, b) = inner.split_once(", ")?; - Some((a.trim().parse().ok()?, b.trim().parse().ok()?)) + Some(( + a.trim() + .parse() + .ok()?, + b.trim() + .parse() + .ok()?, + )) } diff --git a/src/domain/source/adapter.rs b/src/domain/source/adapter.rs index 8e997e8..1efba76 100644 --- a/src/domain/source/adapter.rs +++ b/src/domain/source/adapter.rs @@ -48,11 +48,13 @@ fn coalesce(fragments: Vec) -> Vec { && frag.syntax != "BlockBegin" && frag.syntax != "BlockEnd" { - last.content.push_str(&frag.content); + last.content + .push_str(&frag.content); continue; } if is_text_whitespace(&frag) { - last.content.push_str(&frag.content); + last.content + .push_str(&frag.content); continue; } } @@ -64,10 +66,11 @@ fn coalesce(fragments: Vec) -> Vec { fn is_text_whitespace(frag: &Fragment) -> bool { (frag.syntax == "Neutral" || frag.syntax == "Description") - && !frag.content.is_empty() + && !frag + .content + .is_empty() && frag .content .bytes() .all(|b| b == b' ') } - diff --git a/src/domain/source/typst.rs b/src/domain/source/typst.rs index d956c07..8aae3ce 100644 --- a/src/domain/source/typst.rs +++ b/src/domain/source/typst.rs @@ -55,11 +55,7 @@ impl Render for Fragment { } else if self.syntax == "Newline" { out.raw(&format!("#{}()\n", func)); } else { - out.raw(&format!( - "#{}(\"{}\")", - func, - escape_string(&self.content) - )); + out.raw(&format!("#{}(\"{}\")", func, escape_string(&self.content))); } } } diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index bd96e71..44ef27b 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -1511,10 +1511,18 @@ impl<'i> Parser<'i> { Ok(Expression::Execution(function)) } else { let identifier = self.read_identifier()?; - if self.source.starts_with('"') { + if self + .source + .starts_with('"') + { return Err(ParsingError::InvalidFunction( - self.offset - identifier.0.len(), - identifier.0.len(), + self.offset + - identifier + .0 + .len(), + identifier + .0 + .len(), )); } Ok(Expression::Variable(identifier)) From d9937a13fd22feb5fc54c957ecde16c26ee54b7b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 5 May 2026 19:35:41 +1000 Subject: [PATCH 04/30] Introduce types for Intermediate Representation --- src/lib.rs | 1 + src/translation/types.rs | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/translation/types.rs diff --git a/src/lib.rs b/src/lib.rs index 24351f2..f28ab2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,3 +5,4 @@ pub mod language; pub mod parsing; pub(crate) mod regex; pub mod templating; +pub mod translation; diff --git a/src/translation/types.rs b/src/translation/types.rs new file mode 100644 index 0000000..1736a44 --- /dev/null +++ b/src/translation/types.rs @@ -0,0 +1,55 @@ +// Intermediate Representation types for translated Technique procedures. +// +// These types complement those found in `crate::language::types` for the +// parser's abstract syntax tree but lifts and reshapes constructs toward a +// form that can be walked - and subsequently executed. + +use crate::language::Identifier; + +/// Top-level Technique translated to a runnable program. +#[derive(Debug, Eq, PartialEq, Default)] +pub struct Program<'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. + pub procedures: Vec>, +} + +impl<'i> Program<'i> { + pub fn empty() -> Self { + Program { + procedures: Vec::new(), + } + } +} + +/// Index of a procedure in `Program.procedures`. Used as the resolved form +/// of a procedure invocation. +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub struct ProcedureId(pub usize); + +#[derive(Debug, Eq, PartialEq)] +pub struct Procedure<'i> { + /// If this is a synthetic wrapper around a top-level `Technique::Steps` + /// then `None`, otherwise all procedures have names. + pub name: Option>, + pub body: Block<'i>, +} + +/// A sequence of operations forming any scope, be it procedure body, a step, +/// the body of a control structure creating a loop, etc. +#[derive(Debug, Eq, PartialEq, Default)] +pub struct Block<'i> { + pub operations: Vec>, +} + +/// One unit of work in a `Block`. +#[derive(Debug, Eq, PartialEq)] +pub enum Operation<'i> { + Placeholder(std::marker::PhantomData<&'i ()>), +} + +#[derive(Debug, Eq, PartialEq)] +pub enum TranslationError<'i> { + Placeholder(std::marker::PhantomData<&'i ()>), +} From dc0472fea72300484b8fc894d8451d6fafbe4c4d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 5 May 2026 19:36:29 +1000 Subject: [PATCH 05/30] Stubs for translation phase --- src/translation/checks/errors.rs | 11 +++++++++++ src/translation/checks/translate.rs | 15 +++++++++++++++ src/translation/mod.rs | 16 ++++++++++++++++ src/translation/passes.rs | 10 ++++++++++ 4 files changed, 52 insertions(+) create mode 100644 src/translation/checks/errors.rs create mode 100644 src/translation/checks/translate.rs create mode 100644 src/translation/mod.rs create mode 100644 src/translation/passes.rs diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs new file mode 100644 index 0000000..3278d61 --- /dev/null +++ b/src/translation/checks/errors.rs @@ -0,0 +1,11 @@ +// Error-case checks for translation. Currently a placeholder; populated as +// translation steps land their corresponding error variants +// (duplicate procedure name, orphan response, etc.). + +use super::*; + +#[test] +fn placeholder() { + // Ensures the module compiles before real error tests are added. + let _ = TranslationError::Placeholder(std::marker::PhantomData); +} diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs new file mode 100644 index 0000000..5331357 --- /dev/null +++ b/src/translation/checks/translate.rs @@ -0,0 +1,15 @@ +// Hand-written check suite for translation: surface AST -> IR. +// +// Modelled on `src/parsing/checks/parser.rs`. Tests grow alongside the +// translation passes; for now this file is a placeholder so the +// `mod check;` wiring in `mod.rs` resolves. + +use super::*; + +#[test] +fn empty_program_translates() { + let program = Program::empty(); + assert!(program + .procedures + .is_empty()); +} diff --git a/src/translation/mod.rs b/src/translation/mod.rs new file mode 100644 index 0000000..f44843c --- /dev/null +++ b/src/translation/mod.rs @@ -0,0 +1,16 @@ +//! Translation from the surface AST (`crate::language`) to an Intermediate +//! Representation suitable for a runner. + +mod passes; +mod types; + +pub use passes::translate; +pub use types::{Block, Operation, Procedure, ProcedureId, Program, TranslationError}; + +#[cfg(test)] +#[path = "checks/translate.rs"] +mod check; + +#[cfg(test)] +#[path = "checks/errors.rs"] +mod errors; diff --git a/src/translation/passes.rs b/src/translation/passes.rs new file mode 100644 index 0000000..ae37f5e --- /dev/null +++ b/src/translation/passes.rs @@ -0,0 +1,10 @@ +// Translation of the internal parser abstract syntax tree to an internal +// intermediate representation. + +use crate::language::Document; + +use super::types::{Program, TranslationError}; + +pub fn translate<'i>(_document: &Document<'i>) -> Result, Vec>> { + Ok(Program::empty()) +} From 25bb07a7d2972d2138742dc8e032f8c8152b2771 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 6 May 2026 15:19:45 +1000 Subject: [PATCH 06/30] Scaffolding for translation phase errors --- .gitignore | 1 + src/main.rs | 16 +++++++++++++--- src/problem/format.rs | 20 ++++++++++++++++++-- src/problem/messages.rs | 29 ++++++++++++++++++++++++++++- src/translation/checks/errors.rs | 10 ++++------ src/translation/mod.rs | 10 ++++++---- src/translation/passes.rs | 10 ---------- src/translation/translator.rs | 19 +++++++++++++++++++ 8 files changed, 89 insertions(+), 26 deletions(-) delete mode 100644 src/translation/passes.rs create mode 100644 src/translation/translator.rs diff --git a/.gitignore b/.gitignore index 33c0e46..d14796c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.vscode /target +/plans # rendering artifacts *.pdf diff --git a/src/main.rs b/src/main.rs index 3e7bbab..2f0fd64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -259,9 +259,19 @@ fn main() { let program = match translation::translate(&technique) { Ok(program) => program, - Err(_errors) => { - // Translation error rendering is a later step. - eprintln!("{}", "translation failed".bright_red()); + Err(errors) => { + for (i, error) in errors + .iter() + .enumerate() + { + if i > 0 { + eprintln!(); + } + eprintln!( + "{}", + problem::concise_translation_error(&error, &filename, &Terminal) + ); + } std::process::exit(1); } }; diff --git a/src/problem/format.rs b/src/problem/format.rs index cfb55d9..fe0f50e 100644 --- a/src/problem/format.rs +++ b/src/problem/format.rs @@ -1,7 +1,10 @@ -use super::messages::generate_error_message; +use super::messages::{generate_error_message, generate_translation_error}; use owo_colors::OwoColorize; use std::path::Path; -use technique::{formatting::Render, language::LoadingError, parsing::ParsingError}; +use technique::{ + formatting::Render, language::LoadingError, parsing::ParsingError, + translation::TranslationError, +}; /// Format a parsing error with full details including source code context pub fn full_parsing_error<'i>( @@ -89,6 +92,19 @@ pub fn concise_parsing_error<'i>( ) } +/// Format a translation error with concise single-line output. Translation +/// errors do not yet carry source positions, so file:line:column is omitted. +pub fn concise_translation_error<'i>( + error: &TranslationError<'i>, + filename: &'i Path, + renderer: &impl Render, +) -> String { + let (problem, _) = generate_translation_error(error, renderer); + let input = generate_filename(filename); + + format!("{}: {}: {}", "error".bright_red(), input, problem.bold(),) +} + /// Format a LoadingError with concise single-line output pub fn concise_loading_error<'i>(error: &LoadingError<'i>) -> String { format!( diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 0b9e03a..a691ab2 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1,5 +1,7 @@ use crate::problem::Present; -use technique::{formatting::Render, language::*, parsing::ParsingError}; +use technique::{ + formatting::Render, language::*, parsing::ParsingError, translation::TranslationError, +}; /// Generate problem and detail messages for parsing errors using AST construction pub fn generate_error_message<'i>(error: &ParsingError, renderer: &dyn Render) -> (String, String) { @@ -961,3 +963,28 @@ Hyphens, underscores, spaces, or subscripts are not valid in unit symbols. } } } + +/// Generate problem and detail messages for translation errors. +pub fn generate_translation_error<'i>( + error: &TranslationError<'i>, + _renderer: &dyn Render, +) -> (String, String) { + match error { + TranslationError::DuplicateProcedure(Identifier(name)) => ( + format!("Duplicate procedure name '{}'", name), + "A procedure with this name has already been declared in this document.".to_string(), + ), + TranslationError::DuplicateTitle(Identifier(name)) => ( + format!("Duplicate title in procedure '{}'", name), + "A procedure can have at most one title.".to_string(), + ), + TranslationError::InterleavedDescription(Identifier(name)) => ( + 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(), + ), + TranslationError::OrphanResponse => ( + "Response block without a parent step".to_string(), + "A response block ('Yes' | 'No') must follow a step it qualifies; it cannot stand alone.".to_string(), + ), + } +} diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 3278d61..84f0d8a 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -1,11 +1,9 @@ -// Error-case checks for translation. Currently a placeholder; populated as -// translation steps land their corresponding error variants -// (duplicate procedure name, orphan response, etc.). +// Error-case checks for translation. Populated as translation steps land +// their corresponding error variants. use super::*; #[test] -fn placeholder() { - // Ensures the module compiles before real error tests are added. - let _ = TranslationError::Placeholder(std::marker::PhantomData); +fn translation_error_variants_construct() { + let _ = TranslationError::OrphanResponse; } diff --git a/src/translation/mod.rs b/src/translation/mod.rs index f44843c..7bd2a3c 100644 --- a/src/translation/mod.rs +++ b/src/translation/mod.rs @@ -1,11 +1,13 @@ //! Translation from the surface AST (`crate::language`) to an Intermediate -//! Representation suitable for a runner. +//! Representation suitable for an interpreter. -mod passes; +mod translator; mod types; -pub use passes::translate; -pub use types::{Block, Operation, Procedure, ProcedureId, Program, TranslationError}; +pub use translator::{translate, TranslationError}; +pub use types::{ + Invoke, Operation, Ordinal, Pair, Piece, Procedure, ProcedureId, ProcedureRef, Program, +}; #[cfg(test)] #[path = "checks/translate.rs"] diff --git a/src/translation/passes.rs b/src/translation/passes.rs deleted file mode 100644 index ae37f5e..0000000 --- a/src/translation/passes.rs +++ /dev/null @@ -1,10 +0,0 @@ -// Translation of the internal parser abstract syntax tree to an internal -// intermediate representation. - -use crate::language::Document; - -use super::types::{Program, TranslationError}; - -pub fn translate<'i>(_document: &Document<'i>) -> Result, Vec>> { - Ok(Program::empty()) -} diff --git a/src/translation/translator.rs b/src/translation/translator.rs new file mode 100644 index 0000000..f361206 --- /dev/null +++ b/src/translation/translator.rs @@ -0,0 +1,19 @@ +// Translation of the internal parser abstract syntax tree to an internal +// intermediate representation. + +use crate::language; +use crate::language::Document; + +use super::types::Program; + +pub fn translate<'i>(_document: &Document<'i>) -> Result, Vec>> { + Ok(Program::empty()) +} + +#[derive(Debug, Eq, PartialEq)] +pub enum TranslationError<'i> { + DuplicateProcedure(language::Identifier<'i>), + DuplicateTitle(language::Identifier<'i>), + InterleavedDescription(language::Identifier<'i>), + OrphanResponse, +} From 454d2c506db53f1dd08e8a2d9e7ae8cc7f11e435 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 6 May 2026 15:29:21 +1000 Subject: [PATCH 07/30] Introduce Operation type for Intermediate Representation --- src/translation/mod.rs | 2 +- src/translation/types.rs | 110 +++++++++++++++++++++++++++++++++------ 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/translation/mod.rs b/src/translation/mod.rs index 7bd2a3c..e8df5b4 100644 --- a/src/translation/mod.rs +++ b/src/translation/mod.rs @@ -6,7 +6,7 @@ mod types; pub use translator::{translate, TranslationError}; pub use types::{ - Invoke, Operation, Ordinal, Pair, Piece, Procedure, ProcedureId, ProcedureRef, Program, + Entry, Fragment, Invoke, Operation, Ordinal, Procedure, ProcedureId, ProcedureRef, Program, }; #[cfg(test)] diff --git a/src/translation/types.rs b/src/translation/types.rs index 1736a44..d44b27b 100644 --- a/src/translation/types.rs +++ b/src/translation/types.rs @@ -1,10 +1,16 @@ // Intermediate Representation types for translated Technique procedures. // // These types complement those found in `crate::language::types` for the -// parser's abstract syntax tree but lifts and reshapes constructs toward a -// form that can be walked - and subsequently executed. +// parser's abstract syntax tree but lift and reshape constructs toward a form +// that can be walked by an interpreter. Where the parsed input already +// carries the right shape - descriptive paragraphs, signatures, response +// values, attributes - the types here borrow from the parser's internal +// abstract syntax tree output rather than mirroring it. Where translation +// adds information - resolved procedure references, attributes lifted onto +// steps, the desugared spine of a procedure body - then the object here will +// own the data. -use crate::language::Identifier; +use crate::language; /// Top-level Technique translated to a runnable program. #[derive(Debug, Eq, PartialEq, Default)] @@ -24,7 +30,7 @@ impl<'i> Program<'i> { } /// Index of a procedure in `Program.procedures`. Used as the resolved form -/// of a procedure invocation. +/// for the target of an invocation. #[derive(Debug, Eq, PartialEq, Clone, Copy)] pub struct ProcedureId(pub usize); @@ -32,24 +38,96 @@ pub struct ProcedureId(pub usize); pub struct Procedure<'i> { /// If this is a synthetic wrapper around a top-level `Technique::Steps` /// then `None`, otherwise all procedures have names. - pub name: Option>, - pub body: Block<'i>, + pub name: Option>, + pub title: Option<&'i str>, + pub description: &'i [language::Paragraph<'i>], + pub parameters: Option<&'i [language::Identifier<'i>]>, + pub signature: Option<&'i language::Signature<'i>>, + pub body: Operation<'i>, } -/// A sequence of operations forming any scope, be it procedure body, a step, -/// the body of a control structure creating a loop, etc. -#[derive(Debug, Eq, PartialEq, Default)] -pub struct Block<'i> { - pub operations: Vec>, +/// Every node of the Intermediate Representation form resulting from +/// desugaring the surface language is an `Operation` (c.f. opcode in an +/// instruction set). Later this will be instantiated into a tree that the +/// interpreter can walks recursively and reduce. +#[derive(Debug, Eq, PartialEq)] +pub enum Operation<'i> { + Variable(language::Identifier<'i>), + Number(language::Numeric<'i>), + String(Vec>), + Multiline(Option<&'i str>, Vec<&'i str>), + Tablet(Vec>), + Invoke(Invoke<'i>), + Execution { + target: language::Identifier<'i>, + arguments: Vec>, + }, + + Sequence(Vec>), + Section { + numeral: &'i str, + title: Option<&'i language::Paragraph<'i>>, + body: Box>, + }, + Step { + ordinal: Ordinal<'i>, + attributes: Vec<&'i [language::Attribute<'i>]>, + description: &'i [language::Paragraph<'i>], + body: Box>, + expects: Option<&'i [language::Response<'i>]>, + }, + Loop { + names: Vec>, + over: Option>>, + body: Box>, + }, + Bind { + names: Vec>, + value: Box>, + }, } -/// One unit of work in a `Block`. +/// A step's lexical kind. `Dependent` carries the verbatim ordinal string as +/// captured by the parser (`"1"`, `"a"`, `"iii"`, ...); `Parallel` has no +/// captured form, its position deriving from its index in the surrounding +/// `Sequence`. #[derive(Debug, Eq, PartialEq)] -pub enum Operation<'i> { - Placeholder(std::marker::PhantomData<&'i ()>), +pub enum Ordinal<'i> { + Dependent(&'i str), + Parallel, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct Invoke<'i> { + pub target: ProcedureRef<'i>, + pub arguments: Vec>, +} + +/// Reference to a procedure. The collect pass registers every declared +/// procedure into `Program.procedures`; the resolve pass walks the IR +/// replacing matching `Unresolved` references with `Resolved`. Names that +/// don't match any declared procedure remain `Unresolved` - they are +/// typically builtin functions (`exec`, `now`, `zip`, ...) and are not +/// translation errors. +#[derive(Debug, Eq, PartialEq)] +pub enum ProcedureRef<'i> { + Unresolved(language::Identifier<'i>), + Resolved(ProcedureId), +} + +/// A fragment of a string literal: either inline text or an interpolated +/// expression. Defined IR-side (rather than reusing `language::Piece`) +/// because interpolations are themselves `Operation`s and may carry resolved +/// procedure references. +#[derive(Debug, Eq, PartialEq)] +pub enum Fragment<'i> { + Text(&'i str), + Interpolation(Operation<'i>), } +/// An entry in a tablet: a label paired with a value-producing operation. #[derive(Debug, Eq, PartialEq)] -pub enum TranslationError<'i> { - Placeholder(std::marker::PhantomData<&'i ()>), +pub struct Entry<'i> { + pub label: &'i str, + pub value: Operation<'i>, } From a797b13823425173c498b7a51f1574c35fc34595 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 6 May 2026 18:06:33 +1000 Subject: [PATCH 08/30] Preliminary collection of procedure names --- src/translation/checks/errors.rs | 31 +++++++- src/translation/checks/translate.rs | 98 ++++++++++++++++++++++-- src/translation/translator.rs | 115 +++++++++++++++++++++++++++- src/translation/types.rs | 3 +- 4 files changed, 233 insertions(+), 14 deletions(-) diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 84f0d8a..da18392 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -1,9 +1,34 @@ -// Error-case checks for translation. Populated as translation steps land -// their corresponding error variants. +// Error-case checks for translation. Source strings parsed through the +// real parser inline match what the runner sees in production. -use super::*; +use std::path::Path; + +use crate::language; +use crate::parsing; +use crate::translation::{translate, TranslationError}; #[test] fn translation_error_variants_construct() { let _ = TranslationError::OrphanResponse; } + +#[test] +fn duplicate_procedure_name_is_error() { + let source = r#" +% technique v1 + +make_coffee : + +make_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); + assert_eq!( + errors[0], + TranslationError::DuplicateProcedure(language::Identifier("make_coffee")) + ); +} diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 5331357..b94996d 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -1,15 +1,101 @@ // Hand-written check suite for translation: surface AST -> IR. // -// Modelled on `src/parsing/checks/parser.rs`. Tests grow alongside the -// translation passes; for now this file is a placeholder so the -// `mod check;` wiring in `mod.rs` resolves. +// Modelled on `src/parsing/checks/parser.rs`. Source strings parsed through +// the real parser inline matches what the runner sees in production. -use super::*; +use std::path::Path; + +use crate::language; +use crate::parsing; +use crate::translation::translate; #[test] -fn empty_program_translates() { - let program = Program::empty(); +fn empty_input_yields_empty_program() { + let source = ""; + let path = Path::new("test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); assert!(program .procedures .is_empty()); } + +#[test] +fn single_procedure_registered() { + let source = r#" +% technique v1 + +make_coffee : + "# + .trim_ascii(); + let path = Path::new("test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + assert_eq!( + program + .procedures + .len(), + 1 + ); + assert_eq!( + program.procedures[0].name, + Some(language::Identifier("make_coffee")) + ); +} + +#[test] +fn multiple_procedures_registered_in_order() { + let source = r#" +% technique v1 + +first : + +second : + +third : + "# + .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 + .procedures + .iter() + .map(|p| { + p.name + .as_ref() + .map(|id| id.0) + }) + .collect(); + 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 + .procedures + .iter() + .map(|p| { + p.name + .as_ref() + .map(|id| id.0) + }) + .collect(); + assert_eq!(names, vec![Some("outer"), Some("inner")]); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index f361206..1e6078e 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -1,13 +1,27 @@ // Translation of the internal parser abstract syntax tree to an internal // intermediate representation. +use std::collections::HashMap; + use crate::language; use crate::language::Document; -use super::types::Program; +use super::types::{Operation, Procedure, ProcedureId, Program}; + +pub fn translate<'i>(document: &Document<'i>) -> Result, Vec>> { + let mut program = Program::new(); + let mut errors = Vec::new(); + let mut known: HashMap<&'i str, ProcedureId> = HashMap::new(); + + if let Some(body) = &document.body { + collect_technique(body, &mut program, &mut known, &mut errors); + } -pub fn translate<'i>(_document: &Document<'i>) -> Result, Vec>> { - Ok(Program::empty()) + if errors.is_empty() { + Ok(program) + } else { + Err(errors) + } } #[derive(Debug, Eq, PartialEq)] @@ -17,3 +31,98 @@ pub enum TranslationError<'i> { InterleavedDescription(language::Identifier<'i>), OrphanResponse, } + +// Walk a Technique node, registering any procedures it declares directly or +// transitively through nested sections. Procedures are hoisted into the +// flat Program.procedures list regardless of where in the section tree they +// were declared. +fn collect_technique<'i>( + technique: &language::Technique<'i>, + program: &mut Program<'i>, + known: &mut HashMap<&'i str, ProcedureId>, + errors: &mut Vec>, +) { + match technique { + language::Technique::Procedures(procedures) => { + for procedure in procedures { + register_procedure(procedure, program, known, errors); + + // 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. + for element in &procedure.elements { + if let language::Element::Steps(scopes) = element { + for scope in scopes { + collect_scope(scope, program, known, errors); + } + } + } + } + } + language::Technique::Steps(scopes) => { + for scope in scopes { + collect_scope(scope, program, known, errors); + } + } + language::Technique::Empty => {} + } +} + +fn register_procedure<'i>( + procedure: &language::Procedure<'i>, + program: &mut Program<'i>, + known: &mut HashMap<&'i str, ProcedureId>, + errors: &mut Vec>, +) { + let name = procedure + .name + .0; + + if known.contains_key(name) { + errors.push(TranslationError::DuplicateProcedure(language::Identifier( + name, + ))); + return; + } + + let id = ProcedureId( + program + .procedures + .len(), + ); + known.insert(name, id); + program + .procedures + .push(Procedure { + name: Some(language::Identifier(name)), + title: None, + description: &[], + parameters: None, + signature: None, + body: Operation::Sequence(Vec::new()), + }); +} + +fn collect_scope<'i>( + scope: &language::Scope<'i>, + program: &mut Program<'i>, + known: &mut HashMap<&'i str, ProcedureId>, + errors: &mut Vec>, +) { + match scope { + language::Scope::SectionChunk { body, .. } => { + collect_technique(body, program, known, errors); + } + language::Scope::DependentBlock { subscopes, .. } + | language::Scope::ParallelBlock { subscopes, .. } + | language::Scope::AttributeBlock { subscopes, .. } + | language::Scope::CodeBlock { subscopes, .. } => { + for sub in subscopes { + collect_scope(sub, program, known, errors); + } + } + language::Scope::ResponseBlock { .. } => {} + } +} diff --git a/src/translation/types.rs b/src/translation/types.rs index d44b27b..fb4f672 100644 --- a/src/translation/types.rs +++ b/src/translation/types.rs @@ -22,7 +22,7 @@ pub struct Program<'i> { } impl<'i> Program<'i> { - pub fn empty() -> Self { + pub fn new() -> Self { Program { procedures: Vec::new(), } @@ -62,7 +62,6 @@ pub enum Operation<'i> { target: language::Identifier<'i>, arguments: Vec>, }, - Sequence(Vec>), Section { numeral: &'i str, From 1f24a77fb367d6317e5ff2915d914d906c1f1f87 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 12:10:52 +1000 Subject: [PATCH 09/30] Update TranslationErrors with better detail --- src/problem/messages.rs | 12 ++- src/translation/checks/errors.rs | 61 ++++++++++++- src/translation/translator.rs | 146 +++++++++++++++++++++++++++---- 3 files changed, 198 insertions(+), 21 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 0f60171..97a1d0c 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1028,15 +1028,21 @@ pub fn generate_translation_error<'i>( format!("Duplicate procedure name '{}'", name), "A procedure with this name has already been declared in this document.".to_string(), ), - TranslationError::DuplicateTitle(Identifier { value: name, .. }) => ( + TranslationError::DuplicateTitle { + procedure: Identifier { value: name, .. }, + .. + } => ( format!("Duplicate title in procedure '{}'", name), "A procedure can have at most one title.".to_string(), ), - TranslationError::InterleavedDescription(Identifier { value: name, .. }) => ( + TranslationError::InterleavedDescription { + procedure: Identifier { value: name, .. }, + .. + } => ( 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(), ), - TranslationError::OrphanResponse => ( + TranslationError::OrphanResponse(_) => ( "Response block without a parent step".to_string(), "A response block ('Yes' | 'No') must follow a step it qualifies; it cannot stand alone.".to_string(), ), diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 858362d..bdec14e 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -4,12 +4,13 @@ use std::path::Path; use crate::language; +use crate::language::Span; use crate::parsing; use crate::translation::{translate, TranslationError}; #[test] fn translation_error_variants_construct() { - let _ = TranslationError::OrphanResponse; + let _ = TranslationError::OrphanResponse(Span::default()); } #[test] @@ -32,3 +33,61 @@ make_coffee : TranslationError::DuplicateProcedure(language::Identifier::new("make_coffee")) ); } + +#[test] +fn duplicate_title_is_error() { + let source = r#" +% technique v1 + +make_coffee : + +# First Title + +# Second Title + "# + .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::DuplicateTitle { procedure, at } = &errors[0] else { + panic!("expected DuplicateTitle, got {:?}", errors[0]); + }; + assert_eq!(procedure.value, "make_coffee"); + let expected = source + .rfind("# Second Title") + .expect("second title in source"); + assert_eq!(at.offset, expected); + assert!(at.length >= "# Second Title".len()); +} + +#[test] +fn description_after_code_block_is_error() { + let source = r#" +% technique v1 + +make_coffee : + +{ + journal("step 1") +} + +This text comes too late. + "# + .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::InterleavedDescription { procedure, at } = &errors[0] else { + panic!("expected InterleavedDescription, got {:?}", errors[0]); + }; + assert_eq!(procedure.value, "make_coffee"); + let expected = source + .find("This text comes too late.") + .expect("description in source"); + assert_eq!(at.offset, expected); + assert!(at.length >= "This text comes too late.".len()); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 1ba2f31..e1d8e2f 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -8,12 +8,27 @@ use crate::language::Document; use super::types::{Operation, Procedure, ProcedureId, Program}; -pub fn translate<'i>(document: &Document<'i>) -> Result, Vec>> { +pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { let mut program = Program::new(); let mut errors = Vec::new(); let mut known: HashMap<&'i str, ProcedureId> = HashMap::new(); if let Some(body) = &document.body { + if let language::Technique::Steps(_) = body { + // Top-level Steps-only document is wrapped in a synthetic + // anonymous procedure at index 0, so downstream code can + // assume a uniform Vec. + program + .procedures + .push(Procedure { + name: None, + title: None, + description: &[], + parameters: None, + signature: None, + body: Operation::Sequence(Vec::new()), + }); + } collect_technique(body, &mut program, &mut known, &mut errors); } @@ -27,17 +42,24 @@ pub fn translate<'i>(document: &Document<'i>) -> Result, Vec { DuplicateProcedure(language::Identifier<'i>), - DuplicateTitle(language::Identifier<'i>), - InterleavedDescription(language::Identifier<'i>), - OrphanResponse, + DuplicateTitle { + procedure: language::Identifier<'i>, + at: Span, + }, + InterleavedDescription { + procedure: language::Identifier<'i>, + at: Span, + }, + OrphanResponse(Span), } // Walk a Technique node, registering any procedures it declares directly or -// transitively through nested sections. Procedures are hoisted into the -// flat Program.procedures list regardless of where in the section tree they -// were declared. +// transitively through nested sections. Procedures are hoisted into the flat +// Program.procedures list regardless of where in the section tree they were +// declared. The top-level synthetic anonymous wrapper (for a +// Technique::Steps-only document) is added by translate(), not here. fn collect_technique<'i>( - technique: &language::Technique<'i>, + technique: &'i language::Technique<'i>, program: &mut Program<'i>, known: &mut HashMap<&'i str, ProcedureId>, errors: &mut Vec>, @@ -45,8 +67,10 @@ fn collect_technique<'i>( match technique { language::Technique::Procedures(procedures) => { for procedure in procedures { - register_procedure(procedure, program, known, errors); - + if let Some(id) = register_procedure(procedure, program, known, errors) { + translate_procedure(id, procedure, program, errors); + } + // Element::Steps scopes may contain Sections, whose bodies // can in turn declare further procedures. Walk the // procedure's scopes so those nested declarations are @@ -70,12 +94,16 @@ fn collect_technique<'i>( } } +// Pass 1: gather a procedure's name. Fails fast on duplicate, returning None +// so the caller can skip the shell-translation step. On first occurrence, +// reserves a slot in Program.procedures with a stub Procedure that subsequent +// passes fill in. fn register_procedure<'i>( - procedure: &language::Procedure<'i>, + procedure: &'i language::Procedure<'i>, program: &mut Program<'i>, known: &mut HashMap<&'i str, ProcedureId>, errors: &mut Vec>, -) { +) -> Option { let name = procedure .name .value; @@ -84,10 +112,11 @@ fn register_procedure<'i>( .span; if known.contains_key(name) { - errors.push(TranslationError::DuplicateProcedure( - language::Identifier { value: name, span }, - )); - return; + errors.push(TranslationError::DuplicateProcedure(language::Identifier { + value: name, + span, + })); + return None; } let id = ProcedureId( @@ -106,10 +135,93 @@ fn register_procedure<'i>( signature: None, body: Operation::Sequence(Vec::new()), }); + Some(id) +} + +// Pass 2 (step 4): fill the shell of a previously-registered procedure with +// its title, description, parameters, and signature. The body Operation is +// left empty; subsequent translation steps fill it. +fn translate_procedure<'i>( + id: ProcedureId, + procedure: &'i language::Procedure<'i>, + program: &mut Program<'i>, + errors: &mut Vec>, +) { + let (title, description) = extract_title_and_description(procedure, errors); + let entry = &mut program.procedures[id.0]; + entry.title = title; + entry.description = description; + entry.parameters = procedure + .parameters + .as_ref() + .map(|v| v.as_slice()); + entry.signature = procedure + .signature + .as_ref(); +} + +// Walk a procedure's elements to extract the procedure-shell title and +// description, surfacing the two structural errors: +// DuplicateTitle - a second Element::Title in this procedure. +// InterleavedDescription - an Element::Description after a Steps or +// CodeBlock element. +// Multiple Element::Description occurrences before any Steps/CodeBlock are +// not an error; the first wins. +fn extract_title_and_description<'i>( + procedure: &'i language::Procedure<'i>, + errors: &mut Vec>, +) -> (Option<&'i str>, &'i [language::Paragraph<'i>]) { + let mut title: Option<&'i str> = None; + let mut description: Option<&'i [language::Paragraph<'i>]> = None; + let mut blocked = false; + + for element in &procedure.elements { + match element { + language::Element::Title(value, span) => { + if title.is_some() { + errors.push(TranslationError::DuplicateTitle { + procedure: language::Identifier { + value: procedure + .name + .value, + span: procedure + .name + .span, + }, + at: *span, + }); + } else { + title = Some(*value); + } + } + language::Element::Description(paragraphs, span) => { + if blocked { + errors.push(TranslationError::InterleavedDescription { + procedure: language::Identifier { + value: procedure + .name + .value, + span: procedure + .name + .span, + }, + at: *span, + }); + } else if description.is_none() { + description = Some(paragraphs.as_slice()); + } + } + language::Element::Steps(_, _) | language::Element::CodeBlock(_, _) => { + blocked = true; + } + } + } + + (title, description.unwrap_or(&[])) } fn collect_scope<'i>( - scope: &language::Scope<'i>, + scope: &'i language::Scope<'i>, program: &mut Program<'i>, known: &mut HashMap<&'i str, ProcedureId>, errors: &mut Vec>, From 9e37b36549664e6650ff114c9c0654ebb278631e Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 12:17:00 +1000 Subject: [PATCH 10/30] Rename intermediate to Subroutine --- src/translation/checks/translate.rs | 109 ++++++++++++++++++++++++++-- src/translation/mod.rs | 2 +- src/translation/translator.rs | 56 ++++++-------- src/translation/types.rs | 53 +++++++++++--- 4 files changed, 167 insertions(+), 53 deletions(-) diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index c17f69d..433db40 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -16,7 +16,7 @@ fn empty_input_yields_empty_program() { let document = parsing::parse(path, source).expect("parse"); let program = translate(&document).expect("translate"); assert!(program - .procedures + .subroutines .is_empty()); } @@ -34,12 +34,12 @@ make_coffee : assert_eq!( program - .procedures + .subroutines .len(), 1 ); assert_eq!( - program.procedures[0].name, + program.subroutines[0].name, Some(language::Identifier::new("make_coffee")) ); } @@ -61,7 +61,7 @@ third : let program = translate(&document).expect("translate"); let names: Vec<_> = program - .procedures + .subroutines .iter() .map(|p| { p.name @@ -89,7 +89,7 @@ inner : () -> () let program = translate(&document).expect("translate"); let names: Vec<_> = program - .procedures + .subroutines .iter() .map(|p| { p.name @@ -99,3 +99,102 @@ inner : () -> () .collect(); assert_eq!(names, vec![Some("outer"), Some("inner")]); } + +#[test] +fn procedure_title_extracted() { + let source = r#" +% technique v1 + +make_coffee : + +# Coffee Time + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + assert_eq!(program.subroutines[0].title, Some("Coffee Time")); +} + +#[test] +fn procedure_description_extracted() { + let source = r#" +% technique v1 + +make_coffee : + +# Coffee Time + +This is how to make coffee. + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + assert_eq!( + program.subroutines[0] + .description + .len(), + 1 + ); +} + +#[test] +fn procedure_parameters_borrowed() { + let source = r#" +% technique v1 + +make_coffee(beans, water) : + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + let params = program.subroutines[0] + .parameters + .expect("parameters present"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].value, "beans"); + assert_eq!(params[1].value, "water"); +} + +#[test] +fn procedure_signature_borrowed() { + let source = r#" +% technique v1 + +make_coffee : Beans -> Coffee + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + assert!(program.subroutines[0] + .signature + .is_some()); +} + +#[test] +fn anonymous_wrapper_for_top_level_steps() { + let source = r#" +1. First step + +2. Second step + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + assert_eq!( + program + .subroutines + .len(), + 1 + ); + assert_eq!(program.subroutines[0].name, None); +} diff --git a/src/translation/mod.rs b/src/translation/mod.rs index e8df5b4..2393c29 100644 --- a/src/translation/mod.rs +++ b/src/translation/mod.rs @@ -6,7 +6,7 @@ mod types; pub use translator::{translate, TranslationError}; pub use types::{ - Entry, Fragment, Invoke, Operation, Ordinal, Procedure, ProcedureId, ProcedureRef, Program, + Entry, Fragment, Invoke, Operation, Ordinal, Program, Subroutine, SubroutineId, SubroutineRef, }; #[cfg(test)] diff --git a/src/translation/translator.rs b/src/translation/translator.rs index e1d8e2f..2eb1576 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -4,30 +4,23 @@ use std::collections::HashMap; use crate::language; -use crate::language::Document; +use crate::language::{Document, Span}; -use super::types::{Operation, Procedure, ProcedureId, Program}; +use super::types::{Program, Subroutine, SubroutineId}; pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { let mut program = Program::new(); let mut errors = Vec::new(); - let mut known: HashMap<&'i str, ProcedureId> = HashMap::new(); + let mut known: HashMap<&'i str, SubroutineId> = HashMap::new(); if let Some(body) = &document.body { if let language::Technique::Steps(_) = body { // Top-level Steps-only document is wrapped in a synthetic - // anonymous procedure at index 0, so downstream code can - // assume a uniform Vec. + // anonymous subroutine at index 0, so downstream code can + // assume a uniform Vec. program - .procedures - .push(Procedure { - name: None, - title: None, - description: &[], - parameters: None, - signature: None, - body: Operation::Sequence(Vec::new()), - }); + .subroutines + .push(Subroutine::anonymous()); } collect_technique(body, &mut program, &mut known, &mut errors); } @@ -55,13 +48,13 @@ pub enum TranslationError<'i> { // Walk a Technique node, registering any procedures it declares directly or // transitively through nested sections. Procedures are hoisted into the flat -// Program.procedures list regardless of where in the section tree they were +// Program.subroutines list regardless of where in the section tree they were // declared. The top-level synthetic anonymous wrapper (for a // Technique::Steps-only document) is added by translate(), not here. fn collect_technique<'i>( technique: &'i language::Technique<'i>, program: &mut Program<'i>, - known: &mut HashMap<&'i str, ProcedureId>, + known: &mut HashMap<&'i str, SubroutineId>, errors: &mut Vec>, ) { match technique { @@ -70,7 +63,7 @@ fn collect_technique<'i>( if let Some(id) = register_procedure(procedure, program, known, errors) { translate_procedure(id, procedure, program, errors); } - + // Element::Steps scopes may contain Sections, whose bodies // can in turn declare further procedures. Walk the // procedure's scopes so those nested declarations are @@ -96,14 +89,14 @@ fn collect_technique<'i>( // Pass 1: gather a procedure's name. Fails fast on duplicate, returning None // so the caller can skip the shell-translation step. On first occurrence, -// reserves a slot in Program.procedures with a stub Procedure that subsequent -// passes fill in. +// reserves a slot in Program.subroutines with a stub Subroutine that +// subsequent passes fill in. fn register_procedure<'i>( procedure: &'i language::Procedure<'i>, program: &mut Program<'i>, - known: &mut HashMap<&'i str, ProcedureId>, + known: &mut HashMap<&'i str, SubroutineId>, errors: &mut Vec>, -) -> Option { +) -> Option { let name = procedure .name .value; @@ -119,22 +112,15 @@ fn register_procedure<'i>( return None; } - let id = ProcedureId( + let id = SubroutineId( program - .procedures + .subroutines .len(), ); known.insert(name, id); program - .procedures - .push(Procedure { - name: Some(language::Identifier { value: name, span }), - title: None, - description: &[], - parameters: None, - signature: None, - body: Operation::Sequence(Vec::new()), - }); + .subroutines + .push(Subroutine::new(language::Identifier { value: name, span })); Some(id) } @@ -142,13 +128,13 @@ fn register_procedure<'i>( // its title, description, parameters, and signature. The body Operation is // left empty; subsequent translation steps fill it. fn translate_procedure<'i>( - id: ProcedureId, + id: SubroutineId, procedure: &'i language::Procedure<'i>, program: &mut Program<'i>, errors: &mut Vec>, ) { let (title, description) = extract_title_and_description(procedure, errors); - let entry = &mut program.procedures[id.0]; + let entry = &mut program.subroutines[id.0]; entry.title = title; entry.description = description; entry.parameters = procedure @@ -223,7 +209,7 @@ fn extract_title_and_description<'i>( fn collect_scope<'i>( scope: &'i language::Scope<'i>, program: &mut Program<'i>, - known: &mut HashMap<&'i str, ProcedureId>, + known: &mut HashMap<&'i str, SubroutineId>, errors: &mut Vec>, ) { match scope { diff --git a/src/translation/types.rs b/src/translation/types.rs index fb4f672..faf8fc9 100644 --- a/src/translation/types.rs +++ b/src/translation/types.rs @@ -18,24 +18,24 @@ pub struct Program<'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. - pub procedures: Vec>, + pub subroutines: Vec>, } impl<'i> Program<'i> { pub fn new() -> Self { Program { - procedures: Vec::new(), + subroutines: Vec::new(), } } } -/// Index of a procedure in `Program.procedures`. Used as the resolved form +/// Index of a subroutine in `Program.subroutines`. Used as the resolved form /// for the target of an invocation. #[derive(Debug, Eq, PartialEq, Clone, Copy)] -pub struct ProcedureId(pub usize); +pub struct SubroutineId(pub usize); #[derive(Debug, Eq, PartialEq)] -pub struct Procedure<'i> { +pub struct Subroutine<'i> { /// If this is a synthetic wrapper around a top-level `Technique::Steps` /// then `None`, otherwise all procedures have names. pub name: Option>, @@ -46,6 +46,35 @@ pub struct Procedure<'i> { pub body: Operation<'i>, } +impl<'i> Subroutine<'i> { + /// Stub procedure with the given name and otherwise empty fields. + /// Subsequent translation passes fill in title, description, parameters, + /// signature, and body. + pub fn new(name: language::Identifier<'i>) -> Self { + Subroutine { + name: Some(name), + title: None, + description: &[], + parameters: None, + signature: None, + body: Operation::Sequence(Vec::new()), + } + } + + /// Synthetic anonymous-wrapper procedure used when a document has no + /// procedure shell (a top-level `Technique::Steps`-only document). + pub fn anonymous() -> Self { + Subroutine { + name: None, + title: None, + description: &[], + parameters: None, + signature: None, + body: Operation::Sequence(Vec::new()), + } + } +} + /// Every node of the Intermediate Representation form resulting from /// desugaring the surface language is an `Operation` (c.f. opcode in an /// instruction set). Later this will be instantiated into a tree that the @@ -98,26 +127,26 @@ pub enum Ordinal<'i> { #[derive(Debug, Eq, PartialEq)] pub struct Invoke<'i> { - pub target: ProcedureRef<'i>, + pub target: SubroutineRef<'i>, pub arguments: Vec>, } -/// Reference to a procedure. The collect pass registers every declared -/// procedure into `Program.procedures`; the resolve pass walks the IR +/// Reference to a subroutine. The collect pass registers every declared +/// subroutine into `Program.subroutines`; the resolve pass walks the IR /// replacing matching `Unresolved` references with `Resolved`. Names that -/// don't match any declared procedure remain `Unresolved` - they are +/// don't match any declared subroutine remain `Unresolved` - they are /// typically builtin functions (`exec`, `now`, `zip`, ...) and are not /// translation errors. #[derive(Debug, Eq, PartialEq)] -pub enum ProcedureRef<'i> { +pub enum SubroutineRef<'i> { Unresolved(language::Identifier<'i>), - Resolved(ProcedureId), + Resolved(SubroutineId), } /// A fragment of a string literal: either inline text or an interpolated /// expression. Defined IR-side (rather than reusing `language::Piece`) /// because interpolations are themselves `Operation`s and may carry resolved -/// procedure references. +/// subroutine references. #[derive(Debug, Eq, PartialEq)] pub enum Fragment<'i> { Text(&'i str), From 7bd4e9d8b81aede2e489e5c25e5ff20da68ed4a0 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 12:57:30 +1000 Subject: [PATCH 11/30] Translate section scopes --- src/translation/checks/translate.rs | 110 +++++++++++++++++++++++++++- src/translation/translator.rs | 67 ++++++++++++++--- src/translation/types.rs | 14 ++-- 3 files changed, 171 insertions(+), 20 deletions(-) diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 433db40..e994708 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -1,4 +1,4 @@ -// Hand-written check suite for translation: surface AST -> IR. +// Hand-written check suite for translation phase // // Modelled on `src/parsing/checks/parser.rs`. Source strings parsed through // the real parser inline matches what the runner sees in production. @@ -7,7 +7,7 @@ use std::path::Path; use crate::language; use crate::parsing; -use crate::translation::translate; +use crate::translation::{translate, Operation}; #[test] fn empty_input_yields_empty_program() { @@ -198,3 +198,109 @@ fn anonymous_wrapper_for_top_level_steps() { ); assert_eq!(program.subroutines[0].name, None); } + +#[test] +fn section_with_empty_body_translated() { + let source = r#" +% technique v1 + +outer : + +I. First section + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + let body = &program.subroutines[0].body; + let Operation::Sequence(ops) = body else { + panic!("expected Sequence, got {:?}", body); + }; + assert_eq!(ops.len(), 1); + let Operation::Section { + numeral, + title, + body: section_body, + } = &ops[0] + else { + panic!("expected Section, got {:?}", ops[0]); + }; + assert_eq!(*numeral, "I"); + assert!(title.is_some()); + let Operation::Sequence(inner) = section_body.as_ref() else { + panic!("expected inner Sequence"); + }; + assert!(inner.is_empty()); +} + +#[test] +fn section_holding_procedures_yields_empty_body() { + 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"); + + // The inner procedure was hoisted into the flat list. + assert_eq!( + program + .subroutines + .len(), + 2 + ); + + let Operation::Sequence(ops) = &program.subroutines[0].body else { + panic!("expected Sequence"); + }; + assert_eq!(ops.len(), 1); + let Operation::Section { + body: section_body, .. + } = &ops[0] + else { + panic!("expected Section"); + }; + let Operation::Sequence(inner) = section_body.as_ref() else { + panic!("expected inner Sequence"); + }; + // Procedures-bodied section carries no executable operations of its own. + assert!(inner.is_empty()); +} + +#[test] +fn nested_sections_translate_recursively() { + let source = r#" +% technique v1 + +outer : + +I. Outer + +II. Sibling + "# + .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"); + }; + assert_eq!(ops.len(), 2); + let numerals: Vec<&str> = ops + .iter() + .map(|op| match op { + Operation::Section { numeral, .. } => *numeral, + _ => panic!("expected Section"), + }) + .collect(); + assert_eq!(numerals, vec!["I", "II"]); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 2eb1576..71b0b5f 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -1,12 +1,11 @@ -// Translation of the internal parser abstract syntax tree to an internal -// intermediate representation. +// Translation of the parser's abstract syntax tree into a runnable Program. use std::collections::HashMap; use crate::language; use crate::language::{Document, Span}; -use super::types::{Program, Subroutine, SubroutineId}; +use super::types::{Operation, Program, Subroutine, SubroutineId}; pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { let mut program = Program::new(); @@ -87,10 +86,8 @@ fn collect_technique<'i>( } } -// Pass 1: gather a procedure's name. Fails fast on duplicate, returning None -// so the caller can skip the shell-translation step. On first occurrence, -// reserves a slot in Program.subroutines with a stub Subroutine that -// subsequent passes fill in. +// Pass 1: gather a procedure's name and reserve its slot in +// Program.subroutines. fn register_procedure<'i>( procedure: &'i language::Procedure<'i>, program: &mut Program<'i>, @@ -124,9 +121,8 @@ fn register_procedure<'i>( Some(id) } -// Pass 2 (step 4): fill the shell of a previously-registered procedure with -// its title, description, parameters, and signature. The body Operation is -// left empty; subsequent translation steps fill it. +// Pass 2: populate a registered subroutine with its title, description, +// parameters, signature, and body. fn translate_procedure<'i>( id: SubroutineId, procedure: &'i language::Procedure<'i>, @@ -134,16 +130,65 @@ fn translate_procedure<'i>( errors: &mut Vec>, ) { let (title, description) = extract_title_and_description(procedure, errors); + + // build body + let mut ops = Vec::new(); + for element in &procedure.elements { + if let language::Element::Steps(scopes, _) = element { + for scope in scopes { + ops.push(translate_scope(scope)); + } + } + } + let body = Operation::Sequence(ops); + let entry = &mut program.subroutines[id.0]; entry.title = title; entry.description = description; entry.parameters = procedure .parameters .as_ref() - .map(|v| v.as_slice()); + .map(Vec::as_slice); entry.signature = procedure .signature .as_ref(); + entry.body = body; +} + +fn translate_scope<'i>(scope: &'i language::Scope<'i>) -> Operation<'i> { + match scope { + language::Scope::SectionChunk { + numeral, + title, + body, + .. + } => { + let inner = match body { + language::Technique::Steps(scopes) => { + let ops: Vec<_> = scopes + .iter() + .map(translate_scope) + .collect(); + Operation::Sequence(ops) + } + // Procedures declared inside a section are hoisted into + // Program.subroutines by the collect pass; the section + // node carries no executable body for them. + language::Technique::Procedures(_) => Operation::Sequence(Vec::new()), + language::Technique::Empty => Operation::Sequence(Vec::new()), + }; + Operation::Section { + numeral, + title: title.as_ref(), + body: Box::new(inner), + } + } + language::Scope::DependentBlock { .. } => todo!("dependent block"), + language::Scope::ParallelBlock { .. } => todo!("parallel block"), + language::Scope::AttributeBlock { .. } => todo!("attribute block"), + language::Scope::CodeBlock { .. } => todo!("code block"), + language::Scope::ResponseBlock { .. } => todo!("response block"), + } } // Walk a procedure's elements to extract the procedure-shell title and diff --git a/src/translation/types.rs b/src/translation/types.rs index faf8fc9..be9c733 100644 --- a/src/translation/types.rs +++ b/src/translation/types.rs @@ -132,11 +132,11 @@ pub struct Invoke<'i> { } /// Reference to a subroutine. The collect pass registers every declared -/// subroutine into `Program.subroutines`; the resolve pass walks the IR -/// replacing matching `Unresolved` references with `Resolved`. Names that -/// don't match any declared subroutine remain `Unresolved` - they are -/// typically builtin functions (`exec`, `now`, `zip`, ...) and are not -/// translation errors. +/// subroutine into `Program.subroutines`; the resolve pass walks the +/// translated tree replacing matching `Unresolved` references with +/// `Resolved`. Names that don't match any declared subroutine remain +/// `Unresolved` - they are typically builtin functions (`exec`, `now`, +/// `zip`, ...) and are not translation errors. #[derive(Debug, Eq, PartialEq)] pub enum SubroutineRef<'i> { Unresolved(language::Identifier<'i>), @@ -144,8 +144,8 @@ pub enum SubroutineRef<'i> { } /// A fragment of a string literal: either inline text or an interpolated -/// expression. Defined IR-side (rather than reusing `language::Piece`) -/// because interpolations are themselves `Operation`s and may carry resolved +/// expression. Defined here (rather than reusing `language::Piece`) because +/// interpolations are themselves `Operation`s and may carry resolved /// subroutine references. #[derive(Debug, Eq, PartialEq)] pub enum Fragment<'i> { From 7f87173a7aaa46421ea110a7af1c393601023b7b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 13:28:18 +1000 Subject: [PATCH 12/30] Translate dependent and parallel steps --- src/translation/checks/translate.rs | 133 +++++++++++++++++++++++++++- src/translation/translator.rs | 49 +++++++--- 2 files changed, 169 insertions(+), 13 deletions(-) diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index e994708..d2d5159 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -7,7 +7,7 @@ use std::path::Path; use crate::language; use crate::parsing; -use crate::translation::{translate, Operation}; +use crate::translation::{translate, Operation, Ordinal}; #[test] fn empty_input_yields_empty_program() { @@ -304,3 +304,134 @@ II. Sibling .collect(); assert_eq!(numerals, vec!["I", "II"]); } + +#[test] +fn dependent_step_translated() { + let source = r#" +% technique v1 + +make_coffee : + +1. Grind the beans. + +2. Pour the water. + "# + .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"); + }; + assert_eq!(ops.len(), 2); + let ordinals: Vec<&str> = ops + .iter() + .map(|op| match op { + Operation::Step { + ordinal: Ordinal::Dependent(n), + .. + } => *n, + _ => panic!("expected dependent step, got {:?}", op), + }) + .collect(); + assert_eq!(ordinals, vec!["1", "2"]); +} + +#[test] +fn parallel_step_translated() { + let source = r#" +% technique v1 + +make_coffee : + +- Order beans. + +- Boil water. + "# + .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"); + }; + assert_eq!(ops.len(), 2); + for op in ops { + let Operation::Step { + ordinal: Ordinal::Parallel, + .. + } = op + else { + panic!("expected parallel step, got {:?}", op); + }; + } +} + +#[test] +fn substeps_recurse_into_step_body() { + let source = r#" +% technique v1 + +make_coffee : + +1. Outer step. + + a. First substep. + + b. Second substep. + "# + .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"); + }; + assert_eq!(ops.len(), 1); + let Operation::Step { + ordinal: Ordinal::Dependent(outer), + body: outer_body, + .. + } = &ops[0] + else { + panic!("expected outer dependent step"); + }; + assert_eq!(*outer, "1"); + + let Operation::Sequence(inner) = outer_body.as_ref() else { + panic!("expected inner Sequence"); + }; + let inner_ordinals: Vec<&str> = inner + .iter() + .map(|op| match op { + Operation::Step { + ordinal: Ordinal::Dependent(n), + .. + } => *n, + _ => panic!("expected dependent substep"), + }) + .collect(); + assert_eq!(inner_ordinals, vec!["a", "b"]); +} + +#[test] +fn top_level_steps_populate_anonymous_wrapper_body() { + let source = r#" +1. First step. + +2. Second step. + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + assert_eq!(program.subroutines[0].name, None); + let Operation::Sequence(ops) = &program.subroutines[0].body else { + panic!("expected Sequence"); + }; + assert_eq!(ops.len(), 2); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 71b0b5f..c71666d 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use crate::language; use crate::language::{Document, Span}; -use super::types::{Operation, Program, Subroutine, SubroutineId}; +use super::types::{Operation, Ordinal, Program, Subroutine, SubroutineId}; pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { let mut program = Program::new(); @@ -13,13 +13,15 @@ pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec = HashMap::new(); if let Some(body) = &document.body { - if let language::Technique::Steps(_) = body { + if let language::Technique::Steps(scopes) = body { // Top-level Steps-only document is wrapped in a synthetic // anonymous subroutine at index 0, so downstream code can // assume a uniform Vec. + let mut wrapper = Subroutine::anonymous(); + wrapper.body = translate_subscopes(scopes); program .subroutines - .push(Subroutine::anonymous()); + .push(wrapper); } collect_technique(body, &mut program, &mut known, &mut errors); } @@ -164,13 +166,7 @@ fn translate_scope<'i>(scope: &'i language::Scope<'i>) -> Operation<'i> { .. } => { let inner = match body { - language::Technique::Steps(scopes) => { - let ops: Vec<_> = scopes - .iter() - .map(translate_scope) - .collect(); - Operation::Sequence(ops) - } + language::Technique::Steps(scopes) => translate_subscopes(scopes), // Procedures declared inside a section are hoisted into // Program.subroutines by the collect pass; the section // node carries no executable body for them. @@ -183,14 +179,43 @@ fn translate_scope<'i>(scope: &'i language::Scope<'i>) -> Operation<'i> { body: Box::new(inner), } } - language::Scope::DependentBlock { .. } => todo!("dependent block"), - language::Scope::ParallelBlock { .. } => todo!("parallel block"), + language::Scope::DependentBlock { + ordinal, + description, + subscopes, + .. + } => Operation::Step { + ordinal: Ordinal::Dependent(ordinal), + attributes: Vec::new(), + description: description.as_slice(), + body: Box::new(translate_subscopes(subscopes)), + expects: None, + }, + language::Scope::ParallelBlock { + description, + subscopes, + .. + } => Operation::Step { + ordinal: Ordinal::Parallel, + attributes: Vec::new(), + description: description.as_slice(), + body: Box::new(translate_subscopes(subscopes)), + expects: None, + }, language::Scope::AttributeBlock { .. } => todo!("attribute block"), language::Scope::CodeBlock { .. } => todo!("code block"), language::Scope::ResponseBlock { .. } => todo!("response block"), } } +fn translate_subscopes<'i>(scopes: &'i [language::Scope<'i>]) -> Operation<'i> { + let ops: Vec<_> = scopes + .iter() + .map(translate_scope) + .collect(); + Operation::Sequence(ops) +} + // Walk a procedure's elements to extract the procedure-shell title and // description, surfacing the two structural errors: // DuplicateTitle - a second Element::Title in this procedure. From 0e305881df3580935573d47a4a256dae5873c135 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 13:28:32 +1000 Subject: [PATCH 13/30] Supress warning temporarily --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 2f0fd64..296d3dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod output; mod problem; #[derive(Eq, Debug, PartialEq)] +#[allow(dead_code)] enum Output { Terminal, Native, From 41f2aa5a018891bb66566b56766439831a79a76e Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 13:44:31 +1000 Subject: [PATCH 14/30] Lift attributes onto enclosing Steps --- src/translation/checks/translate.rs | 128 ++++++++++++++++++++++++++++ src/translation/translator.rs | 65 +++++++++++--- 2 files changed, 179 insertions(+), 14 deletions(-) diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index d2d5159..972f782 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -435,3 +435,131 @@ fn top_level_steps_populate_anonymous_wrapper_body() { }; assert_eq!(ops.len(), 2); } + +#[test] +fn attribute_lifts_onto_enclosed_step() { + let source = r#" +% technique v1 + +make_coffee : + +@chef + 1. Grind beans. + "# + .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"); + }; + // AttributeBlock disappears: only the Step appears at this level. + assert_eq!(ops.len(), 1); + let Operation::Step { attributes, .. } = &ops[0] else { + panic!("expected Step, got {:?}", ops[0]); + }; + assert_eq!(attributes.len(), 1); + assert_eq!(attributes[0].len(), 1); + let language::Attribute::Role(id, _) = &attributes[0][0] else { + panic!("expected Role attribute"); + }; + assert_eq!(id.value, "chef"); +} + +#[test] +fn composite_attributes_form_single_frame() { + let source = r#" +% technique v1 + +make_coffee : + +@chef + ^kitchen + 1. Grind beans. + "# + .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::Step { attributes, .. } = &ops[0] else { + panic!("expected Step"); + }; + assert_eq!(attributes.len(), 1, "one frame"); + assert_eq!(attributes[0].len(), 2, "two attributes in the frame"); +} + +#[test] +fn step_without_attribute_block_has_empty_attributes() { + let source = r#" +% technique v1 + +make_coffee : + +1. Plain step. + "# + .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::Step { attributes, .. } = &ops[0] else { + panic!("expected Step"); + }; + assert!(attributes.is_empty()); +} + +#[test] +fn substep_inherits_attribute_through_step_body() { + // The parser nests an AttributeBlock under a Step's body, so the + // attribute applies only to the enclosed substep, not the outer step. + let source = r#" +% technique v1 + +make_coffee : + +1. Outer step. + + @chef + a. Substep under chef. + "# + .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::Step { + attributes: outer_attrs, + body: outer_body, + .. + } = &ops[0] + else { + panic!("expected outer Step"); + }; + assert!(outer_attrs.is_empty(), "outer step has no attributes"); + + let Operation::Sequence(inner) = outer_body.as_ref() else { + panic!("expected inner Sequence"); + }; + let Operation::Step { + attributes: substep_attrs, + .. + } = &inner[0] + else { + panic!("expected substep"); + }; + assert_eq!(substep_attrs.len(), 1); + let language::Attribute::Role(id, _) = &substep_attrs[0][0] else { + panic!("expected Role"); + }; + assert_eq!(id.value, "chef"); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index c71666d..0208d17 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -18,7 +18,7 @@ pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec. let mut wrapper = Subroutine::anonymous(); - wrapper.body = translate_subscopes(scopes); + wrapper.body = translate_subscopes(scopes, &[]); program .subroutines .push(wrapper); @@ -138,7 +138,7 @@ fn translate_procedure<'i>( for element in &procedure.elements { if let language::Element::Steps(scopes, _) = element { for scope in scopes { - ops.push(translate_scope(scope)); + append_attributes(&mut ops, scope, &[]); } } } @@ -157,7 +157,10 @@ fn translate_procedure<'i>( entry.body = body; } -fn translate_scope<'i>(scope: &'i language::Scope<'i>) -> Operation<'i> { +fn translate_scope<'i>( + scope: &'i language::Scope<'i>, + attrs: &[&'i [language::Attribute<'i>]], +) -> Operation<'i> { match scope { language::Scope::SectionChunk { numeral, @@ -166,7 +169,7 @@ fn translate_scope<'i>(scope: &'i language::Scope<'i>) -> Operation<'i> { .. } => { let inner = match body { - language::Technique::Steps(scopes) => translate_subscopes(scopes), + language::Technique::Steps(scopes) => translate_subscopes(scopes, attrs), // Procedures declared inside a section are hoisted into // Program.subroutines by the collect pass; the section // node carries no executable body for them. @@ -186,9 +189,9 @@ fn translate_scope<'i>(scope: &'i language::Scope<'i>) -> Operation<'i> { .. } => Operation::Step { ordinal: Ordinal::Dependent(ordinal), - attributes: Vec::new(), + attributes: attrs.to_vec(), description: description.as_slice(), - body: Box::new(translate_subscopes(subscopes)), + body: Box::new(translate_subscopes(subscopes, attrs)), expects: None, }, language::Scope::ParallelBlock { @@ -197,22 +200,56 @@ fn translate_scope<'i>(scope: &'i language::Scope<'i>) -> Operation<'i> { .. } => Operation::Step { ordinal: Ordinal::Parallel, - attributes: Vec::new(), + attributes: attrs.to_vec(), description: description.as_slice(), - body: Box::new(translate_subscopes(subscopes)), + body: Box::new(translate_subscopes(subscopes, attrs)), expects: None, }, - language::Scope::AttributeBlock { .. } => todo!("attribute block"), + // AttributeBlock is not represented as an Operation; it disappears + // from the output, contributing only its attribute list onto every + // enclosed Step. translate_subscopes / append_attributes intercept it + // before reaching here. + language::Scope::AttributeBlock { .. } => { + unreachable!("AttributeBlock handled by append_attributes") + } language::Scope::CodeBlock { .. } => todo!("code block"), language::Scope::ResponseBlock { .. } => todo!("response block"), } } -fn translate_subscopes<'i>(scopes: &'i [language::Scope<'i>]) -> Operation<'i> { - let ops: Vec<_> = scopes - .iter() - .map(translate_scope) - .collect(); +// Translate a single scope into operations appended to `ops`. Most scopes +// contribute exactly one Operation; AttributeBlock contributes no Operation +// of its own and inlines its (recursively-translated) children with its +// attribute list pushed onto the enclosing-attribute stack. +fn append_attributes<'i>( + ops: &mut Vec>, + scope: &'i language::Scope<'i>, + attrs: &[&'i [language::Attribute<'i>]], +) { + if let language::Scope::AttributeBlock { + attributes, + subscopes, + .. + } = scope + { + let mut nested: Vec<&'i [language::Attribute<'i>]> = attrs.to_vec(); + nested.push(attributes.as_slice()); + for sub in subscopes { + append_attributes(ops, sub, &nested); + } + } else { + ops.push(translate_scope(scope, attrs)); + } +} + +fn translate_subscopes<'i>( + scopes: &'i [language::Scope<'i>], + attrs: &[&'i [language::Attribute<'i>]], +) -> Operation<'i> { + let mut ops = Vec::new(); + for scope in scopes { + append_attributes(&mut ops, scope, attrs); + } Operation::Sequence(ops) } From 52f5a798a44e4be5832224e74045680c7b9f1e78 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 16:56:39 +1000 Subject: [PATCH 15/30] Attach response blocks to enclosing Steps --- src/translation/checks/errors.rs | 27 +++++++ src/translation/checks/translate.rs | 51 ++++++++++++ src/translation/translator.rs | 115 ++++++++++++++++++---------- 3 files changed, 153 insertions(+), 40 deletions(-) diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index bdec14e..3681632 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -91,3 +91,30 @@ This text comes too late. assert_eq!(at.offset, expected); assert!(at.length >= "This text comes too late.".len()); } + +#[test] +fn orphan_response_is_error() { + // ResponseBlock under an AttributeBlock with no enclosing Step. + let source = r#" +% technique v1 + +check : + +@chef + 'Yes' | 'No' + "# + .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::OrphanResponse(at) = &errors[0] else { + panic!("expected OrphanResponse, got {:?}", errors[0]); + }; + let expected = source + .find("'Yes' | 'No'") + .expect("response in source"); + assert_eq!(at.offset, expected); + assert!(at.length >= "'Yes' | 'No'".len()); +} diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 972f782..067f3d9 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -563,3 +563,54 @@ make_coffee : }; assert_eq!(id.value, "chef"); } + +#[test] +fn response_block_attaches_to_parent_step() { + let source = r#" +% technique v1 + +check : + +1. Is everything ready? + + 'Yes' | 'No' + "# + .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::Step { expects, .. } = &ops[0] else { + panic!("expected Step"); + }; + let responses = expects.expect("expects present"); + assert_eq!(responses.len(), 2); + assert_eq!(responses[0].value, "Yes"); + assert_eq!(responses[1].value, "No"); +} + +#[test] +fn step_without_response_block_has_none_expects() { + let source = r#" +% technique v1 + +check : + +1. Plain step. + "# + .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::Step { expects, .. } = &ops[0] else { + panic!("expected Step"); + }; + assert!(expects.is_none()); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 0208d17..0aa8ce0 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -18,7 +18,7 @@ pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec. let mut wrapper = Subroutine::anonymous(); - wrapper.body = translate_subscopes(scopes, &[]); + wrapper.body = translate_subscopes(scopes, &[], &mut errors); program .subroutines .push(wrapper); @@ -131,14 +131,14 @@ fn translate_procedure<'i>( program: &mut Program<'i>, errors: &mut Vec>, ) { - let (title, description) = extract_title_and_description(procedure, errors); + let (title, description) = extract_procedure_elements(procedure, errors); // build body let mut ops = Vec::new(); for element in &procedure.elements { if let language::Element::Steps(scopes, _) = element { for scope in scopes { - append_attributes(&mut ops, scope, &[]); + append_attributes(&mut ops, scope, &[], errors); } } } @@ -160,6 +160,7 @@ fn translate_procedure<'i>( fn translate_scope<'i>( scope: &'i language::Scope<'i>, attrs: &[&'i [language::Attribute<'i>]], + errors: &mut Vec>, ) -> Operation<'i> { match scope { language::Scope::SectionChunk { @@ -169,7 +170,7 @@ fn translate_scope<'i>( .. } => { let inner = match body { - language::Technique::Steps(scopes) => translate_subscopes(scopes, attrs), + language::Technique::Steps(scopes) => translate_subscopes(scopes, attrs, errors), // Procedures declared inside a section are hoisted into // Program.subroutines by the collect pass; the section // node carries no executable body for them. @@ -187,68 +188,102 @@ fn translate_scope<'i>( description, subscopes, .. - } => Operation::Step { - ordinal: Ordinal::Dependent(ordinal), - attributes: attrs.to_vec(), - description: description.as_slice(), - body: Box::new(translate_subscopes(subscopes, attrs)), - expects: None, - }, + } => { + let (body, expects) = translate_step_subscopes(subscopes, attrs, errors); + Operation::Step { + ordinal: Ordinal::Dependent(ordinal), + attributes: attrs.to_vec(), + description: description.as_slice(), + body: Box::new(body), + expects, + } + } language::Scope::ParallelBlock { description, subscopes, .. - } => Operation::Step { - ordinal: Ordinal::Parallel, - attributes: attrs.to_vec(), - description: description.as_slice(), - body: Box::new(translate_subscopes(subscopes, attrs)), - expects: None, - }, - // AttributeBlock is not represented as an Operation; it disappears - // from the output, contributing only its attribute list onto every - // enclosed Step. translate_subscopes / append_attributes intercept it - // before reaching here. - language::Scope::AttributeBlock { .. } => { - unreachable!("AttributeBlock handled by append_attributes") + } => { + let (body, expects) = translate_step_subscopes(subscopes, attrs, errors); + Operation::Step { + ordinal: Ordinal::Parallel, + attributes: attrs.to_vec(), + description: description.as_slice(), + body: Box::new(body), + expects, + } + } + // AttributeBlock and ResponseBlock are intercepted by + // append_attributes before reaching here: AttributeBlock contributes + // its attribute list to enclosed Steps, and ResponseBlock either + // attaches to its parent Step's `expects` (handled by + // translate_step_subscopes) or is reported as an orphan. + language::Scope::AttributeBlock { .. } | language::Scope::ResponseBlock { .. } => { + unreachable!() } language::Scope::CodeBlock { .. } => todo!("code block"), - language::Scope::ResponseBlock { .. } => todo!("response block"), } } +// Walk a step's subscopes, splitting ResponseBlock(s) off for the step's +// `expects` field while emitting the remaining scopes as the step's body. +// If multiple ResponseBlock subscopes appear, the first wins. +fn translate_step_subscopes<'i>( + subscopes: &'i [language::Scope<'i>], + attrs: &[&'i [language::Attribute<'i>]], + errors: &mut Vec>, +) -> (Operation<'i>, Option<&'i [language::Response<'i>]>) { + let mut expects: Option<&'i [language::Response<'i>]> = None; + let mut ops = Vec::new(); + for sub in subscopes { + if let language::Scope::ResponseBlock { responses, .. } = sub { + if expects.is_none() { + expects = Some(responses.as_slice()); + } + } else { + append_attributes(&mut ops, sub, attrs, errors); + } + } + (Operation::Sequence(ops), expects) +} + // Translate a single scope into operations appended to `ops`. Most scopes // contribute exactly one Operation; AttributeBlock contributes no Operation // of its own and inlines its (recursively-translated) children with its -// attribute list pushed onto the enclosing-attribute stack. +// attribute list pushed onto the enclosing-attribute stack. ResponseBlock +// reaching here has no parent step and is reported as an orphan. fn append_attributes<'i>( ops: &mut Vec>, scope: &'i language::Scope<'i>, attrs: &[&'i [language::Attribute<'i>]], + errors: &mut Vec>, ) { - if let language::Scope::AttributeBlock { - attributes, - subscopes, - .. - } = scope - { - let mut nested: Vec<&'i [language::Attribute<'i>]> = attrs.to_vec(); - nested.push(attributes.as_slice()); - for sub in subscopes { - append_attributes(ops, sub, &nested); + match scope { + language::Scope::AttributeBlock { + attributes, + subscopes, + .. + } => { + let mut nested: Vec<&'i [language::Attribute<'i>]> = attrs.to_vec(); + nested.push(attributes.as_slice()); + for sub in subscopes { + append_attributes(ops, sub, &nested, errors); + } } - } else { - ops.push(translate_scope(scope, attrs)); + language::Scope::ResponseBlock { span, .. } => { + errors.push(TranslationError::OrphanResponse(*span)); + } + _ => ops.push(translate_scope(scope, attrs, errors)), } } fn translate_subscopes<'i>( scopes: &'i [language::Scope<'i>], attrs: &[&'i [language::Attribute<'i>]], + errors: &mut Vec>, ) -> Operation<'i> { let mut ops = Vec::new(); for scope in scopes { - append_attributes(&mut ops, scope, attrs); + append_attributes(&mut ops, scope, attrs, errors); } Operation::Sequence(ops) } @@ -260,7 +295,7 @@ fn translate_subscopes<'i>( // CodeBlock element. // Multiple Element::Description occurrences before any Steps/CodeBlock are // not an error; the first wins. -fn extract_title_and_description<'i>( +fn extract_procedure_elements<'i>( procedure: &'i language::Procedure<'i>, errors: &mut Vec>, ) -> (Option<&'i str>, &'i [language::Paragraph<'i>]) { From fc45d865c864baaf5ec1d586ffaade8ac6365316 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 17:01:52 +1000 Subject: [PATCH 16/30] Refactor into stateful Translator --- src/translation/translator.rs | 584 +++++++++++++++++----------------- 1 file changed, 299 insertions(+), 285 deletions(-) diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 0aa8ce0..e094baf 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -8,9 +8,7 @@ use crate::language::{Document, Span}; use super::types::{Operation, Ordinal, Program, Subroutine, SubroutineId}; pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { - let mut program = Program::new(); - let mut errors = Vec::new(); - let mut known: HashMap<&'i str, SubroutineId> = HashMap::new(); + let mut translator = Translator::new(); if let Some(body) = &document.body { if let language::Technique::Steps(scopes) = body { @@ -18,18 +16,22 @@ pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec. let mut wrapper = Subroutine::anonymous(); - wrapper.body = translate_subscopes(scopes, &[], &mut errors); - program + wrapper.body = translator.translate_subscopes(scopes, &[]); + translator + .program .subroutines .push(wrapper); } - collect_technique(body, &mut program, &mut known, &mut errors); + translator.collect_technique(body); } - if errors.is_empty() { - Ok(program) + if translator + .problems + .is_empty() + { + Ok(translator.program) } else { - Err(errors) + Err(translator.problems) } } @@ -47,325 +49,337 @@ pub enum TranslationError<'i> { OrphanResponse(Span), } -// Walk a Technique node, registering any procedures it declares directly or -// transitively through nested sections. Procedures are hoisted into the flat -// Program.subroutines list regardless of where in the section tree they were -// declared. The top-level synthetic anonymous wrapper (for a -// Technique::Steps-only document) is added by translate(), not here. -fn collect_technique<'i>( - technique: &'i language::Technique<'i>, - program: &mut Program<'i>, - known: &mut HashMap<&'i str, SubroutineId>, - errors: &mut Vec>, -) { - match technique { - language::Technique::Procedures(procedures) => { - for procedure in procedures { - if let Some(id) = register_procedure(procedure, program, known, errors) { - translate_procedure(id, procedure, program, errors); - } +struct Translator<'i> { + program: Program<'i>, + problems: Vec>, + known: HashMap<&'i str, SubroutineId>, +} + +impl<'i> Translator<'i> { + fn new() -> Self { + Translator { + program: Program::new(), + problems: Vec::new(), + known: HashMap::new(), + } + } + + // Walk a Technique node, registering any procedures it declares directly + // or transitively through nested sections. Procedures are hoisted into + // the flat Program.subroutines list regardless of where in the section + // tree they were declared. The top-level synthetic anonymous wrapper + // (for a Technique::Steps-only document) is added by translate(), not + // here. + fn collect_technique(&mut self, technique: &'i language::Technique<'i>) { + match technique { + language::Technique::Procedures(procedures) => { + for procedure in procedures { + if let Some(id) = self.register_procedure(procedure) { + self.translate_procedure(id, procedure); + } - // 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. - for element in &procedure.elements { - if let language::Element::Steps(scopes, _) = element { - for scope in scopes { - collect_scope(scope, program, known, errors); + // 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. + for element in &procedure.elements { + if let language::Element::Steps(scopes, _) = element { + for scope in scopes { + self.collect_scope(scope); + } } } } } - } - language::Technique::Steps(scopes) => { - for scope in scopes { - collect_scope(scope, program, known, errors); + language::Technique::Steps(scopes) => { + for scope in scopes { + self.collect_scope(scope); + } } + language::Technique::Empty => {} } - language::Technique::Empty => {} } -} -// Pass 1: gather a procedure's name and reserve its slot in -// Program.subroutines. -fn register_procedure<'i>( - procedure: &'i language::Procedure<'i>, - program: &mut Program<'i>, - known: &mut HashMap<&'i str, SubroutineId>, - errors: &mut Vec>, -) -> Option { - let name = procedure - .name - .value; - let span = procedure - .name - .span; + // Pass 1: gather a procedure's name and reserve its slot in + // Program.subroutines. + fn register_procedure( + &mut self, + procedure: &'i language::Procedure<'i>, + ) -> Option { + let name = procedure + .name + .value; + let span = procedure + .name + .span; - if known.contains_key(name) { - errors.push(TranslationError::DuplicateProcedure(language::Identifier { - value: name, - span, - })); - return None; - } + if self + .known + .contains_key(name) + { + self.problems + .push(TranslationError::DuplicateProcedure(language::Identifier { + value: name, + span, + })); + return None; + } - let id = SubroutineId( - program + let id = SubroutineId( + self.program + .subroutines + .len(), + ); + self.known + .insert(name, id); + self.program .subroutines - .len(), - ); - known.insert(name, id); - program - .subroutines - .push(Subroutine::new(language::Identifier { value: name, span })); - Some(id) -} + .push(Subroutine::new(language::Identifier { value: name, span })); + Some(id) + } -// Pass 2: populate a registered subroutine with its title, description, -// parameters, signature, and body. -fn translate_procedure<'i>( - id: SubroutineId, - procedure: &'i language::Procedure<'i>, - program: &mut Program<'i>, - errors: &mut Vec>, -) { - let (title, description) = extract_procedure_elements(procedure, errors); + // Pass 2: populate a registered subroutine with its title, description, + // parameters, signature, and body. + fn translate_procedure(&mut self, id: SubroutineId, procedure: &'i language::Procedure<'i>) { + let (title, description) = self.extract_procedure_elements(procedure); - // build body - let mut ops = Vec::new(); - for element in &procedure.elements { - if let language::Element::Steps(scopes, _) = element { - for scope in scopes { - append_attributes(&mut ops, scope, &[], errors); + // build body + let mut ops = Vec::new(); + for element in &procedure.elements { + if let language::Element::Steps(scopes, _) = element { + for scope in scopes { + self.append_attributes(&mut ops, scope, &[]); + } } } - } - let body = Operation::Sequence(ops); + let body = Operation::Sequence(ops); - let entry = &mut program.subroutines[id.0]; - entry.title = title; - entry.description = description; - entry.parameters = procedure - .parameters - .as_ref() - .map(Vec::as_slice); - entry.signature = procedure - .signature - .as_ref(); - entry.body = body; -} + let entry = &mut self + .program + .subroutines[id.0]; + entry.title = title; + entry.description = description; + entry.parameters = procedure + .parameters + .as_ref() + .map(Vec::as_slice); + entry.signature = procedure + .signature + .as_ref(); + entry.body = body; + } -fn translate_scope<'i>( - scope: &'i language::Scope<'i>, - attrs: &[&'i [language::Attribute<'i>]], - errors: &mut Vec>, -) -> Operation<'i> { - match scope { - language::Scope::SectionChunk { - numeral, - title, - body, - .. - } => { - let inner = match body { - language::Technique::Steps(scopes) => translate_subscopes(scopes, attrs, errors), - // Procedures declared inside a section are hoisted into - // Program.subroutines by the collect pass; the section - // node carries no executable body for them. - language::Technique::Procedures(_) => Operation::Sequence(Vec::new()), - language::Technique::Empty => Operation::Sequence(Vec::new()), - }; - Operation::Section { + fn translate_scope( + &mut self, + scope: &'i language::Scope<'i>, + attrs: &[&'i [language::Attribute<'i>]], + ) -> Operation<'i> { + match scope { + language::Scope::SectionChunk { numeral, - title: title.as_ref(), - body: Box::new(inner), + title, + body, + .. + } => { + let inner = match body { + language::Technique::Steps(scopes) => self.translate_subscopes(scopes, attrs), + // Procedures declared inside a section are hoisted into + // Program.subroutines by the collect pass; the section + // node carries no executable body for them. + language::Technique::Procedures(_) => Operation::Sequence(Vec::new()), + language::Technique::Empty => Operation::Sequence(Vec::new()), + }; + Operation::Section { + numeral, + title: title.as_ref(), + body: Box::new(inner), + } } - } - language::Scope::DependentBlock { - ordinal, - description, - subscopes, - .. - } => { - let (body, expects) = translate_step_subscopes(subscopes, attrs, errors); - Operation::Step { - ordinal: Ordinal::Dependent(ordinal), - attributes: attrs.to_vec(), - description: description.as_slice(), - body: Box::new(body), - expects, + language::Scope::DependentBlock { + ordinal, + description, + subscopes, + .. + } => { + let (body, expects) = self.translate_step_subscopes(subscopes, attrs); + Operation::Step { + ordinal: Ordinal::Dependent(ordinal), + attributes: attrs.to_vec(), + description: description.as_slice(), + body: Box::new(body), + expects, + } } - } - language::Scope::ParallelBlock { - description, - subscopes, - .. - } => { - let (body, expects) = translate_step_subscopes(subscopes, attrs, errors); - Operation::Step { - ordinal: Ordinal::Parallel, - attributes: attrs.to_vec(), - description: description.as_slice(), - body: Box::new(body), - expects, + language::Scope::ParallelBlock { + description, + subscopes, + .. + } => { + let (body, expects) = self.translate_step_subscopes(subscopes, attrs); + Operation::Step { + ordinal: Ordinal::Parallel, + attributes: attrs.to_vec(), + description: description.as_slice(), + body: Box::new(body), + expects, + } } + // AttributeBlock and ResponseBlock are intercepted by + // append_attributes before reaching here: AttributeBlock + // contributes its attribute list to enclosed Steps, and + // ResponseBlock either attaches to its parent Step's `expects` + // (handled by translate_step_subscopes) or is reported as an + // orphan. + language::Scope::AttributeBlock { .. } | language::Scope::ResponseBlock { .. } => { + unreachable!() + } + language::Scope::CodeBlock { .. } => todo!("code block"), } - // AttributeBlock and ResponseBlock are intercepted by - // append_attributes before reaching here: AttributeBlock contributes - // its attribute list to enclosed Steps, and ResponseBlock either - // attaches to its parent Step's `expects` (handled by - // translate_step_subscopes) or is reported as an orphan. - language::Scope::AttributeBlock { .. } | language::Scope::ResponseBlock { .. } => { - unreachable!() - } - language::Scope::CodeBlock { .. } => todo!("code block"), } -} -// Walk a step's subscopes, splitting ResponseBlock(s) off for the step's -// `expects` field while emitting the remaining scopes as the step's body. -// If multiple ResponseBlock subscopes appear, the first wins. -fn translate_step_subscopes<'i>( - subscopes: &'i [language::Scope<'i>], - attrs: &[&'i [language::Attribute<'i>]], - errors: &mut Vec>, -) -> (Operation<'i>, Option<&'i [language::Response<'i>]>) { - let mut expects: Option<&'i [language::Response<'i>]> = None; - let mut ops = Vec::new(); - for sub in subscopes { - if let language::Scope::ResponseBlock { responses, .. } = sub { - if expects.is_none() { - expects = Some(responses.as_slice()); + // Walk a step's subscopes, splitting ResponseBlock(s) off for the step's + // `expects` field while emitting the remaining scopes as the step's + // body. If multiple ResponseBlock subscopes appear, the first wins. + fn translate_step_subscopes( + &mut self, + subscopes: &'i [language::Scope<'i>], + attrs: &[&'i [language::Attribute<'i>]], + ) -> (Operation<'i>, Option<&'i [language::Response<'i>]>) { + let mut expects: Option<&'i [language::Response<'i>]> = None; + let mut ops = Vec::new(); + for sub in subscopes { + if let language::Scope::ResponseBlock { responses, .. } = sub { + if expects.is_none() { + expects = Some(responses.as_slice()); + } + } else { + self.append_attributes(&mut ops, sub, attrs); } - } else { - append_attributes(&mut ops, sub, attrs, errors); } + (Operation::Sequence(ops), expects) } - (Operation::Sequence(ops), expects) -} -// Translate a single scope into operations appended to `ops`. Most scopes -// contribute exactly one Operation; AttributeBlock contributes no Operation -// of its own and inlines its (recursively-translated) children with its -// attribute list pushed onto the enclosing-attribute stack. ResponseBlock -// reaching here has no parent step and is reported as an orphan. -fn append_attributes<'i>( - ops: &mut Vec>, - scope: &'i language::Scope<'i>, - attrs: &[&'i [language::Attribute<'i>]], - errors: &mut Vec>, -) { - match scope { - language::Scope::AttributeBlock { - attributes, - subscopes, - .. - } => { - let mut nested: Vec<&'i [language::Attribute<'i>]> = attrs.to_vec(); - nested.push(attributes.as_slice()); - for sub in subscopes { - append_attributes(ops, sub, &nested, errors); + // Translate a single scope into operations appended to `ops`. Most + // scopes contribute exactly one Operation; AttributeBlock contributes + // no Operation of its own and inlines its (recursively-translated) + // children with its attribute list pushed onto the enclosing-attribute + // stack. ResponseBlock reaching here has no parent step and is reported + // as an orphan. + fn append_attributes( + &mut self, + ops: &mut Vec>, + scope: &'i language::Scope<'i>, + attrs: &[&'i [language::Attribute<'i>]], + ) { + match scope { + language::Scope::AttributeBlock { + attributes, + subscopes, + .. + } => { + let mut nested: Vec<&'i [language::Attribute<'i>]> = attrs.to_vec(); + nested.push(attributes.as_slice()); + for sub in subscopes { + self.append_attributes(ops, sub, &nested); + } } + language::Scope::ResponseBlock { span, .. } => { + self.problems + .push(TranslationError::OrphanResponse(*span)); + } + _ => ops.push(self.translate_scope(scope, attrs)), } - language::Scope::ResponseBlock { span, .. } => { - errors.push(TranslationError::OrphanResponse(*span)); - } - _ => ops.push(translate_scope(scope, attrs, errors)), } -} -fn translate_subscopes<'i>( - scopes: &'i [language::Scope<'i>], - attrs: &[&'i [language::Attribute<'i>]], - errors: &mut Vec>, -) -> Operation<'i> { - let mut ops = Vec::new(); - for scope in scopes { - append_attributes(&mut ops, scope, attrs, errors); + fn translate_subscopes( + &mut self, + scopes: &'i [language::Scope<'i>], + attrs: &[&'i [language::Attribute<'i>]], + ) -> Operation<'i> { + let mut ops = Vec::new(); + for scope in scopes { + self.append_attributes(&mut ops, scope, attrs); + } + Operation::Sequence(ops) } - Operation::Sequence(ops) -} -// Walk a procedure's elements to extract the procedure-shell title and -// description, surfacing the two structural errors: -// DuplicateTitle - a second Element::Title in this procedure. -// InterleavedDescription - an Element::Description after a Steps or -// CodeBlock element. -// Multiple Element::Description occurrences before any Steps/CodeBlock are -// not an error; the first wins. -fn extract_procedure_elements<'i>( - procedure: &'i language::Procedure<'i>, - errors: &mut Vec>, -) -> (Option<&'i str>, &'i [language::Paragraph<'i>]) { - let mut title: Option<&'i str> = None; - let mut description: Option<&'i [language::Paragraph<'i>]> = None; - let mut blocked = false; + // Walk a procedure's elements to extract the procedure-shell title and + // description, surfacing the two structural errors: + // DuplicateTitle - a second Element::Title in this procedure. + // InterleavedDescription - an Element::Description after a Steps or + // CodeBlock element. + // Multiple Element::Description occurrences before any Steps/CodeBlock + // are not an error; the first wins. + fn extract_procedure_elements( + &mut self, + procedure: &'i language::Procedure<'i>, + ) -> (Option<&'i str>, &'i [language::Paragraph<'i>]) { + let mut title: Option<&'i str> = None; + let mut description: Option<&'i [language::Paragraph<'i>]> = None; + let mut blocked = false; - for element in &procedure.elements { - match element { - language::Element::Title(value, span) => { - if title.is_some() { - errors.push(TranslationError::DuplicateTitle { - procedure: language::Identifier { - value: procedure - .name - .value, - span: procedure - .name - .span, - }, - at: *span, - }); - } else { - title = Some(*value); + for element in &procedure.elements { + match element { + language::Element::Title(value, span) => { + if title.is_some() { + self.problems + .push(TranslationError::DuplicateTitle { + procedure: language::Identifier { + value: procedure + .name + .value, + span: procedure + .name + .span, + }, + at: *span, + }); + } else { + title = Some(*value); + } } - } - language::Element::Description(paragraphs, span) => { - if blocked { - errors.push(TranslationError::InterleavedDescription { - procedure: language::Identifier { - value: procedure - .name - .value, - span: procedure - .name - .span, - }, - at: *span, - }); - } else if description.is_none() { - description = Some(paragraphs.as_slice()); + language::Element::Description(paragraphs, span) => { + if blocked { + self.problems + .push(TranslationError::InterleavedDescription { + procedure: language::Identifier { + value: procedure + .name + .value, + span: procedure + .name + .span, + }, + at: *span, + }); + } else if description.is_none() { + description = Some(paragraphs.as_slice()); + } + } + language::Element::Steps(_, _) | language::Element::CodeBlock(_, _) => { + blocked = true; } - } - language::Element::Steps(_, _) | language::Element::CodeBlock(_, _) => { - blocked = true; } } - } - (title, description.unwrap_or(&[])) -} + (title, description.unwrap_or(&[])) + } -fn collect_scope<'i>( - scope: &'i language::Scope<'i>, - program: &mut Program<'i>, - known: &mut HashMap<&'i str, SubroutineId>, - errors: &mut Vec>, -) { - match scope { - language::Scope::SectionChunk { body, .. } => { - collect_technique(body, program, known, errors); - } - language::Scope::DependentBlock { subscopes, .. } - | language::Scope::ParallelBlock { subscopes, .. } - | language::Scope::AttributeBlock { subscopes, .. } - | language::Scope::CodeBlock { subscopes, .. } => { - for sub in subscopes { - collect_scope(sub, program, known, errors); + fn collect_scope(&mut self, scope: &'i language::Scope<'i>) { + match scope { + language::Scope::SectionChunk { body, .. } => { + self.collect_technique(body); + } + language::Scope::DependentBlock { subscopes, .. } + | language::Scope::ParallelBlock { subscopes, .. } + | language::Scope::AttributeBlock { subscopes, .. } + | language::Scope::CodeBlock { subscopes, .. } => { + for sub in subscopes { + self.collect_scope(sub); + } } + language::Scope::ResponseBlock { .. } => {} } - language::Scope::ResponseBlock { .. } => {} } } From 8aa82e91c66f615b62311e27243dbe86fc090192 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 18:16:12 +1000 Subject: [PATCH 17/30] Translate expressions, invocations, and bindings --- src/language/quantity.rs | 2 +- src/language/types.rs | 4 +- src/translation/checks/translate.rs | 208 +++++++++++++++++++++++++++- src/translation/translator.rs | 123 ++++++++++++---- src/translation/types.rs | 4 +- 5 files changed, 307 insertions(+), 34 deletions(-) diff --git a/src/language/quantity.rs b/src/language/quantity.rs index 89e2fb2..f153d69 100644 --- a/src/language/quantity.rs +++ b/src/language/quantity.rs @@ -23,7 +23,7 @@ use crate::regex::*; use std::fmt::{self, Display}; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Quantity<'i> { pub mantissa: Decimal, pub uncertainty: Option, diff --git a/src/language/types.rs b/src/language/types.rs index 7df1fc6..89a91f9 100644 --- a/src/language/types.rs +++ b/src/language/types.rs @@ -108,7 +108,7 @@ impl<'i> Procedure<'i> { } } -#[derive(Eq, Debug)] +#[derive(Clone, Copy, Eq, Debug)] pub struct Identifier<'i> { pub value: &'i str, pub span: Span, @@ -445,7 +445,7 @@ impl PartialEq for Expression<'_> { } } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Numeric<'i> { Integral(i64), Scientific(Quantity<'i>), diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 067f3d9..810e00f 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -7,7 +7,7 @@ use std::path::Path; use crate::language; use crate::parsing; -use crate::translation::{translate, Operation, Ordinal}; +use crate::translation::{translate, Fragment, Operation, Ordinal, SubroutineRef}; #[test] fn empty_input_yields_empty_program() { @@ -614,3 +614,209 @@ check : }; assert!(expects.is_none()); } + +#[test] +fn expression_variable_translates() { + let source = r#" +% technique v1 + +run : + +{ + x +} + "# + .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::Variable(id) = &ops[0] else { + panic!("expected Variable, got {:?}", ops[0]); + }; + assert_eq!(id.value, "x"); +} + +#[test] +fn expression_number_translates() { + let source = r#" +% technique v1 + +run : + +{ + 42 +} + "# + .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::Number(numeric) = &ops[0] else { + panic!("expected Number, got {:?}", ops[0]); + }; + let language::Numeric::Integral(n) = numeric else { + panic!("expected Integral"); + }; + assert_eq!(*n, 42); +} + +#[test] +fn expression_string_translates() { + let source = r#" +% technique v1 + +run : + +{ + journal("hello") +} + "# + .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::Execution { arguments, .. } = &ops[0] else { + panic!("expected Execution"); + }; + let Operation::String(fragments) = &arguments[0] else { + panic!("expected String"); + }; + assert_eq!(fragments.len(), 1); + let Fragment::Text(text) = &fragments[0] else { + panic!("expected Text fragment"); + }; + assert_eq!(*text, "hello"); +} + +#[test] +fn expression_execution_translates() { + let source = r#" +% technique v1 + +run : + +{ + sum(1, 2) +} + "# + .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::Execution { target, arguments } = &ops[0] else { + panic!("expected Execution, got {:?}", ops[0]); + }; + assert_eq!(target.value, "sum"); + assert_eq!(arguments.len(), 2); +} + +#[test] +fn expression_application_translates_as_unresolved_invoke() { + let source = r#" +% technique v1 + +run : + +{ + (x) +} + +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(invoke) = &ops[0] else { + panic!("expected Invoke, got {:?}", ops[0]); + }; + let SubroutineRef::Unresolved(id) = &invoke.target else { + panic!("expected Unresolved (resolution is a later pass)"); + }; + assert_eq!(id.value, "other"); + assert_eq!( + invoke + .arguments + .len(), + 1 + ); +} + +#[test] +fn expression_binding_translates() { + let source = r#" +% technique v1 + +run : + +{ + 42 ~ answer +} + "# + .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::Bind { names, value } = &ops[0] else { + panic!("expected Bind, got {:?}", ops[0]); + }; + assert_eq!(names.len(), 1); + assert_eq!(names[0].value, "answer"); + let Operation::Number(_) = value.as_ref() else { + panic!("expected Number value"); + }; +} + +#[test] +fn expression_tablet_translates() { + let source = r#" +% technique v1 + +run : + +{ + [ + "speed" = 3.0 × 10⁸ m/s + "weight" = 84.0 kg + ] +} + "# + .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::Tablet(entries) = &ops[0] else { + panic!("expected Tablet, got {:?}", ops[0]); + }; + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].label, "speed"); + assert_eq!(entries[1].label, "weight"); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index e094baf..02f6765 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -5,7 +5,9 @@ use std::collections::HashMap; use crate::language; use crate::language::{Document, Span}; -use super::types::{Operation, Ordinal, Program, Subroutine, SubroutineId}; +use super::types::{ + Entry, Fragment, Invoke, Operation, Ordinal, Program, Subroutine, SubroutineId, SubroutineRef, +}; pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { let mut translator = Translator::new(); @@ -110,19 +112,13 @@ impl<'i> Translator<'i> { let name = procedure .name .value; - let span = procedure - .name - .span; if self .known .contains_key(name) { self.problems - .push(TranslationError::DuplicateProcedure(language::Identifier { - value: name, - span, - })); + .push(TranslationError::DuplicateProcedure(procedure.name)); return None; } @@ -135,7 +131,7 @@ impl<'i> Translator<'i> { .insert(name, id); self.program .subroutines - .push(Subroutine::new(language::Identifier { value: name, span })); + .push(Subroutine::new(procedure.name)); Some(id) } @@ -147,10 +143,18 @@ impl<'i> Translator<'i> { // build body let mut ops = Vec::new(); for element in &procedure.elements { - if let language::Element::Steps(scopes, _) = element { - for scope in scopes { - self.append_attributes(&mut ops, scope, &[]); + match element { + language::Element::Steps(scopes, _) => { + for scope in scopes { + self.append_attributes(&mut ops, scope, &[]); + } } + language::Element::CodeBlock(expressions, _) => { + for expression in expressions { + ops.push(self.translate_expression(expression)); + } + } + _ => {} } } let body = Operation::Sequence(ops); @@ -325,14 +329,7 @@ impl<'i> Translator<'i> { if title.is_some() { self.problems .push(TranslationError::DuplicateTitle { - procedure: language::Identifier { - value: procedure - .name - .value, - span: procedure - .name - .span, - }, + procedure: procedure.name, at: *span, }); } else { @@ -343,14 +340,7 @@ impl<'i> Translator<'i> { if blocked { self.problems .push(TranslationError::InterleavedDescription { - procedure: language::Identifier { - value: procedure - .name - .value, - span: procedure - .name - .span, - }, + procedure: procedure.name, at: *span, }); } else if description.is_none() { @@ -382,4 +372,81 @@ impl<'i> Translator<'i> { language::Scope::ResponseBlock { .. } => {} } } + + fn translate_expression(&mut self, expression: &'i language::Expression<'i>) -> Operation<'i> { + match expression { + language::Expression::Variable(id, _) => Operation::Variable(*id), + language::Expression::Number(numeric, _) => Operation::Number(*numeric), + language::Expression::String(pieces, _) => { + let fragments = pieces + .iter() + .map(|piece| match piece { + language::Piece::Text(text) => Fragment::Text(text), + language::Piece::Interpolation(expr) => { + Fragment::Interpolation(self.translate_expression(expr)) + } + }) + .collect(); + Operation::String(fragments) + } + language::Expression::Multiline(lang, lines, _) => { + Operation::Multiline(*lang, lines.clone()) + } + language::Expression::Tablet(pairs, _) => { + let entries = pairs + .iter() + .map(|pair| Entry { + label: pair.label, + value: self.translate_expression(&pair.value), + }) + .collect(); + Operation::Tablet(entries) + } + language::Expression::Application(invocation, _) => { + Operation::Invoke(self.translate_invocation(invocation)) + } + language::Expression::Execution(function, _) => Operation::Execution { + target: function.target, + arguments: function + .parameters + .iter() + .map(|expr| self.translate_expression(expr)) + .collect(), + }, + language::Expression::Repeat(body, _) => Operation::Loop { + names: &[], + over: None, + body: Box::new(self.translate_expression(body)), + }, + // Standalone Foreach has no body in the AST; the body is supplied + // by the enclosing CodeBlock's subscopes when one is present (see + // CodeBlock translation). As a bare expression we emit a Loop + // with an empty Sequence body. + language::Expression::Foreach(names, source, _) => Operation::Loop { + names, + over: Some(Box::new(self.translate_expression(source))), + body: Box::new(Operation::Sequence(Vec::new())), + }, + language::Expression::Binding(value, names, _) => Operation::Bind { + names, + value: Box::new(self.translate_expression(value)), + }, + language::Expression::Separator => Operation::Sequence(Vec::new()), + } + } + + fn translate_invocation(&mut self, invocation: &'i language::Invocation<'i>) -> Invoke<'i> { + let target = match &invocation.target { + language::Target::Local(id) => SubroutineRef::Unresolved(*id), + language::Target::Remote(_) => todo!("remote invocation target"), + }; + let arguments = match &invocation.parameters { + Some(params) => params + .iter() + .map(|expr| self.translate_expression(expr)) + .collect(), + None => Vec::new(), + }; + Invoke { target, arguments } + } } diff --git a/src/translation/types.rs b/src/translation/types.rs index be9c733..347c3aa 100644 --- a/src/translation/types.rs +++ b/src/translation/types.rs @@ -105,12 +105,12 @@ pub enum Operation<'i> { expects: Option<&'i [language::Response<'i>]>, }, Loop { - names: Vec>, + names: &'i [language::Identifier<'i>], over: Option>>, body: Box>, }, Bind { - names: Vec>, + names: &'i [language::Identifier<'i>], value: Box>, }, } From 7f942c5d3711f362d7dac005e5076f18de8c8900 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 19:59:26 +1000 Subject: [PATCH 18/30] Translate code blocks --- src/translation/checks/translate.rs | 60 +++++++++++++++++++++++++++++ src/translation/translator.rs | 19 ++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 810e00f..6afbd86 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -820,3 +820,63 @@ run : assert_eq!(entries[0].label, "speed"); assert_eq!(entries[1].label, "weight"); } + +#[test] +fn foreach_codeblock_becomes_loop_with_subscopes_as_body() { + let source = r#" +% technique v1 + +run : + +@worker + { foreach node in seq(1, 6) } + 1. Check Availability + 2. Confirm. + "# + .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::Loop { names, over, body } = &ops[0] else { + panic!("expected Loop, got {:?}", ops[0]); + }; + assert_eq!(names.len(), 1); + assert_eq!(names[0].value, "node"); + assert!(over.is_some(), "foreach has a source"); + + let Operation::Sequence(inner) = body.as_ref() else { + panic!("expected inner Sequence"); + }; + assert_eq!(inner.len(), 2); +} + +#[test] +fn foreach_with_tuple_names_borrows_all() { + let source = r#" +% technique v1 + +run : + +@worker + { foreach (design, component) in zip(designs, components) } + a. process + "# + .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::Loop { names, .. } = &ops[0] else { + panic!("expected Loop"); + }; + assert_eq!(names.len(), 2); + assert_eq!(names[0].value, "design"); + assert_eq!(names[1].value, "component"); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 02f6765..e3dc25c 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -238,7 +238,24 @@ impl<'i> Translator<'i> { language::Scope::AttributeBlock { .. } | language::Scope::ResponseBlock { .. } => { unreachable!() } - language::Scope::CodeBlock { .. } => todo!("code block"), + language::Scope::CodeBlock { + expressions, + subscopes, + .. + } => match expressions.first() { + Some(language::Expression::Foreach(names, source, _)) => Operation::Loop { + names, + over: Some(Box::new(self.translate_expression(source))), + body: Box::new(self.translate_subscopes(subscopes, attrs)), + }, + Some(language::Expression::Repeat(_, _)) => Operation::Loop { + names: &[], + over: None, + body: Box::new(self.translate_subscopes(subscopes, attrs)), + }, + Some(other) => self.translate_expression(other), + None => self.translate_subscopes(subscopes, attrs), + }, } } From b5d59f2685f4ffce5bc84c6f2eac6f07e0b71ef9 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 22:08:57 +1000 Subject: [PATCH 19/30] Refine translation type names --- src/translation/types.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/translation/types.rs b/src/translation/types.rs index 347c3aa..e188ae5 100644 --- a/src/translation/types.rs +++ b/src/translation/types.rs @@ -86,11 +86,8 @@ pub enum Operation<'i> { String(Vec>), Multiline(Option<&'i str>, Vec<&'i str>), Tablet(Vec>), - Invoke(Invoke<'i>), - Execution { - target: language::Identifier<'i>, - arguments: Vec>, - }, + Invoke(Invocable<'i>), + Execute(Executable<'i>), Sequence(Vec>), Section { numeral: &'i str, @@ -126,7 +123,7 @@ pub enum Ordinal<'i> { } #[derive(Debug, Eq, PartialEq)] -pub struct Invoke<'i> { +pub struct Invocable<'i> { pub target: SubroutineRef<'i>, pub arguments: Vec>, } @@ -134,15 +131,25 @@ pub struct Invoke<'i> { /// Reference to a subroutine. The collect pass registers every declared /// subroutine into `Program.subroutines`; the resolve pass walks the /// translated tree replacing matching `Unresolved` references with -/// `Resolved`. Names that don't match any declared subroutine remain -/// `Unresolved` - they are typically builtin functions (`exec`, `now`, -/// `zip`, ...) and are not translation errors. +/// `Resolved`. Names that don't match any declared subroutine become a +/// translation error. #[derive(Debug, Eq, PartialEq)] pub enum SubroutineRef<'i> { Unresolved(language::Identifier<'i>), Resolved(SubroutineId), } +/// Lowered form of `language::Function`. Functions live in a separate +/// namespace from procedures: they are built-in or host-provided. The target +/// is kept as a plain Identifier here; resolution happens at a later +/// domain-linking phase, against whatever functions the executing domain +/// provides. +#[derive(Debug, Eq, PartialEq)] +pub struct Executable<'i> { + pub target: language::Identifier<'i>, + pub arguments: Vec>, +} + /// A fragment of a string literal: either inline text or an interpolated /// expression. Defined here (rather than reusing `language::Piece`) because /// interpolations are themselves `Operation`s and may carry resolved From 903d28682d6c675918756dffde90c335a53b6cba Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 7 May 2026 22:09:34 +1000 Subject: [PATCH 20/30] Resolve procedure invocations or error --- src/problem/messages.rs | 4 + src/translation/checks/errors.rs | 23 +++++ src/translation/checks/translate.rs | 154 +++++++++++++++++++++++++--- src/translation/mod.rs | 3 +- src/translation/translator.rs | 80 ++++++++++++++- 5 files changed, 243 insertions(+), 21 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 97a1d0c..cedd89c 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1046,5 +1046,9 @@ pub fn generate_translation_error<'i>( "Response block without a parent step".to_string(), "A response block ('Yes' | 'No') must follow a step it qualifies; it cannot stand alone.".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(), + ), } } diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 3681632..eff40a1 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -118,3 +118,26 @@ check : assert_eq!(at.offset, expected); assert!(at.length >= "'Yes' | 'No'".len()); } + +#[test] +fn unresolved_procedure_invocation_is_error() { + let source = r#" +% technique v1 + +main : + +{ + (x) +} + "# + .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::UnresolvedProcedure(id) = &errors[0] else { + panic!("expected UnresolvedProcedure, got {:?}", errors[0]); + }; + assert_eq!(id.value, "does_not_exist"); +} diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 6afbd86..4ff313b 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -7,7 +7,7 @@ use std::path::Path; use crate::language; use crate::parsing; -use crate::translation::{translate, Fragment, Operation, Ordinal, SubroutineRef}; +use crate::translation::{translate, Fragment, Operation, Ordinal, SubroutineId, SubroutineRef}; #[test] fn empty_input_yields_empty_program() { @@ -687,10 +687,10 @@ run : let Operation::Sequence(ops) = &program.subroutines[0].body else { panic!("expected Sequence"); }; - let Operation::Execution { arguments, .. } = &ops[0] else { - panic!("expected Execution"); + let Operation::Execute(executable) = &ops[0] else { + panic!("expected Execute"); }; - let Operation::String(fragments) = &arguments[0] else { + let Operation::String(fragments) = &executable.arguments[0] else { panic!("expected String"); }; assert_eq!(fragments.len(), 1); @@ -719,15 +719,25 @@ run : let Operation::Sequence(ops) = &program.subroutines[0].body else { panic!("expected Sequence"); }; - let Operation::Execution { target, arguments } = &ops[0] else { - panic!("expected Execution, got {:?}", ops[0]); + let Operation::Execute(executable) = &ops[0] else { + panic!("expected Execute, got {:?}", ops[0]); }; - assert_eq!(target.value, "sum"); - assert_eq!(arguments.len(), 2); + assert_eq!( + executable + .target + .value, + "sum" + ); + assert_eq!( + executable + .arguments + .len(), + 2 + ); } #[test] -fn expression_application_translates_as_unresolved_invoke() { +fn expression_application_translates_as_invoke() { let source = r#" % technique v1 @@ -747,15 +757,11 @@ other : X -> Y let Operation::Sequence(ops) = &program.subroutines[0].body else { panic!("expected Sequence"); }; - let Operation::Invoke(invoke) = &ops[0] else { + let Operation::Invoke(invocable) = &ops[0] else { panic!("expected Invoke, got {:?}", ops[0]); }; - let SubroutineRef::Unresolved(id) = &invoke.target else { - panic!("expected Unresolved (resolution is a later pass)"); - }; - assert_eq!(id.value, "other"); assert_eq!( - invoke + invocable .arguments .len(), 1 @@ -880,3 +886,121 @@ run : assert_eq!(names[0].value, "design"); assert_eq!(names[1].value, "component"); } + +#[test] +fn invoke_resolves_forward_reference() { + // helper is declared *after* main, but the resolve pass should still + // wire it up because Pass 1 registered all names before Pass 3 ran. + let source = r#" +% technique v1 + +main : + +{ + (x) +} + +helper : 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 helper_idx = program + .subroutines + .iter() + .position(|s| { + s.name + .as_ref() + .map(|n| n.value) + == Some("helper") + }) + .expect("helper present"); + + let Operation::Sequence(ops) = &program.subroutines[0].body else { + panic!("expected Sequence"); + }; + let Operation::Invoke(invocable) = &ops[0] else { + panic!("expected Invoke"); + }; + let SubroutineRef::Resolved(SubroutineId(idx)) = invocable.target else { + panic!("expected Resolved, got {:?}", invocable.target); + }; + assert_eq!(idx, helper_idx); +} + +#[test] +fn invoke_resolves_backward_reference() { + // helper is declared *before* main; same expectation. + let source = r#" +% technique v1 + +helper : X -> Y + +main : + +{ + (x) +} + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + let main_idx = program + .subroutines + .iter() + .position(|s| { + s.name + .as_ref() + .map(|n| n.value) + == Some("main") + }) + .expect("main present"); + + let Operation::Sequence(ops) = &program.subroutines[main_idx].body else { + panic!("expected Sequence"); + }; + let Operation::Invoke(invocable) = &ops[0] else { + panic!("expected Invoke"); + }; + let SubroutineRef::Resolved(_) = invocable.target else { + panic!("expected Resolved, got {:?}", invocable.target); + }; +} + +#[test] +fn executable_target_left_alone_by_resolve() { + // Function calls (the `name(...)` form, no angle brackets) live in a + // separate namespace -- they're built-in or host-provided and are + // resolved at a later phase against the executing domain. The resolve + // pass must leave Operation::Execute targets untouched. + let source = r#" +% technique v1 + +run : + +{ + journal("started") +} + "# + .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::Execute(executable) = &ops[0] else { + panic!("expected Execute, got {:?}", ops[0]); + }; + assert_eq!( + executable + .target + .value, + "journal" + ); +} diff --git a/src/translation/mod.rs b/src/translation/mod.rs index 2393c29..6681d0e 100644 --- a/src/translation/mod.rs +++ b/src/translation/mod.rs @@ -6,7 +6,8 @@ mod types; pub use translator::{translate, TranslationError}; pub use types::{ - Entry, Fragment, Invoke, Operation, Ordinal, Program, Subroutine, SubroutineId, SubroutineRef, + Entry, Executable, Fragment, Invocable, Operation, Ordinal, Program, Subroutine, SubroutineId, + SubroutineRef, }; #[cfg(test)] diff --git a/src/translation/translator.rs b/src/translation/translator.rs index e3dc25c..86fac56 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -6,7 +6,8 @@ use crate::language; use crate::language::{Document, Span}; use super::types::{ - Entry, Fragment, Invoke, Operation, Ordinal, Program, Subroutine, SubroutineId, SubroutineRef, + Entry, Executable, Fragment, Invocable, Operation, Ordinal, Program, Subroutine, SubroutineId, + SubroutineRef, }; pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { @@ -25,6 +26,7 @@ pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec { at: Span, }, OrphanResponse(Span), + /// A local procedure invocation `(...)` whose `name` doesn't + /// match any procedure declared in this document is an error. + UnresolvedProcedure(language::Identifier<'i>), } struct Translator<'i> { @@ -390,6 +395,71 @@ impl<'i> Translator<'i> { } } + // Pass 3: walk the translated subroutines, resolving every Invoke + // target. Local procedure references that match a name registered by + // Pass 1 become Resolved(SubroutineId); unmatched local references + // are errors. + fn resolve_references(&mut self) { + for subroutine in &mut self + .program + .subroutines + { + Self::resolve_operation(&mut subroutine.body, &self.known, &mut self.problems); + } + } + + fn resolve_operation( + op: &mut Operation<'i>, + known: &HashMap<&'i str, SubroutineId>, + 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), + None => problems.push(TranslationError::UnresolvedProcedure(*id)), + } + } + for arg in &mut invocable.arguments { + Self::resolve_operation(arg, known, problems); + } + } + Operation::Sequence(ops) => { + for op in ops { + Self::resolve_operation(op, known, problems); + } + } + Operation::Section { body, .. } => Self::resolve_operation(body, known, problems), + Operation::Step { body, .. } => Self::resolve_operation(body, known, problems), + Operation::Loop { over, body, .. } => { + if let Some(over) = over { + Self::resolve_operation(over, known, problems); + } + Self::resolve_operation(body, known, 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); + } + } + Operation::String(fragments) => { + for fragment in fragments { + if let Fragment::Interpolation(op) = fragment { + Self::resolve_operation(op, known, problems); + } + } + } + Operation::Tablet(entries) => { + for entry in entries { + Self::resolve_operation(&mut entry.value, known, problems); + } + } + Operation::Variable(_) | Operation::Number(_) | Operation::Multiline(_, _) => {} + } + } + fn translate_expression(&mut self, expression: &'i language::Expression<'i>) -> Operation<'i> { match expression { language::Expression::Variable(id, _) => Operation::Variable(*id), @@ -422,14 +492,14 @@ impl<'i> Translator<'i> { language::Expression::Application(invocation, _) => { Operation::Invoke(self.translate_invocation(invocation)) } - language::Expression::Execution(function, _) => Operation::Execution { + language::Expression::Execution(function, _) => Operation::Execute(Executable { target: function.target, arguments: function .parameters .iter() .map(|expr| self.translate_expression(expr)) .collect(), - }, + }), language::Expression::Repeat(body, _) => Operation::Loop { names: &[], over: None, @@ -452,7 +522,7 @@ impl<'i> Translator<'i> { } } - fn translate_invocation(&mut self, invocation: &'i language::Invocation<'i>) -> Invoke<'i> { + fn translate_invocation(&mut self, invocation: &'i language::Invocation<'i>) -> Invocable<'i> { let target = match &invocation.target { language::Target::Local(id) => SubroutineRef::Unresolved(*id), language::Target::Remote(_) => todo!("remote invocation target"), @@ -464,6 +534,6 @@ impl<'i> Translator<'i> { .collect(), None => Vec::new(), }; - Invoke { target, arguments } + Invocable { target, arguments } } } From 62ea999bc021ff6576be1cf2088cf9d41039f072 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 00:10:57 +1000 Subject: [PATCH 21/30] Translate descriptives --- src/translation/checks/translate.rs | 283 +++++++++++++++++++++++----- src/translation/translator.rs | 111 +++++++---- src/translation/types.rs | 1 - 3 files changed, 307 insertions(+), 88 deletions(-) diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 4ff313b..72b02e8 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -564,57 +564,6 @@ make_coffee : assert_eq!(id.value, "chef"); } -#[test] -fn response_block_attaches_to_parent_step() { - let source = r#" -% technique v1 - -check : - -1. Is everything ready? - - 'Yes' | 'No' - "# - .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::Step { expects, .. } = &ops[0] else { - panic!("expected Step"); - }; - let responses = expects.expect("expects present"); - assert_eq!(responses.len(), 2); - assert_eq!(responses[0].value, "Yes"); - assert_eq!(responses[1].value, "No"); -} - -#[test] -fn step_without_response_block_has_none_expects() { - let source = r#" -% technique v1 - -check : - -1. Plain step. - "# - .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::Step { expects, .. } = &ops[0] else { - panic!("expected Step"); - }; - assert!(expects.is_none()); -} - #[test] fn expression_variable_translates() { let source = r#" @@ -1004,3 +953,235 @@ run : "journal" ); } + +#[test] +fn step_text_binding_hoists_into_body() { + // `text ~ name` in a step's description hoists out as a Bind operation + // in the step's body. The inner Text has no executable form, so the + // bound value is a placeholder empty Sequence. + let source = r#" +% technique v1 + +run : + +1. What is the situation now? ~ situation + "# + .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::Step { body, .. } = &ops[0] else { + panic!("expected Step"); + }; + let Operation::Sequence(body_ops) = body.as_ref() else { + panic!("expected Sequence"); + }; + assert_eq!(body_ops.len(), 1); + let Operation::Bind { names, value } = &body_ops[0] else { + panic!("expected Bind, got {:?}", body_ops[0]); + }; + assert_eq!(names.len(), 1); + assert_eq!(names[0].value, "situation"); + let Operation::Sequence(inner) = value.as_ref() else { + panic!("expected empty Sequence as binding value"); + }; + assert!(inner.is_empty()); +} + +#[test] +fn step_application_binding_hoists_with_invoke_value() { + // ` ~ name` hoists as Bind whose value is the Invoke. + let source = r#" +% technique v1 + +run : + +1. Get the result ~ outcome + +helper : () -> Result + "# + .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::Step { body, .. } = &ops[0] else { + panic!("expected Step"); + }; + let Operation::Sequence(body_ops) = body.as_ref() else { + panic!("expected Sequence"); + }; + assert_eq!(body_ops.len(), 1); + let Operation::Bind { names, value } = &body_ops[0] else { + panic!("expected Bind"); + }; + assert_eq!(names[0].value, "outcome"); + let Operation::Invoke(_) = value.as_ref() else { + panic!("expected Invoke as binding value"); + }; +} + +#[test] +fn step_inline_application_hoists_as_invoke() { + // A bare `` (no binding) inside a step's description hoists as + // an Invoke operation in the step body. + let source = r#" +% technique v1 + +run : + +1. Do first. + +helper : () -> Result + "# + .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::Step { body, .. } = &ops[0] else { + panic!("expected Step"); + }; + let Operation::Sequence(body_ops) = body.as_ref() else { + panic!("expected Sequence"); + }; + assert_eq!(body_ops.len(), 1); + let Operation::Invoke(_) = &body_ops[0] else { + panic!("expected Invoke, got {:?}", body_ops[0]); + }; +} + +#[test] +fn step_plain_text_does_not_hoist() { + // Plain text in a step description is display-only; nothing hoists. + let source = r#" +% technique v1 + +run : + +1. Just a plain step with no executable bits. + "# + .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::Step { body, .. } = &ops[0] else { + panic!("expected Step"); + }; + let Operation::Sequence(body_ops) = body.as_ref() else { + panic!("expected Sequence"); + }; + assert!(body_ops.is_empty()); +} + +#[test] +fn step_inline_codeblock_with_side_effect_hoists() { + // `{ exec_call(args) }` written inline in a step description hoists + // out as an Execute operation in the step body. + let source = r#" +% technique v1 + +run : + +1. Log the start { journal("started") } + "# + .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::Step { body, .. } = &ops[0] else { + panic!("expected Step"); + }; + let Operation::Sequence(body_ops) = body.as_ref() else { + panic!("expected Sequence"); + }; + assert_eq!(body_ops.len(), 1); + let Operation::Execute(_) = &body_ops[0] else { + panic!("expected Execute, got {:?}", body_ops[0]); + }; +} + +#[test] +fn step_inline_variable_hoists_as_variable_op() { + // `{ x }` requires runtime evaluation: the runner must wait for `x` to + // be bound and substitute its value into the rendered description. So + // the CodeInline hoists as Operation::Variable in the step body. + let source = r#" +% technique v1 + +run : + +1. Display { x } + "# + .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::Step { body, .. } = &ops[0] else { + panic!("expected Step"); + }; + let Operation::Sequence(body_ops) = body.as_ref() else { + panic!("expected Sequence"); + }; + assert_eq!(body_ops.len(), 1); + let Operation::Variable(id) = &body_ops[0] else { + panic!("expected Variable, got {:?}", body_ops[0]); + }; + assert_eq!(id.value, "x"); +} + +#[test] +fn procedure_description_executables_hoist_as_prefix() { + // Hoisting at the procedure level: an executable Descriptive in the + // procedure's description forms an "anonymous step 0" prefix ahead of + // the explicit Steps. + let source = r#" +% technique v1 + +run : + +Initialise + +1. Continue from there. + +init : () -> () + "# + .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"); + }; + // Prefix Invoke from the description, then the explicit Step. + assert_eq!(ops.len(), 2); + let Operation::Invoke(_) = &ops[0] else { + panic!("expected Invoke prefix, got {:?}", ops[0]); + }; + let Operation::Step { .. } = &ops[1] else { + panic!("expected Step, got {:?}", ops[1]); + }; +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 86fac56..348ddf6 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -145,8 +145,11 @@ impl<'i> Translator<'i> { fn translate_procedure(&mut self, id: SubroutineId, procedure: &'i language::Procedure<'i>) { let (title, description) = self.extract_procedure_elements(procedure); - // build body + // build body. Executable Descriptives in the procedure's + // description form an "anonymous step 0" prefix in source order + // ahead of the explicit Steps and CodeBlock elements. let mut ops = Vec::new(); + self.translate_descriptions(&mut ops, description); for element in &procedure.elements { match element { language::Element::Steps(scopes, _) => { @@ -211,13 +214,16 @@ impl<'i> Translator<'i> { subscopes, .. } => { - let (body, expects) = self.translate_step_subscopes(subscopes, attrs); + let mut body_ops = Vec::new(); + self.translate_descriptions(&mut body_ops, description); + for sub in subscopes { + self.append_attributes(&mut body_ops, sub, attrs); + } Operation::Step { ordinal: Ordinal::Dependent(ordinal), attributes: attrs.to_vec(), description: description.as_slice(), - body: Box::new(body), - expects, + body: Box::new(Operation::Sequence(body_ops)), } } language::Scope::ParallelBlock { @@ -225,21 +231,23 @@ impl<'i> Translator<'i> { subscopes, .. } => { - let (body, expects) = self.translate_step_subscopes(subscopes, attrs); + let mut body_ops = Vec::new(); + self.translate_descriptions(&mut body_ops, description); + for sub in subscopes { + self.append_attributes(&mut body_ops, sub, attrs); + } Operation::Step { ordinal: Ordinal::Parallel, attributes: attrs.to_vec(), description: description.as_slice(), - body: Box::new(body), - expects, + body: Box::new(Operation::Sequence(body_ops)), } } // AttributeBlock and ResponseBlock are intercepted by // append_attributes before reaching here: AttributeBlock // contributes its attribute list to enclosed Steps, and - // ResponseBlock either attaches to its parent Step's `expects` - // (handled by translate_step_subscopes) or is reported as an - // orphan. + // ResponseBlock lifts to the nearest enclosing Scope's expects + // slot (or surfaces as OrphanResponse). language::Scope::AttributeBlock { .. } | language::Scope::ResponseBlock { .. } => { unreachable!() } @@ -248,42 +256,73 @@ impl<'i> Translator<'i> { subscopes, .. } => match expressions.first() { - Some(language::Expression::Foreach(names, source, _)) => Operation::Loop { - names, - over: Some(Box::new(self.translate_expression(source))), - body: Box::new(self.translate_subscopes(subscopes, attrs)), - }, - Some(language::Expression::Repeat(_, _)) => Operation::Loop { - names: &[], - over: None, - body: Box::new(self.translate_subscopes(subscopes, attrs)), - }, + Some(language::Expression::Foreach(names, source, _)) => { + let body = self.translate_subscopes(subscopes, attrs); + Operation::Loop { + names, + over: Some(Box::new(self.translate_expression(source))), + body: Box::new(body), + } + } + Some(language::Expression::Repeat(_, _)) => { + let body = self.translate_subscopes(subscopes, attrs); + Operation::Loop { + names: &[], + over: None, + body: Box::new(body), + } + } Some(other) => self.translate_expression(other), None => self.translate_subscopes(subscopes, attrs), }, } } - // Walk a step's subscopes, splitting ResponseBlock(s) off for the step's - // `expects` field while emitting the remaining scopes as the step's - // body. If multiple ResponseBlock subscopes appear, the first wins. - fn translate_step_subscopes( + // Hoist executable Descriptives out of description paragraphs and + // append them to `ops` as an "anonymous step 0" prefix. + fn translate_descriptions( &mut self, - subscopes: &'i [language::Scope<'i>], - attrs: &[&'i [language::Attribute<'i>]], - ) -> (Operation<'i>, Option<&'i [language::Response<'i>]>) { - let mut expects: Option<&'i [language::Response<'i>]> = None; - let mut ops = Vec::new(); - for sub in subscopes { - if let language::Scope::ResponseBlock { responses, .. } = sub { - if expects.is_none() { - expects = Some(responses.as_slice()); + ops: &mut Vec>, + paragraphs: &'i [language::Paragraph<'i>], + ) { + for paragraph in paragraphs { + let language::Paragraph(descriptives, _) = paragraph; + for descriptive in descriptives { + if let Some(op) = self.executable_from_descriptive(descriptive) { + ops.push(op); } - } else { - self.append_attributes(&mut ops, sub, attrs); } } - (Operation::Sequence(ops), expects) + } + + /// Plain Text is display-only and not hoisted. Every other Descriptive + /// requires runtime evaluation: Application invokes a procedure; + /// CodeInline evaluates the inner Expression (a Variable read needs the + /// value before the description can be rendered, just as much as an + /// Application does); Binding captures a result. The description + /// Paragraph stays borrowed on the enclosing Step for the renderer to + /// fill the CodeInline holes from these Operations' results. + fn executable_from_descriptive( + &mut self, + descriptive: &'i language::Descriptive<'i>, + ) -> Option> { + match descriptive { + language::Descriptive::Text(_) => None, + language::Descriptive::CodeInline(expr) => Some(self.translate_expression(expr)), + language::Descriptive::Application(invocation) => { + Some(Operation::Invoke(self.translate_invocation(invocation))) + } + // Naked binding `text ~ var` or ` ~ var`. + language::Descriptive::Binding(inner, names) => { + let value = self + .executable_from_descriptive(inner) + .unwrap_or_else(|| Operation::Sequence(Vec::new())); + Some(Operation::Bind { + names: names.as_slice(), + value: Box::new(value), + }) + } + } } // Translate a single scope into operations appended to `ops`. Most diff --git a/src/translation/types.rs b/src/translation/types.rs index e188ae5..6d16c4f 100644 --- a/src/translation/types.rs +++ b/src/translation/types.rs @@ -99,7 +99,6 @@ pub enum Operation<'i> { attributes: Vec<&'i [language::Attribute<'i>]>, description: &'i [language::Paragraph<'i>], body: Box>, - expects: Option<&'i [language::Response<'i>]>, }, Loop { names: &'i [language::Identifier<'i>], From 91696b3b773cc5d5e901e3645f9ff4dcb4b5868f Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 00:51:59 +1000 Subject: [PATCH 22/30] Translate responses --- src/problem/messages.rs | 4 - src/translation/checks/errors.rs | 33 ------- src/translation/checks/translate.rs | 134 +++++++++++++++++++++++++++- src/translation/translator.rs | 88 ++++++++++-------- src/translation/types.rs | 6 ++ 5 files changed, 191 insertions(+), 74 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index cedd89c..fc3afc2 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1042,10 +1042,6 @@ 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(), ), - TranslationError::OrphanResponse(_) => ( - "Response block without a parent step".to_string(), - "A response block ('Yes' | 'No') must follow a step it qualifies; it cannot stand alone.".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(), diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index eff40a1..d8f2677 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -4,15 +4,9 @@ use std::path::Path; use crate::language; -use crate::language::Span; use crate::parsing; use crate::translation::{translate, TranslationError}; -#[test] -fn translation_error_variants_construct() { - let _ = TranslationError::OrphanResponse(Span::default()); -} - #[test] fn duplicate_procedure_name_is_error() { let source = r#" @@ -92,33 +86,6 @@ This text comes too late. assert!(at.length >= "This text comes too late.".len()); } -#[test] -fn orphan_response_is_error() { - // ResponseBlock under an AttributeBlock with no enclosing Step. - let source = r#" -% technique v1 - -check : - -@chef - 'Yes' | 'No' - "# - .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::OrphanResponse(at) = &errors[0] else { - panic!("expected OrphanResponse, got {:?}", errors[0]); - }; - let expected = source - .find("'Yes' | 'No'") - .expect("response in source"); - assert_eq!(at.offset, expected); - assert!(at.length >= "'Yes' | 'No'".len()); -} - #[test] fn unresolved_procedure_invocation_is_error() { let source = r#" diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 72b02e8..682121b 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -222,6 +222,7 @@ I. First section numeral, title, body: section_body, + .. } = &ops[0] else { panic!("expected Section, got {:?}", ops[0]); @@ -796,7 +797,10 @@ run : let Operation::Sequence(ops) = &program.subroutines[0].body else { panic!("expected Sequence"); }; - let Operation::Loop { names, over, body } = &ops[0] else { + let Operation::Loop { + names, over, body, .. + } = &ops[0] + else { panic!("expected Loop, got {:?}", ops[0]); }; assert_eq!(names.len(), 1); @@ -1185,3 +1189,131 @@ init : () -> () panic!("expected Step, got {:?}", ops[1]); }; } + +#[test] +fn response_block_attaches_to_parent_step() { + let source = r#" +% technique v1 + +check : + +1. Is everything ready? + 'Yes' | 'No' + "# + .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::Step { responses, .. } = &ops[0] else { + panic!("expected Step"); + }; + assert_eq!(responses.len(), 2); + assert_eq!(responses[0].value, "Yes"); + assert_eq!(responses[1].value, "No"); +} + +#[test] +fn step_without_response_block_has_empty_responses() { + let source = r#" +% technique v1 + +check : + +1. Plain step. + "# + .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::Step { responses, .. } = &ops[0] else { + panic!("expected Step"); + }; + assert!(responses.is_empty()); +} + +#[test] +fn response_under_attribute_block_attaches_to_enclosing_step() { + // AttributeBlock vanishes; its ResponseBlock subscope attaches to the + // enclosing Step's responses just as if it were a peer. + let source = r#" +% technique v1 + +check : + +1. Outer step. + + @chef + 'Yes' | 'No' + "# + .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::Step { responses, .. } = &ops[0] else { + panic!("expected Step"); + }; + assert_eq!(responses.len(), 2); + assert_eq!(responses[0].value, "Yes"); + assert_eq!(responses[1].value, "No"); +} + +#[test] +fn response_under_foreach_attaches_to_loop() { + // 'Reachable' is a per-iteration response of the foreach Loop, not a + // response of the enclosing Step. NetworkProbe.tq pattern. + let source = r#" +% technique v1 + +run : + +1. Probe global DNS responding. + { foreach nameserver in globals } + 'Reachable' | 'Unreachable' + "# + .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::Step { + responses: step_responses, + body, + .. + } = &ops[0] + else { + panic!("expected Step"); + }; + assert!( + step_responses.is_empty(), + "responses do not lift onto the enclosing Step" + ); + + let Operation::Sequence(step_body) = body.as_ref() else { + panic!("expected Sequence"); + }; + let Operation::Loop { + responses: loop_responses, + .. + } = &step_body[0] + else { + panic!("expected Loop"); + }; + assert_eq!(loop_responses.len(), 2); + assert_eq!(loop_responses[0].value, "Reachable"); + assert_eq!(loop_responses[1].value, "Unreachable"); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 348ddf6..b227abf 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -19,7 +19,13 @@ pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec. let mut wrapper = Subroutine::anonymous(); - wrapper.body = translator.translate_subscopes(scopes, &[]); + let mut ops = Vec::new(); + let mut responses = Vec::new(); + for scope in scopes { + translator.append_attributes(&mut ops, &mut responses, scope, &[]); + } + wrapper.body = Operation::Sequence(ops); + wrapper.responses = responses; translator .program .subroutines @@ -50,7 +56,6 @@ pub enum TranslationError<'i> { procedure: language::Identifier<'i>, at: Span, }, - OrphanResponse(Span), /// A local procedure invocation `(...)` whose `name` doesn't /// match any procedure declared in this document is an error. UnresolvedProcedure(language::Identifier<'i>), @@ -145,16 +150,14 @@ impl<'i> Translator<'i> { fn translate_procedure(&mut self, id: SubroutineId, procedure: &'i language::Procedure<'i>) { let (title, description) = self.extract_procedure_elements(procedure); - // build body. Executable Descriptives in the procedure's - // description form an "anonymous step 0" prefix in source order - // ahead of the explicit Steps and CodeBlock elements. let mut ops = Vec::new(); + let mut responses = Vec::new(); self.translate_descriptions(&mut ops, description); for element in &procedure.elements { match element { language::Element::Steps(scopes, _) => { for scope in scopes { - self.append_attributes(&mut ops, scope, &[]); + self.append_attributes(&mut ops, &mut responses, scope, &[]); } } language::Element::CodeBlock(expressions, _) => { @@ -180,6 +183,7 @@ impl<'i> Translator<'i> { .signature .as_ref(); entry.body = body; + entry.responses = responses; } fn translate_scope( @@ -194,18 +198,18 @@ impl<'i> Translator<'i> { body, .. } => { - let inner = match body { - language::Technique::Steps(scopes) => self.translate_subscopes(scopes, attrs), - // Procedures declared inside a section are hoisted into - // Program.subroutines by the collect pass; the section - // node carries no executable body for them. - language::Technique::Procedures(_) => Operation::Sequence(Vec::new()), - language::Technique::Empty => Operation::Sequence(Vec::new()), - }; + let mut body_ops = Vec::new(); + let mut responses = Vec::new(); + if let language::Technique::Steps(scopes) = body { + for sub in scopes { + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); + } + } Operation::Section { numeral, title: title.as_ref(), - body: Box::new(inner), + body: Box::new(Operation::Sequence(body_ops)), + responses, } } language::Scope::DependentBlock { @@ -215,15 +219,17 @@ impl<'i> Translator<'i> { .. } => { let mut body_ops = Vec::new(); + let mut responses = Vec::new(); self.translate_descriptions(&mut body_ops, description); for sub in subscopes { - self.append_attributes(&mut body_ops, sub, attrs); + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); } Operation::Step { ordinal: Ordinal::Dependent(ordinal), attributes: attrs.to_vec(), description: description.as_slice(), body: Box::new(Operation::Sequence(body_ops)), + responses, } } language::Scope::ParallelBlock { @@ -232,22 +238,21 @@ impl<'i> Translator<'i> { .. } => { let mut body_ops = Vec::new(); + let mut responses = Vec::new(); self.translate_descriptions(&mut body_ops, description); for sub in subscopes { - self.append_attributes(&mut body_ops, sub, attrs); + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); } Operation::Step { ordinal: Ordinal::Parallel, attributes: attrs.to_vec(), description: description.as_slice(), body: Box::new(Operation::Sequence(body_ops)), + responses, } } // AttributeBlock and ResponseBlock are intercepted by - // append_attributes before reaching here: AttributeBlock - // contributes its attribute list to enclosed Steps, and - // ResponseBlock lifts to the nearest enclosing Scope's expects - // slot (or surfaces as OrphanResponse). + // append_attributes before reaching here. language::Scope::AttributeBlock { .. } | language::Scope::ResponseBlock { .. } => { unreachable!() } @@ -257,19 +262,29 @@ impl<'i> Translator<'i> { .. } => match expressions.first() { Some(language::Expression::Foreach(names, source, _)) => { - let body = self.translate_subscopes(subscopes, attrs); + let mut body_ops = Vec::new(); + let mut responses = Vec::new(); + for sub in subscopes { + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); + } Operation::Loop { names, over: Some(Box::new(self.translate_expression(source))), - body: Box::new(body), + body: Box::new(Operation::Sequence(body_ops)), + responses, } } Some(language::Expression::Repeat(_, _)) => { - let body = self.translate_subscopes(subscopes, attrs); + let mut body_ops = Vec::new(); + let mut responses = Vec::new(); + for sub in subscopes { + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); + } Operation::Loop { names: &[], over: None, - body: Box::new(body), + body: Box::new(Operation::Sequence(body_ops)), + responses, } } Some(other) => self.translate_expression(other), @@ -325,15 +340,14 @@ impl<'i> Translator<'i> { } } - // Translate a single scope into operations appended to `ops`. Most - // scopes contribute exactly one Operation; AttributeBlock contributes - // no Operation of its own and inlines its (recursively-translated) - // children with its attribute list pushed onto the enclosing-attribute - // stack. ResponseBlock reaching here has no parent step and is reported - // as an orphan. + // Walk a single scope, pushing translated Operations to `ops` and + // accumulating peer responses (transparent through AttributeBlock) into + // `responses`. AttributeBlock vanishes by inlining its subscopes; + // ResponseBlock vanishes by extending `responses`. fn append_attributes( &mut self, ops: &mut Vec>, + responses: &mut Vec<&'i language::Response<'i>>, scope: &'i language::Scope<'i>, attrs: &[&'i [language::Attribute<'i>]], ) { @@ -346,12 +360,11 @@ impl<'i> Translator<'i> { let mut nested: Vec<&'i [language::Attribute<'i>]> = attrs.to_vec(); nested.push(attributes.as_slice()); for sub in subscopes { - self.append_attributes(ops, sub, &nested); + self.append_attributes(ops, responses, sub, &nested); } } - language::Scope::ResponseBlock { span, .. } => { - self.problems - .push(TranslationError::OrphanResponse(*span)); + language::Scope::ResponseBlock { responses: r, .. } => { + responses.extend(r); } _ => ops.push(self.translate_scope(scope, attrs)), } @@ -363,8 +376,9 @@ impl<'i> Translator<'i> { attrs: &[&'i [language::Attribute<'i>]], ) -> Operation<'i> { let mut ops = Vec::new(); + let mut responses = Vec::new(); for scope in scopes { - self.append_attributes(&mut ops, scope, attrs); + self.append_attributes(&mut ops, &mut responses, scope, attrs); } Operation::Sequence(ops) } @@ -543,6 +557,7 @@ impl<'i> Translator<'i> { names: &[], over: None, body: Box::new(self.translate_expression(body)), + responses: Vec::new(), }, // Standalone Foreach has no body in the AST; the body is supplied // by the enclosing CodeBlock's subscopes when one is present (see @@ -552,6 +567,7 @@ impl<'i> Translator<'i> { names, over: Some(Box::new(self.translate_expression(source))), body: Box::new(Operation::Sequence(Vec::new())), + responses: Vec::new(), }, language::Expression::Binding(value, names, _) => Operation::Bind { names, diff --git a/src/translation/types.rs b/src/translation/types.rs index 6d16c4f..dfb26f5 100644 --- a/src/translation/types.rs +++ b/src/translation/types.rs @@ -44,6 +44,7 @@ pub struct Subroutine<'i> { pub parameters: Option<&'i [language::Identifier<'i>]>, pub signature: Option<&'i language::Signature<'i>>, pub body: Operation<'i>, + pub responses: Vec<&'i language::Response<'i>>, } impl<'i> Subroutine<'i> { @@ -58,6 +59,7 @@ impl<'i> Subroutine<'i> { parameters: None, signature: None, body: Operation::Sequence(Vec::new()), + responses: Vec::new(), } } @@ -71,6 +73,7 @@ impl<'i> Subroutine<'i> { parameters: None, signature: None, body: Operation::Sequence(Vec::new()), + responses: Vec::new(), } } } @@ -93,17 +96,20 @@ pub enum Operation<'i> { numeral: &'i str, title: Option<&'i language::Paragraph<'i>>, body: Box>, + responses: Vec<&'i language::Response<'i>>, }, Step { ordinal: Ordinal<'i>, attributes: Vec<&'i [language::Attribute<'i>]>, description: &'i [language::Paragraph<'i>], body: Box>, + responses: Vec<&'i language::Response<'i>>, }, Loop { names: &'i [language::Identifier<'i>], over: Option>>, body: Box>, + responses: Vec<&'i language::Response<'i>>, }, Bind { names: &'i [language::Identifier<'i>], From 79f4b43928e0a5dce1cd175b88fb17d7feb0e213 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 01:11:22 +1000 Subject: [PATCH 23/30] Common function for finding test samples --- tests/common/mod.rs | 25 +++++++++++++++++++++++++ tests/formatting/golden.rs | 30 +++--------------------------- tests/integration.rs | 1 + tests/parsing/samples.rs | 24 +++--------------------- 4 files changed, 32 insertions(+), 48 deletions(-) create mode 100644 tests/common/mod.rs diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..2e0be49 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,25 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn list_technique_documents(dir: &Path) -> Vec { + assert!(dir.exists(), "samples directory missing"); + + let entries = fs::read_dir(dir).expect("Failed to read samples directory"); + + let mut files = Vec::new(); + for entry in entries { + let entry = entry.expect("Failed to read directory entry"); + let path = entry.path(); + + if path + .extension() + .and_then(|s| s.to_str()) + == Some("tq") + { + files.push(path); + } + } + + assert!(!files.is_empty(), "No .tq files found in directory"); + files +} diff --git a/tests/formatting/golden.rs b/tests/formatting/golden.rs index 11a0370..d5c869e 100644 --- a/tests/formatting/golden.rs +++ b/tests/formatting/golden.rs @@ -1,10 +1,11 @@ -use std::fs; use std::path::Path; use technique::formatting::Identity; use technique::highlighting::render; use technique::parsing; +use crate::common::list_technique_documents; + /// Golden test for the format command /// /// This test: @@ -57,35 +58,10 @@ fn show_diff(original: &str, formatted: &str, file_path: &Path) { } fn check_directory(dir: &Path) { - // Ensure the directory exists - assert!(dir.exists(), "examples directory missing"); - - // Get all .tq files in the directory - let entries = fs::read_dir(dir).expect("Failed to read examples directory"); - - let mut files = Vec::new(); - for entry in entries { - let entry = entry.expect("Failed to read directory entry"); - let path = entry.path(); - - if path - .extension() - .and_then(|s| s.to_str()) - == Some("tq") - { - files.push(path); - } - } - - // Ensure we found some test files - assert!( - !files.is_empty(), - "No .tq files found in examples directory" - ); + let files = list_technique_documents(dir); let mut failures = Vec::new(); - // Test each file for file in &files { // Load the original content let original = parsing::load(&file) diff --git a/tests/integration.rs b/tests/integration.rs index 797e39d..4ba0e7f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,3 +1,4 @@ +mod common; mod formatting; mod parsing; mod templating; diff --git a/tests/parsing/samples.rs b/tests/parsing/samples.rs index 3a342ad..cb398e1 100644 --- a/tests/parsing/samples.rs +++ b/tests/parsing/samples.rs @@ -1,29 +1,11 @@ -use std::fs; use std::path::Path; use technique::parsing; -fn check_directory(dir: &Path) { - // Ensure the directory exists - assert!(dir.exists(), "samples directory missing"); - - let entries = fs::read_dir(dir).expect("Failed to read samples directory"); - - let mut files = Vec::new(); - for entry in entries { - let entry = entry.expect("Failed to read directory entry"); - let path = entry.path(); - - if path - .extension() - .and_then(|s| s.to_str()) - == Some("tq") - { - files.push(path); - } - } +use crate::common::list_technique_documents; - assert!(!files.is_empty(), "No .tq files found in samples directory"); +fn check_directory(dir: &Path) { + let files = list_technique_documents(dir); let mut failures = Vec::new(); From 6543c5070cf15a1c1377e221aa36d8f9cc645eb5 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 01:11:46 +1000 Subject: [PATCH 24/30] Test translation with known-good samples --- tests/integration.rs | 1 + tests/samples/RoastTurkey.tq | 2 +- tests/translation/mod.rs | 1 + tests/translation/samples.rs | 44 ++++++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/translation/mod.rs create mode 100644 tests/translation/samples.rs diff --git a/tests/integration.rs b/tests/integration.rs index 4ba0e7f..2e37703 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2,3 +2,4 @@ mod common; mod formatting; mod parsing; mod templating; +mod translation; diff --git a/tests/samples/RoastTurkey.tq b/tests/samples/RoastTurkey.tq index 322064e..47d9aa2 100644 --- a/tests/samples/RoastTurkey.tq +++ b/tests/samples/RoastTurkey.tq @@ -3,6 +3,6 @@ roast_turkey(i) : Ingredients -> Turkey # Roast Turkey @chef - 1. Set oven temperature { (180 °C) ~ temp } + 1. Set oven temperature { oven(180 °C) ~ temp } 2. Place bacon strips onto bird 3. Put bird into oven diff --git a/tests/translation/mod.rs b/tests/translation/mod.rs new file mode 100644 index 0000000..74f2ea6 --- /dev/null +++ b/tests/translation/mod.rs @@ -0,0 +1 @@ +mod samples; diff --git a/tests/translation/samples.rs b/tests/translation/samples.rs new file mode 100644 index 0000000..14a662d --- /dev/null +++ b/tests/translation/samples.rs @@ -0,0 +1,44 @@ +use std::path::Path; + +use technique::parsing; +use technique::translation; + +use crate::common::list_technique_documents; + +fn check_directory(dir: &Path) { + let files = list_technique_documents(dir); + + let mut failures = Vec::new(); + + for file in &files { + let content = parsing::load(&file) + .unwrap_or_else(|e| panic!("Failed to load file {:?}: {:?}", file, e)); + + let document = match parsing::parse(&file, &content) { + Ok(document) => document, + Err(e) => { + println!("File {:?} failed to parse: {:?}", file, e); + failures.push(file.clone()); + continue; + } + }; + + if let Err(errors) = translation::translate(&document) { + println!("File {:?} failed to translate: {:?}", file, errors); + failures.push(file.clone()); + } + } + + if !failures.is_empty() { + panic!( + "Sample files should translate successfully, but {} files failed", + failures.len() + ); + } +} + +#[test] +fn ensure_translate() { + check_directory(Path::new("tests/samples/")); + check_directory(Path::new("examples/minimal/")); +} From 4128956b260a9b912a5250b5e254cd68346809ca Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 09:25:43 +1000 Subject: [PATCH 25/30] Organize known good and known bad samples by phase --- src/parsing/checks/verify.rs | 2 +- tests/broken/{ => parsing}/BadDeclaration.tq | 0 tests/broken/{ => parsing}/DeclartionParentheses.tq | 0 tests/broken/{ => parsing}/IllegalUnitSymbol.tq | 0 tests/broken/{ => parsing}/MagicLine.tq | 0 tests/broken/{ => parsing}/MissingParenthesis.tq | 0 tests/broken/{ => parsing}/ScrapCode.tq | 0 tests/broken/{ => parsing}/UnclosedInterpolation.tq | 0 tests/parsing/broken.rs | 2 +- tests/parsing/samples.rs | 2 +- tests/samples/{ => parsing}/Demolition.tq | 0 tests/samples/{ => parsing}/EmergencyBroadcast.tq | 0 tests/samples/{ => parsing}/HeaderAndDeclaration.tq | 0 tests/samples/{ => parsing}/KnownSpanLengths.tq | 0 tests/samples/{ => parsing}/LocalNetwork.tq | 0 tests/samples/{ => parsing}/RoastTurkey.tq | 0 tests/samples/{ => parsing}/Sequence.tq | 0 tests/samples/{ => parsing}/TabletOfQuantity.tq | 0 18 files changed, 3 insertions(+), 3 deletions(-) rename tests/broken/{ => parsing}/BadDeclaration.tq (100%) rename tests/broken/{ => parsing}/DeclartionParentheses.tq (100%) rename tests/broken/{ => parsing}/IllegalUnitSymbol.tq (100%) rename tests/broken/{ => parsing}/MagicLine.tq (100%) rename tests/broken/{ => parsing}/MissingParenthesis.tq (100%) rename tests/broken/{ => parsing}/ScrapCode.tq (100%) rename tests/broken/{ => parsing}/UnclosedInterpolation.tq (100%) rename tests/samples/{ => parsing}/Demolition.tq (100%) rename tests/samples/{ => parsing}/EmergencyBroadcast.tq (100%) rename tests/samples/{ => parsing}/HeaderAndDeclaration.tq (100%) rename tests/samples/{ => parsing}/KnownSpanLengths.tq (100%) rename tests/samples/{ => parsing}/LocalNetwork.tq (100%) rename tests/samples/{ => parsing}/RoastTurkey.tq (100%) rename tests/samples/{ => parsing}/Sequence.tq (100%) rename tests/samples/{ => parsing}/TabletOfQuantity.tq (100%) diff --git a/src/parsing/checks/verify.rs b/src/parsing/checks/verify.rs index e6fef53..623decb 100644 --- a/src/parsing/checks/verify.rs +++ b/src/parsing/checks/verify.rs @@ -1566,7 +1566,7 @@ III. Implementation #[test] fn spans_are_populated() { - let source = std::fs::read_to_string("tests/samples/KnownSpanLengths.tq").unwrap(); + let source = std::fs::read_to_string("tests/samples/parsing/KnownSpanLengths.tq").unwrap(); let mut input = Parser::new(); input.initialize(&source); diff --git a/tests/broken/BadDeclaration.tq b/tests/broken/parsing/BadDeclaration.tq similarity index 100% rename from tests/broken/BadDeclaration.tq rename to tests/broken/parsing/BadDeclaration.tq diff --git a/tests/broken/DeclartionParentheses.tq b/tests/broken/parsing/DeclartionParentheses.tq similarity index 100% rename from tests/broken/DeclartionParentheses.tq rename to tests/broken/parsing/DeclartionParentheses.tq diff --git a/tests/broken/IllegalUnitSymbol.tq b/tests/broken/parsing/IllegalUnitSymbol.tq similarity index 100% rename from tests/broken/IllegalUnitSymbol.tq rename to tests/broken/parsing/IllegalUnitSymbol.tq diff --git a/tests/broken/MagicLine.tq b/tests/broken/parsing/MagicLine.tq similarity index 100% rename from tests/broken/MagicLine.tq rename to tests/broken/parsing/MagicLine.tq diff --git a/tests/broken/MissingParenthesis.tq b/tests/broken/parsing/MissingParenthesis.tq similarity index 100% rename from tests/broken/MissingParenthesis.tq rename to tests/broken/parsing/MissingParenthesis.tq diff --git a/tests/broken/ScrapCode.tq b/tests/broken/parsing/ScrapCode.tq similarity index 100% rename from tests/broken/ScrapCode.tq rename to tests/broken/parsing/ScrapCode.tq diff --git a/tests/broken/UnclosedInterpolation.tq b/tests/broken/parsing/UnclosedInterpolation.tq similarity index 100% rename from tests/broken/UnclosedInterpolation.tq rename to tests/broken/parsing/UnclosedInterpolation.tq diff --git a/tests/parsing/broken.rs b/tests/parsing/broken.rs index 69b5dd1..02e1908 100644 --- a/tests/parsing/broken.rs +++ b/tests/parsing/broken.rs @@ -5,7 +5,7 @@ use technique::parsing; #[test] fn ensure_fail() { - let dir = Path::new("tests/broken/"); + let dir = Path::new("tests/broken/parsing/"); assert!(dir.exists(), "broken directory missing"); diff --git a/tests/parsing/samples.rs b/tests/parsing/samples.rs index cb398e1..262aacb 100644 --- a/tests/parsing/samples.rs +++ b/tests/parsing/samples.rs @@ -32,6 +32,6 @@ fn check_directory(dir: &Path) { #[test] fn ensure_parse() { - check_directory(Path::new("tests/samples/")); + check_directory(Path::new("tests/samples/parsing/")); check_directory(Path::new("examples/minimal/")); } diff --git a/tests/samples/Demolition.tq b/tests/samples/parsing/Demolition.tq similarity index 100% rename from tests/samples/Demolition.tq rename to tests/samples/parsing/Demolition.tq diff --git a/tests/samples/EmergencyBroadcast.tq b/tests/samples/parsing/EmergencyBroadcast.tq similarity index 100% rename from tests/samples/EmergencyBroadcast.tq rename to tests/samples/parsing/EmergencyBroadcast.tq diff --git a/tests/samples/HeaderAndDeclaration.tq b/tests/samples/parsing/HeaderAndDeclaration.tq similarity index 100% rename from tests/samples/HeaderAndDeclaration.tq rename to tests/samples/parsing/HeaderAndDeclaration.tq diff --git a/tests/samples/KnownSpanLengths.tq b/tests/samples/parsing/KnownSpanLengths.tq similarity index 100% rename from tests/samples/KnownSpanLengths.tq rename to tests/samples/parsing/KnownSpanLengths.tq diff --git a/tests/samples/LocalNetwork.tq b/tests/samples/parsing/LocalNetwork.tq similarity index 100% rename from tests/samples/LocalNetwork.tq rename to tests/samples/parsing/LocalNetwork.tq diff --git a/tests/samples/RoastTurkey.tq b/tests/samples/parsing/RoastTurkey.tq similarity index 100% rename from tests/samples/RoastTurkey.tq rename to tests/samples/parsing/RoastTurkey.tq diff --git a/tests/samples/Sequence.tq b/tests/samples/parsing/Sequence.tq similarity index 100% rename from tests/samples/Sequence.tq rename to tests/samples/parsing/Sequence.tq diff --git a/tests/samples/TabletOfQuantity.tq b/tests/samples/parsing/TabletOfQuantity.tq similarity index 100% rename from tests/samples/TabletOfQuantity.tq rename to tests/samples/parsing/TabletOfQuantity.tq From f1ca93f611ca534c2bb81034ead6a28bd09f9a22 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 09:41:08 +1000 Subject: [PATCH 26/30] Add known bad translation samples --- .../broken/translation/DuplicateProcedure.tq | 5 ++ tests/broken/translation/DuplicateTitle.tq | 7 +++ .../broken/translation/UnresolvedProcedure.tq | 8 +++ tests/translation/broken.rs | 53 +++++++++++++++++++ tests/translation/mod.rs | 1 + tests/translation/samples.rs | 2 +- 6 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/broken/translation/DuplicateProcedure.tq create mode 100644 tests/broken/translation/DuplicateTitle.tq create mode 100644 tests/broken/translation/UnresolvedProcedure.tq create mode 100644 tests/translation/broken.rs diff --git a/tests/broken/translation/DuplicateProcedure.tq b/tests/broken/translation/DuplicateProcedure.tq new file mode 100644 index 0000000..63ce06b --- /dev/null +++ b/tests/broken/translation/DuplicateProcedure.tq @@ -0,0 +1,5 @@ +% technique v1 + +make_coffee : + +make_coffee : diff --git a/tests/broken/translation/DuplicateTitle.tq b/tests/broken/translation/DuplicateTitle.tq new file mode 100644 index 0000000..a20c359 --- /dev/null +++ b/tests/broken/translation/DuplicateTitle.tq @@ -0,0 +1,7 @@ +% technique v1 + +make_coffee : + +# Make Coffee With Steamed Milk + +# Make Cappuccino diff --git a/tests/broken/translation/UnresolvedProcedure.tq b/tests/broken/translation/UnresolvedProcedure.tq new file mode 100644 index 0000000..0e158d8 --- /dev/null +++ b/tests/broken/translation/UnresolvedProcedure.tq @@ -0,0 +1,8 @@ +% technique v1 + +main : +{ + (x) +} + +I. Lead with \ No newline at end of file diff --git a/tests/translation/broken.rs b/tests/translation/broken.rs new file mode 100644 index 0000000..2000c25 --- /dev/null +++ b/tests/translation/broken.rs @@ -0,0 +1,53 @@ +use std::path::Path; + +use technique::parsing; +use technique::translation; + +use crate::common::list_technique_documents; + +#[test] +fn ensure_fail() { + let dir = Path::new("tests/broken/translation/"); + let files = list_technique_documents(dir); + + let mut unexpected_successes = Vec::new(); + let mut parse_failures = Vec::new(); + + for file in &files { + let content = parsing::load(&file) + .unwrap_or_else(|e| panic!("Failed to load file {:?}: {:?}", file, e)); + + // Translation-failure fixtures must parse cleanly first; the failure + // is meant to come from the translation phase, not the parser. + let document = match parsing::parse(&file, &content) { + Ok(document) => document, + Err(errors) => { + println!( + "File {:?} unexpectedly failed to parse: {:?}", + file, errors + ); + parse_failures.push(file.clone()); + continue; + } + }; + + if translation::translate(&document).is_ok() { + println!("File {:?} unexpectedly translated successfully", file); + unexpected_successes.push(file.clone()); + } + } + + if !parse_failures.is_empty() { + panic!( + "Translation-failure fixtures must parse cleanly, but {} failed at the parser", + parse_failures.len() + ); + } + + if !unexpected_successes.is_empty() { + panic!( + "Broken files should not translate successfully, but {} files passed", + unexpected_successes.len() + ); + } +} diff --git a/tests/translation/mod.rs b/tests/translation/mod.rs index 74f2ea6..1609ecb 100644 --- a/tests/translation/mod.rs +++ b/tests/translation/mod.rs @@ -1 +1,2 @@ +mod broken; mod samples; diff --git a/tests/translation/samples.rs b/tests/translation/samples.rs index 14a662d..2b70fb6 100644 --- a/tests/translation/samples.rs +++ b/tests/translation/samples.rs @@ -39,6 +39,6 @@ fn check_directory(dir: &Path) { #[test] fn ensure_translate() { - check_directory(Path::new("tests/samples/")); + check_directory(Path::new("tests/samples/parsing/")); check_directory(Path::new("examples/minimal/")); } From c2852a42c23a261fb73b6fd42d2e80b1153cd4ee Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 09:42:25 +1000 Subject: [PATCH 27/30] Print line and column position for translation phase errors --- src/main.rs | 4 +++- src/problem/format.rs | 20 +++++++++++++++++--- src/translation/translator.rs | 11 +++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 296d3dc..b6dba84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -270,7 +270,9 @@ fn main() { } eprintln!( "{}", - problem::concise_translation_error(&error, &filename, &Terminal) + problem::concise_translation_error( + &error, &filename, &content, &Terminal + ) ); } std::process::exit(1); diff --git a/src/problem/format.rs b/src/problem/format.rs index fe0f50e..58b41ce 100644 --- a/src/problem/format.rs +++ b/src/problem/format.rs @@ -92,17 +92,31 @@ pub fn concise_parsing_error<'i>( ) } -/// Format a translation error with concise single-line output. Translation -/// errors do not yet carry source positions, so file:line:column is omitted. +/// Format a translation error with concise single-line output. pub fn concise_translation_error<'i>( error: &TranslationError<'i>, filename: &'i Path, + source: &'i str, renderer: &impl Render, ) -> String { let (problem, _) = generate_translation_error(error, renderer); let input = generate_filename(filename); + let offset = error + .span() + .offset; + let i = calculate_line_number(source, offset); + let j = calculate_column_number(source, offset); + let line = i + 1; + let column = j + 1; - format!("{}: {}: {}", "error".bright_red(), input, problem.bold(),) + format!( + "{}: {}:{}:{} {}", + "error".bright_red(), + input, + line, + column, + problem.bold(), + ) } /// Format a LoadingError with concise single-line output diff --git a/src/translation/translator.rs b/src/translation/translator.rs index b227abf..677171e 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -61,6 +61,17 @@ pub enum TranslationError<'i> { UnresolvedProcedure(language::Identifier<'i>), } +impl<'i> TranslationError<'i> { + pub fn span(&self) -> Span { + match self { + TranslationError::DuplicateProcedure(id) => id.span, + TranslationError::DuplicateTitle { at, .. } => *at, + TranslationError::InterleavedDescription { at, .. } => *at, + TranslationError::UnresolvedProcedure(id) => id.span, + } + } +} + struct Translator<'i> { program: Program<'i>, problems: Vec>, From c467463823264400bac3dbd622c2be2ac0b646a5 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 10:29:57 +1000 Subject: [PATCH 28/30] Refine errors around interleaved descriptions --- src/translation/checks/errors.rs | 130 +++++- src/translation/checks/translate.rs | 376 +++++++++++++++++- src/translation/translator.rs | 17 +- .../translation/InterleavedDescription.tq | 15 + tests/translation/broken.rs | 5 +- 5 files changed, 519 insertions(+), 24 deletions(-) create mode 100644 tests/broken/translation/InterleavedDescription.tq diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index d8f2677..2a566a2 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -3,12 +3,11 @@ use std::path::Path; -use crate::language; use crate::parsing; use crate::translation::{translate, TranslationError}; #[test] -fn duplicate_procedure_name_is_error() { +fn duplicate_procedure_name() { let source = r#" % technique v1 @@ -22,14 +21,23 @@ make_coffee : let errors = translate(&document).expect_err("translate should fail"); assert_eq!(errors.len(), 1); + let TranslationError::DuplicateProcedure(id) = &errors[0] else { + panic!("expected DuplicateProcedure, got {:?}", errors[0]); + }; + assert_eq!(id.value, "make_coffee"); + let expected = source + .rfind("make_coffee") + .expect("second declaration in source"); assert_eq!( - errors[0], - TranslationError::DuplicateProcedure(language::Identifier::new("make_coffee")) + id.span + .offset, + expected, + "span points at the duplicate declaration" ); } #[test] -fn duplicate_title_is_error() { +fn duplicate_title() { let source = r#" % technique v1 @@ -57,7 +65,7 @@ make_coffee : } #[test] -fn description_after_code_block_is_error() { +fn description_after_code_block() { let source = r#" % technique v1 @@ -87,7 +95,82 @@ This text comes too late. } #[test] -fn unresolved_procedure_invocation_is_error() { +fn second_description_split_by_title() { + // Two prose blocks separated by a `# Title` parse as + // [Description, Title, Description, Steps]. The procedure shell + // allows at most one description, and the second one fires + // InterleavedDescription regardless of whether body elements have + // appeared yet. + let source = r#" +% technique v1 + +make_coffee : + +First paragraph of preamble. + +# Coffee Time + +Second paragraph would silently disappear. + +1. Grind beans. + "# + .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::InterleavedDescription { procedure, at } = &errors[0] else { + panic!("expected InterleavedDescription, got {:?}", errors[0]); + }; + assert_eq!(procedure.value, "make_coffee"); + let expected = source + .find("Second paragraph") + .expect("second description in source"); + assert_eq!(at.offset, expected); +} + +#[test] +fn description_after_multiple_body_elements() { + // Pin down the rule "a description must come before any body element": + // once `blocked` is set by a body element, it stays set across further + // body elements, and a later Element::Description still trips the + // guard. (A Steps-then-Description case is unreachable today because + // the parser absorbs trailing prose into the previous step's + // description; see plans/PHASE-3.md.) + let source = r#" +% technique v1 + +make_coffee : + +{ + journal("a") +} + +{ + journal("b") +} + +This text is past the body. + "# + .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::InterleavedDescription { procedure, at } = &errors[0] else { + panic!("expected InterleavedDescription, got {:?}", errors[0]); + }; + assert_eq!(procedure.value, "make_coffee"); + let expected = source + .find("This text is past the body.") + .expect("description in source"); + assert_eq!(at.offset, expected); +} + +#[test] +fn unresolved_procedure_invocation() { let source = r#" % technique v1 @@ -102,6 +185,39 @@ main : let document = parsing::parse(path, source).expect("parse"); let errors = translate(&document).expect_err("translate should fail"); + assert_eq!(errors.len(), 1); + let TranslationError::UnresolvedProcedure(id) = &errors[0] else { + panic!("expected UnresolvedProcedure, got {:?}", errors[0]); + }; + assert_eq!(id.value, "does_not_exist"); + let expected = source + .find("does_not_exist") + .expect("identifier in source"); + assert_eq!( + id.span + .offset, + expected, + "span points at the offending identifier" + ); +} + +#[test] +fn unresolved_procedure_in_section_title() { + // After section title hoisting, an `` embedded in the + // title goes through the resolve pass. This ensures section titles + // can't silently reference nonexistent procedures. + let source = r#" +% technique v1 + +main : + +I. Lead with + "# + .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::UnresolvedProcedure(id) = &errors[0] else { panic!("expected UnresolvedProcedure, got {:?}", errors[0]); diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 682121b..c570c3c 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -173,9 +173,17 @@ make_coffee : Beans -> Coffee let document = parsing::parse(path, source).expect("parse"); let program = translate(&document).expect("translate"); - assert!(program.subroutines[0] + let signature = program.subroutines[0] .signature - .is_some()); + .expect("signature present"); + let language::Genus::Single(input) = &signature.requires else { + panic!("expected single Forma input"); + }; + assert_eq!(input.value, "Beans"); + let language::Genus::Single(output) = &signature.provides else { + panic!("expected single Forma output"); + }; + assert_eq!(output.value, "Coffee"); } #[test] @@ -277,15 +285,15 @@ inner : () -> () } #[test] -fn nested_sections_translate_recursively() { +fn sibling_sections_translate_in_order() { let source = r#" % technique v1 outer : -I. Outer +I. First -II. Sibling +II. Second "# .trim_ascii(); let path = Path::new("Test.tq"); @@ -619,7 +627,8 @@ run : } #[test] -fn expression_string_translates() { +fn expression_string_text_fragment_translates() { + // A plain string literal becomes a single Text fragment. let source = r#" % technique v1 @@ -650,6 +659,52 @@ run : assert_eq!(*text, "hello"); } +#[test] +fn expression_string_with_interpolation_translates() { + // A string with `{ x }` interpolations produces alternating Text and + // Interpolation fragments. The interpolated expression is itself an + // Operation, recursively translated. + let source = r#" +% technique v1 + +run : + +{ + journal("hello { name }!") +} + "# + .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::Execute(executable) = &ops[0] else { + panic!("expected Execute"); + }; + let Operation::String(fragments) = &executable.arguments[0] else { + panic!("expected String"); + }; + assert_eq!(fragments.len(), 3); + let Fragment::Text(prefix) = &fragments[0] else { + panic!("expected Text fragment"); + }; + assert_eq!(*prefix, "hello "); + let Fragment::Interpolation(inner) = &fragments[1] else { + panic!("expected Interpolation fragment, got {:?}", fragments[1]); + }; + let Operation::Variable(id) = inner else { + panic!("expected Variable inside interpolation"); + }; + assert_eq!(id.value, "name"); + let Fragment::Text(suffix) = &fragments[2] else { + panic!("expected trailing Text fragment"); + }; + assert_eq!(*suffix, "!"); +} + #[test] fn expression_execution_translates() { let source = r#" @@ -1317,3 +1372,312 @@ run : assert_eq!(loop_responses[0].value, "Reachable"); assert_eq!(loop_responses[1].value, "Unreachable"); } + +#[test] +fn expression_multiline_translates() { + // exec(```bash ... ```) carries a Multiline through as the language tag + // and the lines of content. NetworkProbe.tq pattern. + let source = r#" +% technique v1 + +run : + +{ + exec(```bash + ip addr + ```) +} + "# + .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::Execute(executable) = &ops[0] else { + panic!("expected Execute"); + }; + let Operation::Multiline(lang, lines) = &executable.arguments[0] else { + panic!("expected Multiline, got {:?}", executable.arguments[0]); + }; + assert_eq!(*lang, Some("bash")); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0], "ip addr"); +} + +#[test] +fn expression_repeat_translates() { + // `{ repeat 5 }` becomes Loop with names=[], over=None, body=Number(5). + let source = r#" +% technique v1 + +run : + +{ + repeat 5 +} + "# + .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::Loop { + names, + over, + body, + responses, + } = &ops[0] + else { + panic!("expected Loop, got {:?}", ops[0]); + }; + assert!(names.is_empty()); + assert!(over.is_none(), "repeat has no `over` source"); + assert!(responses.is_empty()); + let Operation::Number(_) = body.as_ref() else { + panic!("expected Number body"); + }; +} + +#[test] +fn expression_foreach_bare_has_empty_body() { + // A `foreach` Expression on its own (not as the head of a CodeBlock with + // subscopes supplying its body) translates to a Loop with an empty + // Sequence body. The CodeBlock-with-subscopes path provides the body + // separately; see `foreach_codeblock_becomes_loop_with_subscopes_as_body`. + let source = r#" +% technique v1 + +run : + +{ + foreach x in xs +} + "# + .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::Loop { + names, over, body, .. + } = &ops[0] + else { + panic!("expected Loop"); + }; + assert_eq!(names.len(), 1); + assert_eq!(names[0].value, "x"); + let Some(over) = over else { + panic!("foreach has an `over` source"); + }; + let Operation::Variable(id) = over.as_ref() else { + panic!("expected Variable as `over`"); + }; + assert_eq!(id.value, "xs"); + let Operation::Sequence(inner) = body.as_ref() else { + panic!("expected empty Sequence body"); + }; + assert!(inner.is_empty()); +} + +#[test] +fn roman_subsubstep_ordinals_preserved() { + // The IR keeps the verbatim ordinal string from the parser; the lexical + // kind (Arabic / Alpha / Roman) is derivable by inspection. + let source = r#" +% technique v1 + +run : + +1. Outer. + a. Substep. + i. First sub-substep. + ii. Second sub-substep. + "# + .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::Step { body: outer, .. } = &ops[0] else { + panic!("expected outer Step"); + }; + let Operation::Sequence(outer_ops) = outer.as_ref() else { + panic!("expected Sequence"); + }; + let Operation::Step { body: substep, .. } = &outer_ops[0] else { + panic!("expected substep"); + }; + let Operation::Sequence(sub_ops) = substep.as_ref() else { + panic!("expected Sequence"); + }; + let ordinals: Vec<&str> = sub_ops + .iter() + .map(|op| match op { + Operation::Step { + ordinal: Ordinal::Dependent(n), + .. + } => *n, + _ => panic!("expected sub-substep"), + }) + .collect(); + assert_eq!(ordinals, vec!["i", "ii"]); +} + +#[test] +fn response_with_condition_carries_condition() { + // 'No' but tired -> Response { value: "No", condition: Some("but tired") } + let source = r#" +% technique v1 + +run : + +1. Decision time? + 'Yes' | 'No' but tired + "# + .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::Step { responses, .. } = &ops[0] else { + panic!("expected Step"); + }; + assert_eq!(responses.len(), 2); + assert_eq!(responses[0].value, "Yes"); + assert!(responses[0] + .condition + .is_none()); + assert_eq!(responses[1].value, "No"); + assert_eq!(responses[1].condition, Some("but tired")); +} + +#[test] +fn multiple_peer_response_blocks_union_on_step() { + // Two distinct ResponseBlocks under different attribute scopes peer to + // the same Step. The Step's responses field accumulates all of them. + let source = r#" +% technique v1 + +run : + +1. Choose + @cook + 'A' | 'B' + @waiter + 'C' | 'D' + "# + .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::Step { responses, .. } = &ops[0] else { + panic!("expected Step"); + }; + let values: Vec<&str> = responses + .iter() + .map(|r| r.value) + .collect(); + assert_eq!(values, vec!["A", "B", "C", "D"]); +} + +#[test] +fn procedure_body_response_block_lands_on_subroutine() { + // A ResponseBlock that is a peer of the procedure body (here under a + // top-level @attribute frame) rolls up into Subroutine.responses, since + // there is no enclosing Step or Loop carrier for it. + let source = r#" +% technique v1 + +choose : + +@me + 'A' | 'B' + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + assert_eq!( + program.subroutines[0] + .responses + .len(), + 2 + ); + let values: Vec<&str> = program.subroutines[0] + .responses + .iter() + .map(|r| r.value) + .collect(); + assert_eq!(values, vec!["A", "B"]); +} + +#[test] +fn section_title_invocation_hoists_into_body() { + // A `` in a section title is an executable Descriptive: it must + // be evaluated to render the title. It hoists into Section.body in the + // same way a procedure description's executables hoist into the + // procedure body, and so passes through the resolve pass like any + // other Invoke. + let source = r#" +% technique v1 + +main : + +I. Begin + +init : () -> () + "# + .trim_ascii(); + let path = Path::new("Test.tq"); + let document = parsing::parse(path, source).expect("parse"); + let program = translate(&document).expect("translate"); + + let init_idx = program + .subroutines + .iter() + .position(|s| { + s.name + .as_ref() + .map(|n| n.value) + == Some("init") + }) + .expect("init declared"); + + let Operation::Sequence(ops) = &program.subroutines[0].body else { + panic!("expected Sequence"); + }; + let Operation::Section { body, .. } = &ops[0] else { + panic!("expected Section"); + }; + let Operation::Sequence(section_body) = body.as_ref() else { + panic!("expected Section body Sequence"); + }; + assert_eq!(section_body.len(), 1, "title's Application is hoisted"); + let Operation::Invoke(invocable) = §ion_body[0] else { + panic!("expected Invoke, got {:?}", section_body[0]); + }; + let SubroutineRef::Resolved(SubroutineId(idx)) = invocable.target else { + panic!("expected Resolved"); + }; + assert_eq!(idx, init_idx); +} diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 677171e..f663b6e 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -211,6 +211,11 @@ impl<'i> Translator<'i> { } => { let mut body_ops = Vec::new(); let mut responses = Vec::new(); + + if let Some(paragraph) = title { + // scan for operations in section content + self.translate_descriptions(&mut body_ops, std::slice::from_ref(paragraph)); + } if let language::Technique::Steps(scopes) = body { for sub in scopes { self.append_attributes(&mut body_ops, &mut responses, sub, attrs); @@ -396,11 +401,9 @@ impl<'i> Translator<'i> { // Walk a procedure's elements to extract the procedure-shell title and // description, surfacing the two structural errors: - // DuplicateTitle - a second Element::Title in this procedure. - // InterleavedDescription - an Element::Description after a Steps or - // CodeBlock element. - // Multiple Element::Description occurrences before any Steps/CodeBlock - // are not an error; the first wins. + // - DuplicateTitle, if a second title appears in this procedure; and + // - InterleavedDescription, as a procedure allows at most one + // description, and it must appear before any body element. fn extract_procedure_elements( &mut self, procedure: &'i language::Procedure<'i>, @@ -423,13 +426,13 @@ impl<'i> Translator<'i> { } } language::Element::Description(paragraphs, span) => { - if blocked { + if blocked || description.is_some() { self.problems .push(TranslationError::InterleavedDescription { procedure: procedure.name, at: *span, }); - } else if description.is_none() { + } else { description = Some(paragraphs.as_slice()); } } diff --git a/tests/broken/translation/InterleavedDescription.tq b/tests/broken/translation/InterleavedDescription.tq new file mode 100644 index 0000000..ad6aeb6 --- /dev/null +++ b/tests/broken/translation/InterleavedDescription.tq @@ -0,0 +1,15 @@ +% technique v1 + +make_coffee : + +{ + journal("started") +} + +A procedure can have at most one free-text description, and it must appear +before any steps or code blocks. The code block above counts as a body +element, so this paragraph has nowhere coherent to render — the preamble +is already past. + +1. Grind beans +2. Steam milk diff --git a/tests/translation/broken.rs b/tests/translation/broken.rs index 2000c25..262886b 100644 --- a/tests/translation/broken.rs +++ b/tests/translation/broken.rs @@ -22,10 +22,7 @@ fn ensure_fail() { let document = match parsing::parse(&file, &content) { Ok(document) => document, Err(errors) => { - println!( - "File {:?} unexpectedly failed to parse: {:?}", - file, errors - ); + println!("File {:?} unexpectedly failed to parse: {:?}", file, errors); parse_failures.push(file.clone()); continue; } From d8483b47cc6b2e28cb024d276b1f1a7ba66365b1 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 11:43:22 +1000 Subject: [PATCH 29/30] Fix multiple Expressions in CodeBlock --- src/translation/checks/translate.rs | 58 +++++++++++++++++++++ src/translation/translator.rs | 79 +++++++++++++++-------------- 2 files changed, 98 insertions(+), 39 deletions(-) diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index c570c3c..eac8741 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -1488,6 +1488,64 @@ run : assert!(inner.is_empty()); } +#[test] +fn codeblock_preserves_multi_expressions() { + // A code block can contain multiple expressions. + let source = r#" +% technique v1 + +delete_rds_instance : + +1. Disable termination protection + { + click("Modify") + navigate("bottom") + deselect("Enable deletion protection") + click("Continue") + select("Apply Immediately") + click("Modify DB instance") + } + "# + .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::Step { body, .. } = &ops[0] else { + panic!("expected Step"); + }; + let Operation::Sequence(step_body) = body.as_ref() else { + panic!("expected Step body Sequence"); + }; + // The CodeBlock translates to a Sequence pushed into the Step body. + let Operation::Sequence(block_ops) = &step_body[0] else { + panic!("expected CodeBlock Sequence, got {:?}", step_body[0]); + }; + let names: Vec<&str> = block_ops + .iter() + .map(|op| match op { + Operation::Execute(executable) => executable + .target + .value, + other => panic!("expected Execute, got {:?}", other), + }) + .collect(); + assert_eq!( + names, + vec![ + "click", + "navigate", + "deselect", + "click", + "select", + "click" + ] + ); +} + #[test] fn roman_subsubstep_ordinals_preserved() { // The IR keeps the verbatim ordinal string from the parser; the lexical diff --git a/src/translation/translator.rs b/src/translation/translator.rs index f663b6e..27e4f2d 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -276,36 +276,50 @@ impl<'i> Translator<'i> { expressions, subscopes, .. - } => match expressions.first() { - Some(language::Expression::Foreach(names, source, _)) => { - let mut body_ops = Vec::new(); - let mut responses = Vec::new(); - for sub in subscopes { - self.append_attributes(&mut body_ops, &mut responses, sub, attrs); - } - Operation::Loop { - names, - over: Some(Box::new(self.translate_expression(source))), - body: Box::new(Operation::Sequence(body_ops)), - responses, + } => { + let single = expressions.len() == 1; + match expressions.first() { + Some(language::Expression::Foreach(names, source, _)) if single => { + let mut body_ops = Vec::new(); + let mut responses = Vec::new(); + for sub in subscopes { + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); + } + Operation::Loop { + names, + over: Some(Box::new(self.translate_expression(source))), + body: Box::new(Operation::Sequence(body_ops)), + responses, + } } - } - Some(language::Expression::Repeat(_, _)) => { - let mut body_ops = Vec::new(); - let mut responses = Vec::new(); - for sub in subscopes { - self.append_attributes(&mut body_ops, &mut responses, sub, attrs); + Some(language::Expression::Repeat(_, _)) if single => { + let mut body_ops = Vec::new(); + let mut responses = Vec::new(); + for sub in subscopes { + self.append_attributes(&mut body_ops, &mut responses, sub, attrs); + } + Operation::Loop { + names: &[], + over: None, + body: Box::new(Operation::Sequence(body_ops)), + responses, + } } - Operation::Loop { - names: &[], - over: None, - body: Box::new(Operation::Sequence(body_ops)), - responses, + _ => { + let mut ops = Vec::new(); + for expression in expressions { + ops.push(self.translate_expression(expression)); + } + let mut responses = Vec::new(); + for sub in subscopes { + self.append_attributes(&mut ops, &mut responses, sub, attrs); + } + + let _ = responses; + Operation::Sequence(ops) } } - Some(other) => self.translate_expression(other), - None => self.translate_subscopes(subscopes, attrs), - }, + } } } @@ -386,19 +400,6 @@ impl<'i> Translator<'i> { } } - fn translate_subscopes( - &mut self, - scopes: &'i [language::Scope<'i>], - attrs: &[&'i [language::Attribute<'i>]], - ) -> Operation<'i> { - let mut ops = Vec::new(); - let mut responses = Vec::new(); - for scope in scopes { - self.append_attributes(&mut ops, &mut responses, scope, attrs); - } - Operation::Sequence(ops) - } - // Walk a procedure's elements to extract the procedure-shell title and // description, surfacing the two structural errors: // - DuplicateTitle, if a second title appears in this procedure; and From 2f7a4074da255e3dfae0ffbc42cd8fe0ac6b827f Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 8 May 2026 12:07:29 +1000 Subject: [PATCH 30/30] Move Intermediate Representation types to program:: --- src/lib.rs | 1 + src/program/mod.rs | 6 ++++++ src/{translation => program}/types.rs | 0 src/translation/checks/translate.rs | 20 ++++++++------------ src/translation/mod.rs | 5 ----- src/translation/translator.rs | 2 +- 6 files changed, 16 insertions(+), 18 deletions(-) create mode 100644 src/program/mod.rs rename src/{translation => program}/types.rs (100%) diff --git a/src/lib.rs b/src/lib.rs index f28ab2a..4920ebf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod formatting; pub mod highlighting; pub mod language; pub mod parsing; +pub mod program; pub(crate) mod regex; pub mod templating; pub mod translation; diff --git a/src/program/mod.rs b/src/program/mod.rs new file mode 100644 index 0000000..0fe49c2 --- /dev/null +++ b/src/program/mod.rs @@ -0,0 +1,6 @@ +//! Intermediate Representation suitable for an interpreter. + +mod types; + +// Re-export all public symbols +pub use types::*; diff --git a/src/translation/types.rs b/src/program/types.rs similarity index 100% rename from src/translation/types.rs rename to src/program/types.rs diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index eac8741..968cd70 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -7,7 +7,8 @@ use std::path::Path; use crate::language; use crate::parsing; -use crate::translation::{translate, Fragment, Operation, Ordinal, SubroutineId, SubroutineRef}; +use crate::program::{Fragment, Operation, Ordinal, SubroutineId, SubroutineRef}; +use crate::translation::translate; #[test] fn empty_input_yields_empty_program() { @@ -1527,22 +1528,17 @@ delete_rds_instance : let names: Vec<&str> = block_ops .iter() .map(|op| match op { - Operation::Execute(executable) => executable - .target - .value, + Operation::Execute(executable) => { + executable + .target + .value + } other => panic!("expected Execute, got {:?}", other), }) .collect(); assert_eq!( names, - vec![ - "click", - "navigate", - "deselect", - "click", - "select", - "click" - ] + vec!["click", "navigate", "deselect", "click", "select", "click"] ); } diff --git a/src/translation/mod.rs b/src/translation/mod.rs index 6681d0e..79ff682 100644 --- a/src/translation/mod.rs +++ b/src/translation/mod.rs @@ -2,13 +2,8 @@ //! Representation suitable for an interpreter. mod translator; -mod types; pub use translator::{translate, TranslationError}; -pub use types::{ - Entry, Executable, Fragment, Invocable, Operation, Ordinal, Program, Subroutine, SubroutineId, - SubroutineRef, -}; #[cfg(test)] #[path = "checks/translate.rs"] diff --git a/src/translation/translator.rs b/src/translation/translator.rs index 27e4f2d..fd9fe2a 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use crate::language; use crate::language::{Document, Span}; -use super::types::{ +use crate::program::{ Entry, Executable, Fragment, Invocable, Operation, Ordinal, Program, Subroutine, SubroutineId, SubroutineRef, };