diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 7823d1cb..243aba3e 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -2,6 +2,8 @@ name: Check on: pull_request: workflow_dispatch: +permissions: + contents: read jobs: build: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e3260f45..42ae54e4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,6 +2,8 @@ name: Release on: release: types: [published] +permissions: + contents: read jobs: build: diff --git a/.gitignore b/.gitignore index d14796c8..0742404b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # rendering artifacts *.pdf .*.typ +/.store # documentation symlinks /doc/references diff --git a/Cargo.lock b/Cargo.lock index d672f2d2..1ca2890c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,15 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "errno" version = "0.3.14" @@ -291,6 +300,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "once_cell" version = "1.21.4" @@ -315,6 +330,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -482,6 +503,7 @@ dependencies = [ "regex", "serde", "serde_json", + "time", "tinytemplate", "tracing", "tracing-subscriber", @@ -506,6 +528,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinytemplate" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 58856c40..03346d8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ owo-colors = "4" regex = "1.11.1" serde = { version = "1.0.209", features = [ "derive" ] } serde_json = "1.0" +time = { version = "0.3", features = [ "formatting" ] } tinytemplate = "1.2.1" tracing = "0.1.40" diff --git a/src/domain/engine.rs b/src/domain/engine.rs index 32c797a7..76e6b0b5 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -79,12 +79,7 @@ impl<'i> Scope<'i> { /// Filters out ResponseBlock, CodeBlock, AttributeBlock, etc. pub fn substeps(&self) -> impl Iterator> { self.children() - .filter(|s| { - matches!( - s, - Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } - ) - }) + .filter(|s| s.is_step()) } /// Returns the text content of this step (first paragraph). @@ -168,10 +163,10 @@ impl<'i> Scope<'i> { /// Returns true if this scope represents a step (dependent or parallel). pub fn is_step(&self) -> bool { - matches!( - self, - Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } - ) + match self { + Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } => true, + _ => false, + } } /// Returns section info (numeral, title) if this is a SectionChunk. diff --git a/src/domain/nasa_esa_iss/adapter.rs b/src/domain/nasa_esa_iss/adapter.rs index 3880643f..f21bcedb 100644 --- a/src/domain/nasa_esa_iss/adapter.rs +++ b/src/domain/nasa_esa_iss/adapter.rs @@ -51,9 +51,19 @@ fn inline_procedures(doc: &mut Document) { } } - doc.body.retain(|node| { - !matches!(node, Node::Sequential { title: None, invocations, .. } if !invocations.is_empty()) - }); + doc.body + .retain(|node| { + if let Node::Sequential { + title: None, + invocations, + .. + } = node + { + invocations.is_empty() + } else { + true + } + }); } /// Collect ordinals from invocation-only steps: procedure_name -> ordinal. diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 8d5c8902..749a9524 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -685,7 +685,13 @@ impl<'i> Formatter<'i> { if func .parameters .iter() - .any(|p| matches!(p, Expression::Multiline(_, _, _))) => + .any(|p| { + if let Expression::Multiline(_, _, _) = p { + true + } else { + false + } + }) => { line.flush(); self.add_fragment_reference(Syntax::Neutral, " "); diff --git a/src/highlighting/typst.rs b/src/highlighting/typst.rs index d8052d62..f6d5a7da 100644 --- a/src/highlighting/typst.rs +++ b/src/highlighting/typst.rs @@ -74,7 +74,11 @@ mod check { let result = escape_typst(input); // Should return borrowed reference when no quotes to escape - assert!(matches!(result, Cow::Borrowed(_))); + if let Cow::Borrowed(_) = result { + // ok + } else { + panic!("expected Cow::Borrowed"); + } assert_eq!(result, "hello world"); } @@ -84,7 +88,11 @@ mod check { let result = escape_typst(input); // Should return owned string when quotes need escaping - assert!(matches!(result, Cow::Owned(_))); + if let Cow::Owned(_) = result { + // ok + } else { + panic!("expected Cow::Owned"); + } assert_eq!(result, "hello \\\"world\\\""); } diff --git a/src/lib.rs b/src/lib.rs index 4920ebf4..bdb74fb5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,7 @@ pub mod language; pub mod parsing; pub mod program; pub(crate) mod regex; +pub mod runner; pub mod templating; pub mod translation; +pub mod value; diff --git a/src/main.rs b/src/main.rs index b6dba84a..a18c31fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use tracing_subscriber::{self, EnvFilter}; use technique::formatting::{self, Identity}; use technique::highlighting::{self, Terminal}; use technique::parsing; +use technique::runner::{self, Outcome, RunId}; use technique::templating::{self, Checklist, NasaEsaIss, Procedure, Recipe, Source}; use technique::translation; @@ -170,6 +171,35 @@ fn main() { .help("The file containing the Technique you want to render."), ), ) + .subcommand( + Command::new("run") + .about("Interactively work through a Technique procedure.") + .long_about("Walk through the steps of a Technique procedure interactively, \ + prompting you at each step and recording results locally. \ + When a Technique document is instantiated as a running procedure \ + it is allocated a unique identifier. That identifier can be used with \ + `technique resume` to continue an interrupted workflow.") + .arg( + Arg::new("filename") + .required(true) + .help("The file containing the Technique document to run."), + ) + .arg( + Arg::new("arguments") + .num_args(0..) + .action(ArgAction::Append) + .help("Values here, if any, will be bound as the entry procedure's parameters."), + ), + ) + .subcommand( + Command::new("resume") + .about("Resume an interrupted procedure.") + .arg( + Arg::new("id") + .required(true) + .help("The identifier of the run to continue. Can be written as `000007` or just `7`."), + ), + ) .subcommand( Command::new("language") .about("Language Server Protocol integration for editors and IDEs.") @@ -470,6 +500,162 @@ fn main() { _ => panic!("Unrecognized --output value"), } } + Some(("run", submatches)) => { + let filename = submatches + .get_one::("filename") + .unwrap(); + + debug!(filename); + + let filename = Path::new(filename); + let content = match parsing::load(&filename) { + Ok(data) => data, + Err(error) => { + eprintln!("{}", problem::concise_loading_error(&error)); + std::process::exit(1); + } + }; + + let technique = match parsing::parse(&filename, &content) { + Ok(document) => document, + Err(errors) => { + for (i, error) in errors + .iter() + .enumerate() + { + if i > 0 { + eprintln!(); + } + eprintln!( + "{}", + problem::concise_parsing_error(&error, &filename, &content, &Terminal) + ); + } + + eprintln!( + "\nUnable to parse input file. Try `technique check {}` for details.", + &filename.to_string_lossy() + ); + std::process::exit(1); + } + }; + + 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); + } + }; + + match runner::start(filename, &program) { + Ok((id, Outcome::Quit)) => { + eprintln!("paused; resume with `technique resume {}`", id.render()); + std::process::exit(0); + } + Ok((_, _)) => std::process::exit(0), + Err(error) => { + eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); + std::process::exit(1); + } + } + } + Some(("resume", submatches)) => { + let id = submatches + .get_one::("id") + .unwrap(); + + debug!(id); + + let id = match RunId::parse(id) { + Ok(id) => id, + Err(error) => { + eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); + std::process::exit(1); + } + }; + + let filename = match runner::locate(id) { + Ok(path) => path, + Err(error) => { + eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); + std::process::exit(1); + } + }; + + let content = match parsing::load(&filename) { + Ok(data) => data, + Err(error) => { + eprintln!("{}", problem::concise_loading_error(&error)); + std::process::exit(1); + } + }; + + let technique = match parsing::parse(&filename, &content) { + Ok(document) => document, + Err(errors) => { + for (i, error) in errors + .iter() + .enumerate() + { + if i > 0 { + eprintln!(); + } + eprintln!( + "{}", + problem::full_parsing_error(&error, &filename, &content, &Terminal) + ); + } + std::process::exit(1); + } + }; + + 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); + } + }; + + match runner::resume(id, &program) { + Ok(Outcome::Quit) => { + eprintln!("paused; continue with `technique resume {}`", id.render()); + std::process::exit(0); + } + Ok(_) => std::process::exit(0), + Err(error) => { + eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); + std::process::exit(1); + } + } + } Some(("language", _)) => { debug!("Starting Language Server"); diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index deb8f3e6..70664a8a 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -2293,7 +2293,11 @@ fn parse_collecting_errors_basic() { assert!(errors.len() > 0); assert!(errors .iter() - .any(|e| matches!(e, ParsingError::InvalidHeader(_)))); + .any(|e| if let ParsingError::InvalidHeader(_) = e { + true + } else { + false + })); } } diff --git a/src/parsing/checks/verify.rs b/src/parsing/checks/verify.rs index 623decbc..bee7e067 100644 --- a/src/parsing/checks/verify.rs +++ b/src/parsing/checks/verify.rs @@ -1044,7 +1044,11 @@ fn multiple_roles_with_dependent_substeps() { .. } => { for substep in substeps { - assert!(matches!(substep, Scope::DependentBlock { .. })); + if let Scope::DependentBlock { .. } = substep { + // ok + } else { + panic!("Expected DependentBlock substep"); + } } } _ => panic!("Expected AttributedBlock scopes"), diff --git a/src/problem/format.rs b/src/problem/format.rs index 58b41ce1..d3a6b1dc 100644 --- a/src/problem/format.rs +++ b/src/problem/format.rs @@ -1,8 +1,8 @@ -use super::messages::{generate_error_message, generate_translation_error}; +use super::messages::{generate_error_message, generate_runner_error, generate_translation_error}; use owo_colors::OwoColorize; use std::path::Path; use technique::{ - formatting::Render, language::LoadingError, parsing::ParsingError, + formatting::Render, language::LoadingError, parsing::ParsingError, runner::RunnerError, translation::TranslationError, }; @@ -119,6 +119,12 @@ pub fn concise_translation_error<'i>( ) } +/// Format a runner error with concise single-line output. +pub fn concise_runner_error(error: &RunnerError, renderer: &impl Render) -> String { + let (problem, _) = generate_runner_error(error, renderer); + format!("{}: {}", "error".bright_red(), 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 fc3afc29..af71a4a7 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1,6 +1,7 @@ use crate::problem::Present; use technique::{ - formatting::Render, language::*, parsing::ParsingError, translation::TranslationError, + formatting::Render, language::*, parsing::ParsingError, runner::RunnerError, + translation::TranslationError, }; /// Generate problem and detail messages for parsing errors using AST construction @@ -1018,7 +1019,8 @@ Hyphens, underscores, spaces, or subscripts are not valid in unit symbols. } } -/// Generate problem and detail messages for translation errors. +/// Generate problem and detail messages for errors occuring during the +/// translation phase. pub fn generate_translation_error<'i>( error: &TranslationError<'i>, _renderer: &dyn Render, @@ -1048,3 +1050,56 @@ pub fn generate_translation_error<'i>( ), } } + +/// Generate problem and detail messages for errors occuring when a proceure +/// is being evaluated by the runner. +pub fn generate_runner_error(error: &RunnerError, _renderer: &dyn Render) -> (String, String) { + match error { + RunnerError::NoSuchRun(id) => ( + format!("No such run '{:06}'", id.0), + "The directory for this run identifier was not found in the local state store.".to_string(), + ), + RunnerError::StoreError { path, error } => ( + format!("I/O error with local state store at {}", path.display()), + format!("{}", error), + ), + RunnerError::MalformedRecord { run, .. } => ( + format!("Malformed record for run '{:06}'", run.0), + "The PFFTT state file for this run could not be parsed.".to_string(), + ), + RunnerError::ManifestMissing(id) => ( + format!("Manifest missing in run '{:06}'", id.0), + "The state file is present but its first tablet (the manifest) is missing or malformed.".to_string(), + ), + RunnerError::InvalidRunId(text) => ( + format!("Invalid run identifier '{}'", text), + "Run identifiers are integer values, conventionally rendered as six zero-padded digits.".to_string(), + ), + RunnerError::MissingEntryProcedure => ( + "No entry procedure".to_string(), + "The document has neither procedure declarations nor top-level steps so the runner can't start its walk.".to_string(), + ), + RunnerError::UnboundVariable(name) => ( + format!("Unbound variable '{}'", name), + "The procedure has not yet supplied a value for this variable!".to_string(), + ), + RunnerError::BindArityMismatch { expected, actual } => ( + format!( + "Binding arity mismatch: {} names but {} values", + expected, actual + ), + "Binding multiple variables requires the procedure being invoked or function being called to return a tuple of the same size.".to_string(), + ), + RunnerError::BindNotTuple { expected } => ( + format!( + "Binding requires a tuple: {} names but the value is not a tuple", + expected + ), + "Binding multiple variables requires the procedure being invoked or function being called to return a tuple of the same size.".to_string(), + ), + RunnerError::UserQuit => ( + "Interrupted".to_string(), + "The user quit before the procedure was completed. Use `technique resume ` to continue.".to_string(), + ), + } +} diff --git a/src/program/types.rs b/src/program/types.rs index dfb26f5b..401a28fe 100644 --- a/src/program/types.rs +++ b/src/program/types.rs @@ -40,7 +40,7 @@ pub struct Subroutine<'i> { /// then `None`, otherwise all procedures have names. pub name: Option>, pub title: Option<&'i str>, - pub description: &'i [language::Paragraph<'i>], + pub description: Vec>, pub parameters: Option<&'i [language::Identifier<'i>]>, pub signature: Option<&'i language::Signature<'i>>, pub body: Operation<'i>, @@ -55,7 +55,7 @@ impl<'i> Subroutine<'i> { Subroutine { name: Some(name), title: None, - description: &[], + description: Vec::new(), parameters: None, signature: None, body: Operation::Sequence(Vec::new()), @@ -69,7 +69,7 @@ impl<'i> Subroutine<'i> { Subroutine { name: None, title: None, - description: &[], + description: Vec::new(), parameters: None, signature: None, body: Operation::Sequence(Vec::new()), @@ -94,14 +94,14 @@ pub enum Operation<'i> { Sequence(Vec>), Section { numeral: &'i str, - title: Option<&'i language::Paragraph<'i>>, + title: Option>>, body: Box>, responses: Vec<&'i language::Response<'i>>, }, Step { ordinal: Ordinal<'i>, attributes: Vec<&'i [language::Attribute<'i>]>, - description: &'i [language::Paragraph<'i>], + description: Vec>, body: Box>, responses: Vec<&'i language::Response<'i>>, }, diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs new file mode 100644 index 00000000..6455dbe6 --- /dev/null +++ b/src/runner/checks/evaluator.rs @@ -0,0 +1,213 @@ +use crate::language::{Identifier, Numeric as LangNumeric}; +use crate::program::{Entry, Fragment, Operation}; +use crate::runner::evaluator::{evaluate, Environment}; +use crate::runner::runner::RunnerError; +use crate::value; + +#[test] +fn variable_lookup() { + let op = Operation::Variable(Identifier::new("missing")); + let mut env = Environment::new(); + match evaluate(&mut env, &op) { + Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "missing"), + other => panic!("expected UnboundVariable, got {:?}", other), + } + + let mut env = Environment::new(); + env.extend( + "name".to_string(), + value::Value::Literali("World".to_string()), + ); + let op = Operation::Variable(Identifier::new("name")); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!(v, value::Value::Literali("World".to_string())); +} + +#[test] +fn number_evaluates_to_quanticle() { + let op = Operation::Number(LangNumeric::Integral(42)); + let mut env = Environment::new(); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!(v, value::Value::Quanticle(value::Numeric::Integral(42))); +} + +#[test] +fn string_interpolation() { + let mut env = Environment::new(); + env.extend( + "name".to_string(), + value::Value::Literali("World".to_string()), + ); + let op = Operation::String(vec![ + Fragment::Text("Hello, "), + Fragment::Interpolation(Operation::Variable(Identifier::new("name"))), + Fragment::Text("!"), + ]); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!(v, value::Value::Literali("Hello, World!".to_string())); + + let op = Operation::String(vec![ + Fragment::Text("hi "), + Fragment::Interpolation(Operation::Variable(Identifier::new("nope"))), + ]); + let mut env = Environment::new(); + match evaluate(&mut env, &op) { + Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "nope"), + other => panic!("expected UnboundVariable, got {:?}", other), + } +} + +#[test] +fn multiline_joins_with_newlines() { + let op = Operation::Multiline(None, vec!["foo", "bar", "baz"]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!(v, value::Value::Literali("foo\nbar\nbaz".to_string())); +} + +#[test] +fn tablet_entries_evaluate() { + let op = Operation::Tablet(vec![ + Entry { + label: "name", + value: Operation::String(vec![Fragment::Text("Kowalski")]), + }, + Entry { + label: "count", + value: Operation::Number(LangNumeric::Integral(7)), + }, + ]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!( + v, + value::Value::Tabularum(vec![ + ( + "name".to_string(), + value::Value::Literali("Kowalski".to_string()) + ), + ( + "count".to_string(), + value::Value::Quanticle(value::Numeric::Integral(7)) + ), + ]) + ); +} + +#[test] +fn bind_extends_env_for_subsequent_lookup() { + let names = [Identifier::new("greeting")]; + let bind = Operation::Bind { + names: &names, + value: Box::new(Operation::String(vec![Fragment::Text("Hello")])), + }; + let lookup = Operation::Variable(Identifier::new("greeting")); + let seq = Operation::Sequence(vec![bind, lookup]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &seq).expect("evaluated"); + assert_eq!(v, value::Value::Literali("Hello".to_string())); +} + +#[test] +fn sequence_evaluation() { + let seq = Operation::Sequence(vec![ + Operation::Number(LangNumeric::Integral(1)), + Operation::Number(LangNumeric::Integral(2)), + Operation::Number(LangNumeric::Integral(3)), + ]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &seq).expect("evaluated"); + assert_eq!(v, value::Value::Quanticle(value::Numeric::Integral(3))); + + let seq = Operation::Sequence(vec![]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &seq).expect("evaluated"); + assert_eq!(v, value::Value::Unitus); +} + +#[test] +fn multi_name_bind_destructures_parametriq() { + // Build a Parametriq of three values by reducing a wrapped construction. + // Simplest path: pre-stuff env with a Parametriq, then bind a tuple of + // names to a Variable that looks it up. + let mut env = Environment::new(); + env.extend( + "triple".to_string(), + value::Value::Parametriq(vec![ + value::Value::Literali("one".to_string()), + value::Value::Literali("two".to_string()), + value::Value::Quanticle(value::Numeric::Integral(3)), + ]), + ); + let names = [ + Identifier::new("a"), + Identifier::new("b"), + Identifier::new("c"), + ]; + let bind = Operation::Bind { + names: &names, + value: Box::new(Operation::Variable(Identifier::new("triple"))), + }; + let result = evaluate(&mut env, &bind).expect("evaluated"); + assert_eq!(result, value::Value::Unitus); + assert_eq!( + env.lookup("a"), + Some(&value::Value::Literali("one".to_string())) + ); + assert_eq!( + env.lookup("b"), + Some(&value::Value::Literali("two".to_string())) + ); + assert_eq!( + env.lookup("c"), + Some(&value::Value::Quanticle(value::Numeric::Integral(3))) + ); +} + +#[test] +fn multi_name_bind_wrong_arity_errors() { + let mut env = Environment::new(); + env.extend( + "pair".to_string(), + value::Value::Parametriq(vec![ + value::Value::Literali("one".to_string()), + value::Value::Literali("two".to_string()), + ]), + ); + let names = [ + Identifier::new("a"), + Identifier::new("b"), + Identifier::new("c"), + ]; + let bind = Operation::Bind { + names: &names, + value: Box::new(Operation::Variable(Identifier::new("pair"))), + }; + match evaluate(&mut env, &bind) { + Err(RunnerError::BindArityMismatch { expected, actual }) => { + assert_eq!(expected, 3); + assert_eq!(actual, 2); + } + other => panic!("expected BindArityMismatch, got {:?}", other), + } +} + +#[test] +fn multi_name_bind_against_scalar_errors_as_not_tuple() { + let mut env = Environment::new(); + env.extend( + "scalar".to_string(), + value::Value::Literali("just one".to_string()), + ); + let names = [Identifier::new("a"), Identifier::new("b")]; + let bind = Operation::Bind { + names: &names, + value: Box::new(Operation::Variable(Identifier::new("scalar"))), + }; + match evaluate(&mut env, &bind) { + Err(RunnerError::BindNotTuple { expected }) => { + assert_eq!(expected, 2); + } + other => panic!("expected BindNotTuple, got {:?}", other), + } +} diff --git a/src/runner/checks/path.rs b/src/runner/checks/path.rs new file mode 100644 index 00000000..8d5eaa73 --- /dev/null +++ b/src/runner/checks/path.rs @@ -0,0 +1,116 @@ +use crate::language::{Attribute, Identifier, Span}; +use crate::runner::path::{PathSegment, QualifiedPath}; + +#[test] +fn empty_stack_renders_empty_string() { + let stack = QualifiedPath::new(); + assert_eq!(stack.render(), ""); +} + +#[test] +fn single_section_renders_numeral() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Section("I")); + assert_eq!(stack.render(), "I"); +} + +#[test] +fn section_then_step_joins_with_slash() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Section("I")); + stack.push(PathSegment::DependentStep("2")); + assert_eq!(stack.render(), "I/2"); +} + +#[test] +fn dependent_substep_chain() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("2")); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "2/a"); +} + +#[test] +fn parallel_step_uses_dash_prefix() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::ParallelStep(3)); + assert_eq!(stack.render(), "-3"); +} + +#[test] +fn attribute_frame_composes_role_and_place() { + let frame = vec![ + Attribute::Role(Identifier::new("chef"), Span::default()), + Attribute::Place(Identifier::new("kitchen"), Span::default()), + ]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Attributes(&frame)); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "@chef^kitchen/a"); +} + +#[test] +fn nested_attribute_frames_each_contribute_a_segment() { + let outer = vec![Attribute::Role(Identifier::new("team"), Span::default())]; + let inner = vec![ + Attribute::Role(Identifier::new("chef"), Span::default()), + Attribute::Place(Identifier::new("kitchen"), Span::default()), + ]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("2")); + stack.push(PathSegment::Attributes(&outer)); + stack.push(PathSegment::Attributes(&inner)); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "2/@team/@chef^kitchen/a"); +} + +#[test] +fn reset_role() { + // `@*` alone suppresses the whole attribute frame's contribution. + let frame = vec![Attribute::Role(Identifier::new("*"), Span::default())]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("1")); + stack.push(PathSegment::Attributes(&frame)); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "1/a"); + + // A sibling Place attribute in the same frame still renders. + let frame = vec![ + Attribute::Role(Identifier::new("*"), Span::default()), + Attribute::Place(Identifier::new("kitchen"), Span::default()), + ]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("1")); + stack.push(PathSegment::Attributes(&frame)); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "1/^kitchen/a"); +} + +#[test] +fn procedure_segment() { + // Single procedure: name becomes the prefix, joined to the rest with `:`. + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Procedure("make_coffee")); + stack.push(PathSegment::DependentStep("2")); + assert_eq!(stack.render(), "make_coffee:2"); + + // Nested procedure: the inner frame replaces the outer prefix entirely. + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Procedure("outer")); + stack.push(PathSegment::DependentStep("1")); + stack.push(PathSegment::Procedure("inner")); + stack.push(PathSegment::DependentStep("2")); + assert_eq!(stack.render(), "inner:2"); +} + +#[test] +fn full_qualified_example_from_objective() { + // 2/@barista/a/-1 — dependent step 2, role @barista, substep a, first parallel sub-substep + let frame = vec![Attribute::Role(Identifier::new("barista"), Span::default())]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("2")); + stack.push(PathSegment::Attributes(&frame)); + stack.push(PathSegment::DependentStep("a")); + stack.push(PathSegment::ParallelStep(1)); + assert_eq!(stack.render(), "2/@barista/a/-1"); +} diff --git a/src/runner/checks/prompt.rs b/src/runner/checks/prompt.rs new file mode 100644 index 00000000..2f37bdb7 --- /dev/null +++ b/src/runner/checks/prompt.rs @@ -0,0 +1,133 @@ +use std::io::Cursor; + +use crate::runner::prompt::{Console, Event, Mock, Prompt, UserInput}; +use crate::value::Value; + +#[test] +fn mock_returns_canned_answers_in_order() { + let mut p = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Skip, + UserInput::Quit, + ]); + assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(), UserInput::Skip); + assert_eq!(p.ask(), UserInput::Quit); +} + +#[test] +fn mock_records_step_and_ask_events() { + let mut p = Mock::with_answers([UserInput::Done(Value::Unitus)]); + p.step("local_network:I/1", "Check the cable."); + let _ = p.ask(); + assert_eq!( + p.events(), + &[ + Event::Step { + qualified: "local_network:I/1".to_string(), + description: "Check the cable.".to_string(), + }, + Event::Ask, + ] + ); +} + +#[test] +fn mock_records_section_and_announce() { + let mut p = Mock::new(); + p.section("I", "Setup"); + p.announce("Calling helper"); + assert_eq!( + p.events(), + &[ + Event::Section { + qualified: "I".to_string(), + title: "Setup".to_string(), + }, + Event::Announce("Calling helper".to_string()), + ] + ); +} + +#[test] +#[should_panic(expected = "Mock::ask called with no canned answers remaining")] +fn mock_ask_without_answers_panics() { + let mut p = Mock::new(); + let _ = p.ask(); +} + +#[test] +fn console_input() { + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b"d\n"), &mut output); + assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); + + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b"s\n"), &mut output); + assert_eq!(p.ask(), UserInput::Skip); + + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b"f\n"), &mut output); + assert_eq!(p.ask(), UserInput::Fail); + + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b"q\n"), &mut output); + assert_eq!(p.ask(), UserInput::Quit); + + // Case-insensitive on the first character. + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b"DONE\n"), &mut output); + assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); + + // Leading whitespace is tolerated. + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b" q\n"), &mut output); + assert_eq!(p.ask(), UserInput::Quit); +} + +#[test] +fn console_unrecognized_input_reprompts() { + let input = Cursor::new(b"x\nd\n"); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); + // Two prompts written: one for the rejected `x`, one for the + // accepted `d`. The prompt text contains "[d]one". + let written = String::from_utf8(output).expect("utf8"); + assert!( + written + .matches("[d]one") + .count() + >= 2 + ); +} + +#[test] +fn console_eof_returns_quit() { + let input = Cursor::new(b""); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Quit); +} + +#[test] +fn console_step_writes_fqn_and_description() { + let input = Cursor::new(b""); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + p.step("local_network:I/1", "Check the cable."); + let written = String::from_utf8(output).expect("utf8"); + assert!(written.contains("local_network:I/1")); + assert!(written.contains("Check the cable.")); +} + +#[test] +fn console_section_writes_fqn_and_title() { + let input = Cursor::new(b""); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + p.section("I", "Setup"); + let written = String::from_utf8(output).expect("utf8"); + assert!(written.contains("I")); + assert!(written.contains("Setup")); +} diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs new file mode 100644 index 00000000..6e8616a7 --- /dev/null +++ b/src/runner/checks/runner.rs @@ -0,0 +1,545 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::parsing; +use crate::program::{Operation, Ordinal, Program, Subroutine}; +use crate::runner::prompt::{Event, Mock, UserInput}; +use crate::runner::runner::{Outcome, Runner}; +use crate::runner::state::{parse_record, Appender, Outcome as RecordOutcome, Store}; +use crate::translation::translate; +use crate::value::Value; + +// A small fixture builder. The Program borrows from its inputs, so the +// caller has to keep storage alive for the duration of the run. We +// return a (base, run_dir, appender) tuple so the test can clean up +// the temp directory afterwards. +struct StoreFixture { + base: PathBuf, + appender: Option, +} + +impl StoreFixture { + fn new(test_name: &str) -> Self { + let base = std::env::temp_dir().join(format!("technique-runner-{}", test_name)); + let _ = std::fs::remove_dir_all(&base); + let store = Store::new(base.clone()); + let document = PathBuf::from("/tmp/Test.tq"); + let (_, run_dir, _) = store + .create(&document, "2026-05-16T00:00:00Z".to_string()) + .expect("create"); + let pfftt = crate::runner::state::construct_state_path(&run_dir, &document); + let appender = Appender::open(pfftt).expect("open appender"); + StoreFixture { + base, + appender: Some(appender), + } + } + + fn take_appender(&mut self) -> Appender { + self.appender + .take() + .expect("appender") + } + + fn pfftt_contents(&self) -> String { + let entries = std::fs::read_dir(&self.base) + .expect("read base") + .next() + .expect("at least one run dir") + .expect("entry"); + for f in std::fs::read_dir(entries.path()).expect("read run dir") { + let f = f.expect("entry"); + if f.path() + .extension() + .and_then(|e| e.to_str()) + == Some("pfftt") + { + return std::fs::read_to_string(f.path()).expect("read pfftt"); + } + } + panic!("no pfftt file"); + } +} + +impl Drop for StoreFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } +} + +fn step(ordinal: Ordinal<'static>, body: Operation<'static>) -> Operation<'static> { + Operation::Step { + ordinal, + attributes: Vec::new(), + description: Vec::new(), + body: Box::new(body), + responses: Vec::new(), + } +} + +fn anonymous_with_body(body: Operation<'static>) -> Program<'static> { + let mut program = Program::new(); + let mut sub = Subroutine::anonymous(); + sub.body = body; + program + .subroutines + .push(sub); + program +} + +#[test] +fn step_outcomes_recorded() { + let mut fixture = StoreFixture::new("step-done"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::Sequence(vec![]), + )]); + let program = anonymous_with_body(body); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let outcome = runner + .run() + .expect("run"); + assert_eq!(outcome, Outcome::Done(Value::Unitus)); + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + assert_eq!(lines.len(), 2); + assert_eq!( + lines[0], + "[ document = file:///tmp/Test.tq, started = 2026-05-16T00:00:00Z ]" + ); + let record = parse_record(lines[1]).expect("parse record"); + assert_eq!(record.path, "1"); + let RecordOutcome::Done(_) = record.outcome else { + panic!("expected Done, got {:?}", record.outcome); + }; + + let mut fixture = StoreFixture::new("step-skip"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::Sequence(vec![]), + )]); + let program = anonymous_with_body(body); + let prompt = Mock::with_answers([UserInput::Skip]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + let record = parse_record(lines[1]).expect("parse record"); + assert_eq!(record.outcome, RecordOutcome::Skipped); + + let mut fixture = StoreFixture::new("step-fail"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::Sequence(vec![]), + )]); + let program = anonymous_with_body(body); + let prompt = Mock::with_answers([UserInput::Fail]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + let record = parse_record(lines[1]).expect("parse record"); + assert_eq!( + record.outcome, + RecordOutcome::Failed(Some("\"Failed\"".to_string())) + ); +} + +#[test] +fn two_steps_prompted_in_source_order() { + let mut fixture = StoreFixture::new("two-steps"); + let body = Operation::Sequence(vec![ + step(Ordinal::Dependent("1"), Operation::Sequence(vec![])), + step(Ordinal::Dependent("2"), Operation::Sequence(vec![])), + ]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(step_fqns, vec!["1", "2"]); +} + +#[test] +fn pre_completed_step_short_circuits() { + let mut fixture = StoreFixture::new("short-circuit"); + let body = Operation::Sequence(vec![ + step(Ordinal::Dependent("1"), Operation::Sequence(vec![])), + step(Ordinal::Dependent("2"), Operation::Sequence(vec![])), + ]); + let program = anonymous_with_body(body); + + // Pre-mark step 1 completed; the walker should skip its prompt + // and only ask about step 2. + let mut completed = HashSet::new(); + completed.insert("1".to_string()); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), completed, prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(step_fqns, vec!["2"]); +} + +#[test] +fn quit_propagates_and_stops_walking() { + let mut fixture = StoreFixture::new("quit-propagates"); + let body = Operation::Sequence(vec![ + step(Ordinal::Dependent("1"), Operation::Sequence(vec![])), + step(Ordinal::Dependent("2"), Operation::Sequence(vec![])), + ]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Quit]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let outcome = runner + .run() + .expect("run"); + assert_eq!(outcome, Outcome::Quit); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + // Only the first Step was prompted; the second never fired. + assert_eq!(step_fqns, vec!["1"]); + + // Quit doesn't record an outcome — the PFFTT file contains the + // manifest tablet and nothing else. + let pfftt = fixture.pfftt_contents(); + assert_eq!( + pfftt + .matches("outcome =") + .count(), + 0 + ); +} + +#[test] +fn section_walking() { + use crate::program::Fragment; + + let mut fixture = StoreFixture::new("section-no-title"); + let inner = step(Ordinal::Dependent("1"), Operation::Sequence(vec![])); + let body = Operation::Sequence(vec![Operation::Section { + numeral: "I", + title: None, + body: Box::new(Operation::Sequence(vec![inner])), + responses: Vec::new(), + }]); + let program = anonymous_with_body(body); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + let prompt = runner.into_prompt(); + let events = prompt.events(); + let section_fqns: Vec<&str> = events + .iter() + .filter_map(|e| { + if let Event::Section { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + let step_fqns: Vec<&str> = events + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(section_fqns, vec!["I"]); + assert_eq!(step_fqns, vec!["I/1"]); + + let mut fixture = StoreFixture::new("section-with-title"); + let title = Operation::String(vec![Fragment::Text("Setup")]); + let inner = step(Ordinal::Dependent("1"), Operation::Sequence(vec![])); + let body = Operation::Sequence(vec![Operation::Section { + numeral: "I", + title: Some(Box::new(title)), + body: Box::new(Operation::Sequence(vec![inner])), + responses: Vec::new(), + }]); + let program = anonymous_with_body(body); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + let prompt = runner.into_prompt(); + let section_title = prompt + .events() + .iter() + .find_map(|e| { + if let Event::Section { title, .. } = e { + Some(title.as_str()) + } else { + None + } + }) + .expect("section event"); + assert_eq!(section_title, "Setup"); +} + +#[test] +fn parallel_step_index_starts_at_one() { + let mut fixture = StoreFixture::new("parallel-index"); + let body = Operation::Sequence(vec![ + step(Ordinal::Parallel, Operation::Sequence(vec![])), + step(Ordinal::Parallel, Operation::Sequence(vec![])), + ]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(step_fqns, vec!["-1", "-2"]); +} + +#[test] +fn bind_in_body_interpolates_into_description() { + let source = r#" +% technique v1 + +test : + +1. { 42 ~ answer } +2. Result: { answer } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("bind-then-interpolate"); + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let descriptions: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { description, .. } = e { + Some(description.as_str()) + } else { + None + } + }) + .collect(); + // Step 1 only binds, so its description renders to empty. Step 2 + // sees the binding established by step 1 and interpolates it. + assert_eq!(descriptions, vec!["", "Result: 42"]); +} + +#[test] +fn resolved_invoke_descends_into_subroutine() { + let source = r#" +% technique v1 + +main : + +{ + () +} + +helper : + +1. helper step + "# + .trim_ascii(); + let document = parsing::parse(Path::new("test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("invoke-descent"); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + // The Step inside `helper` was reached and prompted, with the + // helper procedure as the FQN prefix (the outer `main` frame is + // overridden by the inner Procedure segment). + assert_eq!(step_fqns, vec!["helper:1"]); +} + +#[test] +fn execute_announces_function_call() { + let source = r#" +% technique v1 + +test : + +1. Do this { journal("hello") } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("execute-announce"); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let announcements: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Announce(text) = e { + Some(text.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(announcements, vec!["journal(...)"]); +} + +#[test] +fn loop_inside_step_produces_one_result() { + let mut fixture = StoreFixture::new("loop-in-step"); + + // A Step whose body contains a Loop. The Loop announces but does + // not record a Result; the enclosing Step records exactly one. + let loop_op = Operation::Loop { + names: &[], + over: None, + body: Box::new(Operation::Sequence(vec![])), + responses: Vec::new(), + }; + let the_step = Operation::Step { + ordinal: Ordinal::Dependent("1"), + attributes: Vec::new(), + description: Vec::new(), + body: Box::new(loop_op), + responses: Vec::new(), + }; + let body = Operation::Sequence(vec![the_step]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let pfftt = fixture.pfftt_contents(); + assert_eq!( + pfftt + .matches("outcome =") + .count(), + 1 + ); +} diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs new file mode 100644 index 00000000..e04e1643 --- /dev/null +++ b/src/runner/checks/state.rs @@ -0,0 +1,416 @@ +use std::path::{Path, PathBuf}; + +use crate::runner::runner::RunnerError; +use crate::runner::state::{ + format_record, parse_manifest, parse_record, Outcome, Record, RunId, Store, +}; + +// A scratch directory under the system temp dir, cleaned up on drop so panics +// in a test do not leak it. Tests construct one per fixture they need. +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!("technique-{}", name)); + let _ = std::fs::remove_dir_all(&path); + TempDir { path } + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +#[test] +fn run_id_parse() { + let id = RunId::parse("7").expect("parse"); + assert_eq!(id, RunId(7)); + + let id = RunId::parse("000007").expect("parse"); + assert_eq!(id, RunId(7)); + + let id = RunId::parse("123456").expect("parse"); + assert_eq!(id, RunId(123456)); +} + +#[test] +fn run_id_parse_rejects_non_decimal() { + for text in ["", "abc", "-1"] { + match RunId::parse(text) { + Err(RunnerError::InvalidRunId(got)) => assert_eq!(got, text), + other => panic!("expected InvalidRunId for {:?}, got {:?}", text, other), + } + } +} + +#[test] +fn run_id_render_six_digit_padding() { + assert_eq!(RunId(0).render(), "000000"); + assert_eq!(RunId(7).render(), "000007"); + assert_eq!(RunId(15003).render(), "015003"); + // Six digits is the convention, but larger values render unpadded. + assert_eq!(RunId(1_234_567).render(), "1234567"); +} + +#[test] +fn store_allocate_assigns_monotonic_ids() { + let dir = TempDir::new("allocate-monotonic"); + + let store = Store::new( + dir.path + .clone(), + ); + let (first, _) = store + .allocate() + .expect("first"); + let (second, _) = store + .allocate() + .expect("second"); + assert_eq!(first, RunId(1)); + assert_eq!(second, RunId(2)); +} + +#[test] +fn store_allocate_resumes_from_existing_max() { + let dir = TempDir::new("allocate-resume"); + std::fs::create_dir_all( + dir.path + .join("000007"), + ) + .unwrap(); + + let store = Store::new( + dir.path + .clone(), + ); + let (id, _) = store + .allocate() + .expect("allocate"); + assert_eq!(id, RunId(8)); +} + +#[test] +fn manifest_round_trip_through_create_and_open() { + let dir = TempDir::new("manifest-roundtrip"); + + let document = PathBuf::from("/somewhere/NetworkProbe.tq"); + let started = "2026-05-14T12:34:56Z".to_string(); + + let store = Store::new( + dir.path + .clone(), + ); + let (id, _, written) = store + .create(&document, started.clone()) + .expect("create"); + let (read, completed, _) = store + .open(id) + .expect("open"); + + assert_eq!(written, read); + assert_eq!(read.document, document); + assert_eq!(read.started, started); + assert!(completed.is_empty()); +} + +#[test] +fn open_replays_three_result_paths() { + let dir = TempDir::new("replay-three"); + + let run_dir = dir + .path + .join("000001"); + std::fs::create_dir_all(&run_dir).unwrap(); + let mut file = String::new(); + file.push_str("[ document = file:///foo/Test.tq, started = 2026-05-14T12:00:00Z ]\n"); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:01Z".to_string(), + path: "test:1".to_string(), + outcome: Outcome::Done(None), + })); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:02Z".to_string(), + path: "test:2".to_string(), + outcome: Outcome::Skipped, + })); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:03Z".to_string(), + path: "test:3".to_string(), + outcome: Outcome::Failed(None), + })); + std::fs::write(run_dir.join("Test.pfftt"), file).unwrap(); + + let store = Store::new( + dir.path + .clone(), + ); + let (manifest, completed, _) = store + .open(RunId(1)) + .expect("open"); + + assert_eq!(manifest.document, Path::new("/foo/Test.tq")); + assert_eq!(completed.len(), 3); + assert!(completed.contains("test:1")); + assert!(completed.contains("test:2")); + assert!(completed.contains("test:3")); +} + +#[test] +fn open_missing_run_returns_no_such_run() { + let dir = TempDir::new("no-such-run"); + std::fs::create_dir_all(&dir.path).unwrap(); + + let store = Store::new( + dir.path + .clone(), + ); + match store.open(RunId(42)) { + Err(RunnerError::NoSuchRun(id)) => assert_eq!(id, RunId(42)), + other => panic!("expected NoSuchRun, got {:?}", other), + } +} + +#[test] +fn create_writes_pfftt_file_with_expected_content() { + let dir = TempDir::new("create-bytes"); + + let document = PathBuf::from("/somewhere/NetworkProbe.tq"); + let started = "2026-05-14T12:34:56Z".to_string(); + + let store = Store::new( + dir.path + .clone(), + ); + let (_, run_dir, _) = store + .create(&document, started) + .expect("create"); + + let pfftt = run_dir.join("NetworkProbe.pfftt"); + let on_disk = std::fs::read_to_string(&pfftt).expect("read pfftt"); + assert_eq!( + on_disk, + "[ document = file:///somewhere/NetworkProbe.tq, started = 2026-05-14T12:34:56Z ]\n" + ); +} + +// Each variant produces a distinct line shape — sibling fields appear only +// when the corresponding Outcome carries a payload. The round-trip test +// below covers parse compatibility; this one pins exact bytes. +#[test] +fn format_record_pins_on_disk_text() { + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "make_coffee:2".to_string(), + outcome: Outcome::Done(None), + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Done ]\n" + ); + + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "make_coffee:2".to_string(), + outcome: Outcome::Done(Some("\"penguin\"".to_string())), + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Done, result = \"penguin\" ]\n" + ); + + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "make_coffee:2".to_string(), + outcome: Outcome::Skipped, + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Skipped ]\n" + ); + + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "make_coffee:2".to_string(), + outcome: Outcome::Failed(None), + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Failed ]\n" + ); + + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "make_coffee:2".to_string(), + outcome: Outcome::Failed(Some("\"network unplugged\"".to_string())), + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Failed, reason = \"network unplugged\" ]\n" + ); +} + +#[test] +fn record_round_trips_through_format_and_parse() { + let original = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "a:1".to_string(), + outcome: Outcome::Done(None), + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); + + let original = Record { + recorded: "2026-05-14T12:00:01Z".to_string(), + path: "a:2".to_string(), + outcome: Outcome::Done(Some("\"penguin\"".to_string())), + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); + + let original = Record { + recorded: "2026-05-14T12:00:02Z".to_string(), + path: "a:3".to_string(), + outcome: Outcome::Skipped, + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); + + let original = Record { + recorded: "2026-05-14T12:00:03Z".to_string(), + path: "a:4".to_string(), + outcome: Outcome::Failed(None), + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); + + let original = Record { + recorded: "2026-05-14T12:00:04Z".to_string(), + path: "a:5".to_string(), + outcome: Outcome::Failed(Some("\"unreachable\"".to_string())), + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); +} + +#[test] +fn parse_manifest_reads_expected_text() { + let line = "[ document = file:///foo/bar.tq, started = 2026-05-14T01:02:03Z ]"; + let manifest = parse_manifest(line).expect("parse"); + assert_eq!(manifest.document, Path::new("/foo/bar.tq")); + assert_eq!(manifest.started, "2026-05-14T01:02:03Z"); +} + +#[test] +fn parse_record_rejects_unknown_outcome() { + let line = "[ recorded = 2026-05-14T12:00:00Z, path = x:1, outcome = Maybe ]"; + match parse_record(line) { + Err(crate::runner::state::RecordError::UnknownOutcome(text)) => { + assert_eq!(text, "Maybe"); + } + other => panic!("expected UnknownOutcome, got {:?}", other), + } +} + +#[test] +fn parse_manifest_rejects_missing_required_field() { + let line = "[ started = 2026-05-14T01:02:03Z ]"; + match parse_manifest(line) { + Err(crate::runner::state::RecordError::MissingField(name)) => { + assert_eq!(name, "document"); + } + other => panic!("expected MissingField, got {:?}", other), + } + + let line = "[ document = file:///x.tq ]"; + match parse_manifest(line) { + Err(crate::runner::state::RecordError::MissingField(name)) => { + assert_eq!(name, "started"); + } + other => panic!("expected MissingField, got {:?}", other), + } +} + +#[test] +fn parse_record_rejects_missing_required_field() { + let line = "[ recorded = 2026-05-14T12:00:00Z, outcome = Done ]"; + match parse_record(line) { + Err(crate::runner::state::RecordError::MissingField(name)) => { + assert_eq!(name, "path"); + } + other => panic!("expected MissingField, got {:?}", other), + } + + let line = "[ recorded = 2026-05-14T12:00:00Z, path = a:1 ]"; + match parse_record(line) { + Err(crate::runner::state::RecordError::MissingField(name)) => { + assert_eq!(name, "outcome"); + } + other => panic!("expected MissingField, got {:?}", other), + } +} + +#[test] +fn parse_record_without_brackets_errors() { + let line = "recorded = 2026-05-14T12:00:00Z, path = a:1, outcome = Done"; + match parse_record(line) { + Err(crate::runner::state::RecordError::MalformedTablet) => {} + other => panic!("expected MalformedTablet, got {:?}", other), + } +} + +// `ManifestMissing` covers two setups: a run directory containing an empty +// PFFTT file (file present, no manifest tablet inside), and a run directory +// with no PFFTT file at all. +#[test] +fn open_missing_manifest() { + let dir = TempDir::new("empty-pfftt"); + let run_dir = dir + .path + .join("000001"); + std::fs::create_dir_all(&run_dir).unwrap(); + std::fs::write(run_dir.join("Test.pfftt"), "").unwrap(); + + let store = Store::new( + dir.path + .clone(), + ); + match store.open(RunId(1)) { + Err(RunnerError::ManifestMissing(id)) => assert_eq!(id, RunId(1)), + other => panic!("expected ManifestMissing, got {:?}", other), + } + + let dir = TempDir::new("no-pfftt"); + std::fs::create_dir_all( + dir.path + .join("000001"), + ) + .unwrap(); + + let store = Store::new( + dir.path + .clone(), + ); + match store.open(RunId(1)) { + Err(RunnerError::ManifestMissing(id)) => assert_eq!(id, RunId(1)), + other => panic!("expected ManifestMissing, got {:?}", other), + } +} diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs new file mode 100644 index 00000000..460475f5 --- /dev/null +++ b/src/runner/evaluator.rs @@ -0,0 +1,136 @@ +//! Variable bindings and the evaluator that turns value-bearing +//! Operations into Values for description rendering and binding. + +use std::collections::HashMap; + +use super::runner::RunnerError; +use crate::program::{Fragment, Operation}; +use crate::value::{Numeric, Value}; + +/// Variable bindings established by the walker as `Bind` operations +/// evaluate. Lookup is by identifier name. +#[allow(dead_code)] +#[derive(Debug, Default, Clone)] +pub struct Environment { + bindings: HashMap, +} + +#[allow(dead_code)] +impl Environment { + pub fn new() -> Self { + Environment { + bindings: HashMap::new(), + } + } + + pub fn lookup(&self, name: &str) -> Option<&Value> { + self.bindings + .get(name) + } + + pub fn extend(&mut self, name: String, value: Value) { + self.bindings + .insert(name, value); + } +} + +/// Evaluate an `Operation` to a `Value`. +/// +/// Fails with `UnboundVariable` etc if the operation cannot be resolved; +/// specifically at this point values of variables need to be known from the +/// `Environment` otherwise the `Operation` can't be evaluated. +/// +/// Non-value variants (Section / Step / Loop / Invoke / Execute) evaluate to +/// `Unitus` rather than failing — `evaluate` is only meant to be called on +/// value-bearing positions and that fallback keeps it total. +#[allow(dead_code)] +pub fn evaluate<'i>(env: &mut Environment, op: &Operation<'i>) -> Result { + match op { + Operation::Variable(id) => env + .lookup(id.value) + .cloned() + .ok_or_else(|| { + RunnerError::UnboundVariable( + id.value + .to_string(), + ) + }), + Operation::Number(n) => Ok(Value::Quanticle(Numeric::from(n))), + Operation::String(fragments) => { + let mut text = String::new(); + for fragment in fragments { + match fragment { + Fragment::Text(t) => text.push_str(t), + Fragment::Interpolation(inner) => match evaluate(env, inner)? { + Value::Literali(s) => text.push_str(&s), + other => text.push_str(&other.to_string()), + }, + } + } + Ok(Value::Literali(text)) + } + Operation::Multiline(_, lines) => Ok(Value::Literali(lines.join("\n"))), + Operation::Tablet(entries) => { + let mut pairs = Vec::with_capacity(entries.len()); + for entry in entries { + let v = evaluate(env, &entry.value)?; + pairs.push(( + entry + .label + .to_string(), + v, + )); + } + Ok(Value::Tabularum(pairs)) + } + Operation::Bind { names, value } => { + let v = evaluate(env, value)?; + match names.len() { + 1 => env.extend( + names[0] + .value + .to_string(), + v, + ), + n => { + let Value::Parametriq(values) = v else { + return Err(RunnerError::BindNotTuple { expected: n }); + }; + if values.len() != n { + return Err(RunnerError::BindArityMismatch { + expected: n, + actual: values.len(), + }); + } + for (name, value) in names + .iter() + .zip(values) + { + env.extend( + name.value + .to_string(), + value, + ); + } + } + } + Ok(Value::Unitus) + } + Operation::Sequence(ops) => { + let mut last = Value::Unitus; + for child in ops { + last = evaluate(env, child)?; + } + Ok(last) + } + Operation::Section { .. } + | Operation::Step { .. } + | Operation::Loop { .. } + | Operation::Invoke(_) + | Operation::Execute(_) => Ok(Value::Unitus), + } +} + +#[cfg(test)] +#[path = "checks/evaluator.rs"] +mod check; diff --git a/src/runner/manifest.rs b/src/runner/manifest.rs new file mode 100644 index 00000000..e6957796 --- /dev/null +++ b/src/runner/manifest.rs @@ -0,0 +1,11 @@ +//! PFFTT manifest — the first tablet of a run's state file. Captures the +//! source document URL and the run's start time. + +use std::path::PathBuf; + +#[allow(dead_code)] +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Manifest { + pub document: PathBuf, + pub started: String, +} diff --git a/src/runner/mod.rs b/src/runner/mod.rs new file mode 100644 index 00000000..af62185f --- /dev/null +++ b/src/runner/mod.rs @@ -0,0 +1,58 @@ +//! Interactive runner that walks a translated Program step-by-step, +//! prompting the operator and recording each completed step to a state store +//! so a run can be resumed after interruption. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::program::Program; + +mod evaluator; +mod manifest; +mod path; +mod prompt; +mod runner; +mod state; + +pub use runner::{Outcome, RunnerError}; +pub use state::{RecordError, RunId}; + +use prompt::Console; +use runner::{now_iso8601, Runner}; +use state::{construct_state_path, Appender, Store}; + +const STORE_ROOT: &str = ".store"; + +/// Allocate a new run, persist its manifest, and walk the program to +/// completion or until the user signals they are quitting. +pub fn start<'i>( + document: &Path, + program: &'i Program<'i>, +) -> Result<(RunId, Outcome), RunnerError> { + let store = Store::new(PathBuf::from(STORE_ROOT)); + let (id, run_dir, _manifest) = store.create(document, now_iso8601())?; + let pfftt = construct_state_path(&run_dir, document); + let appender = Appender::open(pfftt)?; + let mut runner = Runner::with_pieces(program, appender, HashSet::new(), Console::new()); + let outcome = runner.run()?; + Ok((id, outcome)) +} + +/// Read the manifest of an existing run, returning the source document +/// path so the caller can load and re-translate it before resuming. +pub fn locate(id: RunId) -> Result { + let store = Store::new(PathBuf::from(STORE_ROOT)); + let (manifest, _, _) = store.open(id)?; + Ok(manifest.document) +} + +/// Open an existing run and walk the given program, short-circuiting +/// any step whose FQN has already been recorded. +pub fn resume<'i>(id: RunId, program: &'i Program<'i>) -> Result { + let store = Store::new(PathBuf::from(STORE_ROOT)); + let (manifest, completed, run_dir) = store.open(id)?; + let pfftt = construct_state_path(&run_dir, &manifest.document); + let appender = Appender::open(pfftt)?; + let mut runner = Runner::with_pieces(program, appender, completed, Console::new()); + runner.run() +} diff --git a/src/runner/path.rs b/src/runner/path.rs new file mode 100644 index 00000000..ecba5f55 --- /dev/null +++ b/src/runner/path.rs @@ -0,0 +1,116 @@ +//! Fully-qualified path rendering for steps as the walker descends. + +use crate::language; + +/// One position in the structural path to a step. The runner pushes one +/// of these as it enters each containing scope, pops on exit. Strings +/// are borrowed from the source via the IR; the renderer materialises +/// an owned String only on demand. +#[allow(dead_code)] +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum PathSegment<'i> { + Section(&'i str), + DependentStep(&'i str), + ParallelStep(usize), + Attributes(&'i [language::Attribute<'i>]), + Procedure(&'i str), +} + +/// Absolute path from the document root to the walker's current +/// position. The runner pushes a segment on scope entry, pops on exit; +/// `render()` produces the document-rooted form (the tail of the +/// fully-qualified identifier — URL, file path, and run id are added by +/// outer layers). +#[allow(dead_code)] +#[derive(Debug, Default)] +pub struct QualifiedPath<'i> { + segments: Vec>, +} + +#[allow(dead_code)] +impl<'i> QualifiedPath<'i> { + pub fn new() -> Self { + QualifiedPath { + segments: Vec::new(), + } + } + + pub fn push(&mut self, segment: PathSegment<'i>) { + self.segments + .push(segment); + } + + pub fn pop(&mut self) -> Option> { + self.segments + .pop() + } + + /// Render the current path as a string. A `Procedure` segment opens + /// a fresh scope, so only segments after the last `Procedure` are + /// shown; the procedure's name becomes the prefix, joined to the + /// rest with `:`. Other segments are `/`-joined. An attribute frame + /// containing the `@*` reset role contributes nothing. + pub fn render(&self) -> String { + let mut prefix: Option<&str> = None; + let mut start: usize = 0; + for (i, segment) in self + .segments + .iter() + .enumerate() + { + if let PathSegment::Procedure(name) = segment { + prefix = Some(*name); + start = i + 1; + } + } + + let pieces: Vec = self.segments[start..] + .iter() + .filter_map(render_segment) + .collect(); + + match prefix { + Some(name) if pieces.is_empty() => name.to_string(), + Some(name) => format!("{}:{}", name, pieces.join("/")), + None => pieces.join("/"), + } + } +} + +fn render_segment(segment: &PathSegment) -> Option { + match segment { + PathSegment::Section(numeral) => Some(numeral.to_string()), + PathSegment::DependentStep(ordinal) => Some(ordinal.to_string()), + PathSegment::ParallelStep(idx) => Some(format!("-{}", idx)), + PathSegment::Attributes(frame) => render_attributes(frame), + PathSegment::Procedure(_) => None, + } +} + +fn render_attributes(frame: &[language::Attribute]) -> Option { + let mut text = String::new(); + for attr in frame { + match attr { + // `@*` resets the inherited role; it contributes no segment of + // its own. A sibling Place attribute in the same frame is + // unaffected and still renders. + language::Attribute::Role(id, _) if id.value == "*" => {} + language::Attribute::Role(id, _) => { + text.push('@'); + text.push_str(id.value); + } + language::Attribute::Place(id, _) => { + text.push('^'); + text.push_str(id.value); + } + } + } + if text.is_empty() { + return None; + } + Some(text) +} + +#[cfg(test)] +#[path = "checks/path.rs"] +mod check; diff --git a/src/runner/prompt.rs b/src/runner/prompt.rs new file mode 100644 index 00000000..d354d8e7 --- /dev/null +++ b/src/runner/prompt.rs @@ -0,0 +1,212 @@ +//! Prompt trait, console implementation, and test mock. +//! +//! The walker tells the prompt what to show (`step`, `section`, +//! `announce`) and then asks for the operator's verdict (`ask`). The +//! split lets a console implementation render output to stdout, then +//! read a line from stdin, while a test mock simply records what the +//! walker tried to show and returns canned responses. + +use std::io::{self, BufRead, Write}; + +use crate::value::Value; + +/// The person executing each step indicates a verdict on each prompt as +/// follows: +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum UserInput { + Done(Value), + Skip, + Fail, + Quit, +} + +/// What the walker uses to talk to the user executing the Technique. The two +/// initial implementations are for an interactive console, `Console`, and for +/// test cases, `Mock`. +#[allow(dead_code)] +pub trait Prompt { + /// Show the step's Qualified Name and rendered description. + /// The implementation displays them; it does not block waiting for + /// input — the walker calls `ask` for that separately. + fn step(&mut self, qualified: &str, description: &str); + + /// Announce entry to a Section, with its Qualified Name and title text. + fn section(&mut self, qualified: &str, title: &str); + + /// Surface an informational line — Loop body announcements, + /// Execute / Unresolved Invoke announce-only, resume diagnostics. + fn announce(&mut self, message: &str); + + /// Block until the operator answers the most recent `step` prompt. + fn ask(&mut self) -> UserInput; +} + +/// Interactive console prompt. Writes to stdout, reads line-buffered +/// stdin. `d` → Done, `s` → Skip, `f` → Fail, `q` → Quit; matched +/// case-insensitively on the first non-whitespace character. Anything +/// else re-prompts. +#[allow(dead_code)] +pub struct Console { + input: R, + output: W, +} + +#[allow(dead_code)] +impl Console, io::Stdout> { + /// Default console wired to process stdin (line-buffered) and + /// stdout. + pub fn new() -> Self { + Console { + input: io::BufReader::new(io::stdin()), + output: io::stdout(), + } + } +} + +#[allow(dead_code)] +impl Console { + /// Construct a console over arbitrary input and output handles. + /// Useful for end-to-end tests that exercise the actual rendering + /// and parsing paths without needing a TTY. + pub fn with_handles(input: R, output: W) -> Self { + Console { input, output } + } +} + +impl Prompt for Console { + fn step(&mut self, fqn: &str, description: &str) { + let _ = writeln!(self.output, " {}", fqn); + let _ = writeln!(self.output, "{}", description); + } + + fn section(&mut self, qualified: &str, title: &str) { + let _ = writeln!(self.output); + let _ = writeln!(self.output, "=== {} ===", qualified); + if !title.is_empty() { + let _ = writeln!(self.output, "{}", title); + } + } + + fn announce(&mut self, message: &str) { + let _ = writeln!(self.output, "{}", message); + } + + fn ask(&mut self) -> UserInput { + loop { + let _ = write!(self.output, "[d]one / [s]kip / [f]ail / [q]uit ? "); + let _ = self + .output + .flush(); + let mut line = String::new(); + match self + .input + .read_line(&mut line) + { + Ok(0) => return UserInput::Quit, + Ok(_) => {} + Err(_) => return UserInput::Quit, + } + match line + .trim_start() + .chars() + .next() + .map(|c| c.to_ascii_lowercase()) + { + Some('d') => return UserInput::Done(Value::Unitus), + Some('s') => return UserInput::Skip, + Some('f') => return UserInput::Fail, + Some('q') => return UserInput::Quit, + _ => continue, + } + } + } +} + +/// Simulated prompt responses for test cases. Returns answers from a +/// pre-loaded queue and records every announcement / step / section call so a +/// test can assert what the walker tried to show. +#[allow(dead_code)] +#[derive(Debug, Default)] +pub struct Mock { + answers: std::collections::VecDeque, + events: Vec, +} + +/// One thing the walker showed (or attempted to show). Tests use this +/// to inspect ordering and content of the walker's user-facing output. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum Event { + Step { + qualified: String, + description: String, + }, + Section { + qualified: String, + title: String, + }, + Announce(String), + Ask, +} + +#[allow(dead_code)] +impl Mock { + /// Construct a Mock with no canned answers — useful for tests that + /// only inspect announcements and never reach an `ask` call. + pub fn new() -> Self { + Mock::default() + } + + /// Construct a Mock pre-loaded with the answers `ask()` will pop + /// in order. Tests that walk past `n` Step prompts need at least + /// `n` answers. + pub fn with_answers>(answers: I) -> Self { + Mock { + answers: answers + .into_iter() + .collect(), + events: Vec::new(), + } + } + + /// Snapshot the event log so far. Cheap clone for assertion in tests. + pub fn events(&self) -> &[Event] { + &self.events + } +} + +impl Prompt for Mock { + fn step(&mut self, fqn: &str, description: &str) { + self.events + .push(Event::Step { + qualified: fqn.to_string(), + description: description.to_string(), + }); + } + + fn section(&mut self, fqn: &str, title: &str) { + self.events + .push(Event::Section { + qualified: fqn.to_string(), + title: title.to_string(), + }); + } + + fn announce(&mut self, message: &str) { + self.events + .push(Event::Announce(message.to_string())); + } + + fn ask(&mut self) -> UserInput { + self.events + .push(Event::Ask); + self.answers + .pop_front() + .expect("Mock::ask called with no canned answers remaining") + } +} + +#[cfg(test)] +#[path = "checks/prompt.rs"] +mod check; diff --git a/src/runner/runner.rs b/src/runner/runner.rs new file mode 100644 index 00000000..7fdf5f75 --- /dev/null +++ b/src/runner/runner.rs @@ -0,0 +1,402 @@ +//! Interactive walker over a translated Program. + +use std::collections::HashSet; +use std::io; +use std::path::PathBuf; + +use super::evaluator::Environment; +use super::path::{PathSegment, QualifiedPath}; +use super::prompt::{Prompt, UserInput}; +use super::state::{Appender, Outcome as RecordOutcome, Record, RecordError, RunId}; +use crate::program::{Executable, Invocable, Operation, Ordinal, Program, SubroutineRef}; +use crate::value::Value; + +/// What executing an Operation (or evaluating a Step at any scale) +/// produced. `Done(Value)` is the natural success — for a leaf Step +/// the operator's recorded value, for a Sequence / Section / procedure +/// body the unit value once the whole subtree is finished. `Skipped` and +/// `Failed` are operator verdicts on individual Steps. `Quit` is a +/// control signal that propagates immediately up the call stack: +/// nothing is recorded, a `technique resume` would pick up where +/// this run paused. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum Outcome { + Done(Value), + Skipped, + Failed(Failure), + Quit, +} + +/// Why a Step failed. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum Failure { + Aborted(String), +} + +/// Anything that can go wrong while preparing or running a Technique. +/// Variants are populated as the implementing steps land; the formatter +/// in `crate::problem` knows how to render each one. +#[allow(dead_code)] +#[derive(Debug)] +pub enum RunnerError { + NoSuchRun(RunId), + StoreError { path: PathBuf, error: io::Error }, + MalformedRecord { run: RunId, error: RecordError }, + ManifestMissing(RunId), + InvalidRunId(String), + MissingEntryProcedure, + UnboundVariable(String), + BindArityMismatch { expected: usize, actual: usize }, + BindNotTuple { expected: usize }, + UserQuit, +} + +/// Execute a Technique interactively by walking the `Program` tree. Tracks +/// the position in the document via a `QaulifiedPath` stack, carries an +/// `Environment` with known result values. Maintains a set of +/// already-completed step FQNs, an append handle to write results, and the +/// prompt the operator interacts through. +#[allow(dead_code)] +pub struct Runner<'i, P: Prompt> { + program: &'i Program<'i>, + appender: Appender, + completed: HashSet, + prompt: P, + env: Environment, + path: QualifiedPath<'i>, +} + +impl<'i, P: Prompt> Runner<'i, P> { + pub fn with_pieces( + program: &'i Program<'i>, + appender: Appender, + completed: HashSet, + prompt: P, + ) -> Self { + Runner { + program, + appender, + completed, + prompt, + env: Environment::new(), + path: QualifiedPath::new(), + } + } + + /// Consume the runner and return the inner prompt. Tests use this + /// to assert on the Mock's event log after a run completes. + #[allow(dead_code)] + pub fn into_prompt(self) -> P { + self.prompt + } + + /// Walk the entry procedure top to bottom. Entry-procedure + /// selection here is `program.subroutines[0]` — the synthetic + /// anonymous wrapper if the document is top-level Steps, otherwise + /// the first declared procedure. + pub fn run(&mut self) -> Result { + let entry = self + .program + .subroutines + .first() + .ok_or(RunnerError::MissingEntryProcedure)?; + let name = entry + .name + .as_ref() + .map(|n| n.value); + if let Some(name) = name { + self.path + .push(PathSegment::Procedure(name)); + } + let result = self.walk(&entry.body); + if name.is_some() { + self.path + .pop(); + } + result + } + + fn walk(&mut self, op: &'i Operation<'i>) -> Result { + match op { + Operation::Sequence(ops) => self.walk_sequence(ops), + Operation::Section { + numeral, + title, + body, + .. + } => self.walk_section(numeral, title.as_deref(), body), + Operation::Step { .. } => { + // Dependent vs Parallel ordinal index needs the + // surrounding Sequence's parallel counter; a Step + // encountered outside a Sequence (i.e. as the entire + // body of a procedure) is treated as Dependent. + self.walk_step(op, 0) + } + Operation::Loop { + names, over, body, .. + } => { + self.prompt + .announce(&describe_loop(names, over.as_deref())); + self.walk(body) + } + Operation::Invoke(invocable) => self.walk_invoke(invocable), + Operation::Execute(executable) => { + self.prompt + .announce(&describe_execute(executable)); + Ok(Outcome::Done(Value::Unitus)) + } + Operation::Bind { .. } + | Operation::Variable(_) + | Operation::Number(_) + | Operation::String(_) + | Operation::Multiline(_, _) + | Operation::Tablet(_) => { + let value = super::evaluator::evaluate(&mut self.env, op)?; + Ok(Outcome::Done(value)) + } + } + } + + fn walk_invoke(&mut self, invocable: &'i Invocable<'i>) -> Result { + match &invocable.target { + SubroutineRef::Resolved(id) => { + let subroutine = &self + .program + .subroutines[id.0]; + let name = subroutine + .name + .as_ref() + .map(|n| n.value); + if let Some(name) = name { + self.path + .push(PathSegment::Procedure(name)); + } + let result = self.walk(&subroutine.body); + if name.is_some() { + self.path + .pop(); + } + result + } + SubroutineRef::Unresolved(id) => { + self.prompt + .announce(&format!("<{}>", id.value)); + Ok(Outcome::Done(Value::Unitus)) + } + } + } + + fn walk_sequence(&mut self, ops: &'i [Operation<'i>]) -> Result { + let mut parallel_idx: usize = 0; + for op in ops { + let outcome = match op { + Operation::Step { ordinal, .. } => { + let index = match ordinal { + Ordinal::Parallel => { + parallel_idx += 1; + parallel_idx + } + Ordinal::Dependent(_) => 0, + }; + self.walk_step(op, index)? + } + _ => self.walk(op)?, + }; + if let Outcome::Quit = outcome { + return Ok(Outcome::Quit); + } + } + Ok(Outcome::Done(Value::Unitus)) + } + + fn walk_section( + &mut self, + numeral: &'i str, + title: Option<&'i Operation<'i>>, + body: &'i Operation<'i>, + ) -> Result { + self.path + .push(PathSegment::Section(numeral)); + let result = self.perform_section(title, body); + self.path + .pop(); + result + } + + fn perform_section( + &mut self, + title: Option<&'i Operation<'i>>, + body: &'i Operation<'i>, + ) -> Result { + let qualified = self + .path + .render(); + let title_text = match title { + Some(op) => match super::evaluator::evaluate(&mut self.env, op)? { + Value::Literali(s) => s, + other => other.to_string(), + }, + None => String::new(), + }; + self.prompt + .section(&qualified, &title_text); + self.walk(body) + } + + fn walk_step( + &mut self, + op: &'i Operation<'i>, + parallel_index: usize, + ) -> Result { + let Operation::Step { + ordinal, + attributes, + description, + body, + .. + } = op + else { + unreachable!("walk_step called with non-Step operation"); + }; + + for frame in attributes { + self.path + .push(PathSegment::Attributes(frame)); + } + let segment = match ordinal { + Ordinal::Dependent(s) => PathSegment::DependentStep(s), + Ordinal::Parallel => PathSegment::ParallelStep(parallel_index), + }; + self.path + .push(segment); + let qualified = self + .path + .render(); + + let result = self.perform_step(&qualified, body, description); + + self.path + .pop(); + for _ in attributes { + self.path + .pop(); + } + + result + } + + fn perform_step( + &mut self, + qualified: &str, + body: &'i Operation<'i>, + description: &'i [Operation<'i>], + ) -> Result { + if self + .completed + .contains(qualified) + { + return Ok(Outcome::Done(Value::Unitus)); + } + if let Outcome::Quit = self.walk(body)? { + return Ok(Outcome::Quit); + } + + let mut description_text = String::new(); + for op in description { + if !description_text.is_empty() { + description_text.push('\n'); + } + match super::evaluator::evaluate(&mut self.env, op)? { + Value::Literali(s) => description_text.push_str(&s), + other => description_text.push_str(&other.to_string()), + } + } + + self.prompt + .step(qualified, &description_text); + let outcome = outcome_from( + self.prompt + .ask(), + ); + if let Outcome::Quit = outcome { + return Ok(Outcome::Quit); + } + + let record = Record { + recorded: now_iso8601(), + path: qualified.to_string(), + outcome: record_outcome(&outcome), + }; + self.appender + .append(&record)?; + self.completed + .insert(qualified.to_string()); + Ok(outcome) + } +} + +fn describe_loop( + names: &[crate::language::Identifier<'_>], + over: Option<&Operation<'_>>, +) -> String { + let joined: Vec<&str> = names + .iter() + .map(|n| n.value) + .collect(); + match over { + None => "repeat".to_string(), + Some(_) if joined.is_empty() => "foreach".to_string(), + Some(_) => format!("foreach {}", joined.join(", ")), + } +} + +fn describe_execute(executable: &Executable<'_>) -> String { + format!( + "{}(...)", + executable + .target + .value + ) +} + +/// Lift a `UserInput` from the prompt into the runner's `Outcome`. +fn outcome_from(input: UserInput) -> Outcome { + match input { + UserInput::Done(value) => Outcome::Done(value), + UserInput::Skip => Outcome::Skipped, + UserInput::Fail => Outcome::Failed(Failure::Aborted("Failed".to_string())), + UserInput::Quit => Outcome::Quit, + } +} + +/// Project the runner's in-memory `Outcome` into the on-disk shape +/// the PFFTT writer expects. Done always serializes as `result = ()` +/// for now — rendered-value persistence is future work. Quit is +/// unreachable here: the caller filters it out before recording. +fn record_outcome(outcome: &Outcome) -> RecordOutcome { + match outcome { + Outcome::Done(_) => RecordOutcome::Done(Some("()".to_string())), + Outcome::Skipped => RecordOutcome::Skipped, + Outcome::Failed(Failure::Aborted(reason)) => { + RecordOutcome::Failed(Some(format!("\"{}\"", reason))) + } + Outcome::Quit => unreachable!("Quit is not recorded"), + } +} + +/// Current UTC time as an RFC3339 second-precision string, used for +/// the `recorded` field of every Result tablet. +pub(super) fn now_iso8601() -> String { + use time::format_description::well_known::Rfc3339; + time::OffsetDateTime::now_utc() + .replace_nanosecond(0) + .unwrap() // Zero nanoseconds is always valid + .format(&Rfc3339) + .unwrap() // Rfc3339 formatting is infallible for a valid OffsetDateTime +} + +#[cfg(test)] +#[path = "checks/runner.rs"] +mod check; diff --git a/src/runner/state.rs b/src/runner/state.rs new file mode 100644 index 00000000..e779f09b --- /dev/null +++ b/src/runner/state.rs @@ -0,0 +1,428 @@ +//! On-disk state store and run identifiers. + +use std::collections::HashSet; +use std::io; +use std::path::{Path, PathBuf}; + +use super::manifest::Manifest; +use super::runner::RunnerError; + +/// Monotonic identifier for a run. Conventionally rendered and stored as a +/// six-digit zero-padded string. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub struct RunId(pub u32); + +impl RunId { + /// Parse a run identifier. Both unpadded (`7`) and zero-padded + /// (`000007`) decimal forms are accepted. + pub fn parse(text: &str) -> Result { + text.parse::() + .map(RunId) + .map_err(|_| RunnerError::InvalidRunId(text.to_string())) + } + + /// Render as a six-digit zero-padded decimal string. + pub fn render(self) -> String { + format!("{:06}", self.0) + } +} + +/// Errors raised if a PFFTT file is malformed or invalid. +#[derive(Debug, Eq, PartialEq)] +pub enum RecordError { + MalformedTablet, + MissingField(&'static str), + UnknownOutcome(String), +} + +/// One Result, recorded on disk in the form of a tablet. +#[allow(dead_code)] +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Record { + pub recorded: String, + pub path: String, + pub outcome: Outcome, +} + +/// Outcome of executing a step. + +/// The on-disk PFFTT format keeps `result` and `reason` as sibling fields of +/// `outcome` so these can be grepped for directly without needing to +/// destructure this type's constructors. +/// +/// It also facilitates future combinations (e.g. partial result accompanying +/// a failure) that we currently don't need, but can accomodate in the future +/// without changing the on disk format. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Outcome { + Done(Option), + Skipped, + Failed(Option), +} + +impl Outcome { + fn as_str(&self) -> &'static str { + match self { + Outcome::Done(_) => "Done", + Outcome::Skipped => "Skipped", + Outcome::Failed(_) => "Failed", + } + } +} + +/// On-disk store of runs, rooted at some base directory (conventionally +/// `.store/` relative to the operator's current directory). +#[allow(dead_code)] +pub struct Store { + base: PathBuf, +} + +// Cap the number of times the allocator retries when another process has +// taken the identifier we just computed. The race window is small; a +// handful of retries is more than enough in practice. +#[allow(dead_code)] +const ALLOCATE_RETRIES: usize = 4; + +#[allow(dead_code)] +impl Store { + /// Build a handle to a store rooted at `base`. No I/O happens here; the + /// directory is created on the first call to `allocate`. + pub fn new(base: PathBuf) -> Self { + Store { base } + } + + /// Allocate a new run identifier and create its directory. Returns the + /// identifier and the path of the new directory. + pub fn allocate(&self) -> Result<(RunId, PathBuf), RunnerError> { + // Make sure the store root exists before scanning for siblings. + if let Err(error) = std::fs::create_dir_all(&self.base) { + return Err(RunnerError::StoreError { + path: self + .base + .clone(), + error, + }); + } + + for _ in 0..ALLOCATE_RETRIES { + let next = self.next_identifier()?; + let path = self + .base + .join(next.render()); + match std::fs::create_dir(&path) { + Ok(()) => return Ok((next, path)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(RunnerError::StoreError { path, error }), + } + } + + Err(RunnerError::StoreError { + path: self + .base + .clone(), + error: io::Error::new( + io::ErrorKind::AlreadyExists, + "exhausted retries allocating a run identifier", + ), + }) + } + + /// Allocate a new run, write its manifest, and return the resulting run + /// identifier and start state. The PFFTT file is named after the source + /// document's basename (e.g. `NetworkProbe.pfftt`). + pub fn create( + &self, + document: &Path, + started: String, + ) -> Result<(RunId, PathBuf, Manifest), RunnerError> { + let absolute = std::path::absolute(document).map_err(|error| RunnerError::StoreError { + path: document.to_path_buf(), + error, + })?; + let (id, run_dir) = self.allocate()?; + let manifest = Manifest { + document: absolute, + started, + }; + let pfftt = construct_state_path(&run_dir, &manifest.document); + let text = format_manifest(&manifest); + std::fs::write(&pfftt, text) + .map_err(|error| RunnerError::StoreError { path: pfftt, error })?; + Ok((id, run_dir, manifest)) + } + + /// Open an existing run. Parses the manifest and replays Result tablets + /// into a set of completed step paths. + pub fn open(&self, id: RunId) -> Result<(Manifest, HashSet, PathBuf), RunnerError> { + let run_dir = self + .base + .join(id.render()); + if !run_dir.is_dir() { + return Err(RunnerError::NoSuchRun(id)); + } + let pfftt = find_pfftt_file(&run_dir, id)?; + let content = std::fs::read_to_string(&pfftt).map_err(|error| RunnerError::StoreError { + path: pfftt.clone(), + error, + })?; + + let mut tablets = content + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }); + + let manifest = match tablets.next() { + Some(line) => parse_manifest(line) + .map_err(|error| RunnerError::MalformedRecord { run: id, error })?, + None => return Err(RunnerError::ManifestMissing(id)), + }; + + let mut completed = HashSet::new(); + for line in tablets { + let record = parse_record(line) + .map_err(|error| RunnerError::MalformedRecord { run: id, error })?; + completed.insert(record.path); + } + Ok((manifest, completed, run_dir)) + } + + // Scan the store for the highest existing run identifier and return + // one more. Entries whose names are not valid decimal integers are + // ignored, which keeps the allocator robust against editor scratch + // files left in `.store/`. + fn next_identifier(&self) -> Result { + let mut max: u32 = 0; + let entries = std::fs::read_dir(&self.base).map_err(|error| RunnerError::StoreError { + path: self + .base + .clone(), + error, + })?; + for entry in entries { + let entry = entry.map_err(|error| RunnerError::StoreError { + path: self + .base + .clone(), + error, + })?; + if let Some(name) = entry + .file_name() + .to_str() + { + if let Ok(n) = name.parse::() { + if n > max { + max = n; + } + } + } + } + Ok(RunId(max + 1)) + } +} + +// Compute the on-disk PFFTT file path for a run, named using the source +// document's stem. +pub(crate) fn construct_state_path(run_dir: &Path, document: &Path) -> PathBuf { + let stem = document + .file_stem() + .map(|s| s.to_os_string()) + .unwrap_or_default(); + let mut name = PathBuf::from(stem); + name.set_extension("pfftt"); + run_dir.join(name) +} + +/// Append-only writer for a PFFTT file. This is used by the runner to record +/// a Result tablet for each completed Step. +#[allow(dead_code)] +pub struct Appender { + file: std::fs::File, + path: PathBuf, +} + +#[allow(dead_code)] +impl Appender { + /// Open the PFFTT file for append. The file must already exist (the + /// runner writes the manifest first via `Store::create`). + pub fn open(path: PathBuf) -> Result { + let file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .map_err(|error| RunnerError::StoreError { + path: path.clone(), + error, + })?; + Ok(Appender { file, path }) + } + + /// Append one Result tablet line. Flushes are left to the OS; on Quit + /// the runner drops the Appender, which closes the file. + pub fn append(&mut self, record: &Record) -> Result<(), RunnerError> { + use std::io::Write; + let text = format_record(record); + self.file + .write_all(text.as_bytes()) + .map_err(|error| RunnerError::StoreError { + path: self + .path + .clone(), + error, + }) + } +} + +// Locate the single `*.pfftt` file in a run directory. +fn find_pfftt_file(run_dir: &Path, id: RunId) -> Result { + let entries = std::fs::read_dir(run_dir).map_err(|error| RunnerError::StoreError { + path: run_dir.to_path_buf(), + error, + })?; + for entry in entries { + let entry = entry.map_err(|error| RunnerError::StoreError { + path: run_dir.to_path_buf(), + error, + })?; + let path = entry.path(); + if path + .extension() + .and_then(|s| s.to_str()) + == Some("pfftt") + { + return Ok(path); + } + } + Err(RunnerError::ManifestMissing(id)) +} + +// Serialize a manifest as a single PFFTT tablet line. The trailing +// newline is part of the on-disk shape — every tablet occupies its own +// line. +pub(crate) fn format_manifest(m: &Manifest) -> String { + format!( + "[ document = file://{}, started = {} ]\n", + m.document + .display(), + m.started, + ) +} + +// Parse a manifest line into a Manifest. The `document` field is stored +// as a `file://` URL; the prefix is stripped on read. +pub(crate) fn parse_manifest(line: &str) -> Result { + let entries = parse_tablet(line)?; + let document = entries + .iter() + .find(|(k, _)| *k == "document") + .map(|(_, v)| *v) + .ok_or(RecordError::MissingField("document"))?; + let started = entries + .iter() + .find(|(k, _)| *k == "started") + .map(|(_, v)| *v) + .ok_or(RecordError::MissingField("started"))?; + let document = document + .strip_prefix("file://") + .unwrap_or(document); + Ok(Manifest { + document: PathBuf::from(document), + started: started.to_string(), + }) +} + +// Serialize a Result tablet from a Record. The `outcome` field carries +// the bare discriminator; `result` and `reason` are sibling fields +// emitted only when the corresponding Outcome variant carries a payload. +#[allow(dead_code)] +pub(crate) fn format_record(record: &Record) -> String { + let mut text = format!( + "[ recorded = {}, path = {}, outcome = {}", + record.recorded, + record.path, + record + .outcome + .as_str(), + ); + match &record.outcome { + Outcome::Done(Some(result)) => { + text.push_str(", result = "); + text.push_str(result); + } + Outcome::Failed(Some(reason)) => { + text.push_str(", reason = "); + text.push_str(reason); + } + _ => {} + } + text.push_str(" ]\n"); + text +} + +// Parse a Result tablet line into a Record. Sibling fields `result` and +// `reason` are folded into the Outcome variant they belong to. +pub(crate) fn parse_record(line: &str) -> Result { + let entries = Entries(parse_tablet(line)?); + let recorded = entries.required("recorded")?; + let path = entries.required("path")?; + let keyword = entries.required("outcome")?; + let result = entries.optional("result"); + let reason = entries.optional("reason"); + let outcome = match keyword { + "Done" => Outcome::Done(result.map(str::to_string)), + "Skipped" => Outcome::Skipped, + "Failed" => Outcome::Failed(reason.map(str::to_string)), + other => return Err(RecordError::UnknownOutcome(other.to_string())), + }; + Ok(Record { + recorded: recorded.to_string(), + path: path.to_string(), + outcome, + }) +} + +struct Entries<'a>(Vec<(&'a str, &'a str)>); + +impl<'a> Entries<'a> { + fn required(&self, name: &'static str) -> Result<&'a str, RecordError> { + self.optional(name) + .ok_or(RecordError::MissingField(name)) + } + + fn optional(&self, name: &'static str) -> Option<&'a str> { + self.0 + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| *v) + } +} + +// Split a `[ k = v, k = v, ... ]` line into (key, value) pairs. Permissive on +// whitespace; rejects anything without the bracketed envelope. +// +// TODO support nested tablets. +fn parse_tablet(line: &str) -> Result, RecordError> { + let trimmed = line.trim(); + let inner = trimmed + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or(RecordError::MalformedTablet)? + .trim(); + let mut entries = Vec::new(); + for entry in inner.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let (k, v) = entry + .split_once('=') + .ok_or(RecordError::MalformedTablet)?; + entries.push((k.trim(), v.trim())); + } + Ok(entries) +} + +#[cfg(test)] +#[path = "checks/state.rs"] +mod check; diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 2a566a25..3eb37415 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -137,7 +137,7 @@ fn description_after_multiple_body_elements() { // 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.) + // description) let source = r#" % technique v1 diff --git a/src/translation/translator.rs b/src/translation/translator.rs index fd9fe2ac..67248c90 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -180,12 +180,13 @@ impl<'i> Translator<'i> { } } let body = Operation::Sequence(ops); + let description_ops = self.translate_paragraphs(description); let entry = &mut self .program .subroutines[id.0]; entry.title = title; - entry.description = description; + entry.description = description_ops; entry.parameters = procedure .parameters .as_ref() @@ -221,9 +222,12 @@ impl<'i> Translator<'i> { self.append_attributes(&mut body_ops, &mut responses, sub, attrs); } } + let title_op = title + .as_ref() + .map(|p| Box::new(self.translate_paragraph(p))); Operation::Section { numeral, - title: title.as_ref(), + title: title_op, body: Box::new(Operation::Sequence(body_ops)), responses, } @@ -240,10 +244,11 @@ impl<'i> Translator<'i> { for sub in subscopes { self.append_attributes(&mut body_ops, &mut responses, sub, attrs); } + let description_ops = self.translate_paragraphs(description); Operation::Step { ordinal: Ordinal::Dependent(ordinal), attributes: attrs.to_vec(), - description: description.as_slice(), + description: description_ops, body: Box::new(Operation::Sequence(body_ops)), responses, } @@ -259,10 +264,11 @@ impl<'i> Translator<'i> { for sub in subscopes { self.append_attributes(&mut body_ops, &mut responses, sub, attrs); } + let description_ops = self.translate_paragraphs(description); Operation::Step { ordinal: Ordinal::Parallel, attributes: attrs.to_vec(), - description: description.as_slice(), + description: description_ops, body: Box::new(Operation::Sequence(body_ops)), responses, } @@ -340,6 +346,58 @@ impl<'i> Translator<'i> { } } + fn translate_paragraphs( + &mut self, + paragraphs: &'i [language::Paragraph<'i>], + ) -> Vec> { + paragraphs + .iter() + .map(|p| self.translate_paragraph(p)) + .collect() + } + + // Descriptive paragraphs are whitespace-agnostic and re-wrappable: the + // parser strips edge whitespace from each Text fragment, so the original + // single-space joins between adjacent tokens are lost. Reinsert one + // separator space between each pair of fragments here so the runtime + // renderer sees the canonical form the formatter would emit. Expression + // string literals translated elsewhere preserve whitespace verbatim and + // are not affected by this rule. + fn translate_paragraph(&mut self, paragraph: &'i language::Paragraph<'i>) -> Operation<'i> { + let language::Paragraph(descriptives, _) = paragraph; + let mut fragments = Vec::with_capacity( + descriptives + .len() + .saturating_mul(2), + ); + for (i, descriptive) in descriptives + .iter() + .enumerate() + { + if i > 0 { + fragments.push(Fragment::Text(" ")); + } + fragments.push(self.fragment_from_descriptive(descriptive)); + } + Operation::String(fragments) + } + + fn fragment_from_descriptive( + &mut self, + descriptive: &'i language::Descriptive<'i>, + ) -> Fragment<'i> { + match descriptive { + language::Descriptive::Text(text) => Fragment::Text(text), + language::Descriptive::CodeInline(expr) => { + Fragment::Interpolation(self.translate_expression(expr)) + } + language::Descriptive::Application(invocation) => { + Fragment::Interpolation(Operation::Invoke(self.translate_invocation(invocation))) + } + language::Descriptive::Binding(inner, _) => self.fragment_from_descriptive(inner), + } + } + /// 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 diff --git a/src/value/checks/types.rs b/src/value/checks/types.rs new file mode 100644 index 00000000..cf6f2b0a --- /dev/null +++ b/src/value/checks/types.rs @@ -0,0 +1,27 @@ +use crate::value::{Numeric, Value}; + +#[test] +fn value_display() { + assert_eq!(Value::Unitus.to_string(), ""); + + let v = Value::Literali("just text".to_string()); + assert_eq!(v.to_string(), "\"just text\""); + + let v = Value::Quanticle(Numeric::Integral(42)); + assert_eq!(v.to_string(), "42"); + + let v = Value::Tabularum(vec![ + ("first".to_string(), Value::Literali("Anna".to_string())), + ("last".to_string(), Value::Literali("Kowalski".to_string())), + ]); + assert_eq!(v.to_string(), "[first = \"Anna\", last = \"Kowalski\"]"); + + let v = Value::Parametriq(vec![ + Value::Literali("a".to_string()), + Value::Literali("b".to_string()), + Value::Quanticle(Numeric::Integral(3)), + ]); + assert_eq!(v.to_string(), "[\"a\", \"b\", 3]"); + + assert_eq!(Value::Futurae("name".to_string()).to_string(), "{name}"); +} diff --git a/src/value/mod.rs b/src/value/mod.rs new file mode 100644 index 00000000..08cf3881 --- /dev/null +++ b/src/value/mod.rs @@ -0,0 +1,8 @@ +//! Runtime values. Owned mirror of the data that a translated Program +//! produces: the result of reducing value-bearing operations, the payload of +//! completed step Results in PFFTT files, and the eventual binding target for +//! CLI-supplied entry-procedure arguments. + +mod types; + +pub use types::*; diff --git a/src/value/types.rs b/src/value/types.rs new file mode 100644 index 00000000..29f936d9 --- /dev/null +++ b/src/value/types.rs @@ -0,0 +1,141 @@ +//! Values are the heart of the Technique language; they are the payload of +//! the results of executing steps. Values are produced at runtime by reducing +//! Operations, and are stored in PFFTT records on-disk. + +use std::fmt::{self, Display}; + +use crate::language; + +/// A value is the payload in the result of a step. +/// +/// Need names? Science names newly discovered creatures in Latin. I don't +/// speak Latin, but neither does anyone else so we can just make words up. +/// Yeay! (The lengths some people will go to in order to avoid qualified +/// imports is really impressive, isn't it?) +/// +/// `Value` is fully owned as results in PFFTT files stand alone irrespective +/// of a source document or live runtime environment. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Unitus, + Literali(String), + Quanticle(Numeric), + Tabularum(Vec<(String, Value)>), + Parametriq(Vec), + Futurae(String), +} + +/// Owned mirror of `language::Numeric`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Numeric { + Integral(i64), + Scientific(Quantity), +} + +/// Owned mirror of `language::Quantity`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Quantity { + pub mantissa: language::Decimal, + pub uncertainty: Option, + pub magnitude: Option, + pub symbol: String, +} + +impl From<&language::Numeric<'_>> for Numeric { + fn from(n: &language::Numeric<'_>) -> Self { + match n { + language::Numeric::Integral(i) => Numeric::Integral(*i), + language::Numeric::Scientific(q) => Numeric::Scientific(Quantity::from(q)), + } + } +} + +impl From<&language::Quantity<'_>> for Quantity { + fn from(q: &language::Quantity<'_>) -> Self { + Quantity { + mantissa: q.mantissa, + uncertainty: q.uncertainty, + magnitude: q.magnitude, + symbol: q + .symbol + .to_string(), + } + } +} + +/// Plain-text rendering of a Value, suitable for splicing into an +/// interpolated string fragment or showing the operator in a step +/// description prompt. Numeric formatting delegates to +/// `crate::formatting`'s number renderer so the operator sees the +/// same shape they would in source. +/// +/// Not intended for on-disk serialization. +impl Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::Unitus => Ok(()), + Value::Literali(text) => write!(f, "\"{}\"", text), + Value::Quanticle(numeric) => write!(f, "{}", numeric), + Value::Tabularum(pairs) => { + f.write_str("[")?; + for (i, (label, value)) in pairs + .iter() + .enumerate() + { + if i > 0 { + f.write_str(", ")?; + } + write!(f, "{} = {}", label, value)?; + } + f.write_str("]") + } + Value::Parametriq(values) => { + f.write_str("[")?; + for (i, value) in values + .iter() + .enumerate() + { + if i > 0 { + f.write_str(", ")?; + } + write!(f, "{}", value)?; + } + f.write_str("]") + } + Value::Futurae(name) => write!(f, "{{{}}}", name), + } + } +} + +// Numeric rendering goes through `crate::formatting`'s number renderer. +// To call into it we briefly reconstruct a borrowed `language::Numeric` +impl Display for Numeric { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Numeric::Integral(i) => { + let n = language::Numeric::Integral(*i); + f.write_str(&crate::formatting::render_numeric( + &n, + &crate::formatting::Identity, + )) + } + Numeric::Scientific(q) => { + let qb = language::Quantity { + mantissa: q.mantissa, + uncertainty: q.uncertainty, + magnitude: q.magnitude, + symbol: &q.symbol, + }; + let n = language::Numeric::Scientific(qb); + f.write_str(&crate::formatting::render_numeric( + &n, + &crate::formatting::Identity, + )) + } + } + } +} + +#[cfg(test)] +#[path = "checks/types.rs"] +mod check;