From 58828806c9286233a77457dddf77fbee0c1ef275 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 10 Jan 2026 14:11:46 +1100 Subject: [PATCH 01/25] Extract Syntax enum --- src/editor/server.rs | 4 +- src/formatting/mod.rs | 4 +- src/formatting/syntax.rs | 47 +++++++++++++++++++++++ src/lib.rs | 1 + src/problem/present.rs | 5 +-- src/{formatting => rendering}/renderer.rs | 47 +---------------------- src/rendering/terminal.rs | 2 +- src/rendering/typst.rs | 2 +- tests/formatting/golden.rs | 3 +- 9 files changed, 58 insertions(+), 57 deletions(-) create mode 100644 src/formatting/syntax.rs rename src/{formatting => rendering}/renderer.rs (59%) diff --git a/src/editor/server.rs b/src/editor/server.rs index ca57b576..3285647f 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -16,7 +16,7 @@ use technique::formatting::Identity; use technique::language::{Document, Technique}; use tracing::{debug, error, info, warn}; -use crate::formatting; +use crate::rendering; use crate::parsing; use crate::parsing::ParsingError; use crate::problem::{calculate_column_number, calculate_line_number, Present}; @@ -353,7 +353,7 @@ impl TechniqueLanguageServer { } }; - let result = formatting::render(&Identity, &document, 78); + let result = rendering::render(&Identity, &document, 78); // convert to LSP type for return to editor. let edit = TextEdit { diff --git a/src/formatting/mod.rs b/src/formatting/mod.rs index 0b6187fa..de6b8e55 100644 --- a/src/formatting/mod.rs +++ b/src/formatting/mod.rs @@ -1,6 +1,6 @@ pub mod formatter; -mod renderer; +mod syntax; // Re-export all public symbols pub use formatter::*; -pub use renderer::*; +pub use syntax::*; diff --git a/src/formatting/syntax.rs b/src/formatting/syntax.rs new file mode 100644 index 00000000..1ec2ecf6 --- /dev/null +++ b/src/formatting/syntax.rs @@ -0,0 +1,47 @@ +//! Renderers for colourizing Technique language + +/// Types of content that can be rendered with different styles +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Syntax { + Neutral, // default + Indent, + Newline, + Header, + Declaration, + Description, + Forma, + StepItem, + CodeBlock, + Variable, + Section, + String, + Numeric, + Response, + Invocation, + Title, + Keyword, + Function, + Multiline, + Label, + Operator, + Quote, + Language, + Attribute, + Structure, +} + +/// Trait for different rendering backends (the no-op no-markup one, ANSI +/// escapes for terminal colouring, Typst markup for documents) +pub trait Render { + /// Apply styling to content with the specified syntax type + fn style(&self, content_type: Syntax, content: &str) -> String; +} + +/// Returns content unchanged, with no markup applied +pub struct Identity; + +impl Render for Identity { + fn style(&self, _syntax: Syntax, content: &str) -> String { + content.to_string() + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 929e66c3..d8fc3978 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,3 +2,4 @@ pub mod formatting; pub mod language; pub mod parsing; pub mod regex; +pub mod rendering; diff --git a/src/problem/present.rs b/src/problem/present.rs index 87c1ba45..4535dca3 100644 --- a/src/problem/present.rs +++ b/src/problem/present.rs @@ -1,7 +1,4 @@ -use technique::{ - formatting::{formatter, Render}, - language::*, -}; +use technique::{formatting::*, language::*}; /// Trait for AST types that can present themselves via a renderer pub trait Present { diff --git a/src/formatting/renderer.rs b/src/rendering/renderer.rs similarity index 59% rename from src/formatting/renderer.rs rename to src/rendering/renderer.rs index 0ec994ad..b4551b56 100644 --- a/src/formatting/renderer.rs +++ b/src/rendering/renderer.rs @@ -1,52 +1,7 @@ //! Renderers for colourizing Technique language use crate::language::*; - -/// Types of content that can be rendered with different styles -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Syntax { - Neutral, // default - Indent, - Newline, - Header, - Declaration, - Description, - Forma, - StepItem, - CodeBlock, - Variable, - Section, - String, - Numeric, - Response, - Invocation, - Title, - Keyword, - Function, - Multiline, - Label, - Operator, - Quote, - Language, - Attribute, - Structure, -} - -/// Trait for different rendering backends (the no-op no-markup one, ANSI -/// escapes for terminal colouring, Typst markup for documents) -pub trait Render { - /// Apply styling to content with the specified syntax type - fn style(&self, content_type: Syntax, content: &str) -> String; -} - -/// Returns content unchanged, with no markup applied -pub struct Identity; - -impl Render for Identity { - fn style(&self, _syntax: Syntax, content: &str) -> String { - content.to_string() - } -} +use crate::formatting::*; /// We do the code formatting in two passes. First we convert from our /// Abstract Syntax Tree types into a Vec of "fragments" (Syntax tag, String diff --git a/src/rendering/terminal.rs b/src/rendering/terminal.rs index bade4992..9a1c4809 100644 --- a/src/rendering/terminal.rs +++ b/src/rendering/terminal.rs @@ -1,7 +1,7 @@ //! Renderers for colourizing Technique language use owo_colors::OwoColorize; -use technique::formatting::*; +use crate::formatting::*; /// Embellish fragments with ANSI escapes to create syntax highlighting in /// terminal output. diff --git a/src/rendering/typst.rs b/src/rendering/typst.rs index 31dff068..bbd0bb9c 100644 --- a/src/rendering/typst.rs +++ b/src/rendering/typst.rs @@ -1,7 +1,7 @@ //! Renderers for colourizing Technique language use std::borrow::Cow; -use technique::formatting::*; +use crate::formatting::*; /// Add markup around syntactic elements for use when including /// Technique source in Typst documents. diff --git a/tests/formatting/golden.rs b/tests/formatting/golden.rs index ad7a48b8..9b6d1170 100644 --- a/tests/formatting/golden.rs +++ b/tests/formatting/golden.rs @@ -1,8 +1,9 @@ use std::fs; use std::path::Path; -use technique::formatting::*; +use technique::formatting::Identity; use technique::parsing; +use technique::rendering::render; /// Golden test for the format command /// From 3052a6c9b6e031c3395e950c967f36651ca7e610 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 10 Jan 2026 14:21:42 +1100 Subject: [PATCH 02/25] Extract writing logic to new output module in application crate --- src/main.rs | 15 ++++---- src/output/mod.rs | 87 +++++++++++++++++++++++++++++++++++++++++++ src/rendering/mod.rs | 88 ++------------------------------------------ 3 files changed, 97 insertions(+), 93 deletions(-) create mode 100644 src/output/mod.rs diff --git a/src/main.rs b/src/main.rs index 434193d4..2d5405ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,18 @@ use clap::value_parser; use clap::{Arg, ArgAction, Command}; use owo_colors::OwoColorize; -use rendering::{Terminal, Typst}; use std::io::IsTerminal; use std::path::Path; use tracing::debug; use tracing_subscriber::{self, EnvFilter}; -use technique::formatting::*; -use technique::formatting::{self}; +use technique::formatting::{self, Identity}; use technique::parsing; +use technique::rendering::{self, Terminal, Typst}; mod editor; +mod output; mod problem; -mod rendering; #[derive(Eq, Debug, PartialEq)] enum Output { @@ -251,9 +250,9 @@ fn main() { let result; if raw_output || std::io::stdout().is_terminal() { - result = formatting::render(&Terminal, &technique, wrap_width); + result = rendering::render(&Terminal, &technique, wrap_width); } else { - result = formatting::render(&Identity, &technique, wrap_width); + result = rendering::render(&Identity, &technique, wrap_width); } print!("{}", result); @@ -309,7 +308,7 @@ fn main() { } }; - let result = formatting::render(&Typst, &technique, 70); + let result = rendering::render(&Typst, &technique, 70); match output { Output::Typst => { @@ -322,7 +321,7 @@ fn main() { } } - rendering::via_typst(&filename, &result); + output::via_typst(&filename, &result); } Some(("language", _)) => { debug!("Starting Language Server"); diff --git a/src/output/mod.rs b/src/output/mod.rs new file mode 100644 index 00000000..c13f1143 --- /dev/null +++ b/src/output/mod.rs @@ -0,0 +1,87 @@ +//! Output generation for the Technique CLI application + +use owo_colors::OwoColorize; +use serde::Serialize; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use tinytemplate::TinyTemplate; +use tracing::{debug, info}; + +static TEMPLATE: &'static str = r#" +#show text: set text(font: "Inconsolata") +#show raw: set block(breakable: true) +"#; + +#[derive(Serialize)] +struct Context { + filename: String, +} + +pub fn via_typst(filename: &Path, markup: &str) { + info!("Printing file: {}", filename.display()); + + // Verify that the file actually exists + if filename.to_str() == Some("-") { + eprintln!( + "{}: Unable to render to PDF from standard input.", + "error".bright_red() + ); + std::process::exit(1); + } + if !filename.exists() { + panic!( + "Supplied procedure file does not exist: {}", + filename.display() + ); + } + + let target = filename.with_extension("pdf"); + + let mut child = Command::new("typst") + .arg("compile") + .arg("-") + .arg(target) + .stdin(Stdio::piped()) + .spawn() + .expect("Failed to start external Typst process"); + + // Write the file contents to the process's stdin + let mut stdin = child + .stdin + .take() + .unwrap(); + + let mut tt = TinyTemplate::new(); + tt.add_template("hello", TEMPLATE) + .unwrap(); + + let context = Context { + filename: filename + .to_string_lossy() + .to_string(), + }; + + let rendered = tt + .render("hello", &context) + .unwrap(); + stdin + .write(rendered.as_bytes()) + .expect("Write header to child process"); + + // write markup to stdin handle + + stdin + .write(markup.as_bytes()) + .expect("Write document to child process"); + + drop(stdin); + + // Wait for the process to complete + let output = child + .wait_with_output() + .expect("Failed to read stdout"); + + // Log the output + debug!("Process output: {:?}", output); +} diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs index f2d92501..ec78eee8 100644 --- a/src/rendering/mod.rs +++ b/src/rendering/mod.rs @@ -1,91 +1,9 @@ -use owo_colors::OwoColorize; -use serde::Serialize; -use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; -use tinytemplate::TinyTemplate; -use tracing::{debug, info}; +//! Rendering of Technique source code with syntax highlighting +mod renderer; mod terminal; mod typst; +pub use renderer::render; pub use terminal::Terminal; pub use typst::Typst; - -static TEMPLATE: &'static str = r#" -#show text: set text(font: "Inconsolata") -#show raw: set block(breakable: true) -"#; - -#[derive(Serialize)] -struct Context { - filename: String, -} - -pub(crate) fn via_typst(filename: &Path, markup: &str) { - info!("Printing file: {}", filename.display()); - - // Verify that the file actually exists - if filename.to_str() == Some("-") { - eprintln!( - "{}: Unable to render to PDF from standard input.", - "error".bright_red() - ); - std::process::exit(1); - } - if !filename.exists() { - panic!( - "Supplied procedure file does not exist: {}", - filename.display() - ); - } - - let target = filename.with_extension("pdf"); - - let mut child = Command::new("typst") - .arg("compile") - .arg("-") - .arg(target) - .stdin(Stdio::piped()) - .spawn() - .expect("Failed to start external Typst process"); - - // Write the file contents to the process's stdin - let mut stdin = child - .stdin - .take() - .unwrap(); - - let mut tt = TinyTemplate::new(); - tt.add_template("hello", TEMPLATE) - .unwrap(); - - let context = Context { - filename: filename - .to_string_lossy() - .to_string(), - }; - - let rendered = tt - .render("hello", &context) - .unwrap(); - stdin - .write(rendered.as_bytes()) - .expect("Write header to child process"); - - // write markup to stdin handle - - stdin - .write(markup.as_bytes()) - .expect("Write document to child process"); - - drop(stdin); - - // Wait for the process to complete - let output = child - .wait_with_output() - .expect("Failed to read stdout"); - - // Log the output - debug!("Process output: {:?}", output); -} From 48bc76616277d9effeb90c0ecaf1c44dfc2c0b1d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 10 Jan 2026 15:53:20 +1100 Subject: [PATCH 03/25] Implement preliminary template options to render subcommand --- src/lib.rs | 1 + src/main.rs | 58 ++++++++++++++++++++++---------------- src/templating/mod.rs | 14 +++++++++ src/templating/source.rs | 15 ++++++++++ src/templating/template.rs | 9 ++++++ 5 files changed, 72 insertions(+), 25 deletions(-) create mode 100644 src/templating/mod.rs create mode 100644 src/templating/source.rs create mode 100644 src/templating/template.rs diff --git a/src/lib.rs b/src/lib.rs index d8fc3978..f9686abc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,4 @@ pub mod language; pub mod parsing; pub mod regex; pub mod rendering; +pub mod templating; diff --git a/src/main.rs b/src/main.rs index 2d5405ed..cf43011f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,8 @@ use tracing_subscriber::{self, EnvFilter}; use technique::formatting::{self, Identity}; use technique::parsing; -use technique::rendering::{self, Terminal, Typst}; +use technique::rendering::{self, Terminal}; +use technique::templating::{self, Source}; mod editor; mod output; @@ -17,7 +18,6 @@ mod problem; #[derive(Eq, Debug, PartialEq)] enum Output { Native, - Typst, Silent, } @@ -105,23 +105,31 @@ fn main() { .subcommand( Command::new("render") .about("Render the Technique document into a printable PDF.") - .long_about("Render the Technique document into a printable \ - PDF. By default this will highlight the source of the \ - input file for the purposes of reviewing the raw \ - procedure in code form.") + .long_about("Render the Technique document into a formatted \ + PDF using a template. This allows you to transform the code of \ + the procedure into the intended layout suitable to the \ + domain you're app.") .arg( Arg::new("output") .short('o') .long("output") - .value_parser(["typst", "none"]) - .default_value("none") + .value_parser(["pdf", "typst"]) + .default_value("pdf") + .action(ArgAction::Set) + .help("Whether to write PDF to a file on disk, or print the Typst markup that would be used to create that PDF (for debugging)."), + ) + .arg( + Arg::new("template") + .short('t') + .long("template") + .default_value("source") .action(ArgAction::Set) - .help("Which kind of diagnostic output to print when rendering.") + .help("Template to use for rendering."), ) .arg( Arg::new("filename") .required(true) - .help("The file containing the code for the Technique you want to print."), + .help("The file containing the Technique you want to render."), ), ) .subcommand( @@ -261,13 +269,12 @@ fn main() { let output = submatches .get_one::("output") .unwrap(); - let output = match output.as_str() { - "typst" => Output::Typst, - "none" => Output::Silent, - _ => panic!("Unrecognized --output value"), - }; - debug!(?output); + let template_name = submatches + .get_one::("template") + .unwrap(); + + debug!(output, template_name); let filename = submatches .get_one::("filename") @@ -308,20 +315,21 @@ fn main() { } }; - let result = rendering::render(&Typst, &technique, 70); + // Select template and render + let result = match template_name.as_str() { + "source" => templating::fill(&Source, &technique, 70), + _ => panic!("Unrecognized template: {}", template_name), + }; - match output { - Output::Typst => { + match output.as_str() { + "typst" => { print!("{}", result); } - _ => { - // ignore; the default is to not output any intermediate - // representations and instead proceed to invoke the - // typesetter to generate the desired PDF. + "pdf" => { + output::via_typst(&filename, &result); } + _ => panic!("Unrecognized --output value"), } - - output::via_typst(&filename, &result); } Some(("language", _)) => { debug!("Starting Language Server"); diff --git a/src/templating/mod.rs b/src/templating/mod.rs new file mode 100644 index 00000000..4a85ca11 --- /dev/null +++ b/src/templating/mod.rs @@ -0,0 +1,14 @@ +//! Templates for rendering Technique documents into formatted output + +mod source; +mod template; + +pub use source::Source; +pub use template::Template; + +use crate::language::Document; + +/// Render a Technique document using the specified template +pub fn fill(template: &impl Template, document: &Document, width: u8) -> String { + template.render(document, width) +} diff --git a/src/templating/source.rs b/src/templating/source.rs new file mode 100644 index 00000000..daf5a8f0 --- /dev/null +++ b/src/templating/source.rs @@ -0,0 +1,15 @@ +//! Source template - syntax-highlighted source code rendering + +use crate::language::Document; +use crate::rendering::{render, Typst}; + +use super::Template; + +/// Template for rendering Technique source code with syntax highlighting +pub struct Source; + +impl Template for Source { + fn render(&self, document: &Document, width: u8) -> String { + render(&Typst, document, width) + } +} diff --git a/src/templating/template.rs b/src/templating/template.rs new file mode 100644 index 00000000..7038d57d --- /dev/null +++ b/src/templating/template.rs @@ -0,0 +1,9 @@ +//! Template trait for rendering Technique documents + +use crate::language::Document; + +/// Trait for templates that transform Technique documents into Typst markup +pub trait Template { + /// Render a Technique document into Typst markup + fn render(&self, document: &Document, width: u8) -> String; +} From faa5a0d5ff1efa12a892a1c66a4df83e87f20af0 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 10 Jan 2026 15:57:05 +1100 Subject: [PATCH 04/25] Bump version reflecting command-line changes --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b473f27..2e8d21a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,7 +478,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.4.6" +version = "0.5.0" dependencies = [ "clap", "ignore", diff --git a/Cargo.toml b/Cargo.toml index 3136923f..4bf6c04d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.4.6" +version = "0.5.0" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] From 6d489332662cf00620098b6cdb6c89ed35ecf707 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 15 Jan 2026 21:44:28 +1100 Subject: [PATCH 05/25] Formatting and imports --- src/editor/server.rs | 2 +- src/formatting/syntax.rs | 2 +- src/main.rs | 3 ++- src/rendering/renderer.rs | 2 +- src/rendering/terminal.rs | 2 +- src/rendering/typst.rs | 2 +- src/templating/mod.rs | 10 +++++++--- src/templating/template.rs | 8 ++++---- 8 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/editor/server.rs b/src/editor/server.rs index 3285647f..d1a7aa96 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -16,10 +16,10 @@ use technique::formatting::Identity; use technique::language::{Document, Technique}; use tracing::{debug, error, info, warn}; -use crate::rendering; use crate::parsing; use crate::parsing::ParsingError; use crate::problem::{calculate_column_number, calculate_line_number, Present}; +use crate::rendering; pub struct TechniqueLanguageServer { /// Map from URI to document content diff --git a/src/formatting/syntax.rs b/src/formatting/syntax.rs index 1ec2ecf6..eb016ecf 100644 --- a/src/formatting/syntax.rs +++ b/src/formatting/syntax.rs @@ -44,4 +44,4 @@ impl Render for Identity { fn style(&self, _syntax: Syntax, content: &str) -> String { content.to_string() } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index cf43011f..a1918a8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use tracing_subscriber::{self, EnvFilter}; use technique::formatting::{self, Identity}; use technique::parsing; use technique::rendering::{self, Terminal}; -use technique::templating::{self, Source}; +use technique::templating::{self, Checklist, Source}; mod editor; mod output; @@ -318,6 +318,7 @@ fn main() { // Select template and render let result = match template_name.as_str() { "source" => templating::fill(&Source, &technique, 70), + "checklist" => templating::fill(&Checklist, &technique, 70), _ => panic!("Unrecognized template: {}", template_name), }; diff --git a/src/rendering/renderer.rs b/src/rendering/renderer.rs index b4551b56..ef8ebfb8 100644 --- a/src/rendering/renderer.rs +++ b/src/rendering/renderer.rs @@ -1,7 +1,7 @@ //! Renderers for colourizing Technique language -use crate::language::*; use crate::formatting::*; +use crate::language::*; /// We do the code formatting in two passes. First we convert from our /// Abstract Syntax Tree types into a Vec of "fragments" (Syntax tag, String diff --git a/src/rendering/terminal.rs b/src/rendering/terminal.rs index 9a1c4809..66fbeed3 100644 --- a/src/rendering/terminal.rs +++ b/src/rendering/terminal.rs @@ -1,7 +1,7 @@ //! Renderers for colourizing Technique language -use owo_colors::OwoColorize; use crate::formatting::*; +use owo_colors::OwoColorize; /// Embellish fragments with ANSI escapes to create syntax highlighting in /// terminal output. diff --git a/src/rendering/typst.rs b/src/rendering/typst.rs index bbd0bb9c..6d92c555 100644 --- a/src/rendering/typst.rs +++ b/src/rendering/typst.rs @@ -1,7 +1,7 @@ //! Renderers for colourizing Technique language -use std::borrow::Cow; use crate::formatting::*; +use std::borrow::Cow; /// Add markup around syntactic elements for use when including /// Technique source in Typst documents. diff --git a/src/templating/mod.rs b/src/templating/mod.rs index 4a85ca11..e43e27ed 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -1,14 +1,18 @@ //! Templates for rendering Technique documents into formatted output +mod checklist; +mod engine; +mod semantic; mod source; mod template; +pub use checklist::Checklist; pub use source::Source; -pub use template::Template; +pub use template::{Content, Section, Step, Task, Template}; -use crate::language::Document; +use crate::language; /// Render a Technique document using the specified template -pub fn fill(template: &impl Template, document: &Document, width: u8) -> String { +pub fn fill(template: &impl Template, document: &language::Document, width: u8) -> String { template.render(document, width) } diff --git a/src/templating/template.rs b/src/templating/template.rs index 7038d57d..ec6acfb9 100644 --- a/src/templating/template.rs +++ b/src/templating/template.rs @@ -1,9 +1,9 @@ -//! Template trait for rendering Technique documents +//! Trait for transforming Technique documents and then rendering them. -use crate::language::Document; +use crate::language; -/// Trait for templates that transform Technique documents into Typst markup +/// Templates transform Technique documents into Typst markup. pub trait Template { /// Render a Technique document into Typst markup - fn render(&self, document: &Document, width: u8) -> String; + fn render(&self, document: &language::Document, width: u8) -> String; } From 90e66a237855fc1b3af22d3fa69c95726ebe356b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 19 Feb 2026 17:04:26 +1100 Subject: [PATCH 06/25] Add drivers license example --- examples/prototype/GovernmentForm.tq | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 examples/prototype/GovernmentForm.tq diff --git a/examples/prototype/GovernmentForm.tq b/examples/prototype/GovernmentForm.tq new file mode 100644 index 00000000..c3df2bcc --- /dev/null +++ b/examples/prototype/GovernmentForm.tq @@ -0,0 +1,15 @@ +% technique v1 +! Official; © 2026 Heard Island Government +& form + +drivers_license_application : +{ + [ + "Name" = "Kowalski" + "Species" = "Emporer Penguin" + "Age" = 4.2 winters + "Unique Penguin Identifier" = uuid() + "Occupation" = "Operations Planning Specialist" + "Permanent Address" = "Central Park Zoo, New York, NY, 10021, United States" + ] +} From f2f51d164d1acdda001ac7467f5e7dbed049252b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 19 Feb 2026 17:09:57 +1100 Subject: [PATCH 07/25] Add ISS crew procedure example --- examples/prototype/AirlockPowerdown.tq | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 examples/prototype/AirlockPowerdown.tq diff --git a/examples/prototype/AirlockPowerdown.tq b/examples/prototype/AirlockPowerdown.tq new file mode 100644 index 00000000..852c0593 --- /dev/null +++ b/examples/prototype/AirlockPowerdown.tq @@ -0,0 +1,39 @@ +% technique v1 +! PD; © 2003 National Aeronautics and Space Administration, Canadian Space Agency, European Space Agency, and Others +& nasa-flight-plan,v4.0 + +emergency_procedures : + +# ISS Powerdown and Recovery + + 1. + 2. + 3. + +rs_load_powerdown : + +# RS Load Powerdown + +ARCU deactivation is requested by MCC-H and performed after MCC-M concurrence. + +node1_htr_avail_16 : + +# Inhibiting Node 1 B HTRS (1 to 6) + + @pcs + { foreach node in seq(6) } + 1. Check Availability + 2. Perform { cmd("Inhibit") } + 3. Check Availability + 'Inhibited' + +node1_htr_avail_79 : + +# Inhibiting Node 1 B HTRS (7 to 9) + + @pcs + { foreach node in seq(9) } + 1. Check Availability + 2. Perform { cmd("Inhibit") } + 3. Check Availability + 'Inhibited' From a1fb2acb8efc8e1fdb9bc695622b8b2b529a343b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 3 Mar 2026 17:49:12 +1100 Subject: [PATCH 08/25] Utility methods for extracting structure from AST --- src/templating/checklist.rs | 320 ++++++++++++++++++++++++++++++++++++ src/templating/engine.rs | 164 ++++++++++++++++++ src/templating/mod.rs | 3 +- 3 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 src/templating/checklist.rs create mode 100644 src/templating/engine.rs diff --git a/src/templating/checklist.rs b/src/templating/checklist.rs new file mode 100644 index 00000000..0988e3a2 --- /dev/null +++ b/src/templating/checklist.rs @@ -0,0 +1,320 @@ +//! Checklist template - renders procedures as printable checklists +//! +//! A checklist is moderately structured and relatively flat: sections with +//! headings, steps with checkboxes, response options, and limited nesting. + +use crate::language; + +use super::template::Template; + +// ============================================================================ +// Checklist domain types +// ============================================================================ + +/// A checklist document: sections containing steps. +struct Document { + sections: Vec
, +} + +impl Document { + fn new() -> Self { + Document { + sections: Vec::new(), + } + } +} + +/// A section within a checklist. +struct Section { + #[allow(dead_code)] + ordinal: Option, + heading: Option, + steps: Vec, +} + +/// A step within a checklist section. +struct Step { + #[allow(dead_code)] + name: Option, + ordinal: Option, + title: Option, + body: Vec, + role: Option, + responses: Vec, + children: Vec, +} + +// ============================================================================ +// Template implementation +// ============================================================================ + +pub struct Checklist; + +impl Template for Checklist { + fn render(&self, document: &language::Document, _width: u8) -> String { + let extracted = extract(document); + render(&extracted) + } +} + +/// Transform the parsed AST Document into template Document +fn extract(document: &language::Document) -> Document { + let mut extracted = Document::new(); + + // Handle procedures + for procedure in document.procedures() { + extract_procedure(&mut extracted, procedure); + } + + // Handle top-level steps (if no procedures) + if extracted + .sections + .is_empty() + { + let steps: Vec = document + .steps() + .filter(|s| s.is_step()) + .map(|s| step_from_scope(s, None)) + .collect(); + + if !steps.is_empty() { + extracted + .sections + .push(Section { + ordinal: None, + heading: None, + steps, + }); + } + } + + extracted +} + +fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { + // Extract steps into a section + let steps: Vec = procedure + .steps() + .flat_map(|s| steps_from_scope(s, None)) + .collect(); + + if !steps.is_empty() { + content + .sections + .push(Section { + ordinal: None, + heading: procedure + .title() + .map(String::from), + steps, + }); + } +} + +/// Extract steps from a scope, handling different scope types. +fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Vec { + if scope.is_step() { + return vec![step_from_scope(scope, inherited_role)]; + } + + // Handle AttributeBlock - extract role and process children + let roles: Vec<_> = scope + .roles() + .collect(); + if !roles.is_empty() { + let role = roles + .first() + .copied(); + return scope + .children() + .flat_map(|s| steps_from_scope(s, role)) + .collect(); + } + + // Handle SectionChunk + if let Some((numeral, title)) = scope.section_info() { + let heading = match title { + Some(para) => format!("{}. {}", numeral, para.text()), + None => format!("{}.", numeral), + }; + + let mut steps = vec![Step { + name: None, + ordinal: None, + title: Some(heading), + body: Vec::new(), + role: None, + responses: Vec::new(), + children: Vec::new(), + }]; + + // Handle nested procedures in section body + if let language::Scope::SectionChunk { body, .. } = scope { + if let language::Technique::Procedures(procedures) = body { + for procedure in procedures { + if let Some(title) = procedure.title() { + let children: Vec = procedure + .steps() + .flat_map(|s| steps_from_scope(s, None)) + .collect(); + + steps.push(Step { + name: Some( + procedure + .name + .0 + .to_string(), + ), + ordinal: None, + title: Some(title.to_string()), + body: Vec::new(), + role: None, + responses: Vec::new(), + children, + }); + } + } + } + } + + return steps; + } + + Vec::new() +} + +/// Convert a step-like scope into a Step. +fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Step { + let mut responses = Vec::new(); + let mut children = Vec::new(); + + for subscope in scope.children() { + // Collect responses + for response in subscope.responses() { + responses.push( + response + .value + .to_string(), + ); + } + + // Collect children (substeps) + children.extend(steps_from_scope(subscope, inherited_role)); + } + + // First paragraph becomes title, rest becomes body + let paragraphs: Vec = scope + .description() + .map(|p| p.text()) + .collect(); + let (title, body) = match paragraphs.split_first() { + Some((first, rest)) => (Some(first.clone()), rest.to_vec()), + None => (None, Vec::new()), + }; + + Step { + name: None, + ordinal: scope + .ordinal() + .map(String::from), + title, + body, + role: inherited_role.map(String::from), + responses, + children, + } +} + +fn render(document: &Document) -> String { + let mut output = String::new(); + + // Typst preamble + output.push_str("#set page(margin: 1.5cm)\n"); + output.push_str("#set text(size: 10pt)\n\n"); + + // Sections + for section in &document.sections { + render_section(&mut output, section); + } + + output +} + +fn render_section(output: &mut String, section: &Section) { + if let Some(heading) = §ion.heading { + output.push_str(&format!("== {}\n\n", escape(heading))); + } + + for step in §ion.steps { + render_step(output, step, 0); + } + + output.push('\n'); +} + +fn render_step(output: &mut String, step: &Step, depth: usize) { + let indent = " ".repeat(depth); + + // Role header if present + if let Some(role) = &step.role { + output.push_str(&format!("{}#text(weight: \"bold\")[{}]\n\n", indent, role)); + } + + // Checkbox with ordinal and title + output.push_str(&format!( + "{}#box(stroke: 0.5pt, width: 0.8em, height: 0.8em) ", + indent + )); + + if let Some(ordinal) = &step.ordinal { + output.push_str(&format!("*{}.* ", ordinal)); + } + + if let Some(title) = &step.title { + output.push_str(&escape(title)); + } + output.push_str("\n\n"); + + // Body paragraphs + for para in &step.body { + output.push_str(&format!("{} {}\n\n", indent, escape(para))); + } + + // Response options + if !step + .responses + .is_empty() + { + output.push_str(&format!("{} ", indent)); + for (i, response) in step + .responses + .iter() + .enumerate() + { + if i > 0 { + output.push_str(" | "); + } + output.push_str(&format!( + "#box(stroke: 0.5pt, width: 0.6em, height: 0.6em) _{}_", + response + )); + } + output.push_str("\n\n"); + } + + // Children + for child in &step.children { + render_step(output, child, depth + 1); + } +} + +fn escape(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('#', "\\#") + .replace('$', "\\$") + .replace('*', "\\*") + .replace('_', "\\_") + .replace('@', "\\@") + .replace('<', "\\<") + .replace('>', "\\>") +} diff --git a/src/templating/engine.rs b/src/templating/engine.rs new file mode 100644 index 00000000..9e8688c6 --- /dev/null +++ b/src/templating/engine.rs @@ -0,0 +1,164 @@ +//! Templating engine: AST accessor methods. +//! +//! This module provides accessor methods on AST types for convenient +//! iteration when extracting content for templates. This allows us to hide +//! parser artifacts like `Scope` and iterate over the constructs we recognize +//! as sections, steps, and tasks. + +use crate::language::{ + Attribute, Descriptive, Document, Element, Paragraph, Procedure, Response, Scope, Technique, +}; + +impl<'i> Document<'i> { + /// Get all the procedures in the document as an iterator. + pub fn procedures(&self) -> impl Iterator> { + let slice: &[Procedure<'i>] = match &self.body { + Some(Technique::Procedures(procedures)) => procedures, + _ => &[], + }; + slice.iter() + } + + /// Get all the document's top-level steps as an iterator. + pub fn steps(&self) -> impl Iterator> { + let slice: &[Scope<'i>] = match &self.body { + Some(Technique::Steps(steps)) => steps, + _ => &[], + }; + slice.iter() + } +} + +impl<'i> Procedure<'i> { + // a title() method already exists in language/types.rs + + /// Returns an iterator over the procedure's top-level steps. + pub fn steps(&self) -> impl Iterator> { + self.elements + .iter() + .flat_map(|element| match element { + Element::Steps(steps) => steps.iter(), + _ => [].iter(), + }) + } + + /// Returns an iterator over the procedure's descriptive paragraphs. + pub fn description(&self) -> impl Iterator> { + self.elements + .iter() + .flat_map(|element| match element { + Element::Description(paragraphs) => paragraphs.iter(), + _ => [].iter(), + }) + } +} + +impl<'i> Scope<'i> { + /// Returns an iterator over all children. + pub fn children(&self) -> impl Iterator> { + let slice: &[Scope<'i>] = match self { + Scope::DependentBlock { subscopes, .. } => subscopes, + Scope::ParallelBlock { subscopes, .. } => subscopes, + Scope::AttributeBlock { subscopes, .. } => subscopes, + Scope::CodeBlock { subscopes, .. } => subscopes, + Scope::ResponseBlock { .. } => &[], + Scope::SectionChunk { .. } => &[], + }; + slice.iter() + } + + /// Returns an iterator over child steps only (DependentBlock, ParallelBlock). + /// Filters out ResponseBlock, CodeBlock, AttributeBlock, etc. + pub fn substeps(&self) -> impl Iterator> { + self.children() + .filter(|s| matches!(s, Scope::DependentBlock { .. } | Scope::ParallelBlock { .. })) + } + + /// Returns the text content of this step (first paragraph). + pub fn text(&self) -> Option { + self.description() + .next() + .map(|p| p.text()) + } + + /// Returns an iterator over description paragraphs (for step-like scopes). + pub fn description(&self) -> impl Iterator> { + let slice: &[Paragraph<'i>] = match self { + Scope::DependentBlock { description, .. } => description, + Scope::ParallelBlock { description, .. } => description, + _ => &[], + }; + slice.iter() + } + + /// Returns the ordinal if this is a DependentBlock (numbered step). + pub fn ordinal(&self) -> Option<&'i str> { + match self { + Scope::DependentBlock { ordinal, .. } => Some(ordinal), + _ => None, + } + } + + /// Returns an iterator over responses if this is a ResponseBlock. + pub fn responses(&self) -> impl Iterator> { + let slice: &[Response<'i>] = match self { + Scope::ResponseBlock { responses } => responses, + _ => &[], + }; + slice.iter() + } + + /// Returns an iterator over role names if this is an AttributeBlock. + pub fn roles(&self) -> impl Iterator { + match self { + Scope::AttributeBlock { attributes, .. } => attributes + .iter() + .filter_map(|attr| match attr { + Attribute::Role(id) => Some(id.0), + _ => None, + }) + .collect::>() + .into_iter(), + _ => Vec::new().into_iter(), + } + } + + /// Returns true if this scope represents a step (dependent or parallel). + pub fn is_step(&self) -> bool { + matches!( + self, + Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } + ) + } + + /// Returns section info (numeral, title) if this is a SectionChunk. + pub fn section_info(&self) -> Option<(&'i str, Option<&Paragraph<'i>>)> { + match self { + Scope::SectionChunk { numeral, title, .. } => Some((numeral, title.as_ref())), + _ => None, + } + } +} + +impl<'i> Paragraph<'i> { + /// Returns the text content of this paragraph as a single String. + /// Code, invocations, and bindings are omitted. + pub fn text(&self) -> String { + let mut result = String::new(); + for descriptive in &self.0 { + if let Descriptive::Text(text) = descriptive { + if !result.is_empty() && !result.ends_with(' ') { + result.push(' '); + } + result.push_str(text); + } + } + result + } + + /// Returns an iterator over the descriptive elements. + pub fn elements(&self) -> impl Iterator> { + self.0 + .iter() + } +} diff --git a/src/templating/mod.rs b/src/templating/mod.rs index e43e27ed..c11927a1 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -2,13 +2,12 @@ mod checklist; mod engine; -mod semantic; mod source; mod template; pub use checklist::Checklist; pub use source::Source; -pub use template::{Content, Section, Step, Task, Template}; +pub use template::Template; use crate::language; From b2e2dd50ccdcba888a256413c57ebe5990e9a43f Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 5 Mar 2026 17:33:30 +1100 Subject: [PATCH 09/25] Ignore reference documentation --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1b2a7b36..c4a6b556 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ # rendered code fragments /*.pdf + +# documentation symlinks +/doc/references From aff74a6ed32f326935c5d26cd9496b88d85365d3 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 5 Mar 2026 22:17:14 +1100 Subject: [PATCH 10/25] Add traits for Template, Adapter, and Renderer --- src/templating/engine.rs | 125 +++++++++++++++++++++++++++++++++++-- src/templating/mod.rs | 9 ++- src/templating/source.rs | 22 +++++-- src/templating/template.rs | 29 +++++++-- src/templating/typst.rs | 80 ++++++++++++++++++++++++ 5 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 src/templating/typst.rs diff --git a/src/templating/engine.rs b/src/templating/engine.rs index 9e8688c6..1e8251b0 100644 --- a/src/templating/engine.rs +++ b/src/templating/engine.rs @@ -71,7 +71,12 @@ 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| { + matches!( + s, + Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } + ) + }) } /// Returns the text content of this step (first paragraph). @@ -138,22 +143,134 @@ impl<'i> Scope<'i> { _ => None, } } + + /// Returns the body of a SectionChunk. + pub fn body(&self) -> Option<&Technique<'i>> { + match self { + Scope::SectionChunk { body, .. } => Some(body), + _ => None, + } + } +} + +impl<'i> Technique<'i> { + /// Returns an iterator over procedures if this is a Procedures variant. + pub fn procedures(&self) -> impl Iterator> { + let slice: &[Procedure<'i>] = match self { + Technique::Procedures(procedures) => procedures, + _ => &[], + }; + slice.iter() + } + + /// Returns an iterator over steps if this is a Steps variant. + pub fn steps(&self) -> impl Iterator> { + let slice: &[Scope<'i>] = match self { + Technique::Steps(steps) => steps, + _ => &[], + }; + slice.iter() + } +} + +impl<'i> Procedure<'i> { + /// Returns the procedure name. + pub fn name(&self) -> &'i str { + self.name + .0 + } +} + +impl<'i> Response<'i> { + /// Returns the response value. + pub fn value(&self) -> &'i str { + self.value + } + + /// Returns the optional condition. + pub fn condition(&self) -> Option<&'i str> { + self.condition + } } impl<'i> Paragraph<'i> { /// Returns the text content of this paragraph as a single String. - /// Code, invocations, and bindings are omitted. + /// When text is present, invocations are treated as cross-references + /// and omitted. When the only content is invocations (and bindings), + /// the invocation target names are included as fallback text. pub fn text(&self) -> String { + let has_text = self + .0 + .iter() + .any(|d| Self::has_text(d)); + let mut result = String::new(); for descriptive in &self.0 { - if let Descriptive::Text(text) = descriptive { + Self::append_descriptive(&mut result, descriptive, !has_text); + } + result + } + + fn has_text(descriptive: &Descriptive<'i>) -> bool { + match descriptive { + Descriptive::Text(_) => true, + Descriptive::Binding(inner, _) => Self::has_text(inner), + _ => false, + } + } + + fn append_descriptive( + result: &mut String, + descriptive: &Descriptive<'i>, + include_invocations: bool, + ) { + match descriptive { + Descriptive::Text(text) => { if !result.is_empty() && !result.ends_with(' ') { result.push(' '); } result.push_str(text); } + Descriptive::Application(invocation) if include_invocations => { + Self::append_invocation_name(result, invocation); + } + Descriptive::CodeInline(expr) if include_invocations => { + Self::append_expression_name(result, expr); + } + Descriptive::Binding(inner, _) => { + Self::append_descriptive(result, inner, include_invocations); + } + _ => {} + } + } + + fn append_invocation_name(result: &mut String, invocation: &crate::language::Invocation<'i>) { + if !result.is_empty() && !result.ends_with(' ') { + result.push(' '); + } + let name = match &invocation.target { + crate::language::Target::Local(id) => id.0, + crate::language::Target::Remote(ext) => ext.0, + }; + result.push_str(name); + } + + fn append_expression_name(result: &mut String, expr: &crate::language::Expression<'i>) { + match expr { + crate::language::Expression::Application(invocation) => { + Self::append_invocation_name(result, invocation); + } + crate::language::Expression::Repeat(inner) => { + Self::append_expression_name(result, inner); + } + crate::language::Expression::Foreach(_, inner) => { + Self::append_expression_name(result, inner); + } + crate::language::Expression::Binding(inner, _) => { + Self::append_expression_name(result, inner); + } + _ => {} } - result } /// Returns an iterator over the descriptive elements. diff --git a/src/templating/mod.rs b/src/templating/mod.rs index c11927a1..1b2f5c40 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -2,16 +2,19 @@ mod checklist; mod engine; +mod procedure; mod source; mod template; +pub mod typst; pub use checklist::Checklist; +pub use procedure::Procedure; pub use source::Source; -pub use template::Template; +pub use template::{Adapter, Renderer, Template}; use crate::language; /// Render a Technique document using the specified template -pub fn fill(template: &impl Template, document: &language::Document, width: u8) -> String { - template.render(document, width) +pub fn render(template: &impl Template, document: &language::Document) -> String { + template.render(document) } diff --git a/src/templating/source.rs b/src/templating/source.rs index daf5a8f0..e4d9780d 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -1,15 +1,25 @@ -//! Source template - syntax-highlighted source code rendering +//! Render Technique source code with syntax highlighting into Typst. This +//! implements Template directly without the adapter/renderer split used in +//! normal renderers by instead delegating to the existing code formatting +//! pipeline underlying the `format` command. use crate::language::Document; -use crate::rendering::{render, Typst}; +use crate::highlighting::{render, Typst}; use super::Template; -/// Template for rendering Technique source code with syntax highlighting -pub struct Source; +pub struct Source { + width: u8, +} + +impl Source { + pub fn new(width: u8) -> Self { + Source { width } + } +} impl Template for Source { - fn render(&self, document: &Document, width: u8) -> String { - render(&Typst, document, width) + fn render(&self, document: &Document) -> String { + render(&Typst, document, self.width) } } diff --git a/src/templating/template.rs b/src/templating/template.rs index ec6acfb9..39d1486e 100644 --- a/src/templating/template.rs +++ b/src/templating/template.rs @@ -1,9 +1,30 @@ -//! Trait for transforming Technique documents and then rendering them. +//! Traits for the templating pipeline. use crate::language; -/// Templates transform Technique documents into Typst markup. +/// A template transforms a Technique document into Typst markup. Internally +/// this is split into two phases: an adapter, which takes the AST from the +/// parser and converts it to domain types, and a renderer which converts that +/// domain into Typst markup. Not all templates make this split; `Source` is a +/// special case that delegates directly to the code formatting logic. + pub trait Template { - /// Render a Technique document into Typst markup - fn render(&self, document: &language::Document, width: u8) -> String; + fn render(&self, document: &language::Document) -> String; +} + +/// Adapters project the AST into a domain-specific model. Each template +/// defines its own model types (e.g. checklist::Document, +/// procedure::Document) reflecting how that domain thinks about the elements +/// of procedures as encoded in Technique. +pub trait Adapter { + type Model; + fn extract(&self, document: &language::Document) -> Self::Model; +} + +/// Renderers convert from a domain model into Typst markup. Shared `typst` +/// primitives are made available as helpers to make for more consistent +/// output. +pub trait Renderer { + type Model; + fn render(&self, model: &Self::Model) -> String; } diff --git a/src/templating/typst.rs b/src/templating/typst.rs new file mode 100644 index 00000000..2d5da098 --- /dev/null +++ b/src/templating/typst.rs @@ -0,0 +1,80 @@ +//! Shared Typst markup primitives available for use by all template +//! renderers. +//! +//! This provides building blocks (headings, steps, roles, responses, etc) +//! that renderers can compose into complete output documents. +//! +//! Note that this is distinct from `rendering::typst` which renders Technique +//! in its original surface language syntax form; this module operates over +//! constructs made in any particular domain. + +/// Escape special Typst characters in text content. +pub fn escape(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('#', "\\#") + .replace('$', "\\$") + .replace('*', "\\*") + .replace('_', "\\_") + .replace('@', "\\@") + .replace('<', "\\<") + .replace('>', "\\>") +} + +/// Standard page and text setup preamble. +pub fn preamble() -> String { + "#set page(margin: 1.5cm)\n#set text(size: 10pt)\n\n".to_string() +} + +/// Section heading. +pub fn heading(level: u8, text: &str) -> String { + let markers = "=".repeat(level as usize); + format!("{} {}\n\n", markers, escape(text)) +} + +/// Descriptive text paragraph. +pub fn description(indent: &str, text: &str) -> String { + format!("{}{}\n\n", indent, escape(text)) +} + +/// Step with checkbox, optional ordinal, and text. +pub fn step(indent: &str, ordinal: Option<&str>, text: Option<&str>) -> String { + let mut out = format!( + "{}#box(stroke: 0.5pt, width: 0.8em, height: 0.8em) ", + indent + ); + if let Some(ord) = ordinal { + out.push_str(&format!("*{}.* ", ord)); + } + if let Some(t) = text { + out.push_str(&escape(t)); + } + out.push_str("\n\n"); + out +} + +/// Role attribution header. +pub fn role(indent: &str, name: &str) -> String { + format!("{}#text(weight: \"bold\")[{}]\n\n", indent, name) +} + +/// Response options with small checkboxes. +pub fn responses(indent: &str, options: &[String]) -> String { + if options.is_empty() { + return String::new(); + } + let mut out = format!("{}", indent); + for (i, option) in options + .iter() + .enumerate() + { + if i > 0 { + out.push_str(" | "); + } + out.push_str(&format!( + "#box(stroke: 0.5pt, width: 0.6em, height: 0.6em) _{}_", + option + )); + } + out.push_str("\n\n"); + out +} From 3e59f9e9d0ede73cc4a72fea29eb66c187891217 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 5 Mar 2026 22:20:00 +1100 Subject: [PATCH 11/25] Rename rendering module to highlighting --- src/editor/server.rs | 4 ++-- src/{rendering => highlighting}/mod.rs | 0 src/{rendering => highlighting}/renderer.rs | 0 src/{rendering => highlighting}/terminal.rs | 0 src/{rendering => highlighting}/typst.rs | 0 src/lib.rs | 2 +- src/main.rs | 8 ++++---- src/templating/source.rs | 2 +- tests/formatting/golden.rs | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) rename src/{rendering => highlighting}/mod.rs (100%) rename src/{rendering => highlighting}/renderer.rs (100%) rename src/{rendering => highlighting}/terminal.rs (100%) rename src/{rendering => highlighting}/typst.rs (100%) diff --git a/src/editor/server.rs b/src/editor/server.rs index d1a7aa96..996df2c4 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -16,10 +16,10 @@ use technique::formatting::Identity; use technique::language::{Document, Technique}; use tracing::{debug, error, info, warn}; +use crate::highlighting; use crate::parsing; use crate::parsing::ParsingError; use crate::problem::{calculate_column_number, calculate_line_number, Present}; -use crate::rendering; pub struct TechniqueLanguageServer { /// Map from URI to document content @@ -353,7 +353,7 @@ impl TechniqueLanguageServer { } }; - let result = rendering::render(&Identity, &document, 78); + let result = highlighting::render(&Identity, &document, 78); // convert to LSP type for return to editor. let edit = TextEdit { diff --git a/src/rendering/mod.rs b/src/highlighting/mod.rs similarity index 100% rename from src/rendering/mod.rs rename to src/highlighting/mod.rs diff --git a/src/rendering/renderer.rs b/src/highlighting/renderer.rs similarity index 100% rename from src/rendering/renderer.rs rename to src/highlighting/renderer.rs diff --git a/src/rendering/terminal.rs b/src/highlighting/terminal.rs similarity index 100% rename from src/rendering/terminal.rs rename to src/highlighting/terminal.rs diff --git a/src/rendering/typst.rs b/src/highlighting/typst.rs similarity index 100% rename from src/rendering/typst.rs rename to src/highlighting/typst.rs diff --git a/src/lib.rs b/src/lib.rs index f9686abc..84777d26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ pub mod formatting; +pub mod highlighting; pub mod language; pub mod parsing; pub mod regex; -pub mod rendering; pub mod templating; diff --git a/src/main.rs b/src/main.rs index a1918a8e..224169ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,9 +7,9 @@ use tracing::debug; use tracing_subscriber::{self, EnvFilter}; use technique::formatting::{self, Identity}; +use technique::highlighting::{self, Terminal}; use technique::parsing; -use technique::rendering::{self, Terminal}; -use technique::templating::{self, Checklist, Source}; +use technique::templating::{self, Checklist, Procedure, Source}; mod editor; mod output; @@ -258,9 +258,9 @@ fn main() { let result; if raw_output || std::io::stdout().is_terminal() { - result = rendering::render(&Terminal, &technique, wrap_width); + result = highlighting::render(&Terminal, &technique, wrap_width); } else { - result = rendering::render(&Identity, &technique, wrap_width); + result = highlighting::render(&Identity, &technique, wrap_width); } print!("{}", result); diff --git a/src/templating/source.rs b/src/templating/source.rs index e4d9780d..2760c604 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -3,8 +3,8 @@ //! normal renderers by instead delegating to the existing code formatting //! pipeline underlying the `format` command. -use crate::language::Document; use crate::highlighting::{render, Typst}; +use crate::language::Document; use super::Template; diff --git a/tests/formatting/golden.rs b/tests/formatting/golden.rs index 9b6d1170..11a0370c 100644 --- a/tests/formatting/golden.rs +++ b/tests/formatting/golden.rs @@ -2,8 +2,8 @@ use std::fs; use std::path::Path; use technique::formatting::Identity; +use technique::highlighting::render; use technique::parsing; -use technique::rendering::render; /// Golden test for the format command /// From 79bfe8ebae52c565ef184af9ebe1e721de9376b0 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 5 Mar 2026 22:52:09 +1100 Subject: [PATCH 12/25] Implenent engine as templating infrastructure --- src/templating/engine.rs | 29 ++++++++++++++++++++--------- src/templating/mod.rs | 19 ++++++++++++++++++- src/templating/typst.rs | 6 +++--- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/templating/engine.rs b/src/templating/engine.rs index 1e8251b0..44c865a8 100644 --- a/src/templating/engine.rs +++ b/src/templating/engine.rs @@ -1,9 +1,16 @@ -//! Templating engine: AST accessor methods. -//! -//! This module provides accessor methods on AST types for convenient -//! iteration when extracting content for templates. This allows us to hide -//! parser artifacts like `Scope` and iterate over the constructs we recognize -//! as sections, steps, and tasks. +//! Engine: accessor helpers over the parser's AST types. +//! +//! The Technique language parser deals with considerable complexity and +//! ambiguity in the surface language, and as a result the parser's AST is +//! somewhat tailored to the form of that surface language. This is fine for +//! compiling and code formatting, but contains too much internal detail for +//! someone writing an output renderer to deal with. +//! +//! This module thus provides convenient iteration methods on AST types so +//! that adapters can extract content without having to match on parser +//! internals directly. The types returned are still the parser's own types +//! (Scope, Paragraph, Response, etc.) — the "adapters" are responsible for +//! projecting these into domain-specific models. use crate::language::{ Attribute, Descriptive, Document, Element, Paragraph, Procedure, Response, Scope, Technique, @@ -195,9 +202,13 @@ impl<'i> Response<'i> { impl<'i> Paragraph<'i> { /// Returns the text content of this paragraph as a single String. - /// When text is present, invocations are treated as cross-references - /// and omitted. When the only content is invocations (and bindings), - /// the invocation target names are included as fallback text. + /// Only extracts `Descriptive::Text` nodes and recurses into bindings. + /// + /// When a paragraph has no text (i.e. its content is only invocations + /// or code inlines), this falls back to extracting invocation target + /// names. This is a workaround — the adapters should resolve + /// invocations to procedure titles instead. See `elements()` for + /// access to the full paragraph content. pub fn text(&self) -> String { let has_text = self .0 diff --git a/src/templating/mod.rs b/src/templating/mod.rs index 1b2f5c40..9d595748 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -1,4 +1,21 @@ -//! Templates for rendering Technique documents into formatted output +//! Render Technique documents into formatted output. +//! +//! The rendering pipeline has four layers: +//! +//! - **Engine** contains accessors and helpers for working over the abstract syntax +//! tree types that emerge from the parser, providing convenient iteration +//! without exposing parser internals. +//! +//! - The **Adapter** trait that projects the AST types into a domain-specific +//! model (e.g. checklist flattens to checkable items, procedure preserves the +//! full hierarchy). +//! +//! - The **Renderer** trait formats domain model types into Typst markup, +//! using the shared `typst` primitives. Finally, +//! +//! - A **Template** trait which acts as a top-level interface that provides +//! `render()` as an entry point. Each domain template composes an adapter and +//! renderer internally. mod checklist; mod engine; diff --git a/src/templating/typst.rs b/src/templating/typst.rs index 2d5da098..131f6e8b 100644 --- a/src/templating/typst.rs +++ b/src/templating/typst.rs @@ -4,9 +4,9 @@ //! This provides building blocks (headings, steps, roles, responses, etc) //! that renderers can compose into complete output documents. //! -//! Note that this is distinct from `rendering::typst` which renders Technique -//! in its original surface language syntax form; this module operates over -//! constructs made in any particular domain. +//! Note that this is distinct from `highlighting::typst` which renders +//! Technique in its original surface language syntax form; this module +//! operates over constructs made in any particular domain. /// Escape special Typst characters in text content. pub fn escape(text: &str) -> String { From 1fae572c8badf3d3bdb95dd3058e3c9d4bb7de86 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 5 Mar 2026 23:04:19 +1100 Subject: [PATCH 13/25] Add checklist renderer --- src/templating/checklist.rs | 320 --------------------------- src/templating/checklist/adapter.rs | 180 +++++++++++++++ src/templating/checklist/mod.rs | 28 +++ src/templating/checklist/renderer.rs | 80 +++++++ src/templating/checklist/types.rs | 42 ++++ src/templating/typst.rs | 19 +- 6 files changed, 338 insertions(+), 331 deletions(-) delete mode 100644 src/templating/checklist.rs create mode 100644 src/templating/checklist/adapter.rs create mode 100644 src/templating/checklist/mod.rs create mode 100644 src/templating/checklist/renderer.rs create mode 100644 src/templating/checklist/types.rs diff --git a/src/templating/checklist.rs b/src/templating/checklist.rs deleted file mode 100644 index 0988e3a2..00000000 --- a/src/templating/checklist.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! Checklist template - renders procedures as printable checklists -//! -//! A checklist is moderately structured and relatively flat: sections with -//! headings, steps with checkboxes, response options, and limited nesting. - -use crate::language; - -use super::template::Template; - -// ============================================================================ -// Checklist domain types -// ============================================================================ - -/// A checklist document: sections containing steps. -struct Document { - sections: Vec
, -} - -impl Document { - fn new() -> Self { - Document { - sections: Vec::new(), - } - } -} - -/// A section within a checklist. -struct Section { - #[allow(dead_code)] - ordinal: Option, - heading: Option, - steps: Vec, -} - -/// A step within a checklist section. -struct Step { - #[allow(dead_code)] - name: Option, - ordinal: Option, - title: Option, - body: Vec, - role: Option, - responses: Vec, - children: Vec, -} - -// ============================================================================ -// Template implementation -// ============================================================================ - -pub struct Checklist; - -impl Template for Checklist { - fn render(&self, document: &language::Document, _width: u8) -> String { - let extracted = extract(document); - render(&extracted) - } -} - -/// Transform the parsed AST Document into template Document -fn extract(document: &language::Document) -> Document { - let mut extracted = Document::new(); - - // Handle procedures - for procedure in document.procedures() { - extract_procedure(&mut extracted, procedure); - } - - // Handle top-level steps (if no procedures) - if extracted - .sections - .is_empty() - { - let steps: Vec = document - .steps() - .filter(|s| s.is_step()) - .map(|s| step_from_scope(s, None)) - .collect(); - - if !steps.is_empty() { - extracted - .sections - .push(Section { - ordinal: None, - heading: None, - steps, - }); - } - } - - extracted -} - -fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { - // Extract steps into a section - let steps: Vec = procedure - .steps() - .flat_map(|s| steps_from_scope(s, None)) - .collect(); - - if !steps.is_empty() { - content - .sections - .push(Section { - ordinal: None, - heading: procedure - .title() - .map(String::from), - steps, - }); - } -} - -/// Extract steps from a scope, handling different scope types. -fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Vec { - if scope.is_step() { - return vec![step_from_scope(scope, inherited_role)]; - } - - // Handle AttributeBlock - extract role and process children - let roles: Vec<_> = scope - .roles() - .collect(); - if !roles.is_empty() { - let role = roles - .first() - .copied(); - return scope - .children() - .flat_map(|s| steps_from_scope(s, role)) - .collect(); - } - - // Handle SectionChunk - if let Some((numeral, title)) = scope.section_info() { - let heading = match title { - Some(para) => format!("{}. {}", numeral, para.text()), - None => format!("{}.", numeral), - }; - - let mut steps = vec![Step { - name: None, - ordinal: None, - title: Some(heading), - body: Vec::new(), - role: None, - responses: Vec::new(), - children: Vec::new(), - }]; - - // Handle nested procedures in section body - if let language::Scope::SectionChunk { body, .. } = scope { - if let language::Technique::Procedures(procedures) = body { - for procedure in procedures { - if let Some(title) = procedure.title() { - let children: Vec = procedure - .steps() - .flat_map(|s| steps_from_scope(s, None)) - .collect(); - - steps.push(Step { - name: Some( - procedure - .name - .0 - .to_string(), - ), - ordinal: None, - title: Some(title.to_string()), - body: Vec::new(), - role: None, - responses: Vec::new(), - children, - }); - } - } - } - } - - return steps; - } - - Vec::new() -} - -/// Convert a step-like scope into a Step. -fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Step { - let mut responses = Vec::new(); - let mut children = Vec::new(); - - for subscope in scope.children() { - // Collect responses - for response in subscope.responses() { - responses.push( - response - .value - .to_string(), - ); - } - - // Collect children (substeps) - children.extend(steps_from_scope(subscope, inherited_role)); - } - - // First paragraph becomes title, rest becomes body - let paragraphs: Vec = scope - .description() - .map(|p| p.text()) - .collect(); - let (title, body) = match paragraphs.split_first() { - Some((first, rest)) => (Some(first.clone()), rest.to_vec()), - None => (None, Vec::new()), - }; - - Step { - name: None, - ordinal: scope - .ordinal() - .map(String::from), - title, - body, - role: inherited_role.map(String::from), - responses, - children, - } -} - -fn render(document: &Document) -> String { - let mut output = String::new(); - - // Typst preamble - output.push_str("#set page(margin: 1.5cm)\n"); - output.push_str("#set text(size: 10pt)\n\n"); - - // Sections - for section in &document.sections { - render_section(&mut output, section); - } - - output -} - -fn render_section(output: &mut String, section: &Section) { - if let Some(heading) = §ion.heading { - output.push_str(&format!("== {}\n\n", escape(heading))); - } - - for step in §ion.steps { - render_step(output, step, 0); - } - - output.push('\n'); -} - -fn render_step(output: &mut String, step: &Step, depth: usize) { - let indent = " ".repeat(depth); - - // Role header if present - if let Some(role) = &step.role { - output.push_str(&format!("{}#text(weight: \"bold\")[{}]\n\n", indent, role)); - } - - // Checkbox with ordinal and title - output.push_str(&format!( - "{}#box(stroke: 0.5pt, width: 0.8em, height: 0.8em) ", - indent - )); - - if let Some(ordinal) = &step.ordinal { - output.push_str(&format!("*{}.* ", ordinal)); - } - - if let Some(title) = &step.title { - output.push_str(&escape(title)); - } - output.push_str("\n\n"); - - // Body paragraphs - for para in &step.body { - output.push_str(&format!("{} {}\n\n", indent, escape(para))); - } - - // Response options - if !step - .responses - .is_empty() - { - output.push_str(&format!("{} ", indent)); - for (i, response) in step - .responses - .iter() - .enumerate() - { - if i > 0 { - output.push_str(" | "); - } - output.push_str(&format!( - "#box(stroke: 0.5pt, width: 0.6em, height: 0.6em) _{}_", - response - )); - } - output.push_str("\n\n"); - } - - // Children - for child in &step.children { - render_step(output, child, depth + 1); - } -} - -fn escape(text: &str) -> String { - text.replace('\\', "\\\\") - .replace('#', "\\#") - .replace('$', "\\$") - .replace('*', "\\*") - .replace('_', "\\_") - .replace('@', "\\@") - .replace('<', "\\<") - .replace('>', "\\>") -} diff --git a/src/templating/checklist/adapter.rs b/src/templating/checklist/adapter.rs new file mode 100644 index 00000000..643f8fab --- /dev/null +++ b/src/templating/checklist/adapter.rs @@ -0,0 +1,180 @@ +//! Projects the AST into the checklist domain model. +//! +//! This flattens the parser type hierarchy. Each procedure becomes a section, +//! role assignments are inherited by sub steps, and SectionChunks are +//! rendered as headings with their sub-procedures' steps as children. + +use crate::language; +use crate::templating::template::Adapter; + +use super::types::{Document, Response, Section, Step}; + +pub struct ChecklistAdapter; + +impl Adapter for ChecklistAdapter { + type Model = Document; + + fn extract(&self, document: &language::Document) -> Document { + extract(document) + } +} + +/// Transform the parsed AST into a checklist Document. +fn extract(document: &language::Document) -> Document { + let mut extracted = Document::new(); + + for procedure in document.procedures() { + extract_procedure(&mut extracted, procedure); + } + + // Handle top-level steps (if no procedures) + if extracted + .sections + .is_empty() + { + let steps: Vec = document + .steps() + .filter(|s| s.is_step()) + .map(|s| step_from_scope(s, None)) + .collect(); + + if !steps.is_empty() { + extracted + .sections + .push(Section { + ordinal: None, + heading: None, + steps, + }); + } + } + + extracted +} + +fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { + let steps: Vec = procedure + .steps() + .flat_map(|s| steps_from_scope(s, None)) + .collect(); + + if !steps.is_empty() { + content + .sections + .push(Section { + ordinal: None, + heading: procedure + .title() + .map(String::from), + steps, + }); + } +} + +/// Extract steps from a scope, handling different scope types. +fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Vec { + if scope.is_step() { + return vec![step_from_scope(scope, inherited_role)]; + } + + // Handle AttributeBlock — extract role and process children + let roles: Vec<_> = scope + .roles() + .collect(); + if !roles.is_empty() { + let role = roles + .first() + .copied(); + return scope + .children() + .flat_map(|s| steps_from_scope(s, role)) + .collect(); + } + + // Handle SectionChunk + if let Some((numeral, title)) = scope.section_info() { + let heading = title.map(|para| para.text()); + + let mut steps = vec![Step { + name: None, + ordinal: Some(numeral.to_string()), + title: heading, + body: Vec::new(), + role: None, + responses: Vec::new(), + children: Vec::new(), + }]; + + // Handle nested procedures in section body + if let Some(body) = scope.body() { + for procedure in body.procedures() { + if let Some(title) = procedure.title() { + let children: Vec = procedure + .steps() + .flat_map(|s| steps_from_scope(s, None)) + .collect(); + + steps.push(Step { + name: Some( + procedure + .name() + .to_string(), + ), + ordinal: None, + title: Some(title.to_string()), + body: Vec::new(), + role: None, + responses: Vec::new(), + children, + }); + } + } + } + + return steps; + } + + Vec::new() +} + +/// Convert a step-like scope into a Step. +fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Step { + let mut responses = Vec::new(); + let mut children = Vec::new(); + + for subscope in scope.children() { + for response in subscope.responses() { + responses.push(Response { + value: response + .value() + .to_string(), + condition: response + .condition() + .map(String::from), + }); + } + children.extend(steps_from_scope(subscope, inherited_role)); + } + + // First paragraph becomes title, rest becomes body + let paragraphs: Vec = scope + .description() + .map(|p| p.text()) + .collect(); + let (title, body) = match paragraphs.split_first() { + Some((first, rest)) => (Some(first.clone()), rest.to_vec()), + None => (None, Vec::new()), + }; + + Step { + name: None, + ordinal: scope + .ordinal() + .map(String::from), + title, + body, + role: inherited_role.map(String::from), + responses, + children, + } +} diff --git a/src/templating/checklist/mod.rs b/src/templating/checklist/mod.rs new file mode 100644 index 00000000..7c628482 --- /dev/null +++ b/src/templating/checklist/mod.rs @@ -0,0 +1,28 @@ +//! Checklist template — flattens procedures into printable checklists. +//! +//! The checklist domain model is relatively flat: sections with headings, +//! steps with checkboxes, response options, and limited nesting. Role +//! assignments are inherited downward (an `@surgeon` scope annotates its +//! child steps) rather than forming structural containers. + +mod adapter; +mod renderer; +pub mod types; + +use crate::language; +use crate::templating::template::Template; + +use crate::templating::template::Adapter; +use crate::templating::template::Renderer; +use adapter::ChecklistAdapter; +use renderer::ChecklistRenderer; + +/// Checklist template: adapter + renderer composition. +pub struct Checklist; + +impl Template for Checklist { + fn render(&self, document: &language::Document) -> String { + let model = ChecklistAdapter.extract(document); + ChecklistRenderer.render(&model) + } +} diff --git a/src/templating/checklist/renderer.rs b/src/templating/checklist/renderer.rs new file mode 100644 index 00000000..54d26594 --- /dev/null +++ b/src/templating/checklist/renderer.rs @@ -0,0 +1,80 @@ +//! Format checklist domain types into Typst. + +use crate::templating::template::Renderer; +use crate::templating::typst; + +use super::types::{Document, Section, Step}; + +pub struct ChecklistRenderer; + +impl Renderer for ChecklistRenderer { + type Model = Document; + + fn render(&self, model: &Document) -> String { + render(model) + } +} + +fn render(document: &Document) -> String { + let mut output = typst::preamble(); + + for section in &document.sections { + render_section(&mut output, section); + } + + output +} + +fn render_section(output: &mut String, section: &Section) { + match (§ion.ordinal, §ion.heading) { + (Some(ord), Some(heading)) => { + output.push_str(&typst::heading(2, &format!("{}. {}", ord, heading))); + } + (Some(ord), None) => { + output.push_str(&typst::heading(2, &format!("{}.", ord))); + } + (None, Some(heading)) => { + output.push_str(&typst::heading(2, heading)); + } + (None, None) => {} + } + + for step in §ion.steps { + render_step(output, step); + } + + output.push('\n'); +} + +fn render_step(output: &mut String, step: &Step) { + if let Some(r) = &step.role { + output.push_str(&typst::role(r)); + } + + output.push_str(&typst::step( + step.ordinal + .as_deref(), + step.title + .as_deref(), + )); + + for para in &step.body { + output.push_str(&typst::description(para)); + } + + let display: Vec = step + .responses + .iter() + .map(|r| match &r.condition { + Some(cond) => format!("{} {}", r.value, cond), + None => r + .value + .clone(), + }) + .collect(); + output.push_str(&typst::responses(&display)); + + for child in &step.children { + render_step(output, child); + } +} diff --git a/src/templating/checklist/types.rs b/src/templating/checklist/types.rs new file mode 100644 index 00000000..abe72210 --- /dev/null +++ b/src/templating/checklist/types.rs @@ -0,0 +1,42 @@ +//! Domain types for checklists +//! +//! A checklist is moderately structured and relatively flat: sections with +//! headings, steps with checkboxes, response options, and limited nesting. + +/// A checklist is a document of sections containing steps. +pub struct Document { + pub sections: Vec
, +} + +impl Document { + pub fn new() -> Self { + Document { + sections: Vec::new(), + } + } +} + +/// A section within a checklist. +pub struct Section { + pub ordinal: Option, + pub heading: Option, + pub steps: Vec, +} + +/// A step within a checklist section. +pub struct Step { + #[allow(dead_code)] + pub name: Option, + pub ordinal: Option, + pub title: Option, + pub body: Vec, + pub role: Option, + pub responses: Vec, + pub children: Vec, +} + +/// A response option with an optional condition. +pub struct Response { + pub value: String, + pub condition: Option, +} diff --git a/src/templating/typst.rs b/src/templating/typst.rs index 131f6e8b..95a612a0 100644 --- a/src/templating/typst.rs +++ b/src/templating/typst.rs @@ -32,16 +32,13 @@ pub fn heading(level: u8, text: &str) -> String { } /// Descriptive text paragraph. -pub fn description(indent: &str, text: &str) -> String { - format!("{}{}\n\n", indent, escape(text)) +pub fn description(text: &str) -> String { + format!("{}\n\n", escape(text)) } /// Step with checkbox, optional ordinal, and text. -pub fn step(indent: &str, ordinal: Option<&str>, text: Option<&str>) -> String { - let mut out = format!( - "{}#box(stroke: 0.5pt, width: 0.8em, height: 0.8em) ", - indent - ); +pub fn step(ordinal: Option<&str>, text: Option<&str>) -> String { + let mut out = "#box(stroke: 0.5pt, width: 0.8em, height: 0.8em) ".to_string(); if let Some(ord) = ordinal { out.push_str(&format!("*{}.* ", ord)); } @@ -53,16 +50,16 @@ pub fn step(indent: &str, ordinal: Option<&str>, text: Option<&str>) -> String { } /// Role attribution header. -pub fn role(indent: &str, name: &str) -> String { - format!("{}#text(weight: \"bold\")[{}]\n\n", indent, name) +pub fn role(name: &str) -> String { + format!("#text(weight: \"bold\")[{}]\n\n", name) } /// Response options with small checkboxes. -pub fn responses(indent: &str, options: &[String]) -> String { +pub fn responses(options: &[String]) -> String { if options.is_empty() { return String::new(); } - let mut out = format!("{}", indent); + let mut out = String::new(); for (i, option) in options .iter() .enumerate() From 6ef6e9810c3e9b7f07fefdc4c94ec03c8be6ee87 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 5 Mar 2026 23:08:36 +1100 Subject: [PATCH 14/25] Add procedure renderer --- src/templating/procedure/adapter.rs | 223 +++++++++++++++++++++++++++ src/templating/procedure/mod.rs | 28 ++++ src/templating/procedure/renderer.rs | 104 +++++++++++++ src/templating/procedure/types.rs | 68 ++++++++ 4 files changed, 423 insertions(+) create mode 100644 src/templating/procedure/adapter.rs create mode 100644 src/templating/procedure/mod.rs create mode 100644 src/templating/procedure/renderer.rs create mode 100644 src/templating/procedure/types.rs diff --git a/src/templating/procedure/adapter.rs b/src/templating/procedure/adapter.rs new file mode 100644 index 00000000..4985c8fb --- /dev/null +++ b/src/templating/procedure/adapter.rs @@ -0,0 +1,223 @@ +//! Projects the parser's AST into a domain model suitable for procedures. +//! +//! This model preserves hierarchy. The first procedure may be a container +//! whose steps are SectionChunks (each becoming a Section here). Remaining +//! procedures become additional Sections. AttributeBlocks become RoleGroups +//! (structural containers, not step annotations). ResponseBlocks attach to +//! their parent step. + +use crate::language; +use crate::templating::template::Adapter; + +use super::types::{Document, Item, Response, RoleGroup, Section, Step, StepKind}; + +pub struct ProcedureAdapter; + +impl Adapter for ProcedureAdapter { + type Model = Document; + + fn extract(&self, document: &language::Document) -> Document { + extract(document) + } +} + +fn extract(document: &language::Document) -> Document { + let mut doc = Document::new(); + + // Try procedures first. The first procedure may be a container whose + // steps are SectionChunks; remaining procedures are leaf procedures + // referenced from within sections. + let mut procedures = document.procedures(); + + if let Some(first) = procedures.next() { + doc.title = first + .title() + .map(String::from); + doc.description = first + .description() + .map(|p| p.text()) + .collect(); + + let has_sections = first + .steps() + .any(|s| { + s.section_info() + .is_some() + }); + + if has_sections { + // First procedure is a container with sections + for scope in first.steps() { + if let Some(section) = section_from_scope(scope) { + doc.sections + .push(section); + } + } + } else { + // First procedure has direct steps + doc.sections + .push(section_from_procedure(first)); + } + + // Remaining procedures become additional sections + for procedure in procedures { + doc.sections + .push(section_from_procedure(procedure)); + } + } + + // Handle bare top-level steps (no procedures) + if doc + .sections + .is_empty() + { + let items: Vec = document + .steps() + .flat_map(|s| items_from_scope(s)) + .collect(); + + if !items.is_empty() { + doc.sections + .push(Section { + ordinal: None, + heading: None, + description: Vec::new(), + items, + }); + } + } + + doc +} + +fn section_from_scope(scope: &language::Scope) -> Option
{ + let (numeral, title) = scope.section_info()?; + let heading = title.map(|para| para.text()); + + let mut items = Vec::new(); + + if let Some(body) = scope.body() { + for procedure in body.procedures() { + let proc_items = items_from_procedure(procedure); + items.extend(proc_items); + } + for step in body.steps() { + items.extend(items_from_scope(step)); + } + } + + Some(Section { + ordinal: Some(numeral.to_string()), + heading, + description: Vec::new(), + items, + }) +} + +fn section_from_procedure(procedure: &language::Procedure) -> Section { + let items = items_from_procedure(procedure); + let description: Vec = procedure + .description() + .map(|p| p.text()) + .collect(); + + Section { + ordinal: None, + heading: procedure + .title() + .map(String::from), + description, + items, + } +} + +fn items_from_procedure(procedure: &language::Procedure) -> Vec { + procedure + .steps() + .flat_map(|s| items_from_scope(s)) + .collect() +} + +/// Extract items from a scope, handling different scope types. +fn items_from_scope(scope: &language::Scope) -> Vec { + if scope.is_step() { + return vec![Item::Step(step_from_scope(scope))]; + } + + // Handle AttributeBlock — extract role and process children as a RoleGroup + let roles: Vec<_> = scope + .roles() + .collect(); + if !roles.is_empty() { + let name = roles.join(" + "); + let items: Vec = scope + .children() + .flat_map(|s| items_from_scope(s)) + .collect(); + return vec![Item::RoleGroup(RoleGroup { name, items })]; + } + + // Handle SectionChunk nested within procedures + if scope + .section_info() + .is_some() + { + // Sections within procedures become sub-items + if let Some(body) = scope.body() { + return body + .steps() + .flat_map(|s| items_from_scope(s)) + .collect(); + } + } + + Vec::new() +} + +/// Convert a step-like scope into a Step. +fn step_from_scope(scope: &language::Scope) -> Step { + let kind = match scope { + language::Scope::DependentBlock { .. } => StepKind::Dependent, + _ => StepKind::Parallel, + }; + + let mut responses = Vec::new(); + let mut children = Vec::new(); + + for subscope in scope.children() { + // Collect responses + for response in subscope.responses() { + responses.push(Response { + value: response + .value() + .to_string(), + condition: response + .condition() + .map(String::from), + }); + } + + // Collect child items (steps, role groups, etc.) + children.extend(items_from_scope(subscope)); + } + + let paragraphs: Vec = scope + .description() + .map(|p| p.text()) + .collect(); + let (title, body) = match paragraphs.split_first() { + Some((first, rest)) => (Some(first.clone()), rest.to_vec()), + None => (None, Vec::new()), + }; + + Step { + kind, + ordinal: scope + .ordinal() + .map(String::from), + title, + body, + responses, + children, + } +} diff --git a/src/templating/procedure/mod.rs b/src/templating/procedure/mod.rs new file mode 100644 index 00000000..77310973 --- /dev/null +++ b/src/templating/procedure/mod.rs @@ -0,0 +1,28 @@ +//! Renders procedures preserving the full hierarchy described by the source +//! Technique document. +//! +//! Unlike the checklist template (which flattens structure), the procedure +//! domain model preserves hierarchy. Sections with ordinals, role groups as +//! distinct items rather than step annotations, and nested children. + +mod adapter; +mod renderer; +pub mod types; + +use crate::language; +use crate::templating::template::Template; + +use crate::templating::template::Adapter; +use crate::templating::template::Renderer; +use adapter::ProcedureAdapter; +use renderer::ProcedureRenderer; + +/// Procedure template: adapter + renderer composition. +pub struct Procedure; + +impl Template for Procedure { + fn render(&self, document: &language::Document) -> String { + let model = ProcedureAdapter.extract(document); + ProcedureRenderer.render(&model) + } +} diff --git a/src/templating/procedure/renderer.rs b/src/templating/procedure/renderer.rs new file mode 100644 index 00000000..003ba61b --- /dev/null +++ b/src/templating/procedure/renderer.rs @@ -0,0 +1,104 @@ +//! Formats procedure domain types into Typst. + +use crate::templating::template::Renderer; +use crate::templating::typst; + +use super::types::{Document, Item, Section, Step, StepKind}; + +pub struct ProcedureRenderer; + +impl Renderer for ProcedureRenderer { + type Model = Document; + + fn render(&self, model: &Document) -> String { + render(model) + } +} + +fn render(document: &Document) -> String { + let mut output = typst::preamble(); + + if let Some(title) = &document.title { + output.push_str(&typst::heading(1, title)); + } + + for para in &document.description { + output.push_str(&typst::description(para)); + } + + for section in &document.sections { + render_section(&mut output, section); + } + + output +} + +fn render_section(output: &mut String, section: &Section) { + match (§ion.ordinal, §ion.heading) { + (Some(ord), Some(heading)) => { + output.push_str(&typst::heading(2, &format!("{}. {}", ord, heading))); + } + (Some(ord), None) => { + output.push_str(&typst::heading(2, &format!("{}.", ord))); + } + (None, Some(heading)) => { + output.push_str(&typst::heading(2, heading)); + } + (None, None) => {} + } + + for para in §ion.description { + output.push_str(&typst::description(para)); + } + + for item in §ion.items { + render_item(output, item); + } +} + +fn render_item(output: &mut String, item: &Item) { + match item { + Item::Step(step) => render_step(output, step), + Item::RoleGroup(group) => { + output.push_str(&typst::role(&group.name)); + for child in &group.items { + render_item(output, child); + } + } + } +} + +fn render_step(output: &mut String, step: &Step) { + let ordinal = match step.kind { + StepKind::Dependent => step + .ordinal + .as_deref(), + StepKind::Parallel => None, + }; + + output.push_str(&typst::step( + ordinal, + step.title + .as_deref(), + )); + + for para in &step.body { + output.push_str(&typst::description(para)); + } + + let display: Vec = step + .responses + .iter() + .map(|r| match &r.condition { + Some(cond) => format!("{} {}", r.value, cond), + None => r + .value + .clone(), + }) + .collect(); + output.push_str(&typst::responses(&display)); + + for child in &step.children { + render_item(output, child); + } +} diff --git a/src/templating/procedure/types.rs b/src/templating/procedure/types.rs new file mode 100644 index 00000000..1ae8270f --- /dev/null +++ b/src/templating/procedure/types.rs @@ -0,0 +1,68 @@ +//! Domain types for a procedure. +//! +//! A procedure preserves the full hierarchy of the source Technique document: +//! sections containing procedures, procedures containing steps, with role +//! groups and responses, substeps, etc. This is basically a full fidelity +//! renderer of the input Technique structure. + +/// A procedure document is sections containing items. +pub struct Document { + pub title: Option, + pub description: Vec, + pub sections: Vec
, +} + +impl Document { + pub fn new() -> Self { + Document { + title: None, + description: Vec::new(), + sections: Vec::new(), + } + } +} + +/// A section within a procedure document. +pub struct Section { + pub ordinal: Option, + pub heading: Option, + pub description: Vec, + pub items: Vec, +} + +/// An item within a section: either a step or a role group. This +/// distinction matters because `@beaker` with lettered tasks is a +/// structural container, not a step annotation — the role group +/// owns its children rather than decorating them. +pub enum Item { + Step(Step), + RoleGroup(RoleGroup), +} + +/// A step within a procedure. +pub struct Step { + pub kind: StepKind, + pub ordinal: Option, + pub title: Option, + pub body: Vec, + pub responses: Vec, + pub children: Vec, +} + +/// Whether a step is dependent (numbered) or parallel (bulleted). +pub enum StepKind { + Dependent, + Parallel, +} + +/// A role group: a named container for items assigned to a role. +pub struct RoleGroup { + pub name: String, + pub items: Vec, +} + +/// A response option with an optional condition. +pub struct Response { + pub value: String, + pub condition: Option, +} From 9b8bf4bf1bdc1ed4bda01ca2695914829d31b808 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 5 Mar 2026 23:08:46 +1100 Subject: [PATCH 15/25] Wire template types into main --- src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 224169ba..362577f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -317,8 +317,9 @@ fn main() { // Select template and render let result = match template_name.as_str() { - "source" => templating::fill(&Source, &technique, 70), - "checklist" => templating::fill(&Checklist, &technique, 70), + "source" => templating::render(&Source::new(70), &technique), + "checklist" => templating::render(&Checklist, &technique), + "procedure" => templating::render(&Procedure, &technique), _ => panic!("Unrecognized template: {}", template_name), }; From dd321147117f255cce4cdf6a97cd7bb71c435213 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 6 Mar 2026 13:17:45 +1100 Subject: [PATCH 16/25] Refine extraction of step content when invocations present --- src/templating/checklist/adapter.rs | 12 +--- src/templating/engine.rs | 100 ++++++++++++++-------------- src/templating/procedure/adapter.rs | 6 +- 3 files changed, 55 insertions(+), 63 deletions(-) diff --git a/src/templating/checklist/adapter.rs b/src/templating/checklist/adapter.rs index 643f8fab..4c62ebf7 100644 --- a/src/templating/checklist/adapter.rs +++ b/src/templating/checklist/adapter.rs @@ -19,7 +19,6 @@ impl Adapter for ChecklistAdapter { } } -/// Transform the parsed AST into a checklist Document. fn extract(document: &language::Document) -> Document { let mut extracted = Document::new(); @@ -27,7 +26,6 @@ fn extract(document: &language::Document) -> Document { extract_procedure(&mut extracted, procedure); } - // Handle top-level steps (if no procedures) if extracted .sections .is_empty() @@ -71,13 +69,12 @@ fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { } } -/// Extract steps from a scope, handling different scope types. fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Vec { if scope.is_step() { return vec![step_from_scope(scope, inherited_role)]; } - // Handle AttributeBlock — extract role and process children + // AttributeBlock — extract role and process children let roles: Vec<_> = scope .roles() .collect(); @@ -91,7 +88,7 @@ fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ve .collect(); } - // Handle SectionChunk + // SectionChunk if let Some((numeral, title)) = scope.section_info() { let heading = title.map(|para| para.text()); @@ -105,7 +102,6 @@ fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ve children: Vec::new(), }]; - // Handle nested procedures in section body if let Some(body) = scope.body() { for procedure in body.procedures() { if let Some(title) = procedure.title() { @@ -137,7 +133,6 @@ fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ve Vec::new() } -/// Convert a step-like scope into a Step. fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Step { let mut responses = Vec::new(); let mut children = Vec::new(); @@ -156,10 +151,9 @@ fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ste children.extend(steps_from_scope(subscope, inherited_role)); } - // First paragraph becomes title, rest becomes body let paragraphs: Vec = scope .description() - .map(|p| p.text()) + .map(|p| p.content()) .collect(); let (title, body) = match paragraphs.split_first() { Some((first, rest)) => (Some(first.clone()), rest.to_vec()), diff --git a/src/templating/engine.rs b/src/templating/engine.rs index 44c865a8..60be5078 100644 --- a/src/templating/engine.rs +++ b/src/templating/engine.rs @@ -201,40 +201,38 @@ impl<'i> Response<'i> { } impl<'i> Paragraph<'i> { - /// Returns the text content of this paragraph as a single String. - /// Only extracts `Descriptive::Text` nodes and recurses into bindings. - /// - /// When a paragraph has no text (i.e. its content is only invocations - /// or code inlines), this falls back to extracting invocation target - /// names. This is a workaround — the adapters should resolve - /// invocations to procedure titles instead. See `elements()` for - /// access to the full paragraph content. + /// Returns only the text content of this paragraph. pub fn text(&self) -> String { - let has_text = self - .0 - .iter() - .any(|d| Self::has_text(d)); - let mut result = String::new(); - for descriptive in &self.0 { - Self::append_descriptive(&mut result, descriptive, !has_text); + for d in &self.0 { + Self::append_text(&mut result, d); } result } - fn has_text(descriptive: &Descriptive<'i>) -> bool { - match descriptive { - Descriptive::Text(_) => true, - Descriptive::Binding(inner, _) => Self::has_text(inner), - _ => false, + /// Returns invocation target names from this paragraph. + pub fn invocations(&self) -> Vec<&'i str> { + let mut targets = Vec::new(); + for d in &self.0 { + Self::extract_invocations(&mut targets, d); } + targets } - fn append_descriptive( - result: &mut String, - descriptive: &Descriptive<'i>, - include_invocations: bool, - ) { + /// Returns displayable content: text if present, otherwise the + /// first invocation target name. + pub fn content(&self) -> String { + let text = self.text(); + if !text.is_empty() { + return text; + } + self.invocations() + .first() + .unwrap_or(&"") + .to_string() + } + + fn append_text(result: &mut String, descriptive: &Descriptive<'i>) { match descriptive { Descriptive::Text(text) => { if !result.is_empty() && !result.ends_with(' ') { @@ -242,51 +240,51 @@ impl<'i> Paragraph<'i> { } result.push_str(text); } - Descriptive::Application(invocation) if include_invocations => { - Self::append_invocation_name(result, invocation); + Descriptive::Binding(inner, _) => Self::append_text(result, inner), + _ => {} + } + } + + fn extract_invocations(targets: &mut Vec<&'i str>, descriptive: &Descriptive<'i>) { + match descriptive { + Descriptive::Application(inv) => { + targets.push(Self::invocation_name(inv)); } - Descriptive::CodeInline(expr) if include_invocations => { - Self::append_expression_name(result, expr); + Descriptive::CodeInline(expr) => { + Self::extract_expression_invocations(targets, expr); } Descriptive::Binding(inner, _) => { - Self::append_descriptive(result, inner, include_invocations); + Self::extract_invocations(targets, inner); } _ => {} } } - fn append_invocation_name(result: &mut String, invocation: &crate::language::Invocation<'i>) { - if !result.is_empty() && !result.ends_with(' ') { - result.push(' '); - } - let name = match &invocation.target { - crate::language::Target::Local(id) => id.0, - crate::language::Target::Remote(ext) => ext.0, - }; - result.push_str(name); - } - - fn append_expression_name(result: &mut String, expr: &crate::language::Expression<'i>) { + fn extract_expression_invocations( + targets: &mut Vec<&'i str>, + expr: &crate::language::Expression<'i>, + ) { match expr { - crate::language::Expression::Application(invocation) => { - Self::append_invocation_name(result, invocation); + crate::language::Expression::Application(inv) => { + targets.push(Self::invocation_name(inv)); } crate::language::Expression::Repeat(inner) => { - Self::append_expression_name(result, inner); + Self::extract_expression_invocations(targets, inner); } crate::language::Expression::Foreach(_, inner) => { - Self::append_expression_name(result, inner); + Self::extract_expression_invocations(targets, inner); } crate::language::Expression::Binding(inner, _) => { - Self::append_expression_name(result, inner); + Self::extract_expression_invocations(targets, inner); } _ => {} } } - /// Returns an iterator over the descriptive elements. - pub fn elements(&self) -> impl Iterator> { - self.0 - .iter() + fn invocation_name(inv: &crate::language::Invocation<'i>) -> &'i str { + match &inv.target { + crate::language::Target::Local(id) => id.0, + crate::language::Target::Remote(ext) => ext.0, + } } } diff --git a/src/templating/procedure/adapter.rs b/src/templating/procedure/adapter.rs index 4985c8fb..e2273564 100644 --- a/src/templating/procedure/adapter.rs +++ b/src/templating/procedure/adapter.rs @@ -35,7 +35,7 @@ fn extract(document: &language::Document) -> Document { .map(String::from); doc.description = first .description() - .map(|p| p.text()) + .map(|p| p.content()) .collect(); let has_sections = first @@ -118,7 +118,7 @@ fn section_from_procedure(procedure: &language::Procedure) -> Section { let items = items_from_procedure(procedure); let description: Vec = procedure .description() - .map(|p| p.text()) + .map(|p| p.content()) .collect(); Section { @@ -203,7 +203,7 @@ fn step_from_scope(scope: &language::Scope) -> Step { let paragraphs: Vec = scope .description() - .map(|p| p.text()) + .map(|p| p.content()) .collect(); let (title, body) = match paragraphs.split_first() { Some((first, rest)) => (Some(first.clone()), rest.to_vec()), From 22f166ef7d799de8d7d7bdeb0fa894e56ac83096 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 6 Mar 2026 13:54:48 +1100 Subject: [PATCH 17/25] Include code expression elements in inline invocations --- src/templating/engine.rs | 41 ++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/templating/engine.rs b/src/templating/engine.rs index 60be5078..11128ee3 100644 --- a/src/templating/engine.rs +++ b/src/templating/engine.rs @@ -219,17 +219,46 @@ impl<'i> Paragraph<'i> { targets } - /// Returns displayable content: text if present, otherwise the - /// first invocation target name. + /// Returns text of the step body if present, otherwise (for the scenarion + /// where the step is a bare invocation or code expression) a readable + /// rendering of the first non-text element. pub fn content(&self) -> String { let text = self.text(); if !text.is_empty() { return text; } - self.invocations() - .first() - .unwrap_or(&"") - .to_string() + for descriptive in &self.0 { + let result = Self::descriptive_content(descriptive); + if !result.is_empty() { + return result; + } + } + String::new() + } + + fn descriptive_content(descriptive: &Descriptive<'i>) -> String { + match descriptive { + Descriptive::Application(inv) => Self::invocation_name(inv).to_string(), + Descriptive::CodeInline(expr) => Self::expression_content(expr), + Descriptive::Binding(inner, _) => Self::descriptive_content(inner), + _ => String::new(), + } + } + + fn expression_content(expr: &crate::language::Expression<'i>) -> String { + match expr { + crate::language::Expression::Application(invocation) => { + Self::invocation_name(invocation).to_string() + } + crate::language::Expression::Repeat(inner) => { + format!("repeat {}", Self::expression_content(inner)) + } + crate::language::Expression::Foreach(_, inner) => { + format!("foreach {}", Self::expression_content(inner)) + } + crate::language::Expression::Binding(inner, _) => Self::expression_content(inner), + _ => String::new(), + } } fn append_text(result: &mut String, descriptive: &Descriptive<'i>) { From 8a716bcd83b61960b76cde4522f868ec1ce4b518 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 6 Mar 2026 14:20:19 +1100 Subject: [PATCH 18/25] Test step content extraction --- src/templating/engine.rs | 80 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/templating/engine.rs b/src/templating/engine.rs index 11128ee3..9e7d7a3d 100644 --- a/src/templating/engine.rs +++ b/src/templating/engine.rs @@ -317,3 +317,83 @@ impl<'i> Paragraph<'i> { } } } + +#[cfg(test)] +mod check { + use crate::language::{ + Descriptive, Expression, Identifier, Invocation, Paragraph, Target, + }; + + fn local<'a>(name: &'a str) -> Invocation<'a> { + Invocation { + target: Target::Local(Identifier(name)), + parameters: None, + } + } + + // Pure text: "Ensure physical and digital safety" + #[test] + fn text_only_paragraph() { + let p = Paragraph(vec![Descriptive::Text("Ensure physical and digital safety")]); + assert_eq!(p.text(), "Ensure physical and digital safety"); + assert!(p.invocations().is_empty()); + assert_eq!(p.content(), "Ensure physical and digital safety"); + } + + // Bare invocation: + #[test] + fn invocation_only_paragraph() { + let p = Paragraph(vec![Descriptive::Application(local("ensure_safety"))]); + assert_eq!(p.text(), ""); + assert_eq!(p.invocations(), vec!["ensure_safety"]); + assert_eq!(p.content(), "ensure_safety"); + } + + // Mixed: Define Requirements (concept) + // Text is present so content() returns just the text. + #[test] + fn mixed_text_and_invocation() { + let p = Paragraph(vec![ + Descriptive::Text("Define Requirements"), + Descriptive::Application(local("define_requirements")), + ]); + assert_eq!(p.text(), "Define Requirements"); + assert_eq!(p.invocations(), vec!["define_requirements"]); + assert_eq!(p.content(), "Define Requirements"); + } + + // CodeInline with repeat: { repeat } + #[test] + fn repeat_expression() { + let p = Paragraph(vec![Descriptive::CodeInline(Expression::Repeat( + Box::new(Expression::Application(local("incident_action_cycle"))), + ))]); + assert_eq!(p.text(), ""); + assert_eq!(p.invocations(), vec!["incident_action_cycle"]); + assert_eq!(p.content(), "repeat incident_action_cycle"); + } + + // Binding wrapping an invocation: (s) ~ e + #[test] + fn binding_with_invocation() { + let p = Paragraph(vec![Descriptive::Binding( + Box::new(Descriptive::Application(local("observe"))), + vec![Identifier("e")], + )]); + assert_eq!(p.text(), ""); + assert_eq!(p.invocations(), vec!["observe"]); + assert_eq!(p.content(), "observe"); + } + + // CodeInline with foreach: { foreach design in designs } + #[test] + fn foreach_expression() { + let p = Paragraph(vec![Descriptive::CodeInline(Expression::Foreach( + vec![Identifier("design")], + Box::new(Expression::Application(local("implement"))), + ))]); + assert_eq!(p.text(), ""); + assert_eq!(p.invocations(), vec!["implement"]); + assert_eq!(p.content(), "foreach implement"); + } +} From 0db94e8619333f0bc795d6e2864d51487b939830 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 6 Mar 2026 15:29:38 +1100 Subject: [PATCH 19/25] Add tests for adapters and renderers --- src/templating/checklist/adapter.rs | 94 ++++++++++++++++++ src/templating/checklist/renderer.rs | 84 ++++++++++++++++ src/templating/procedure/adapter.rs | 125 ++++++++++++++++++++++++ src/templating/procedure/renderer.rs | 111 +++++++++++++++++++++ tests/integration.rs | 1 + tests/templating/mod.rs | 1 + tests/templating/rendering/checklist.rs | 9 ++ tests/templating/rendering/mod.rs | 57 +++++++++++ tests/templating/rendering/procedure.rs | 9 ++ 9 files changed, 491 insertions(+) create mode 100644 tests/templating/mod.rs create mode 100644 tests/templating/rendering/checklist.rs create mode 100644 tests/templating/rendering/mod.rs create mode 100644 tests/templating/rendering/procedure.rs diff --git a/src/templating/checklist/adapter.rs b/src/templating/checklist/adapter.rs index 4c62ebf7..740ce7ec 100644 --- a/src/templating/checklist/adapter.rs +++ b/src/templating/checklist/adapter.rs @@ -133,6 +133,7 @@ fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ve Vec::new() } +/// Convert a step-like scope into a Step. fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Step { let mut responses = Vec::new(); let mut children = Vec::new(); @@ -172,3 +173,96 @@ fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ste children, } } + +#[cfg(test)] +mod check { + use std::path::Path; + + use crate::parsing; + use crate::templating::template::Adapter; + + use super::ChecklistAdapter; + + fn trim(s: &str) -> &str { + s.strip_prefix('\n') + .unwrap_or(s) + } + + fn extract(source: &str) -> super::Document { + let path = Path::new("test.tq"); + let doc = parsing::parse(path, source).unwrap(); + ChecklistAdapter.extract(&doc) + } + + #[test] + fn procedure_title_becomes_section_heading() { + let doc = extract(trim( + r#" +preflight : + +# Pre-flight Checks + + 1. Fasten seatbelt + "#, + )); + assert_eq!(doc.sections.len(), 1); + assert_eq!(doc.sections[0].heading.as_deref(), Some("Pre-flight Checks")); + } + + #[test] + fn role_flattened_onto_children() { + let doc = extract(trim( + r#" +checks : + + @surgeon + 1. Confirm identity + 2. Mark surgical site + "#, + )); + let steps = &doc.sections[0].steps; + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].role.as_deref(), Some("surgeon")); + assert_eq!(steps[1].role.as_deref(), Some("surgeon")); + } + + #[test] + fn responses_with_conditions() { + let doc = extract(trim( + r#" +checks : + + 1. Is the patient ready? + 'Yes' | 'No' if complications + "#, + )); + let step = &doc.sections[0].steps[0]; + assert_eq!(step.responses.len(), 2); + assert_eq!(step.responses[0].value, "Yes"); + assert_eq!(step.responses[0].condition, None); + assert_eq!(step.responses[1].value, "No"); + assert_eq!( + step.responses[1].condition.as_deref(), + Some("if complications") + ); + } + + #[test] + fn invocation_only_step_has_content() { + let doc = extract(trim( + r#" +main : + + 1. + +ensure_safety : + +# Safety First + + - Check exits + "#, + )); + let steps = &doc.sections[0].steps; + assert_eq!(steps[0].title.as_deref(), Some("ensure_safety")); + } +} diff --git a/src/templating/checklist/renderer.rs b/src/templating/checklist/renderer.rs index 54d26594..bda64048 100644 --- a/src/templating/checklist/renderer.rs +++ b/src/templating/checklist/renderer.rs @@ -46,6 +46,7 @@ fn render_section(output: &mut String, section: &Section) { output.push('\n'); } +/// Render a single step and its children into the output buffer. fn render_step(output: &mut String, step: &Step) { if let Some(r) = &step.role { output.push_str(&typst::role(r)); @@ -78,3 +79,86 @@ fn render_step(output: &mut String, step: &Step) { render_step(output, child); } } + +#[cfg(test)] +mod check { + use crate::templating::template::Renderer; + + use super::ChecklistRenderer; + use super::super::types::{Document, Response, Section, Step}; + + fn step(ordinal: Option<&str>, title: Option<&str>) -> Step { + Step { + name: None, + ordinal: ordinal.map(String::from), + title: title.map(String::from), + body: Vec::new(), + role: None, + responses: Vec::new(), + children: Vec::new(), + } + } + + #[test] + fn section_heading_with_ordinal() { + let doc = Document { + sections: vec![Section { + ordinal: Some("I".into()), + heading: Some("Before anaesthesia".into()), + steps: vec![step(Some("1"), Some("Check pulse"))], + }], + }; + let out = ChecklistRenderer.render(&doc); + assert!(out.contains("== I. Before anaesthesia")); + } + + #[test] + fn step_with_ordinal_and_title() { + let doc = Document { + sections: vec![Section { + ordinal: None, + heading: None, + steps: vec![step(Some("3"), Some("Verify identity"))], + }], + }; + let out = ChecklistRenderer.render(&doc); + assert!(out.contains("*3.*")); + assert!(out.contains("Verify identity")); + } + + #[test] + fn role_rendered_before_step() { + let mut s = step(Some("1"), Some("Confirm site")); + s.role = Some("surgeon".into()); + let doc = Document { + sections: vec![Section { + ordinal: None, + heading: None, + steps: vec![s], + }], + }; + let out = ChecklistRenderer.render(&doc); + let role_pos = out.find("surgeon").unwrap(); + let step_pos = out.find("Confirm site").unwrap(); + assert!(role_pos < step_pos); + } + + #[test] + fn responses_rendered() { + let mut s = step(Some("1"), Some("Ready?")); + s.responses = vec![ + Response { value: "Yes".into(), condition: None }, + Response { value: "No".into(), condition: Some("if complications".into()) }, + ]; + let doc = Document { + sections: vec![Section { + ordinal: None, + heading: None, + steps: vec![s], + }], + }; + let out = ChecklistRenderer.render(&doc); + assert!(out.contains("Yes")); + assert!(out.contains("No if complications")); + } +} diff --git a/src/templating/procedure/adapter.rs b/src/templating/procedure/adapter.rs index e2273564..91fecd0b 100644 --- a/src/templating/procedure/adapter.rs +++ b/src/templating/procedure/adapter.rs @@ -221,3 +221,128 @@ fn step_from_scope(scope: &language::Scope) -> Step { children, } } + +#[cfg(test)] +mod check { + use std::path::Path; + + use crate::parsing; + use crate::templating::template::Adapter; + + use super::ProcedureAdapter; + use super::super::types::{Item, StepKind}; + + fn trim(s: &str) -> &str { + s.strip_prefix('\n') + .unwrap_or(s) + } + + fn extract(source: &str) -> super::Document { + let path = Path::new("test.tq"); + let doc = parsing::parse(path, source).unwrap(); + ProcedureAdapter.extract(&doc) + } + + #[test] + fn procedure_title_becomes_document_title() { + let doc = extract(trim( + r#" +emergency : + +# Don't Panic + + 1. Stay calm + "#, + )); + assert_eq!(doc.title.as_deref(), Some("Don't Panic")); + } + + #[test] + fn role_preserved_as_group() { + let doc = extract(trim( + r#" +build : + + 1. Define Interfaces + @programmers + a. + "#, + )); + let items = &doc.sections[0].items; + assert_eq!(items.len(), 1); + if let Item::Step(step) = &items[0] { + assert_eq!(step.children.len(), 1); + if let Item::RoleGroup(group) = &step.children[0] { + assert_eq!(group.name, "programmers"); + assert_eq!(group.items.len(), 1); + } else { + panic!("expected RoleGroup"); + } + } else { + panic!("expected Step"); + } + } + + #[test] + fn dependent_step_has_ordinal() { + let doc = extract(trim( + r#" +checks : + + 1. First step + 2. Second step + "#, + )); + let items = &doc.sections[0].items; + assert_eq!(items.len(), 2); + if let Item::Step(s) = &items[0] { + assert!(matches!(s.kind, StepKind::Dependent)); + assert_eq!(s.ordinal.as_deref(), Some("1")); + } else { + panic!("expected Step"); + } + } + + #[test] + fn parallel_step_no_ordinal() { + let doc = extract(trim( + r#" +checks : + + - First item + - Second item + "#, + )); + let items = &doc.sections[0].items; + assert_eq!(items.len(), 2); + if let Item::Step(s) = &items[0] { + assert!(matches!(s.kind, StepKind::Parallel)); + assert_eq!(s.ordinal, None); + } else { + panic!("expected Step"); + } + } + + #[test] + fn invocation_only_step_has_content() { + let doc = extract(trim( + r#" +main : + + 1. + +ensure_safety : + +# Safety First + + - Check exits + "#, + )); + let items = &doc.sections[0].items; + if let Item::Step(step) = &items[0] { + assert_eq!(step.title.as_deref(), Some("ensure_safety")); + } else { + panic!("expected Step"); + } + } +} diff --git a/src/templating/procedure/renderer.rs b/src/templating/procedure/renderer.rs index 003ba61b..b1160500 100644 --- a/src/templating/procedure/renderer.rs +++ b/src/templating/procedure/renderer.rs @@ -102,3 +102,114 @@ fn render_step(output: &mut String, step: &Step) { render_item(output, child); } } + +#[cfg(test)] +mod check { + use crate::templating::template::Renderer; + + use super::ProcedureRenderer; + use super::super::types::{Document, Item, RoleGroup, Section, Step, StepKind}; + + fn dep(ordinal: &str, title: &str) -> Step { + Step { + kind: StepKind::Dependent, + ordinal: Some(ordinal.into()), + title: Some(title.into()), + body: Vec::new(), + responses: Vec::new(), + children: Vec::new(), + } + } + + fn par(title: &str) -> Step { + Step { + kind: StepKind::Parallel, + ordinal: None, + title: Some(title.into()), + body: Vec::new(), + responses: Vec::new(), + children: Vec::new(), + } + } + + #[test] + fn document_title_as_heading() { + let doc = Document { + title: Some("Emergency Procedure".into()), + description: Vec::new(), + sections: Vec::new(), + }; + let out = ProcedureRenderer.render(&doc); + assert!(out.contains("= Emergency Procedure")); + } + + #[test] + fn dependent_step_shows_ordinal() { + let doc = Document { + title: None, + description: Vec::new(), + sections: vec![Section { + ordinal: None, + heading: None, + description: Vec::new(), + items: vec![Item::Step(dep("4", "Engineering Design"))], + }], + }; + let out = ProcedureRenderer.render(&doc); + assert!(out.contains("*4.*")); + assert!(out.contains("Engineering Design")); + } + + #[test] + fn parallel_step_has_title() { + let doc = Document { + title: None, + description: Vec::new(), + sections: vec![Section { + ordinal: None, + heading: None, + description: Vec::new(), + items: vec![Item::Step(par("Check exits"))], + }], + }; + let out = ProcedureRenderer.render(&doc); + assert!(out.contains("Check exits")); + } + + #[test] + fn role_group_wraps_children() { + let doc = Document { + title: None, + description: Vec::new(), + sections: vec![Section { + ordinal: None, + heading: None, + description: Vec::new(), + items: vec![Item::RoleGroup(RoleGroup { + name: "programmers".into(), + items: vec![Item::Step(dep("a", "define_interfaces"))], + })], + }], + }; + let out = ProcedureRenderer.render(&doc); + let role_pos = out.find("programmers").unwrap(); + let step_pos = out.find("define\\_interfaces").unwrap(); + assert!(role_pos < step_pos); + } + + #[test] + fn section_heading_with_ordinal() { + let doc = Document { + title: None, + description: Vec::new(), + sections: vec![Section { + ordinal: Some("III".into()), + heading: Some("Implementation".into()), + description: Vec::new(), + items: Vec::new(), + }], + }; + let out = ProcedureRenderer.render(&doc); + assert!(out.contains("== III. Implementation")); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index c42e530c..797e39d2 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,2 +1,3 @@ mod formatting; mod parsing; +mod templating; diff --git a/tests/templating/mod.rs b/tests/templating/mod.rs new file mode 100644 index 00000000..f5610f49 --- /dev/null +++ b/tests/templating/mod.rs @@ -0,0 +1 @@ +mod rendering; diff --git a/tests/templating/rendering/checklist.rs b/tests/templating/rendering/checklist.rs new file mode 100644 index 00000000..324997c4 --- /dev/null +++ b/tests/templating/rendering/checklist.rs @@ -0,0 +1,9 @@ +use std::path::Path; + +use technique::templating::Checklist; + +#[test] +fn ensure_render() { + super::check_directory(Path::new("examples/minimal/"), &Checklist); + super::check_directory(Path::new("examples/prototype/"), &Checklist); +} diff --git a/tests/templating/rendering/mod.rs b/tests/templating/rendering/mod.rs new file mode 100644 index 00000000..aabaffe6 --- /dev/null +++ b/tests/templating/rendering/mod.rs @@ -0,0 +1,57 @@ +use std::fs; +use std::path::Path; + +use technique::parsing; +use technique::templating; + +fn check_directory(dir: &Path, template: &impl templating::Template) { + assert!(dir.exists(), "directory missing: {:?}", dir); + + let entries = fs::read_dir(dir).expect("Failed to read directory"); + + let mut files = Vec::new(); + for entry in entries { + let entry = entry.expect("Failed to read directory entry"); + let path = entry.path(); + + if path + .extension() + .and_then(|s| s.to_str()) + == Some("tq") + { + files.push(path); + } + } + + assert!(!files.is_empty(), "No .tq files found in {:?}", dir); + + let mut failures = Vec::new(); + + for file in &files { + let source = parsing::load(file) + .unwrap_or_else(|e| panic!("Failed to load {:?}: {:?}", file, e)); + + let doc = parsing::parse(file, &source) + .unwrap_or_else(|e| panic!("Failed to parse {:?}: {:?}", file, e)); + + let output = templating::render(template, &doc); + + if output.is_empty() { + failures.push(file.clone()); + } + } + + if !failures.is_empty() { + panic!( + "Template produced empty output for {} files: {:?}", + failures.len(), + failures + ); + } +} + +#[path = "procedure.rs"] +mod procedure; + +#[path = "checklist.rs"] +mod checklist; diff --git a/tests/templating/rendering/procedure.rs b/tests/templating/rendering/procedure.rs new file mode 100644 index 00000000..ffb11eec --- /dev/null +++ b/tests/templating/rendering/procedure.rs @@ -0,0 +1,9 @@ +use std::path::Path; + +use technique::templating::Procedure; + +#[test] +fn ensure_render() { + super::check_directory(Path::new("examples/minimal/"), &Procedure); + super::check_directory(Path::new("examples/prototype/"), &Procedure); +} From db1079eed28ee2471b68d9cdc6eac7d9ff1b81ee Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 6 Mar 2026 17:20:53 +1100 Subject: [PATCH 20/25] Add Database Upgrade example procedure --- examples/prototype/DatabaseUpgrade.tq | 74 +++++++++++++++++++++++++++ src/templating/procedure/adapter.rs | 41 +++++++++++++-- 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 examples/prototype/DatabaseUpgrade.tq diff --git a/examples/prototype/DatabaseUpgrade.tq b/examples/prototype/DatabaseUpgrade.tq new file mode 100644 index 00000000..2cafc2a4 --- /dev/null +++ b/examples/prototype/DatabaseUpgrade.tq @@ -0,0 +1,74 @@ +% technique v1 + +database_upgrade : + +# Production Database Upgrade + +In order to launch the next version of our e-commerce platform, we need to +upgrade the schema of the core database at the heart of the application. We +also have an outstanding requirement to upgrade the underlying database +software, as we have had trouble with several bugs therein which the vendor +reports fixed. + +I. Take site down + +site_down : + +# Take site down + +Before taking the database offline for its upgrade, we put the site into +maintenance mode and safely down the servers. The start time is critical due +to expected duration of the database schema upgrade scripts. + + 1. Enter maintenance mode + @fozzie + a. Put web site into maintenance mode (load balancer redirect to + alternate web servers with static pages) + @gonzo + b. Activate IVR maintenance mode + 2. Down services + @kermit + a. Stop all VMs + b. Stop GFS on database1, database2 + c. Ensure RAID filesystems still mounted + @gonzo + d. Stop Apache on web1, web2 + 3. Verification + @kermit + a. Verify maintenance mode is active + b. Verify all VMs down + c. GO / NO-GO FOR UPGRADE + +II. Database work + +software_update : + +# Database Software Upgrade + +Run an export of the database in order to ensure we have a good backup prior +to upgrading the database software and running the schema change scripts. +There is not much concurrent activity here, so those not directly involved in +database activity will head for breakfast. + + 4. Database safety + @beaker + a. Database to single user mode + b. Export database to secondary storage + c. Stop database + @gonzo + d. Run out to get coffees for everyone + 5. Software upgrade + @fozzie + a. Install database software upgrade + 6. Restart database + @beaker + a. Start database + 7. Preliminary database testing + @beaker + a. Run access check scripts + b. Run health check scripts + @fozzie + c. Restart database monitoring + 8. Schema upgrade + @beaker + a. Run database schema upgrade scripts diff --git a/src/templating/procedure/adapter.rs b/src/templating/procedure/adapter.rs index 91fecd0b..41aecf89 100644 --- a/src/templating/procedure/adapter.rs +++ b/src/templating/procedure/adapter.rs @@ -94,12 +94,13 @@ fn section_from_scope(scope: &language::Scope) -> Option
{ let (numeral, title) = scope.section_info()?; let heading = title.map(|para| para.text()); + let mut description = Vec::new(); let mut items = Vec::new(); if let Some(body) = scope.body() { for procedure in body.procedures() { - let proc_items = items_from_procedure(procedure); - items.extend(proc_items); + description.extend(procedure.description().map(|p| p.content())); + items.extend(items_from_procedure(procedure)); } for step in body.steps() { items.extend(items_from_scope(step)); @@ -109,7 +110,7 @@ fn section_from_scope(scope: &language::Scope) -> Option
{ Some(Section { ordinal: Some(numeral.to_string()), heading, - description: Vec::new(), + description, items, }) } @@ -345,4 +346,38 @@ ensure_safety : panic!("expected Step"); } } + + #[test] + fn sections_contain_their_procedures() { + let doc = extract(trim( + r#" +main : + +# Upgrade + + I. Preparation + +preparation : + + 1. Check systems + 2. Notify staff + + II. Execution + +execution : + + 3. Run scripts + 4. Verify + "#, + )); + assert_eq!(doc.sections.len(), 2); + + assert_eq!(doc.sections[0].ordinal.as_deref(), Some("I")); + assert_eq!(doc.sections[0].heading.as_deref(), Some("Preparation")); + assert_eq!(doc.sections[0].items.len(), 2); + + assert_eq!(doc.sections[1].ordinal.as_deref(), Some("II")); + assert_eq!(doc.sections[1].heading.as_deref(), Some("Execution")); + assert_eq!(doc.sections[1].items.len(), 2); + } } From f30bd1a4bbf7fefafce22ac72f3da7f85e99c069 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 6 Mar 2026 23:13:46 +1100 Subject: [PATCH 21/25] Refine layout of procedure template --- examples/prototype/DatabaseUpgrade.tq | 1 + src/main.rs | 14 +- src/templating/procedure/renderer.rs | 197 +++++++++++++++++++------- 3 files changed, 161 insertions(+), 51 deletions(-) diff --git a/examples/prototype/DatabaseUpgrade.tq b/examples/prototype/DatabaseUpgrade.tq index 2cafc2a4..29013fe6 100644 --- a/examples/prototype/DatabaseUpgrade.tq +++ b/examples/prototype/DatabaseUpgrade.tq @@ -1,4 +1,5 @@ % technique v1 +& procedure database_upgrade : diff --git a/src/main.rs b/src/main.rs index 362577f0..6f865194 100644 --- a/src/main.rs +++ b/src/main.rs @@ -270,11 +270,11 @@ fn main() { .get_one::("output") .unwrap(); - let template_name = submatches + let cli_template = submatches .get_one::("template") .unwrap(); - debug!(output, template_name); + debug!(output, cli_template); let filename = submatches .get_one::("filename") @@ -315,8 +315,16 @@ fn main() { } }; + // Use template from document metadata if present, otherwise + // fall back to CLI argument (which defaults to "source"). + let template_name = technique + .header + .as_ref() + .and_then(|m| m.template) + .unwrap_or(cli_template.as_str()); + // Select template and render - let result = match template_name.as_str() { + let result = match template_name { "source" => templating::render(&Source::new(70), &technique), "checklist" => templating::render(&Checklist, &technique), "procedure" => templating::render(&Procedure, &technique), diff --git a/src/templating/procedure/renderer.rs b/src/templating/procedure/renderer.rs index b1160500..9c8242bf 100644 --- a/src/templating/procedure/renderer.rs +++ b/src/templating/procedure/renderer.rs @@ -1,4 +1,8 @@ //! Formats procedure domain types into Typst. +//! +//! Produces output styled after operational procedures: sans-serif font, +//! title block with overview, blue "Procedure" bar, numbered steps with +//! bold titles, roles as indented bold names, and lettered substeps. use crate::templating::template::Renderer; use crate::templating::typst; @@ -16,90 +20,186 @@ impl Renderer for ProcedureRenderer { } fn render(document: &Document) -> String { - let mut output = typst::preamble(); + let mut out = String::new(); + + out.push_str("#set page(margin: 1.5cm)\n"); + out.push_str("#set par(justify: false)\n"); + out.push_str("#show text: set text(size: 9pt, font: \"TeX Gyre Heros\")\n\n"); + + // Outer block wraps entire procedure + out.push_str("#block(width: 100%, stroke: 0.1pt, inset: 10pt)[\n"); if let Some(title) = &document.title { - output.push_str(&typst::heading(1, title)); + out.push_str(&format!( + "#text(size: 15pt)[*{}*]\n\n", + typst::escape(title) + )); } - - for para in &document.description { - output.push_str(&typst::description(para)); + if !document.description.is_empty() { + out.push_str("_Overview_\n\n"); + for para in &document.description { + out.push_str(&typst::escape(para)); + out.push('\n'); + } } - for section in &document.sections { - render_section(&mut output, section); + // Procedure bar + out.push_str("#block(width: 100%, fill: rgb(\"#006699\"), inset: 5pt)[#text(fill: white)[*Procedure*]]\n\n"); + + // Sections + let total = document.sections.len(); + for (i, section) in document.sections.iter().enumerate() { + render_section(&mut out, section); + if i + 1 < total { + out.push_str("#line(length: 100%, stroke: (thickness: 0.5pt, paint: rgb(\"#003366\"), dash: (\"dot\", 2pt, 4pt, 2pt)))\n\n"); + } } - output + out.push_str("]\n"); + out } -fn render_section(output: &mut String, section: &Section) { +fn render_section(out: &mut String, section: &Section) { + // Section heading match (§ion.ordinal, §ion.heading) { (Some(ord), Some(heading)) => { - output.push_str(&typst::heading(2, &format!("{}. {}", ord, heading))); + out.push_str(&format!( + "#text(size: 14pt)[*{}.* #h(8pt) *{}*]\n\n", + ord, + typst::escape(heading) + )); } (Some(ord), None) => { - output.push_str(&typst::heading(2, &format!("{}.", ord))); + out.push_str(&format!("#text(size: 14pt)[*{}.*]\n\n", ord)); } (None, Some(heading)) => { - output.push_str(&typst::heading(2, heading)); + out.push_str(&format!( + "#text(size: 14pt)[*{}*]\n\n", + typst::escape(heading) + )); } (None, None) => {} } for para in §ion.description { - output.push_str(&typst::description(para)); + out.push_str(&typst::escape(para)); + out.push_str("\n\n"); } - for item in §ion.items { - render_item(output, item); + // Steps indented slightly from section heading + if !section.items.is_empty() { + out.push_str("#pad(left: 8pt)[\n"); + for item in §ion.items { + render_item(out, item); + } + out.push_str("]\n"); } } -fn render_item(output: &mut String, item: &Item) { +fn render_item(out: &mut String, item: &Item) { match item { - Item::Step(step) => render_step(output, step), + Item::Step(step) => render_step(out, step), Item::RoleGroup(group) => { - output.push_str(&typst::role(&group.name)); - for child in &group.items { - render_item(output, child); + render_role(out, group); + } + } +} + +fn render_role(out: &mut String, group: &super::types::RoleGroup) { + out.push_str(&format!("- *{}*\n", typst::escape(&group.name))); + if !group.items.is_empty() { + let start = ordinal_start(&group.items); + out.push_str(&format!( + "#pad(left: 20pt)[\n#set par(leading: 0.5em)\n#set enum(numbering: \"a.\", start: {}, spacing: 0.8em)\n", + start + )); + for child in &group.items { + render_child(out, child); + } + out.push_str("]\n"); + } +} + +/// Convert the first child's letter ordinal to a numeric start value. +fn ordinal_start(items: &[Item]) -> u32 { + if let Some(Item::Step(step)) = items.first() { + if let Some(ord) = &step.ordinal { + if let Some(c) = ord.chars().next() { + if c.is_ascii_lowercase() { + return (c as u32) - ('a' as u32) + 1; + } + } + } + } + 1 +} + +/// Render items nested under a role group (substeps). +fn render_child(out: &mut String, item: &Item) { + match item { + Item::Step(step) => { + match step.title.as_deref() { + Some(t) => { + out.push_str(&format!("+ {}\n", typst::escape(t))); + } + None => {} } } + Item::RoleGroup(group) => { + render_role(out, group); + } } } -fn render_step(output: &mut String, step: &Step) { - let ordinal = match step.kind { - StepKind::Dependent => step - .ordinal - .as_deref(), +fn render_step(out: &mut String, step: &Step) { + let ord = match step.kind { + StepKind::Dependent => step.ordinal.as_deref(), StepKind::Parallel => None, }; - output.push_str(&typst::step( - ordinal, - step.title - .as_deref(), - )); + // Step heading + match (ord, step.title.as_deref()) { + (Some(o), Some(t)) => { + out.push_str(&format!( + "*{}.* #h(4pt) *{}*\n\n", + o, + typst::escape(t) + )); + } + (Some(o), None) => { + out.push_str(&format!("*{}.*\n\n", o)); + } + (None, Some(t)) => { + out.push_str(&format!("*{}*\n\n", typst::escape(t))); + } + (None, None) => {} + } for para in &step.body { - output.push_str(&typst::description(para)); + out.push_str(&typst::escape(para)); + out.push_str("\n\n"); } - let display: Vec = step - .responses - .iter() - .map(|r| match &r.condition { - Some(cond) => format!("{} {}", r.value, cond), - None => r - .value - .clone(), - }) - .collect(); - output.push_str(&typst::responses(&display)); + if !step.responses.is_empty() { + for r in &step.responses { + match &r.condition { + Some(cond) => out.push_str(&format!( + "- _{} {}_\n", + typst::escape(&r.value), + typst::escape(cond) + )), + None => out.push_str(&format!("- _{}_\n", typst::escape(&r.value))), + } + } + out.push('\n'); + } - for child in &step.children { - render_item(output, child); + if !step.children.is_empty() { + out.push_str("#pad(left: 16pt)[\n"); + for child in &step.children { + render_item(out, child); + } + out.push_str("]\n\n"); } } @@ -133,14 +233,14 @@ mod check { } #[test] - fn document_title_as_heading() { + fn document_title_in_block() { let doc = Document { title: Some("Emergency Procedure".into()), description: Vec::new(), sections: Vec::new(), }; let out = ProcedureRenderer.render(&doc); - assert!(out.contains("= Emergency Procedure")); + assert!(out.contains("*Emergency Procedure*")); } #[test] @@ -157,7 +257,7 @@ mod check { }; let out = ProcedureRenderer.render(&doc); assert!(out.contains("*4.*")); - assert!(out.contains("Engineering Design")); + assert!(out.contains("*Engineering Design*")); } #[test] @@ -210,6 +310,7 @@ mod check { }], }; let out = ProcedureRenderer.render(&doc); - assert!(out.contains("== III. Implementation")); + assert!(out.contains("*III.*")); + assert!(out.contains("*Implementation*")); } } From 07200d02ca5a1e65b3dae065950f6fae709c639c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 7 Mar 2026 16:13:46 +1100 Subject: [PATCH 22/25] Refine --template command-line option behaviour --- src/main.rs | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6f865194..83b1acb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,9 +122,8 @@ fn main() { Arg::new("template") .short('t') .long("template") - .default_value("source") .action(ArgAction::Set) - .help("Template to use for rendering."), + .help("Template to use for rendering. By default the value specified in the input document's template line will be used, falling back to [source] if unspecified."), ) .arg( Arg::new("filename") @@ -270,11 +269,7 @@ fn main() { .get_one::("output") .unwrap(); - let cli_template = submatches - .get_one::("template") - .unwrap(); - - debug!(output, cli_template); + debug!(output); let filename = submatches .get_one::("filename") @@ -315,20 +310,37 @@ fn main() { } }; - // Use template from document metadata if present, otherwise - // fall back to CLI argument (which defaults to "source"). - let template_name = technique - .header - .as_ref() - .and_then(|m| m.template) - .unwrap_or(cli_template.as_str()); + // If present the value of the --template option will override the + // document's metadata template line. If neither is specified then + // the fallback default is "source". + + let template = submatches.get_one::("template"); + let template: &str = match template { + Some(value) => value, + None => { + technique + .header + .as_ref() + .and_then(|m| m.template) + .unwrap_or("source") + } + }; + + debug!(template); // Select template and render - let result = match template_name { + let result = match template { "source" => templating::render(&Source::new(70), &technique), "checklist" => templating::render(&Checklist, &technique), "procedure" => templating::render(&Procedure, &technique), - _ => panic!("Unrecognized template: {}", template_name), + other => { + eprintln!( + "{}: unrecognized template \"{}\"", + "error".bright_red(), + other + ); + std::process::exit(1); + } }; match output.as_str() { From 590df3b40894622374a3ca4ba73c8ee722ee35ef Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 7 Mar 2026 16:16:48 +1100 Subject: [PATCH 23/25] Handle top-level procedures and section chunks --- src/templating/checklist/adapter.rs | 57 ++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/src/templating/checklist/adapter.rs b/src/templating/checklist/adapter.rs index 740ce7ec..5b5c63ca 100644 --- a/src/templating/checklist/adapter.rs +++ b/src/templating/checklist/adapter.rs @@ -30,20 +30,51 @@ fn extract(document: &language::Document) -> Document { .sections .is_empty() { - let steps: Vec = document - .steps() - .filter(|s| s.is_step()) - .map(|s| step_from_scope(s, None)) - .collect(); + // Handle top-level SectionChunks (no procedures) + for scope in document.steps() { + if let Some((numeral, title)) = scope.section_info() { + let heading = title.map(|para| para.text()); + let steps: Vec = match scope.body() { + Some(body) => body + .steps() + .filter(|s| s.is_step()) + .map(|s| step_from_scope(s, None)) + .collect(), + None => Vec::new(), + }; + + if !steps.is_empty() { + extracted + .sections + .push(Section { + ordinal: Some(numeral.to_string()), + heading, + steps, + }); + } + } + } - if !steps.is_empty() { - extracted - .sections - .push(Section { - ordinal: None, - heading: None, - steps, - }); + // Handle bare top-level steps (no sections, no procedures) + if extracted + .sections + .is_empty() + { + let steps: Vec = document + .steps() + .filter(|s| s.is_step()) + .map(|s| step_from_scope(s, None)) + .collect(); + + if !steps.is_empty() { + extracted + .sections + .push(Section { + ordinal: None, + heading: None, + steps, + }); + } } } From ddd62a44c7067e49aa70e73cb227d8abbc3d3a19 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 7 Mar 2026 21:51:45 +1100 Subject: [PATCH 24/25] Improve help text --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 83b1acb1..d9a77461 100644 --- a/src/main.rs +++ b/src/main.rs @@ -123,7 +123,7 @@ fn main() { .short('t') .long("template") .action(ArgAction::Set) - .help("Template to use for rendering. By default the value specified in the input document's template line will be used, falling back to [source] if unspecified."), + .help("Template to use for rendering. By default the value specified in the input document's template line will be used, falling back to source highlighting if unspecified."), ) .arg( Arg::new("filename") From ac9a76909ee3d3f954e8f1cadec094ceda8e563f Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Mon, 9 Mar 2026 18:13:04 +1100 Subject: [PATCH 25/25] Generalize procedure template to better follow source Technique --- src/output/mod.rs | 33 +--- src/templating/procedure/adapter.rs | 260 ++++++++++++-------------- src/templating/procedure/renderer.rs | 263 ++++++++++++++++----------- src/templating/procedure/types.rs | 71 ++++---- src/templating/source.rs | 9 +- 5 files changed, 319 insertions(+), 317 deletions(-) diff --git a/src/output/mod.rs b/src/output/mod.rs index c13f1143..e2f18844 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,23 +1,11 @@ //! Output generation for the Technique CLI application use owo_colors::OwoColorize; -use serde::Serialize; use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; -use tinytemplate::TinyTemplate; use tracing::{debug, info}; -static TEMPLATE: &'static str = r#" -#show text: set text(font: "Inconsolata") -#show raw: set block(breakable: true) -"#; - -#[derive(Serialize)] -struct Context { - filename: String, -} - pub fn via_typst(filename: &Path, markup: &str) { info!("Printing file: {}", filename.display()); @@ -46,31 +34,12 @@ pub fn via_typst(filename: &Path, markup: &str) { .spawn() .expect("Failed to start external Typst process"); - // Write the file contents to the process's stdin + // Write the markup to the process's stdin let mut stdin = child .stdin .take() .unwrap(); - let mut tt = TinyTemplate::new(); - tt.add_template("hello", TEMPLATE) - .unwrap(); - - let context = Context { - filename: filename - .to_string_lossy() - .to_string(), - }; - - let rendered = tt - .render("hello", &context) - .unwrap(); - stdin - .write(rendered.as_bytes()) - .expect("Write header to child process"); - - // write markup to stdin handle - stdin .write(markup.as_bytes()) .expect("Write document to child process"); diff --git a/src/templating/procedure/adapter.rs b/src/templating/procedure/adapter.rs index 41aecf89..60269e77 100644 --- a/src/templating/procedure/adapter.rs +++ b/src/templating/procedure/adapter.rs @@ -1,15 +1,14 @@ //! Projects the parser's AST into a domain model suitable for procedures. //! -//! This model preserves hierarchy. The first procedure may be a container -//! whose steps are SectionChunks (each becoming a Section here). Remaining -//! procedures become additional Sections. AttributeBlocks become RoleGroups -//! (structural containers, not step annotations). ResponseBlocks attach to -//! their parent step. +//! This is a recursive walk of the AST producing a tree of Nodes. The first +//! procedure provides document-level title and description; its steps (and +//! any SectionChunks within) become the body. Remaining top-level procedures +//! are appended as Procedure nodes. use crate::language; use crate::templating::template::Adapter; -use super::types::{Document, Item, Response, RoleGroup, Section, Step, StepKind}; +use super::types::{Document, Node, Response, StepKind}; pub struct ProcedureAdapter; @@ -24,9 +23,6 @@ impl Adapter for ProcedureAdapter { fn extract(document: &language::Document) -> Document { let mut doc = Document::new(); - // Try procedures first. The first procedure may be a container whose - // steps are SectionChunks; remaining procedures are leaf procedures - // referenced from within sections. let mut procedures = document.procedures(); if let Some(first) = procedures.next() { @@ -38,145 +34,97 @@ fn extract(document: &language::Document) -> Document { .map(|p| p.content()) .collect(); - let has_sections = first - .steps() - .any(|s| { - s.section_info() - .is_some() - }); - - if has_sections { - // First procedure is a container with sections - for scope in first.steps() { - if let Some(section) = section_from_scope(scope) { - doc.sections - .push(section); - } - } - } else { - // First procedure has direct steps - doc.sections - .push(section_from_procedure(first)); + for scope in first.steps() { + doc.body + .extend(nodes_from_scope(scope)); } - // Remaining procedures become additional sections for procedure in procedures { - doc.sections - .push(section_from_procedure(procedure)); + doc.body + .push(node_from_procedure(procedure)); } } // Handle bare top-level steps (no procedures) if doc - .sections + .body .is_empty() { - let items: Vec = document - .steps() - .flat_map(|s| items_from_scope(s)) - .collect(); - - if !items.is_empty() { - doc.sections - .push(Section { - ordinal: None, - heading: None, - description: Vec::new(), - items, - }); + for scope in document.steps() { + doc.body + .extend(nodes_from_scope(scope)); } } doc } -fn section_from_scope(scope: &language::Scope) -> Option
{ - let (numeral, title) = scope.section_info()?; - let heading = title.map(|para| para.text()); - - let mut description = Vec::new(); - let mut items = Vec::new(); - - if let Some(body) = scope.body() { - for procedure in body.procedures() { - description.extend(procedure.description().map(|p| p.content())); - items.extend(items_from_procedure(procedure)); - } - for step in body.steps() { - items.extend(items_from_scope(step)); - } +fn node_from_procedure(procedure: &language::Procedure) -> Node { + let mut children = Vec::new(); + for scope in procedure.steps() { + children.extend(nodes_from_scope(scope)); } - Some(Section { - ordinal: Some(numeral.to_string()), - heading, - description, - items, - }) -} - -fn section_from_procedure(procedure: &language::Procedure) -> Section { - let items = items_from_procedure(procedure); - let description: Vec = procedure - .description() - .map(|p| p.content()) - .collect(); - - Section { - ordinal: None, - heading: procedure + Node::Procedure { + name: procedure + .name() + .to_string(), + title: procedure .title() .map(String::from), - description, - items, + description: procedure + .description() + .map(|p| p.content()) + .collect(), + children, } } -fn items_from_procedure(procedure: &language::Procedure) -> Vec { - procedure - .steps() - .flat_map(|s| items_from_scope(s)) - .collect() -} - -/// Extract items from a scope, handling different scope types. -fn items_from_scope(scope: &language::Scope) -> Vec { +/// Extract nodes from a scope, handling different scope types. +fn nodes_from_scope(scope: &language::Scope) -> Vec { if scope.is_step() { - return vec![Item::Step(step_from_scope(scope))]; + return vec![node_from_step(scope)]; } - // Handle AttributeBlock — extract role and process children as a RoleGroup + // AttributeBlock — role group with children let roles: Vec<_> = scope .roles() .collect(); if !roles.is_empty() { let name = roles.join(" + "); - let items: Vec = scope - .children() - .flat_map(|s| items_from_scope(s)) - .collect(); - return vec![Item::RoleGroup(RoleGroup { name, items })]; + let mut children = Vec::new(); + for child in scope.children() { + children.extend(nodes_from_scope(child)); + } + return vec![Node::Attribute { name, children }]; } - // Handle SectionChunk nested within procedures - if scope - .section_info() - .is_some() - { - // Sections within procedures become sub-items + // SectionChunk + if let Some((numeral, title)) = scope.section_info() { + let heading = title.map(|para| para.text()); + let mut children = Vec::new(); + if let Some(body) = scope.body() { - return body - .steps() - .flat_map(|s| items_from_scope(s)) - .collect(); + for procedure in body.procedures() { + children.push(node_from_procedure(procedure)); + } + for step in body.steps() { + children.extend(nodes_from_scope(step)); + } } + + return vec![Node::Section { + ordinal: numeral.to_string(), + heading, + children, + }]; } Vec::new() } -/// Convert a step-like scope into a Step. -fn step_from_scope(scope: &language::Scope) -> Step { +/// Convert a step-like scope into a Step node. +fn node_from_step(scope: &language::Scope) -> Node { let kind = match scope { language::Scope::DependentBlock { .. } => StepKind::Dependent, _ => StepKind::Parallel, @@ -186,7 +134,6 @@ fn step_from_scope(scope: &language::Scope) -> Step { let mut children = Vec::new(); for subscope in scope.children() { - // Collect responses for response in subscope.responses() { responses.push(Response { value: response @@ -197,13 +144,25 @@ fn step_from_scope(scope: &language::Scope) -> Step { .map(String::from), }); } - - // Collect child items (steps, role groups, etc.) - children.extend(items_from_scope(subscope)); + children.extend(nodes_from_scope(subscope)); } - let paragraphs: Vec = scope + let paras: Vec<_> = scope .description() + .collect(); + + let invocations: Vec = paras + .first() + .map(|p| { + p.invocations() + .into_iter() + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + let paragraphs: Vec = paras + .iter() .map(|p| p.content()) .collect(); let (title, body) = match paragraphs.split_first() { @@ -211,13 +170,14 @@ fn step_from_scope(scope: &language::Scope) -> Step { None => (None, Vec::new()), }; - Step { + Node::Step { kind, ordinal: scope .ordinal() .map(String::from), title, body, + invocations, responses, children, } @@ -231,7 +191,7 @@ mod check { use crate::templating::template::Adapter; use super::ProcedureAdapter; - use super::super::types::{Item, StepKind}; + use super::super::types::{Node, StepKind}; fn trim(s: &str) -> &str { s.strip_prefix('\n') @@ -269,13 +229,11 @@ build : a. "#, )); - let items = &doc.sections[0].items; - assert_eq!(items.len(), 1); - if let Item::Step(step) = &items[0] { - assert_eq!(step.children.len(), 1); - if let Item::RoleGroup(group) = &step.children[0] { - assert_eq!(group.name, "programmers"); - assert_eq!(group.items.len(), 1); + if let Node::Step { children, .. } = &doc.body[0] { + assert_eq!(children.len(), 1); + if let Node::Attribute { name, children } = &children[0] { + assert_eq!(name, "programmers"); + assert_eq!(children.len(), 1); } else { panic!("expected RoleGroup"); } @@ -294,11 +252,10 @@ checks : 2. Second step "#, )); - let items = &doc.sections[0].items; - assert_eq!(items.len(), 2); - if let Item::Step(s) = &items[0] { - assert!(matches!(s.kind, StepKind::Dependent)); - assert_eq!(s.ordinal.as_deref(), Some("1")); + assert_eq!(doc.body.len(), 2); + if let Node::Step { kind, ordinal, .. } = &doc.body[0] { + assert!(matches!(kind, StepKind::Dependent)); + assert_eq!(ordinal.as_deref(), Some("1")); } else { panic!("expected Step"); } @@ -314,11 +271,10 @@ checks : - Second item "#, )); - let items = &doc.sections[0].items; - assert_eq!(items.len(), 2); - if let Item::Step(s) = &items[0] { - assert!(matches!(s.kind, StepKind::Parallel)); - assert_eq!(s.ordinal, None); + assert_eq!(doc.body.len(), 2); + if let Node::Step { kind, ordinal, .. } = &doc.body[0] { + assert!(matches!(kind, StepKind::Parallel)); + assert_eq!(*ordinal, None); } else { panic!("expected Step"); } @@ -339,9 +295,8 @@ ensure_safety : - Check exits "#, )); - let items = &doc.sections[0].items; - if let Item::Step(step) = &items[0] { - assert_eq!(step.title.as_deref(), Some("ensure_safety")); + if let Node::Step { title, .. } = &doc.body[0] { + assert_eq!(title.as_deref(), Some("ensure_safety")); } else { panic!("expected Step"); } @@ -370,14 +325,33 @@ execution : 4. Verify "#, )); - assert_eq!(doc.sections.len(), 2); - - assert_eq!(doc.sections[0].ordinal.as_deref(), Some("I")); - assert_eq!(doc.sections[0].heading.as_deref(), Some("Preparation")); - assert_eq!(doc.sections[0].items.len(), 2); + assert_eq!(doc.body.len(), 2); + + if let Node::Section { ordinal, heading, children } = &doc.body[0] { + assert_eq!(ordinal, "I"); + assert_eq!(heading.as_deref(), Some("Preparation")); + // Section contains a Procedure node with 2 steps + assert_eq!(children.len(), 1); + if let Node::Procedure { children, .. } = &children[0] { + assert_eq!(children.len(), 2); + } else { + panic!("expected Procedure in section"); + } + } else { + panic!("expected Section"); + } - assert_eq!(doc.sections[1].ordinal.as_deref(), Some("II")); - assert_eq!(doc.sections[1].heading.as_deref(), Some("Execution")); - assert_eq!(doc.sections[1].items.len(), 2); + if let Node::Section { ordinal, heading, children } = &doc.body[1] { + assert_eq!(ordinal, "II"); + assert_eq!(heading.as_deref(), Some("Execution")); + assert_eq!(children.len(), 1); + if let Node::Procedure { children, .. } = &children[0] { + assert_eq!(children.len(), 2); + } else { + panic!("expected Procedure in section"); + } + } else { + panic!("expected Section"); + } } } diff --git a/src/templating/procedure/renderer.rs b/src/templating/procedure/renderer.rs index 9c8242bf..708a21e1 100644 --- a/src/templating/procedure/renderer.rs +++ b/src/templating/procedure/renderer.rs @@ -7,7 +7,7 @@ use crate::templating::template::Renderer; use crate::templating::typst; -use super::types::{Document, Item, Section, Step, StepKind}; +use super::types::{Document, Node, Response, StepKind}; pub struct ProcedureRenderer; @@ -35,23 +35,34 @@ fn render(document: &Document) -> String { typst::escape(title) )); } - if !document.description.is_empty() { + + if !document.description.is_empty() || has_sections(&document.body) { out.push_str("_Overview_\n\n"); for para in &document.description { out.push_str(&typst::escape(para)); out.push('\n'); } + if has_sections(&document.body) { + out.push_str("\n#grid(columns: (auto, 1fr), column-gutter: 6pt, row-gutter: 0.3em,\n"); + for node in &document.body { + render_outline_entry(&mut out, node); + } + out.push_str(")\n"); + } } // Procedure bar out.push_str("#block(width: 100%, fill: rgb(\"#006699\"), inset: 5pt)[#text(fill: white)[*Procedure*]]\n\n"); - // Sections - let total = document.sections.len(); - for (i, section) in document.sections.iter().enumerate() { - render_section(&mut out, section); + // Body + let total = document.body.len(); + for (i, node) in document.body.iter().enumerate() { + render_node(&mut out, node); + // Section dividers if i + 1 < total { - out.push_str("#line(length: 100%, stroke: (thickness: 0.5pt, paint: rgb(\"#003366\"), dash: (\"dot\", 2pt, 4pt, 2pt)))\n\n"); + if let Node::Section { .. } = node { + out.push_str("#line(length: 100%, stroke: (thickness: 0.5pt, paint: rgb(\"#003366\"), dash: (\"dot\", 2pt, 4pt, 2pt)))\n\n"); + } } } @@ -59,61 +70,102 @@ fn render(document: &Document) -> String { out } -fn render_section(out: &mut String, section: &Section) { - // Section heading - match (§ion.ordinal, §ion.heading) { - (Some(ord), Some(heading)) => { - out.push_str(&format!( - "#text(size: 14pt)[*{}.* #h(8pt) *{}*]\n\n", - ord, - typst::escape(heading) - )); +/// True if any top-level node is a Section. +fn has_sections(body: &[Node]) -> bool { + body.iter() + .any(|n| matches!(n, Node::Section { .. })) +} + +fn render_outline_entry(out: &mut String, node: &Node) { + if let Node::Section { ordinal, heading, .. } = node { + match heading { + Some(heading) => { + out.push_str(&format!( + "[{}.], [{}],\n", + ordinal, + typst::escape(heading) + )); + } + None => { + out.push_str(&format!("[{}.], [],\n", ordinal)); + } + } + } +} + +fn render_node(out: &mut String, node: &Node) { + match node { + Node::Section { ordinal, heading, children } => { + render_section(out, ordinal, heading.as_deref(), children); + } + Node::Procedure { name, title, description, children } => { + render_procedure(out, name, title.as_deref(), description, children); + } + Node::Step { .. } => { + render_step(out, node); } - (Some(ord), None) => { - out.push_str(&format!("#text(size: 14pt)[*{}.*]\n\n", ord)); + Node::Attribute { name, children } => { + render_role(out, name, children); } - (None, Some(heading)) => { + } +} + +fn render_section(out: &mut String, ordinal: &str, heading: Option<&str>, children: &[Node]) { + match heading { + Some(heading) => { out.push_str(&format!( - "#text(size: 14pt)[*{}*]\n\n", + "#text(size: 14pt)[*{}.* #h(8pt) *{}*]\n\n", + ordinal, typst::escape(heading) )); } - (None, None) => {} + None => { + out.push_str(&format!("#text(size: 14pt)[*{}.*]\n\n", ordinal)); + } + } + + for child in children { + render_node(out, child); + } +} + +fn render_procedure(out: &mut String, name: &str, title: Option<&str>, description: &[String], children: &[Node]) { + out.push_str(&format!( + "#text(size: 7pt)[`{}`]\\\n", + name + )); + if let Some(title) = title { + out.push_str(&format!( + "#text(size: 11pt)[*{}*]\n\n", + typst::escape(title) + )); + } else { + out.push('\n'); } - for para in §ion.description { + for para in description { out.push_str(&typst::escape(para)); out.push_str("\n\n"); } - // Steps indented slightly from section heading - if !section.items.is_empty() { + if !children.is_empty() { out.push_str("#pad(left: 8pt)[\n"); - for item in §ion.items { - render_item(out, item); + for child in children { + render_node(out, child); } out.push_str("]\n"); } } -fn render_item(out: &mut String, item: &Item) { - match item { - Item::Step(step) => render_step(out, step), - Item::RoleGroup(group) => { - render_role(out, group); - } - } -} - -fn render_role(out: &mut String, group: &super::types::RoleGroup) { - out.push_str(&format!("- *{}*\n", typst::escape(&group.name))); - if !group.items.is_empty() { - let start = ordinal_start(&group.items); +fn render_role(out: &mut String, name: &str, children: &[Node]) { + out.push_str(&format!("- *{}*\n", typst::escape(name))); + if !children.is_empty() { + let start = ordinal_start(children); out.push_str(&format!( "#pad(left: 20pt)[\n#set par(leading: 0.5em)\n#set enum(numbering: \"a.\", start: {}, spacing: 0.8em)\n", start )); - for child in &group.items { + for child in children { render_child(out, child); } out.push_str("]\n"); @@ -121,9 +173,9 @@ fn render_role(out: &mut String, group: &super::types::RoleGroup) { } /// Convert the first child's letter ordinal to a numeric start value. -fn ordinal_start(items: &[Item]) -> u32 { - if let Some(Item::Step(step)) = items.first() { - if let Some(ord) = &step.ordinal { +fn ordinal_start(children: &[Node]) -> u32 { + if let Some(Node::Step { ordinal, .. }) = children.first() { + if let Some(ord) = ordinal { if let Some(c) = ord.chars().next() { if c.is_ascii_lowercase() { return (c as u32) - ('a' as u32) + 1; @@ -135,30 +187,35 @@ fn ordinal_start(items: &[Item]) -> u32 { } /// Render items nested under a role group (substeps). -fn render_child(out: &mut String, item: &Item) { - match item { - Item::Step(step) => { - match step.title.as_deref() { - Some(t) => { - out.push_str(&format!("+ {}\n", typst::escape(t))); - } - None => {} +fn render_child(out: &mut String, node: &Node) { + match node { + Node::Step { title, .. } => { + if let Some(t) = title { + out.push_str(&format!("+ {}\n", typst::escape(t))); } } - Item::RoleGroup(group) => { - render_role(out, group); + Node::Attribute { name, children } => { + render_role(out, name, children); } + _ => {} } } -fn render_step(out: &mut String, step: &Step) { - let ord = match step.kind { - StepKind::Dependent => step.ordinal.as_deref(), +fn render_step(out: &mut String, node: &Node) { + let Node::Step { kind, ordinal, title, body, invocations, responses, children } = node else { + return; + }; + + let ord = match kind { + StepKind::Dependent => ordinal.as_deref(), StepKind::Parallel => None, }; + // Invocations on the line before the step heading + render_invocations(out, invocations); + // Step heading - match (ord, step.title.as_deref()) { + match (ord, title.as_deref()) { (Some(o), Some(t)) => { out.push_str(&format!( "*{}.* #h(4pt) *{}*\n\n", @@ -175,58 +232,74 @@ fn render_step(out: &mut String, step: &Step) { (None, None) => {} } - for para in &step.body { + for para in body { out.push_str(&typst::escape(para)); out.push_str("\n\n"); } - if !step.responses.is_empty() { - for r in &step.responses { - match &r.condition { - Some(cond) => out.push_str(&format!( - "- _{} {}_\n", - typst::escape(&r.value), - typst::escape(cond) - )), - None => out.push_str(&format!("- _{}_\n", typst::escape(&r.value))), - } - } - out.push('\n'); + if !responses.is_empty() { + render_responses(out, responses); } - if !step.children.is_empty() { + if !children.is_empty() { out.push_str("#pad(left: 16pt)[\n"); - for child in &step.children { - render_item(out, child); + for child in children { + render_node(out, child); } out.push_str("]\n\n"); } } +fn render_invocations(out: &mut String, invocations: &[String]) { + if !invocations.is_empty() { + let names = invocations.join(", "); + out.push_str(&format!( + "#text(size: 7pt)[`{}`]\\\n", + names + )); + } +} + +fn render_responses(out: &mut String, responses: &[Response]) { + for r in responses { + match &r.condition { + Some(cond) => out.push_str(&format!( + "- _{} {}_\n", + typst::escape(&r.value), + typst::escape(cond) + )), + None => out.push_str(&format!("- _{}_\n", typst::escape(&r.value))), + } + } + out.push('\n'); +} + #[cfg(test)] mod check { use crate::templating::template::Renderer; use super::ProcedureRenderer; - use super::super::types::{Document, Item, RoleGroup, Section, Step, StepKind}; + use super::super::types::{Document, Node, StepKind}; - fn dep(ordinal: &str, title: &str) -> Step { - Step { + fn dep(ordinal: &str, title: &str) -> Node { + Node::Step { kind: StepKind::Dependent, ordinal: Some(ordinal.into()), title: Some(title.into()), body: Vec::new(), + invocations: Vec::new(), responses: Vec::new(), children: Vec::new(), } } - fn par(title: &str) -> Step { - Step { + fn par(title: &str) -> Node { + Node::Step { kind: StepKind::Parallel, ordinal: None, title: Some(title.into()), body: Vec::new(), + invocations: Vec::new(), responses: Vec::new(), children: Vec::new(), } @@ -237,7 +310,7 @@ mod check { let doc = Document { title: Some("Emergency Procedure".into()), description: Vec::new(), - sections: Vec::new(), + body: Vec::new(), }; let out = ProcedureRenderer.render(&doc); assert!(out.contains("*Emergency Procedure*")); @@ -248,12 +321,7 @@ mod check { let doc = Document { title: None, description: Vec::new(), - sections: vec![Section { - ordinal: None, - heading: None, - description: Vec::new(), - items: vec![Item::Step(dep("4", "Engineering Design"))], - }], + body: vec![dep("4", "Engineering Design")], }; let out = ProcedureRenderer.render(&doc); assert!(out.contains("*4.*")); @@ -265,12 +333,7 @@ mod check { let doc = Document { title: None, description: Vec::new(), - sections: vec![Section { - ordinal: None, - heading: None, - description: Vec::new(), - items: vec![Item::Step(par("Check exits"))], - }], + body: vec![par("Check exits")], }; let out = ProcedureRenderer.render(&doc); assert!(out.contains("Check exits")); @@ -281,14 +344,9 @@ mod check { let doc = Document { title: None, description: Vec::new(), - sections: vec![Section { - ordinal: None, - heading: None, - description: Vec::new(), - items: vec![Item::RoleGroup(RoleGroup { - name: "programmers".into(), - items: vec![Item::Step(dep("a", "define_interfaces"))], - })], + body: vec![Node::Attribute { + name: "programmers".into(), + children: vec![dep("a", "define_interfaces")], }], }; let out = ProcedureRenderer.render(&doc); @@ -302,11 +360,10 @@ mod check { let doc = Document { title: None, description: Vec::new(), - sections: vec![Section { - ordinal: Some("III".into()), + body: vec![Node::Section { + ordinal: "III".into(), heading: Some("Implementation".into()), - description: Vec::new(), - items: Vec::new(), + children: Vec::new(), }], }; let out = ProcedureRenderer.render(&doc); diff --git a/src/templating/procedure/types.rs b/src/templating/procedure/types.rs index 1ae8270f..26c76f46 100644 --- a/src/templating/procedure/types.rs +++ b/src/templating/procedure/types.rs @@ -1,15 +1,15 @@ //! Domain types for a procedure. //! -//! A procedure preserves the full hierarchy of the source Technique document: -//! sections containing procedures, procedures containing steps, with role -//! groups and responses, substeps, etc. This is basically a full fidelity -//! renderer of the input Technique structure. +//! A procedure is a recursive tree of nodes mirroring the structure of the +//! source Technique document. Sections, procedures, steps, role groups — +//! whatever the author wrote, the domain model preserves. -/// A procedure document is sections containing items. +/// A procedure document: title and description from the first procedure, +/// then a tree of nodes representing the body. pub struct Document { pub title: Option, pub description: Vec, - pub sections: Vec
, + pub body: Vec, } impl Document { @@ -17,36 +17,37 @@ impl Document { Document { title: None, description: Vec::new(), - sections: Vec::new(), + body: Vec::new(), } } } -/// A section within a procedure document. -pub struct Section { - pub ordinal: Option, - pub heading: Option, - pub description: Vec, - pub items: Vec, -} - -/// An item within a section: either a step or a role group. This -/// distinction matters because `@beaker` with lettered tasks is a -/// structural container, not a step annotation — the role group -/// owns its children rather than decorating them. -pub enum Item { - Step(Step), - RoleGroup(RoleGroup), -} - -/// A step within a procedure. -pub struct Step { - pub kind: StepKind, - pub ordinal: Option, - pub title: Option, - pub body: Vec, - pub responses: Vec, - pub children: Vec, +/// A node in the procedure tree. +pub enum Node { + Section { + ordinal: String, + heading: Option, + children: Vec, + }, + Procedure { + name: String, + title: Option, + description: Vec, + children: Vec, + }, + Step { + kind: StepKind, + ordinal: Option, + title: Option, + body: Vec, + invocations: Vec, + responses: Vec, + children: Vec, + }, + Attribute { + name: String, + children: Vec, + }, } /// Whether a step is dependent (numbered) or parallel (bulleted). @@ -55,12 +56,6 @@ pub enum StepKind { Parallel, } -/// A role group: a named container for items assigned to a role. -pub struct RoleGroup { - pub name: String, - pub items: Vec, -} - /// A response option with an optional condition. pub struct Response { pub value: String, diff --git a/src/templating/source.rs b/src/templating/source.rs index 2760c604..31a0070f 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -8,6 +8,11 @@ use crate::language::Document; use super::Template; +static PREAMBLE: &str = r#" +#show text: set text(font: "Inconsolata") +#show raw: set block(breakable: true) +"#; + pub struct Source { width: u8, } @@ -20,6 +25,8 @@ impl Source { impl Template for Source { fn render(&self, document: &Document) -> String { - render(&Typst, document, self.width) + let mut out = String::from(PREAMBLE); + out.push_str(&render(&Typst, document, self.width)); + out } }