diff --git a/Cargo.lock b/Cargo.lock index e854bdb5..d672f2d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,7 +472,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.5.5" +version = "0.5.6" dependencies = [ "clap", "ignore", diff --git a/Cargo.toml b/Cargo.toml index dd11b65f..58856c40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.5.5" +version = "0.5.6" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] diff --git a/src/language/quantity.rs b/src/language/quantity.rs index 89e2fb23..f153d695 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 7df1fc6d..89a91f9d 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/lib.rs b/src/lib.rs index 24351f25..4920ebf4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,5 +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/main.rs b/src/main.rs index ee6b3aa0..b6dba84a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,17 +10,26 @@ 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; mod problem; #[derive(Eq, Debug, PartialEq)] +#[allow(dead_code)] enum Output { + Terminal, Native, Silent, } +#[derive(Eq, Debug, PartialEq)] +enum Phase { + Parsing, + Translation, +} + fn main() { const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); @@ -72,6 +81,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) @@ -173,12 +194,23 @@ fn main() { .unwrap(); let output = match output.as_str() { "native" => Output::Native, - "none" => Output::Silent, + "none" => Output::Terminal, _ => panic!("Unrecognized --output value"), }; 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 @@ -213,12 +245,51 @@ 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) => { + for (i, error) in errors + .iter() + .enumerate() + { + if i > 0 { + eprintln!(); + } + eprintln!( + "{}", + problem::concise_translation_error( + &error, &filename, &content, &Terminal + ) + ); + } + 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)) => { diff --git a/src/parsing/checks/verify.rs b/src/parsing/checks/verify.rs index e6fef53a..623decbc 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/src/problem/format.rs b/src/problem/format.rs index cfb55d92..58b41ce1 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,33 @@ pub fn concise_parsing_error<'i>( ) } +/// 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, + line, + column, + 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 a1a4a8c0..fc3afc29 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) { @@ -1015,3 +1017,34 @@ 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 { value: name, .. }) => ( + format!("Duplicate procedure name '{}'", name), + "A procedure with this name has already been declared in this document.".to_string(), + ), + TranslationError::DuplicateTitle { + procedure: Identifier { value: name, .. }, + .. + } => ( + format!("Duplicate title in procedure '{}'", name), + "A procedure can have at most one title.".to_string(), + ), + 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::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/program/mod.rs b/src/program/mod.rs new file mode 100644 index 00000000..0fe49c20 --- /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/program/types.rs b/src/program/types.rs new file mode 100644 index 00000000..dfb26f5b --- /dev/null +++ b/src/program/types.rs @@ -0,0 +1,173 @@ +// Intermediate Representation types for translated Technique procedures. +// +// These types complement those found in `crate::language::types` for the +// 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; + +/// 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 subroutines: Vec>, +} + +impl<'i> Program<'i> { + pub fn new() -> Self { + Program { + subroutines: Vec::new(), + } + } +} + +/// 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 SubroutineId(pub usize); + +#[derive(Debug, Eq, PartialEq)] +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>, + 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>, + pub responses: Vec<&'i language::Response<'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()), + responses: 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()), + responses: 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 +/// 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(Invocable<'i>), + Execute(Executable<'i>), + Sequence(Vec>), + Section { + 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>], + value: Box>, + }, +} + +/// 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 Ordinal<'i> { + Dependent(&'i str), + Parallel, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct Invocable<'i> { + pub target: SubroutineRef<'i>, + pub arguments: Vec>, +} + +/// 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 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 +/// subroutine 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 struct Entry<'i> { + pub label: &'i str, + pub value: Operation<'i>, +} diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs new file mode 100644 index 00000000..2a566a25 --- /dev/null +++ b/src/translation/checks/errors.rs @@ -0,0 +1,226 @@ +// Error-case checks for translation. Source strings parsed through the +// real parser inline match what the runner sees in production. + +use std::path::Path; + +use crate::parsing; +use crate::translation::{translate, TranslationError}; + +#[test] +fn duplicate_procedure_name() { + 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); + 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!( + id.span + .offset, + expected, + "span points at the duplicate declaration" + ); +} + +#[test] +fn duplicate_title() { + 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() { + 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()); +} + +#[test] +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 + +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"); + 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]); + }; + assert_eq!(id.value, "does_not_exist"); +} diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs new file mode 100644 index 00000000..968cd70d --- /dev/null +++ b/src/translation/checks/translate.rs @@ -0,0 +1,1737 @@ +// 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. + +use std::path::Path; + +use crate::language; +use crate::parsing; +use crate::program::{Fragment, Operation, Ordinal, SubroutineId, SubroutineRef}; +use crate::translation::translate; + +#[test] +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 + .subroutines + .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 + .subroutines + .len(), + 1 + ); + assert_eq!( + program.subroutines[0].name, + Some(language::Identifier::new("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 + .subroutines + .iter() + .map(|p| { + p.name + .as_ref() + .map(|id| id.value) + }) + .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 + .subroutines + .iter() + .map(|p| { + p.name + .as_ref() + .map(|id| id.value) + }) + .collect(); + assert_eq!(names, vec![Some("outer"), Some("inner")]); +} + +#[test] +fn procedure_title_extracted() { + let source = r#" +% 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"); + + let signature = program.subroutines[0] + .signature + .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] +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); +} + +#[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 sibling_sections_translate_in_order() { + let source = r#" +% technique v1 + +outer : + +I. First + +II. Second + "# + .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"]); +} + +#[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); +} + +#[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"); +} + +#[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_text_fragment_translates() { + // A plain string literal becomes a single Text fragment. + 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::Execute(executable) = &ops[0] else { + panic!("expected Execute"); + }; + let Operation::String(fragments) = &executable.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_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#" +% 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::Execute(executable) = &ops[0] else { + panic!("expected Execute, got {:?}", ops[0]); + }; + assert_eq!( + executable + .target + .value, + "sum" + ); + assert_eq!( + executable + .arguments + .len(), + 2 + ); +} + +#[test] +fn expression_application_translates_as_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(invocable) = &ops[0] else { + panic!("expected Invoke, got {:?}", ops[0]); + }; + assert_eq!( + invocable + .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"); +} + +#[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"); +} + +#[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" + ); +} + +#[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]); + }; +} + +#[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"); +} + +#[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 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 + // 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/mod.rs b/src/translation/mod.rs new file mode 100644 index 00000000..79ff6821 --- /dev/null +++ b/src/translation/mod.rs @@ -0,0 +1,14 @@ +//! Translation from the surface AST (`crate::language`) to an Intermediate +//! Representation suitable for an interpreter. + +mod translator; + +pub use translator::{translate, TranslationError}; + +#[cfg(test)] +#[path = "checks/translate.rs"] +mod check; + +#[cfg(test)] +#[path = "checks/errors.rs"] +mod errors; diff --git a/src/translation/translator.rs b/src/translation/translator.rs new file mode 100644 index 00000000..fd9fe2ac --- /dev/null +++ b/src/translation/translator.rs @@ -0,0 +1,609 @@ +// 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 crate::program::{ + Entry, Executable, Fragment, Invocable, Operation, Ordinal, Program, Subroutine, SubroutineId, + SubroutineRef, +}; + +pub fn translate<'i>(document: &'i Document<'i>) -> Result, Vec>> { + let mut translator = Translator::new(); + + if let Some(body) = &document.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(); + 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 + .push(wrapper); + } + translator.collect_technique(body); + translator.resolve_references(); + } + + if translator + .problems + .is_empty() + { + Ok(translator.program) + } else { + Err(translator.problems) + } +} + +#[derive(Debug, Eq, PartialEq)] +pub enum TranslationError<'i> { + DuplicateProcedure(language::Identifier<'i>), + DuplicateTitle { + procedure: language::Identifier<'i>, + at: Span, + }, + InterleavedDescription { + procedure: language::Identifier<'i>, + at: Span, + }, + /// A local procedure invocation `(...)` whose `name` doesn't + /// match any procedure declared in this document is an error. + 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>, + 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 { + self.collect_scope(scope); + } + } + } + } + } + language::Technique::Steps(scopes) => { + for scope in scopes { + self.collect_scope(scope); + } + } + language::Technique::Empty => {} + } + } + + // 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; + + if self + .known + .contains_key(name) + { + self.problems + .push(TranslationError::DuplicateProcedure(procedure.name)); + return None; + } + + let id = SubroutineId( + self.program + .subroutines + .len(), + ); + self.known + .insert(name, id); + self.program + .subroutines + .push(Subroutine::new(procedure.name)); + Some(id) + } + + // 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); + + 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, &mut responses, scope, &[]); + } + } + language::Element::CodeBlock(expressions, _) => { + for expression in expressions { + ops.push(self.translate_expression(expression)); + } + } + _ => {} + } + } + let body = Operation::Sequence(ops); + + 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; + entry.responses = responses; + } + + fn translate_scope( + &mut self, + scope: &'i language::Scope<'i>, + attrs: &[&'i [language::Attribute<'i>]], + ) -> Operation<'i> { + match scope { + language::Scope::SectionChunk { + numeral, + title, + body, + .. + } => { + 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); + } + } + Operation::Section { + numeral, + title: title.as_ref(), + body: Box::new(Operation::Sequence(body_ops)), + responses, + } + } + language::Scope::DependentBlock { + ordinal, + description, + subscopes, + .. + } => { + 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, &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 { + description, + subscopes, + .. + } => { + 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, &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. + language::Scope::AttributeBlock { .. } | language::Scope::ResponseBlock { .. } => { + unreachable!() + } + language::Scope::CodeBlock { + expressions, + subscopes, + .. + } => { + 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(_, _)) 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, + } + } + _ => { + 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) + } + } + } + } + } + + // Hoist executable Descriptives out of description paragraphs and + // append them to `ops` as an "anonymous step 0" prefix. + fn translate_descriptions( + &mut self, + 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); + } + } + } + } + + /// 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), + }) + } + } + } + + // 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>]], + ) { + 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, responses, sub, &nested); + } + } + language::Scope::ResponseBlock { responses: r, .. } => { + responses.extend(r); + } + _ => ops.push(self.translate_scope(scope, attrs)), + } + } + + // 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 + // - 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>, + ) -> (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() { + self.problems + .push(TranslationError::DuplicateTitle { + procedure: procedure.name, + at: *span, + }); + } else { + title = Some(*value); + } + } + language::Element::Description(paragraphs, span) => { + if blocked || description.is_some() { + self.problems + .push(TranslationError::InterleavedDescription { + procedure: procedure.name, + at: *span, + }); + } else { + description = Some(paragraphs.as_slice()); + } + } + language::Element::Steps(_, _) | language::Element::CodeBlock(_, _) => { + blocked = true; + } + } + } + + (title, description.unwrap_or(&[])) + } + + 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 { .. } => {} + } + } + + // 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), + 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::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, + 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 + // 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())), + responses: 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>) -> Invocable<'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(), + }; + Invocable { target, arguments } + } +} 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/broken/translation/DuplicateProcedure.tq b/tests/broken/translation/DuplicateProcedure.tq new file mode 100644 index 00000000..63ce06b1 --- /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 00000000..a20c359b --- /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/InterleavedDescription.tq b/tests/broken/translation/InterleavedDescription.tq new file mode 100644 index 00000000..ad6aeb6d --- /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/broken/translation/UnresolvedProcedure.tq b/tests/broken/translation/UnresolvedProcedure.tq new file mode 100644 index 00000000..0e158d86 --- /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/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 00000000..2e0be494 --- /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 11a0370c..d5c869e5 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 797e39d2..2e37703a 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,3 +1,5 @@ +mod common; mod formatting; mod parsing; mod templating; +mod translation; diff --git a/tests/parsing/broken.rs b/tests/parsing/broken.rs index 69b5dd1a..02e19082 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 3a342add..262aacbd 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(); @@ -50,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 69% rename from tests/samples/RoastTurkey.tq rename to tests/samples/parsing/RoastTurkey.tq index 322064e0..47d9aa2a 100644 --- a/tests/samples/RoastTurkey.tq +++ b/tests/samples/parsing/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/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 diff --git a/tests/translation/broken.rs b/tests/translation/broken.rs new file mode 100644 index 00000000..262886bb --- /dev/null +++ b/tests/translation/broken.rs @@ -0,0 +1,50 @@ +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 new file mode 100644 index 00000000..1609ecb9 --- /dev/null +++ b/tests/translation/mod.rs @@ -0,0 +1,2 @@ +mod broken; +mod samples; diff --git a/tests/translation/samples.rs b/tests/translation/samples.rs new file mode 100644 index 00000000..2b70fb62 --- /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/parsing/")); + check_directory(Path::new("examples/minimal/")); +}