From 58828806c9286233a77457dddf77fbee0c1ef275 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 10 Jan 2026 14:11:46 +1100 Subject: [PATCH 01/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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 } } From 1aa9b69a4b9079649de325ee717bb7c59e2068aa Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 10 Mar 2026 22:14:24 +1100 Subject: [PATCH 26/39] Builder for Typst data literals --- src/templating/typst.rs | 178 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 3 deletions(-) diff --git a/src/templating/typst.rs b/src/templating/typst.rs index 95a612a0..e6bf7a54 100644 --- a/src/templating/typst.rs +++ b/src/templating/typst.rs @@ -1,8 +1,9 @@ -//! Shared Typst markup primitives available for use by all template -//! renderers. +//! Shared Typst primitives available for use by all templates. //! //! This provides building blocks (headings, steps, roles, responses, etc) -//! that renderers can compose into complete output documents. +//! that renderers can compose into complete output documents, and data +//! literal helpers that domain types use to serialize themselves as Typst +//! dictionaries. //! //! Note that this is distinct from `highlighting::typst` which renders //! Technique in its original surface language syntax form; this module @@ -75,3 +76,174 @@ pub fn responses(options: &[String]) -> String { out.push_str("\n\n"); out } + +const INDENT: &str = " "; + +/// Stateful builder for accumulating Typst data literals. +pub struct Data { + out: String, + depth: usize, +} + +impl Data { + pub fn new() -> Self { + Data { + out: String::new(), + depth: 0, + } + } + + /// Consume the builder and return the wrapped output. + pub fn finish(self) -> String { + format!("#let technique =\n{}", self.out) + } + + fn pad(&mut self) { + for _ in 0..self.depth { + self.out.push_str(INDENT); + } + } + + /// Open a dictionary: `(`, newline, and increase depth. + pub fn open(&mut self) { + self.pad(); + self.out.push_str("(\n"); + self.depth += 1; + } + + /// Close a dictionary: decrease depth, closing `)`, and newline. + pub fn close(&mut self) { + self.depth -= 1; + self.pad(); + self.out.push_str(")\n"); + } + + /// Emit a `type: "name",` discriminator field and a newline. + pub fn tag(&mut self, name: &str) { + self.pad(); + self.out + .push_str(&format!("type: \"{}\",\n", name)); + } + + /// Emit a field whose value implements `Field`. + pub fn field(&mut self, key: &str, value: &(impl Field + ?Sized)) { + value.emit(self, key); + } + + /// Emit a list field, calling `Render::render` on each item. + pub fn list(&mut self, key: &str, items: &[T]) { + self.pad(); + self.out + .push_str(&format!("{}: (\n", key)); + self.depth += 1; + for item in items { + item.render(self); + } + self.depth -= 1; + self.pad(); + self.out.push_str("),\n"); + } +} + +/// Emit a domain type as a Typst data literal. +pub trait Render { + fn render(&self, data: &mut Data); +} + +impl Render for String { + fn render(&self, data: &mut Data) { + data.pad(); + data.out + .push_str(&format!("\"{}\",\n", escape_string(self))); + } +} + +/// Any type that knows how to emit itself as a `key: value,` pair in a Typst +/// dictionary should implement Field, which can then be used by the Data +/// builder's field() method. +pub trait Field { + fn emit(&self, data: &mut Data, key: &str); +} + +impl Field for str { + fn emit(&self, data: &mut Data, key: &str) { + data.pad(); + data.out + .push_str(&format!("{}: \"{}\",\n", key, escape_string(self))); + } +} + +impl Field for String { + fn emit(&self, data: &mut Data, key: &str) { + self.as_str().emit(data, key); + } +} + +impl Field for Option { + fn emit(&self, data: &mut Data, key: &str) { + data.pad(); + match self { + Some(v) => data + .out + .push_str(&format!("{}: \"{}\",\n", key, escape_string(v))), + None => data + .out + .push_str(&format!("{}: none,\n", key)), + } + } +} + +/// Escape `\` and `"` for Typst string literals. +pub fn escape_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") +} + +#[cfg(test)] +mod check { + use super::*; + + #[test] + fn escape_string_backslash_and_quote() { + assert_eq!(escape_string(r#"a "b" c\d"#), r#"a \"b\" c\\d"#); + } + + #[test] + fn field_some() { + let mut d = Data::new(); + d.depth = 1; + d.field("title", &Some("Hello".into())); + assert_eq!(d.out, " title: \"Hello\",\n"); + } + + #[test] + fn field_none() { + let mut d = Data::new(); + d.depth = 1; + d.field("title", &None::); + assert_eq!(d.out, " title: none,\n"); + } + + #[test] + fn open_close_tracks_depth() { + let mut d = Data::new(); + d.open(); + assert_eq!(d.depth, 1); + d.close(); + assert_eq!(d.depth, 0); + assert_eq!(d.out, "(\n)\n"); + } + + #[test] + fn nested_dict() { + let mut d = Data::new(); + d.open(); + d.field("name", "outer"); + d.open(); + d.field("name", "inner"); + d.close(); + d.close(); + assert!(d.out.contains(" name: \"outer\",\n")); + assert!(d.out.contains(" name: \"inner\",\n")); + } +} From 5c750ecb48541127dc50abfbf8eb7d61c237ab5f Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 10 Mar 2026 22:15:25 +1100 Subject: [PATCH 27/39] Render implementations for templates --- src/templating/checklist/types.rs | 42 +++++++++++++++ src/templating/procedure/types.rs | 87 ++++++++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/src/templating/checklist/types.rs b/src/templating/checklist/types.rs index abe72210..42031e2f 100644 --- a/src/templating/checklist/types.rs +++ b/src/templating/checklist/types.rs @@ -3,6 +3,8 @@ //! A checklist is moderately structured and relatively flat: sections with //! headings, steps with checkboxes, response options, and limited nesting. +use crate::templating::typst::{Data, Render}; + /// A checklist is a document of sections containing steps. pub struct Document { pub sections: Vec
, @@ -16,6 +18,14 @@ impl Document { } } +impl Render for Document { + fn render(&self, data: &mut Data) { + data.open(); + data.list("sections", &self.sections); + data.close(); + } +} + /// A section within a checklist. pub struct Section { pub ordinal: Option, @@ -23,6 +33,16 @@ pub struct Section { pub steps: Vec, } +impl Render for Section { + fn render(&self, data: &mut Data) { + data.open(); + data.field("ordinal", &self.ordinal); + data.field("heading", &self.heading); + data.list("steps", &self.steps); + data.close(); + } +} + /// A step within a checklist section. pub struct Step { #[allow(dead_code)] @@ -35,8 +55,30 @@ pub struct Step { pub children: Vec, } +impl Render for Step { + fn render(&self, data: &mut Data) { + data.open(); + data.field("ordinal", &self.ordinal); + data.field("title", &self.title); + data.list("body", &self.body); + data.field("role", &self.role); + data.list("responses", &self.responses); + data.list("children", &self.children); + data.close(); + } +} + /// A response option with an optional condition. pub struct Response { pub value: String, pub condition: Option, } + +impl Render for Response { + fn render(&self, data: &mut Data) { + data.open(); + data.field("value", &self.value); + data.field("condition", &self.condition); + data.close(); + } +} diff --git a/src/templating/procedure/types.rs b/src/templating/procedure/types.rs index 26c76f46..d7009027 100644 --- a/src/templating/procedure/types.rs +++ b/src/templating/procedure/types.rs @@ -4,6 +4,8 @@ //! source Technique document. Sections, procedures, steps, role groups — //! whatever the author wrote, the domain model preserves. +use crate::templating::typst::{Data, Render}; + /// A procedure document: title and description from the first procedure, /// then a tree of nodes representing the body. pub struct Document { @@ -22,6 +24,16 @@ impl Document { } } +impl Render for Document { + fn render(&self, data: &mut Data) { + data.open(); + data.field("title", &self.title); + data.list("description", &self.description); + data.list("body", &self.body); + data.close(); + } +} + /// A node in the procedure tree. pub enum Node { Section { @@ -35,9 +47,15 @@ pub enum Node { description: Vec, children: Vec, }, - Step { - kind: StepKind, - ordinal: Option, + Sequential { + ordinal: String, + title: Option, + body: Vec, + invocations: Vec, + responses: Vec, + children: Vec, + }, + Parallel { title: Option, body: Vec, invocations: Vec, @@ -50,10 +68,56 @@ pub enum Node { }, } -/// Whether a step is dependent (numbered) or parallel (bulleted). -pub enum StepKind { - Dependent, - Parallel, +impl Render for Node { + fn render(&self, data: &mut Data) { + match self { + Node::Section { ordinal, heading, children } => { + data.open(); + data.tag("section"); + data.field("ordinal", ordinal); + data.field("heading", heading); + data.list("children", children); + data.close(); + } + Node::Procedure { name, title, description, children } => { + data.open(); + data.tag("procedure"); + data.field("name", name); + data.field("title", title); + data.list("description", description); + data.list("children", children); + data.close(); + } + Node::Sequential { ordinal, title, body, invocations, responses, children } => { + data.open(); + data.tag("sequential"); + data.field("ordinal", ordinal); + data.field("title", title); + data.list("body", body); + data.list("invocations", invocations); + data.list("responses", responses); + data.list("children", children); + data.close(); + } + Node::Parallel { title, body, invocations, responses, children } => { + data.open(); + data.tag("parallel"); + data.field("title", title); + data.list("body", body); + data.list("invocations", invocations); + data.list("responses", responses); + data.list("children", children); + data.close(); + } + Node::Attribute { name, children } => { + data.open(); + data.tag("attribute"); + data.field("name", name); + data.list("children", children); + data.close(); + } + } + } } /// A response option with an optional condition. @@ -61,3 +125,12 @@ pub struct Response { pub value: String, pub condition: Option, } + +impl Render for Response { + fn render(&self, data: &mut Data) { + data.open(); + data.field("value", &self.value); + data.field("condition", &self.condition); + data.close(); + } +} From d8d197fa03ec0cf8e90476a81e541ed28a598a1d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 10 Mar 2026 22:21:36 +1100 Subject: [PATCH 28/39] Implement Template trait for checklist and procedure --- src/templating/checklist/adapter.rs | 25 ++-- src/templating/checklist/mod.rs | 16 ++- src/templating/checklist/renderer.rs | 42 ++++--- src/templating/mod.rs | 36 +++--- src/templating/procedure/adapter.rs | 105 ++++++++++------- src/templating/procedure/mod.rs | 16 ++- src/templating/procedure/renderer.rs | 163 +++++++++++++++------------ src/templating/source.rs | 4 + src/templating/template.rs | 22 ++-- tests/templating/rendering/mod.rs | 4 +- 10 files changed, 244 insertions(+), 189 deletions(-) diff --git a/src/templating/checklist/adapter.rs b/src/templating/checklist/adapter.rs index 5b5c63ca..7cb19ca8 100644 --- a/src/templating/checklist/adapter.rs +++ b/src/templating/checklist/adapter.rs @@ -236,8 +236,12 @@ preflight : 1. Fasten seatbelt "#, )); - assert_eq!(doc.sections.len(), 1); - assert_eq!(doc.sections[0].heading.as_deref(), Some("Pre-flight Checks")); + assert_eq!( + doc.sections + .len(), + 1 + ); + assert_eq!(doc.sections[0].heading, Some("Pre-flight Checks".into())); } #[test] @@ -253,8 +257,8 @@ checks : )); 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")); + assert_eq!(steps[0].role, Some("surgeon".into())); + assert_eq!(steps[1].role, Some("surgeon".into())); } #[test] @@ -268,14 +272,15 @@ checks : "#, )); let step = &doc.sections[0].steps[0]; - assert_eq!(step.responses.len(), 2); + 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") - ); + assert_eq!(step.responses[1].condition, Some("if complications".into())); } #[test] @@ -294,6 +299,6 @@ ensure_safety : "#, )); let steps = &doc.sections[0].steps; - assert_eq!(steps[0].title.as_deref(), Some("ensure_safety")); + assert_eq!(steps[0].title, Some("ensure_safety".into())); } } diff --git a/src/templating/checklist/mod.rs b/src/templating/checklist/mod.rs index 7c628482..7710111b 100644 --- a/src/templating/checklist/mod.rs +++ b/src/templating/checklist/mod.rs @@ -10,12 +10,9 @@ mod renderer; pub mod types; use crate::language; -use crate::templating::template::Template; - -use crate::templating::template::Adapter; -use crate::templating::template::Renderer; +use crate::templating::template::{Adapter, Template}; +use crate::templating::typst::{Data, Render}; use adapter::ChecklistAdapter; -use renderer::ChecklistRenderer; /// Checklist template: adapter + renderer composition. pub struct Checklist; @@ -23,6 +20,13 @@ pub struct Checklist; impl Template for Checklist { fn render(&self, document: &language::Document) -> String { let model = ChecklistAdapter.extract(document); - ChecklistRenderer.render(&model) + renderer::markup(&model) + } + + fn data(&self, document: &language::Document) -> String { + let model = ChecklistAdapter.extract(document); + let mut data = Data::new(); + model.render(&mut data); + data.finish() } } diff --git a/src/templating/checklist/renderer.rs b/src/templating/checklist/renderer.rs index bda64048..99e58a53 100644 --- a/src/templating/checklist/renderer.rs +++ b/src/templating/checklist/renderer.rs @@ -1,21 +1,10 @@ //! 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 { +pub fn markup(document: &Document) -> String { let mut output = typst::preamble(); for section in &document.sections { @@ -82,9 +71,6 @@ fn render_step(output: &mut String, step: &Step) { #[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 { @@ -108,7 +94,7 @@ mod check { steps: vec![step(Some("1"), Some("Check pulse"))], }], }; - let out = ChecklistRenderer.render(&doc); + let out = super::markup(&doc); assert!(out.contains("== I. Before anaesthesia")); } @@ -121,7 +107,7 @@ mod check { steps: vec![step(Some("3"), Some("Verify identity"))], }], }; - let out = ChecklistRenderer.render(&doc); + let out = super::markup(&doc); assert!(out.contains("*3.*")); assert!(out.contains("Verify identity")); } @@ -137,9 +123,13 @@ mod check { steps: vec![s], }], }; - let out = ChecklistRenderer.render(&doc); - let role_pos = out.find("surgeon").unwrap(); - let step_pos = out.find("Confirm site").unwrap(); + let out = super::markup(&doc); + let role_pos = out + .find("surgeon") + .unwrap(); + let step_pos = out + .find("Confirm site") + .unwrap(); assert!(role_pos < step_pos); } @@ -147,8 +137,14 @@ mod check { 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()) }, + Response { + value: "Yes".into(), + condition: None, + }, + Response { + value: "No".into(), + condition: Some("if complications".into()), + }, ]; let doc = Document { sections: vec![Section { @@ -157,7 +153,7 @@ mod check { steps: vec![s], }], }; - let out = ChecklistRenderer.render(&doc); + let out = super::markup(&doc); assert!(out.contains("Yes")); assert!(out.contains("No if complications")); } diff --git a/src/templating/mod.rs b/src/templating/mod.rs index 9d595748..02e580eb 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -1,21 +1,18 @@ //! Render Technique documents into formatted output. //! -//! The rendering pipeline has four layers: +//! The rendering pipeline has three 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. +//! - The **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. +//! +//! - An **Adapter** trait projects the AST types into a domain-specific +//! model (e.g. checklist flattens to checkable items, procedure preserves +//! the full hierarchy). Finally, +//! +//! - The **Template** trait which acts as a top-level interface providing +//! `render()` for Typst markup (PDF path) and `data()` for Typst data +//! literals. Each domain template composes an adapter internally. mod checklist; mod engine; @@ -27,11 +24,16 @@ pub mod typst; pub use checklist::Checklist; pub use procedure::Procedure; pub use source::Source; -pub use template::{Adapter, Renderer, Template}; +pub use template::{Adapter, Template}; use crate::language; -/// Render a Technique document using the specified template +/// Render a Technique document using the specified template. pub fn render(template: &impl Template, document: &language::Document) -> String { template.render(document) } + +/// Serialize a Technique document as a Typst data literal. +pub fn data(template: &impl Template, document: &language::Document) -> String { + template.data(document) +} diff --git a/src/templating/procedure/adapter.rs b/src/templating/procedure/adapter.rs index 60269e77..25e638f9 100644 --- a/src/templating/procedure/adapter.rs +++ b/src/templating/procedure/adapter.rs @@ -8,7 +8,7 @@ use crate::language; use crate::templating::template::Adapter; -use super::types::{Document, Node, Response, StepKind}; +use super::types::{Document, Node, Response}; pub struct ProcedureAdapter; @@ -123,13 +123,8 @@ fn nodes_from_scope(scope: &language::Scope) -> Vec { Vec::new() } -/// Convert a step-like scope into a Step node. +/// Convert a step-like scope into a Sequential or Parallel node. fn node_from_step(scope: &language::Scope) -> Node { - let kind = match scope { - language::Scope::DependentBlock { .. } => StepKind::Dependent, - _ => StepKind::Parallel, - }; - let mut responses = Vec::new(); let mut children = Vec::new(); @@ -170,16 +165,26 @@ fn node_from_step(scope: &language::Scope) -> Node { None => (None, Vec::new()), }; - Node::Step { - kind, - ordinal: scope - .ordinal() - .map(String::from), - title, - body, - invocations, - responses, - children, + match scope { + language::Scope::DependentBlock { .. } => Node::Sequential { + ordinal: scope + .ordinal() + .map(String::from) + .unwrap_or_default(), + title, + body, + invocations, + responses, + children, + }, + language::Scope::ParallelBlock { .. } => Node::Parallel { + title, + body, + invocations, + responses, + children, + }, + _ => panic!("node_from_step called with non-step scope"), } } @@ -190,8 +195,8 @@ mod check { use crate::parsing; use crate::templating::template::Adapter; + use super::super::types::Node; use super::ProcedureAdapter; - use super::super::types::{Node, StepKind}; fn trim(s: &str) -> &str { s.strip_prefix('\n') @@ -215,7 +220,7 @@ emergency : 1. Stay calm "#, )); - assert_eq!(doc.title.as_deref(), Some("Don't Panic")); + assert_eq!(doc.title, Some("Don't Panic".into())); } #[test] @@ -229,7 +234,7 @@ build : a. "#, )); - if let Node::Step { children, .. } = &doc.body[0] { + if let Node::Sequential { children, .. } = &doc.body[0] { assert_eq!(children.len(), 1); if let Node::Attribute { name, children } = &children[0] { assert_eq!(name, "programmers"); @@ -238,7 +243,7 @@ build : panic!("expected RoleGroup"); } } else { - panic!("expected Step"); + panic!("expected Sequential"); } } @@ -252,17 +257,20 @@ checks : 2. Second step "#, )); - 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")); + assert_eq!( + doc.body + .len(), + 2 + ); + if let Node::Sequential { ordinal, .. } = &doc.body[0] { + assert_eq!(ordinal, "1"); } else { - panic!("expected Step"); + panic!("expected Sequential"); } } #[test] - fn parallel_step_no_ordinal() { + fn parallel_step() { let doc = extract(trim( r#" checks : @@ -271,12 +279,15 @@ checks : - Second item "#, )); - assert_eq!(doc.body.len(), 2); - if let Node::Step { kind, ordinal, .. } = &doc.body[0] { - assert!(matches!(kind, StepKind::Parallel)); - assert_eq!(*ordinal, None); + assert_eq!( + doc.body + .len(), + 2 + ); + if let Node::Parallel { .. } = &doc.body[0] { + // ok } else { - panic!("expected Step"); + panic!("expected Parallel"); } } @@ -295,10 +306,10 @@ ensure_safety : - Check exits "#, )); - if let Node::Step { title, .. } = &doc.body[0] { - assert_eq!(title.as_deref(), Some("ensure_safety")); + if let Node::Sequential { title, .. } = &doc.body[0] { + assert_eq!(*title, Some("ensure_safety".into())); } else { - panic!("expected Step"); + panic!("expected Sequential"); } } @@ -325,11 +336,20 @@ execution : 4. Verify "#, )); - assert_eq!(doc.body.len(), 2); + assert_eq!( + doc.body + .len(), + 2 + ); - if let Node::Section { ordinal, heading, children } = &doc.body[0] { + if let Node::Section { + ordinal, + heading, + children, + } = &doc.body[0] + { assert_eq!(ordinal, "I"); - assert_eq!(heading.as_deref(), Some("Preparation")); + assert_eq!(*heading, Some("Preparation".into())); // Section contains a Procedure node with 2 steps assert_eq!(children.len(), 1); if let Node::Procedure { children, .. } = &children[0] { @@ -341,9 +361,14 @@ execution : panic!("expected Section"); } - if let Node::Section { ordinal, heading, children } = &doc.body[1] { + if let Node::Section { + ordinal, + heading, + children, + } = &doc.body[1] + { assert_eq!(ordinal, "II"); - assert_eq!(heading.as_deref(), Some("Execution")); + assert_eq!(*heading, Some("Execution".into())); assert_eq!(children.len(), 1); if let Node::Procedure { children, .. } = &children[0] { assert_eq!(children.len(), 2); diff --git a/src/templating/procedure/mod.rs b/src/templating/procedure/mod.rs index 77310973..e5b002ae 100644 --- a/src/templating/procedure/mod.rs +++ b/src/templating/procedure/mod.rs @@ -10,12 +10,9 @@ mod renderer; pub mod types; use crate::language; -use crate::templating::template::Template; - -use crate::templating::template::Adapter; -use crate::templating::template::Renderer; +use crate::templating::template::{Adapter, Template}; +use crate::templating::typst::{Data, Render}; use adapter::ProcedureAdapter; -use renderer::ProcedureRenderer; /// Procedure template: adapter + renderer composition. pub struct Procedure; @@ -23,6 +20,13 @@ pub struct Procedure; impl Template for Procedure { fn render(&self, document: &language::Document) -> String { let model = ProcedureAdapter.extract(document); - ProcedureRenderer.render(&model) + renderer::markup(&model) + } + + fn data(&self, document: &language::Document) -> String { + let model = ProcedureAdapter.extract(document); + let mut data = Data::new(); + model.render(&mut data); + data.finish() } } diff --git a/src/templating/procedure/renderer.rs b/src/templating/procedure/renderer.rs index 708a21e1..b893080e 100644 --- a/src/templating/procedure/renderer.rs +++ b/src/templating/procedure/renderer.rs @@ -4,22 +4,11 @@ //! 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; -use super::types::{Document, Node, Response, StepKind}; +use super::types::{Document, Node, Response}; -pub struct ProcedureRenderer; - -impl Renderer for ProcedureRenderer { - type Model = Document; - - fn render(&self, model: &Document) -> String { - render(model) - } -} - -fn render(document: &Document) -> String { +pub(super) fn markup(document: &Document) -> String { let mut out = String::new(); out.push_str("#set page(margin: 1.5cm)\n"); @@ -36,7 +25,11 @@ fn render(document: &Document) -> String { )); } - if !document.description.is_empty() || has_sections(&document.body) { + 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)); @@ -55,8 +48,14 @@ fn render(document: &Document) -> String { out.push_str("#block(width: 100%, fill: rgb(\"#006699\"), inset: 5pt)[#text(fill: white)[*Procedure*]]\n\n"); // Body - let total = document.body.len(); - for (i, node) in document.body.iter().enumerate() { + let total = document + .body + .len(); + for (i, node) in document + .body + .iter() + .enumerate() + { render_node(&mut out, node); // Section dividers if i + 1 < total { @@ -73,18 +72,17 @@ fn render(document: &Document) -> String { /// True if any top-level node is a Section. fn has_sections(body: &[Node]) -> bool { body.iter() - .any(|n| matches!(n, Node::Section { .. })) + .any(|n| if let Node::Section { .. } = n { true } else { false }) } fn render_outline_entry(out: &mut String, node: &Node) { - if let Node::Section { ordinal, heading, .. } = node { + if let Node::Section { + ordinal, heading, .. + } = node + { match heading { Some(heading) => { - out.push_str(&format!( - "[{}.], [{}],\n", - ordinal, - typst::escape(heading) - )); + out.push_str(&format!("[{}.], [{}],\n", ordinal, typst::escape(heading))); } None => { out.push_str(&format!("[{}.], [],\n", ordinal)); @@ -95,13 +93,22 @@ fn render_outline_entry(out: &mut String, node: &Node) { fn render_node(out: &mut String, node: &Node) { match node { - Node::Section { ordinal, heading, children } => { + Node::Section { + ordinal, + heading, + children, + } => { render_section(out, ordinal, heading.as_deref(), children); } - Node::Procedure { name, title, description, children } => { + Node::Procedure { + name, + title, + description, + children, + } => { render_procedure(out, name, title.as_deref(), description, children); } - Node::Step { .. } => { + Node::Sequential { .. } | Node::Parallel { .. } => { render_step(out, node); } Node::Attribute { name, children } => { @@ -129,11 +136,14 @@ fn render_section(out: &mut String, ordinal: &str, heading: Option<&str>, childr } } -fn render_procedure(out: &mut String, name: &str, title: Option<&str>, description: &[String], children: &[Node]) { - out.push_str(&format!( - "#text(size: 7pt)[`{}`]\\\n", - name - )); +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", @@ -174,12 +184,13 @@ fn render_role(out: &mut String, name: &str, children: &[Node]) { /// Convert the first child's letter ordinal to a numeric start value. 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; - } + if let Some(Node::Sequential { ordinal, .. }) = children.first() { + if let Some(c) = ordinal + .chars() + .next() + { + if c.is_ascii_lowercase() { + return (c as u32) - ('a' as u32) + 1; } } } @@ -189,7 +200,7 @@ fn ordinal_start(children: &[Node]) -> u32 { /// Render items nested under a role group (substeps). fn render_child(out: &mut String, node: &Node) { match node { - Node::Step { title, .. } => { + Node::Sequential { title, .. } | Node::Parallel { title, .. } => { if let Some(t) = title { out.push_str(&format!("+ {}\n", typst::escape(t))); } @@ -202,26 +213,39 @@ fn render_child(out: &mut String, node: &Node) { } 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, + let (ordinal, title, body, invocations, responses, children) = match node { + Node::Sequential { + ordinal, + title, + body, + invocations, + responses, + children, + } => ( + Some(ordinal.as_str()), + title, + body, + invocations, + responses, + children, + ), + Node::Parallel { + title, + body, + invocations, + responses, + children, + } => (None, title, body, invocations, responses, children), + _ => return, }; // Invocations on the line before the step heading render_invocations(out, invocations); // Step heading - match (ord, title.as_deref()) { + match (ordinal, title.as_deref()) { (Some(o), Some(t)) => { - out.push_str(&format!( - "*{}.* #h(4pt) *{}*\n\n", - o, - typst::escape(t) - )); + out.push_str(&format!("*{}.* #h(4pt) *{}*\n\n", o, typst::escape(t))); } (Some(o), None) => { out.push_str(&format!("*{}.*\n\n", o)); @@ -253,10 +277,7 @@ fn render_step(out: &mut String, node: &Node) { 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 - )); + out.push_str(&format!("#text(size: 7pt)[`{}`]\\\n", names)); } } @@ -276,15 +297,11 @@ fn render_responses(out: &mut String, responses: &[Response]) { #[cfg(test)] mod check { - use crate::templating::template::Renderer; - - use super::ProcedureRenderer; - use super::super::types::{Document, Node, StepKind}; + use super::super::types::{Document, Node}; fn dep(ordinal: &str, title: &str) -> Node { - Node::Step { - kind: StepKind::Dependent, - ordinal: Some(ordinal.into()), + Node::Sequential { + ordinal: ordinal.into(), title: Some(title.into()), body: Vec::new(), invocations: Vec::new(), @@ -294,9 +311,7 @@ mod check { } fn par(title: &str) -> Node { - Node::Step { - kind: StepKind::Parallel, - ordinal: None, + Node::Parallel { title: Some(title.into()), body: Vec::new(), invocations: Vec::new(), @@ -312,7 +327,7 @@ mod check { description: Vec::new(), body: Vec::new(), }; - let out = ProcedureRenderer.render(&doc); + let out = super::markup(&doc); assert!(out.contains("*Emergency Procedure*")); } @@ -323,7 +338,7 @@ mod check { description: Vec::new(), body: vec![dep("4", "Engineering Design")], }; - let out = ProcedureRenderer.render(&doc); + let out = super::markup(&doc); assert!(out.contains("*4.*")); assert!(out.contains("*Engineering Design*")); } @@ -335,7 +350,7 @@ mod check { description: Vec::new(), body: vec![par("Check exits")], }; - let out = ProcedureRenderer.render(&doc); + let out = super::markup(&doc); assert!(out.contains("Check exits")); } @@ -349,9 +364,13 @@ mod check { children: vec![dep("a", "define_interfaces")], }], }; - let out = ProcedureRenderer.render(&doc); - let role_pos = out.find("programmers").unwrap(); - let step_pos = out.find("define\\_interfaces").unwrap(); + let out = super::markup(&doc); + let role_pos = out + .find("programmers") + .unwrap(); + let step_pos = out + .find("define\\_interfaces") + .unwrap(); assert!(role_pos < step_pos); } @@ -366,7 +385,7 @@ mod check { children: Vec::new(), }], }; - let out = ProcedureRenderer.render(&doc); + let out = super::markup(&doc); assert!(out.contains("*III.*")); assert!(out.contains("*Implementation*")); } diff --git a/src/templating/source.rs b/src/templating/source.rs index 31a0070f..79caf015 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -29,4 +29,8 @@ impl Template for Source { out.push_str(&render(&Typst, document, self.width)); out } + + fn data(&self, document: &Document) -> String { + self.render(document) + } } diff --git a/src/templating/template.rs b/src/templating/template.rs index 39d1486e..5eaa930a 100644 --- a/src/templating/template.rs +++ b/src/templating/template.rs @@ -2,14 +2,18 @@ use crate::language; -/// 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. +/// A template transforms a Technique document into output. Internally this +/// is split into two phases: an adapter, which takes the AST from the parser +/// and converts it to domain types, and rendering which converts that domain +/// into output. Not all templates make this split; `Source` is a special case +/// that delegates directly to the code formatting logic. pub trait Template { + /// Render the document as complete Typst markup (for PDF generation). fn render(&self, document: &language::Document) -> String; + + /// Serialize the document as a Typst data literal. + fn data(&self, document: &language::Document) -> String; } /// Adapters project the AST into a domain-specific model. Each template @@ -20,11 +24,3 @@ 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/tests/templating/rendering/mod.rs b/tests/templating/rendering/mod.rs index aabaffe6..d2457307 100644 --- a/tests/templating/rendering/mod.rs +++ b/tests/templating/rendering/mod.rs @@ -28,8 +28,8 @@ fn check_directory(dir: &Path, template: &impl templating::Template) { 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 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)); From c7546d565e15d00ff44b4a2ff429b535994045ab Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Tue, 10 Mar 2026 22:27:54 +1100 Subject: [PATCH 29/39] Select typst intermediate or pdf result when rendering --- src/main.rs | 52 ++++++++++++++++++++++++---------------- src/templating/engine.rs | 22 +++++++++-------- src/templating/source.rs | 12 +++------- 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/src/main.rs b/src/main.rs index d9a77461..949c7f71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -317,37 +317,47 @@ fn main() { 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") - } + None => technique + .header + .as_ref() + .and_then(|m| m.template) + .unwrap_or("source"), }; debug!(template); // Select template and render - let result = match template { - "source" => templating::render(&Source::new(70), &technique), - "checklist" => templating::render(&Checklist, &technique), - "procedure" => templating::render(&Procedure, &technique), - other => { - eprintln!( - "{}: unrecognized template \"{}\"", - "error".bright_red(), - other - ); - std::process::exit(1); - } - }; - match output.as_str() { "typst" => { + let result = match template { + "source" => templating::data(&Source, &technique), + "checklist" => templating::data(&Checklist, &technique), + "procedure" => templating::data(&Procedure, &technique), + other => { + eprintln!( + "{}: unrecognized template \"{}\"", + "error".bright_red(), + other + ); + std::process::exit(1); + } + }; print!("{}", result); } "pdf" => { + let result = match template { + "source" => templating::render(&Source, &technique), + "checklist" => templating::render(&Checklist, &technique), + "procedure" => templating::render(&Procedure, &technique), + other => { + eprintln!( + "{}: unrecognized template \"{}\"", + "error".bright_red(), + other + ); + std::process::exit(1); + } + }; output::via_typst(&filename, &result); } _ => panic!("Unrecognized --output value"), diff --git a/src/templating/engine.rs b/src/templating/engine.rs index 9e7d7a3d..09a92a34 100644 --- a/src/templating/engine.rs +++ b/src/templating/engine.rs @@ -1,11 +1,11 @@ //! 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 @@ -320,9 +320,7 @@ impl<'i> Paragraph<'i> { #[cfg(test)] mod check { - use crate::language::{ - Descriptive, Expression, Identifier, Invocation, Paragraph, Target, - }; + use crate::language::{Descriptive, Expression, Identifier, Invocation, Paragraph, Target}; fn local<'a>(name: &'a str) -> Invocation<'a> { Invocation { @@ -334,9 +332,13 @@ mod check { // Pure text: "Ensure physical and digital safety" #[test] fn text_only_paragraph() { - let p = Paragraph(vec![Descriptive::Text("Ensure physical and digital safety")]); + 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!(p + .invocations() + .is_empty()); assert_eq!(p.content(), "Ensure physical and digital safety"); } @@ -365,9 +367,9 @@ mod check { // CodeInline with repeat: { repeat } #[test] fn repeat_expression() { - let p = Paragraph(vec![Descriptive::CodeInline(Expression::Repeat( - Box::new(Expression::Application(local("incident_action_cycle"))), - ))]); + 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"); diff --git a/src/templating/source.rs b/src/templating/source.rs index 79caf015..3f724311 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -13,20 +13,14 @@ static PREAMBLE: &str = r#" #show raw: set block(breakable: true) "#; -pub struct Source { - width: u8, -} +const WIDTH: u8 = 70; -impl Source { - pub fn new(width: u8) -> Self { - Source { width } - } -} +pub struct Source; impl Template for Source { fn render(&self, document: &Document) -> String { let mut out = String::from(PREAMBLE); - out.push_str(&render(&Typst, document, self.width)); + out.push_str(&render(&Typst, document, WIDTH)); out } From 5483fc1dc96fdce49301cf830bd579d8359625ba Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 14:33:03 +1100 Subject: [PATCH 30/39] Update command-line argument handling to use domain --- src/main.rs | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index 949c7f71..8c48346d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,7 @@ fn main() { Arg::new("output") .short('o') .long("output") + .value_name("type") .value_parser(["native", "none"]) .default_value("none") .action(ArgAction::Set) @@ -113,17 +114,27 @@ fn main() { Arg::new("output") .short('o') .long("output") + .value_name("type") .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("domain") + .short('d') + .long("domain") + .value_parser(["checklist", "procedure", "recipe", "source"]) + .action(ArgAction::Set) + .help("The kind of procedure this Technique document represents. By default the value specified in the input document's metadata will be used, falling back to source if unspecified."), + ) .arg( Arg::new("template") .short('t') .long("template") + .value_name("filename") .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 highlighting if unspecified."), + .help("Path to a Typst template file for rendering."), ) .arg( Arg::new("filename") @@ -310,32 +321,32 @@ fn main() { } }; - // If present the value of the --template option will override the - // document's metadata template line. If neither is specified then + // If present the value of the --domain option will override the + // document's metadata domain line. If neither is specified then // the fallback default is "source". - let template = submatches.get_one::("template"); - let template: &str = match template { + let domain = submatches.get_one::("domain"); + let domain: &str = match domain { Some(value) => value, None => technique .header .as_ref() - .and_then(|m| m.template) + .and_then(|m| m.domain) .unwrap_or("source"), }; - debug!(template); + debug!(domain); - // Select template and render + // Select domain and render match output.as_str() { "typst" => { - let result = match template { + let result = match domain { "source" => templating::data(&Source, &technique), "checklist" => templating::data(&Checklist, &technique), "procedure" => templating::data(&Procedure, &technique), other => { eprintln!( - "{}: unrecognized template \"{}\"", + "{}: unrecognized domain \"{}\"", "error".bright_red(), other ); @@ -345,13 +356,13 @@ fn main() { print!("{}", result); } "pdf" => { - let result = match template { + let result = match domain { "source" => templating::render(&Source, &technique), "checklist" => templating::render(&Checklist, &technique), "procedure" => templating::render(&Procedure, &technique), other => { eprintln!( - "{}: unrecognized template \"{}\"", + "{}: unrecognized domain \"{}\"", "error".bright_red(), other ); From 6123773faf071a73cf43e8913b46656d9b01df6b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 18:46:16 +1100 Subject: [PATCH 31/39] Rename domain-specific modules --- src/{templating => domain}/checklist/adapter.rs | 0 src/{templating => domain}/checklist/types.rs | 0 src/{templating => domain}/engine.rs | 0 src/{templating => domain}/procedure/adapter.rs | 0 src/{templating => domain}/procedure/types.rs | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename src/{templating => domain}/checklist/adapter.rs (100%) rename src/{templating => domain}/checklist/types.rs (100%) rename src/{templating => domain}/engine.rs (100%) rename src/{templating => domain}/procedure/adapter.rs (100%) rename src/{templating => domain}/procedure/types.rs (100%) diff --git a/src/templating/checklist/adapter.rs b/src/domain/checklist/adapter.rs similarity index 100% rename from src/templating/checklist/adapter.rs rename to src/domain/checklist/adapter.rs diff --git a/src/templating/checklist/types.rs b/src/domain/checklist/types.rs similarity index 100% rename from src/templating/checklist/types.rs rename to src/domain/checklist/types.rs diff --git a/src/templating/engine.rs b/src/domain/engine.rs similarity index 100% rename from src/templating/engine.rs rename to src/domain/engine.rs diff --git a/src/templating/procedure/adapter.rs b/src/domain/procedure/adapter.rs similarity index 100% rename from src/templating/procedure/adapter.rs rename to src/domain/procedure/adapter.rs diff --git a/src/templating/procedure/types.rs b/src/domain/procedure/types.rs similarity index 100% rename from src/templating/procedure/types.rs rename to src/domain/procedure/types.rs From 7c940ba736ed8c973b774c1364f9793988c7f0cf Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 19:31:49 +1100 Subject: [PATCH 32/39] Move domain types, adapters, and literal builder to domain module --- src/domain/adapter.rs | 12 ++ src/domain/checklist/adapter.rs | 4 +- src/domain/checklist/mod.rs | 2 + src/domain/checklist/types.rs | 2 +- src/domain/mod.rs | 18 +++ src/domain/procedure/adapter.rs | 4 +- src/domain/procedure/mod.rs | 2 + src/domain/procedure/types.rs | 2 +- src/domain/typst.rs | 177 ++++++++++++++++++++++++++ src/lib.rs | 1 + src/templating/checklist/mod.rs | 9 +- src/templating/checklist/renderer.rs | 4 +- src/templating/mod.rs | 19 +-- src/templating/procedure/mod.rs | 9 +- src/templating/procedure/renderer.rs | 4 +- src/templating/template.rs | 9 -- src/templating/typst.rs | 179 +-------------------------- 17 files changed, 237 insertions(+), 220 deletions(-) create mode 100644 src/domain/adapter.rs create mode 100644 src/domain/checklist/mod.rs create mode 100644 src/domain/mod.rs create mode 100644 src/domain/procedure/mod.rs create mode 100644 src/domain/typst.rs diff --git a/src/domain/adapter.rs b/src/domain/adapter.rs new file mode 100644 index 00000000..cbeb7de1 --- /dev/null +++ b/src/domain/adapter.rs @@ -0,0 +1,12 @@ +//! Adapter trait for domain projections. + +use crate::language; + +/// Adapters project the AST into a domain-specific model. Each domain +/// 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; +} diff --git a/src/domain/checklist/adapter.rs b/src/domain/checklist/adapter.rs index 7cb19ca8..a0cf2711 100644 --- a/src/domain/checklist/adapter.rs +++ b/src/domain/checklist/adapter.rs @@ -5,7 +5,7 @@ //! rendered as headings with their sub-procedures' steps as children. use crate::language; -use crate::templating::template::Adapter; +use crate::domain::Adapter; use super::types::{Document, Response, Section, Step}; @@ -210,7 +210,7 @@ mod check { use std::path::Path; use crate::parsing; - use crate::templating::template::Adapter; + use crate::domain::Adapter; use super::ChecklistAdapter; diff --git a/src/domain/checklist/mod.rs b/src/domain/checklist/mod.rs new file mode 100644 index 00000000..35135afb --- /dev/null +++ b/src/domain/checklist/mod.rs @@ -0,0 +1,2 @@ +pub mod adapter; +pub mod types; diff --git a/src/domain/checklist/types.rs b/src/domain/checklist/types.rs index 42031e2f..e2c7a3c8 100644 --- a/src/domain/checklist/types.rs +++ b/src/domain/checklist/types.rs @@ -3,7 +3,7 @@ //! A checklist is moderately structured and relatively flat: sections with //! headings, steps with checkboxes, response options, and limited nesting. -use crate::templating::typst::{Data, Render}; +use crate::domain::typst::{Data, Render}; /// A checklist is a document of sections containing steps. pub struct Document { diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 00000000..2c88eeeb --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,18 @@ +//! Domain projections of Technique documents. +//! +//! A domain projection takes the parser's AST and transforms it into a +//! model suited to a particular kind of output. The **checklist** domain +//! flattens procedures into printable checklists; the **procedure** domain +//! preserves the full hierarchy; others are forthcoming. +//! +//! The **engine** module provides convenient accessors into the parser's AST +//! types so that adapters can extract content without needing to understand +//! the nuances of correclty matching directly on the internals types. + +mod adapter; +pub mod checklist; +pub mod engine; +pub mod procedure; +pub mod typst; + +pub use adapter::Adapter; diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs index 25e638f9..c0fd4343 100644 --- a/src/domain/procedure/adapter.rs +++ b/src/domain/procedure/adapter.rs @@ -6,7 +6,7 @@ //! are appended as Procedure nodes. use crate::language; -use crate::templating::template::Adapter; +use crate::domain::Adapter; use super::types::{Document, Node, Response}; @@ -193,7 +193,7 @@ mod check { use std::path::Path; use crate::parsing; - use crate::templating::template::Adapter; + use crate::domain::Adapter; use super::super::types::Node; use super::ProcedureAdapter; diff --git a/src/domain/procedure/mod.rs b/src/domain/procedure/mod.rs new file mode 100644 index 00000000..35135afb --- /dev/null +++ b/src/domain/procedure/mod.rs @@ -0,0 +1,2 @@ +pub mod adapter; +pub mod types; diff --git a/src/domain/procedure/types.rs b/src/domain/procedure/types.rs index d7009027..1afcc7a6 100644 --- a/src/domain/procedure/types.rs +++ b/src/domain/procedure/types.rs @@ -4,7 +4,7 @@ //! source Technique document. Sections, procedures, steps, role groups — //! whatever the author wrote, the domain model preserves. -use crate::templating::typst::{Data, Render}; +use crate::domain::typst::{Data, Render}; /// A procedure document: title and description from the first procedure, /// then a tree of nodes representing the body. diff --git a/src/domain/typst.rs b/src/domain/typst.rs new file mode 100644 index 00000000..36781861 --- /dev/null +++ b/src/domain/typst.rs @@ -0,0 +1,177 @@ +//! Typst data literal builder. +//! +//! Domain types serialize themselves as Typst dictionary literals using the +//! `Data` builder. The `Render` trait is implemented by types that emit +//! themselves as complete dictionary entries; `Field` is for individual +//! key-value pairs within a dictionary. + +const INDENT: &str = " "; + +/// Stateful builder for accumulating Typst data literals. +pub struct Data { + out: String, + depth: usize, +} + +impl Data { + pub fn new() -> Self { + Data { + out: String::new(), + depth: 0, + } + } + + /// Consume the builder and return the wrapped output. + pub fn finish(self) -> String { + format!("#let technique =\n{}", self.out) + } + + fn pad(&mut self) { + for _ in 0..self.depth { + self.out.push_str(INDENT); + } + } + + /// Open a dictionary: `(`, newline, and increase depth. + pub fn open(&mut self) { + self.pad(); + self.out.push_str("(\n"); + self.depth += 1; + } + + /// Close a dictionary: decrease depth, closing `)`, and newline. + pub fn close(&mut self) { + self.depth -= 1; + self.pad(); + self.out.push_str(")\n"); + } + + /// Emit a `type: "name",` discriminator field and a newline. + pub fn tag(&mut self, name: &str) { + self.pad(); + self.out + .push_str(&format!("type: \"{}\",\n", name)); + } + + /// Emit a field whose value implements `Field`. + pub fn field(&mut self, key: &str, value: &(impl Field + ?Sized)) { + value.emit(self, key); + } + + /// Emit a list field, calling `Render::render` on each item. + pub fn list(&mut self, key: &str, items: &[T]) { + self.pad(); + self.out + .push_str(&format!("{}: (\n", key)); + self.depth += 1; + for item in items { + item.render(self); + } + self.depth -= 1; + self.pad(); + self.out.push_str("),\n"); + } +} + +/// Emit a domain type as a Typst data literal. +pub trait Render { + fn render(&self, data: &mut Data); +} + +impl Render for String { + fn render(&self, data: &mut Data) { + data.pad(); + data.out + .push_str(&format!("\"{}\",\n", escape_string(self))); + } +} + +/// Any type that knows how to emit itself as a `key: value,` pair in a Typst +/// dictionary should implement Field, which can then be used by the Data +/// builder's field() method. +pub trait Field { + fn emit(&self, data: &mut Data, key: &str); +} + +impl Field for str { + fn emit(&self, data: &mut Data, key: &str) { + data.pad(); + data.out + .push_str(&format!("{}: \"{}\",\n", key, escape_string(self))); + } +} + +impl Field for String { + fn emit(&self, data: &mut Data, key: &str) { + self.as_str().emit(data, key); + } +} + +impl Field for Option { + fn emit(&self, data: &mut Data, key: &str) { + data.pad(); + match self { + Some(v) => data + .out + .push_str(&format!("{}: \"{}\",\n", key, escape_string(v))), + None => data + .out + .push_str(&format!("{}: none,\n", key)), + } + } +} + +/// Escape `\` and `"` for Typst string literals. +pub fn escape_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") +} + +#[cfg(test)] +mod check { + use super::*; + + #[test] + fn escape_string_backslash_and_quote() { + assert_eq!(escape_string(r#"a "b" c\d"#), r#"a \"b\" c\\d"#); + } + + #[test] + fn field_some() { + let mut d = Data::new(); + d.depth = 1; + d.field("title", &Some("Hello".into())); + assert_eq!(d.out, " title: \"Hello\",\n"); + } + + #[test] + fn field_none() { + let mut d = Data::new(); + d.depth = 1; + d.field("title", &None::); + assert_eq!(d.out, " title: none,\n"); + } + + #[test] + fn open_close_tracks_depth() { + let mut d = Data::new(); + d.open(); + assert_eq!(d.depth, 1); + d.close(); + assert_eq!(d.depth, 0); + assert_eq!(d.out, "(\n)\n"); + } + + #[test] + fn nested_dict() { + let mut d = Data::new(); + d.open(); + d.field("name", "outer"); + d.open(); + d.field("name", "inner"); + d.close(); + d.close(); + assert!(d.out.contains(" name: \"outer\",\n")); + assert!(d.out.contains(" name: \"inner\",\n")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 84777d26..430aaa04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod domain; pub mod formatting; pub mod highlighting; pub mod language; diff --git a/src/templating/checklist/mod.rs b/src/templating/checklist/mod.rs index 7710111b..9e3c65e6 100644 --- a/src/templating/checklist/mod.rs +++ b/src/templating/checklist/mod.rs @@ -5,14 +5,13 @@ //! 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::domain::checklist::adapter::ChecklistAdapter; +use crate::domain::typst::{Data, Render}; +use crate::domain::Adapter; use crate::language; -use crate::templating::template::{Adapter, Template}; -use crate::templating::typst::{Data, Render}; -use adapter::ChecklistAdapter; +use crate::templating::template::Template; /// Checklist template: adapter + renderer composition. pub struct Checklist; diff --git a/src/templating/checklist/renderer.rs b/src/templating/checklist/renderer.rs index 99e58a53..12974375 100644 --- a/src/templating/checklist/renderer.rs +++ b/src/templating/checklist/renderer.rs @@ -2,7 +2,7 @@ use crate::templating::typst; -use super::types::{Document, Section, Step}; +use crate::domain::checklist::types::{Document, Section, Step}; pub fn markup(document: &Document) -> String { let mut output = typst::preamble(); @@ -71,7 +71,7 @@ fn render_step(output: &mut String, step: &Step) { #[cfg(test)] mod check { - use super::super::types::{Document, Response, Section, Step}; + use crate::domain::checklist::types::{Document, Response, Section, Step}; fn step(ordinal: Option<&str>, title: Option<&str>) -> Step { Step { diff --git a/src/templating/mod.rs b/src/templating/mod.rs index 02e580eb..7824ca28 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -1,21 +1,10 @@ //! Render Technique documents into formatted output. //! -//! The rendering pipeline has three layers: -//! -//! - The **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. -//! -//! - An **Adapter** trait projects the AST types into a domain-specific -//! model (e.g. checklist flattens to checkable items, procedure preserves -//! the full hierarchy). Finally, -//! -//! - The **Template** trait which acts as a top-level interface providing -//! `render()` for Typst markup (PDF path) and `data()` for Typst data -//! literals. Each domain template composes an adapter internally. +//! The **Template** trait acts as a top-level interface providing `render()` +//! for Typst markup (PDF path) and `data()` for Typst data literals. Each +//! domain template composes an adapter from `crate::domain` internally. mod checklist; -mod engine; mod procedure; mod source; mod template; @@ -24,7 +13,7 @@ pub mod typst; pub use checklist::Checklist; pub use procedure::Procedure; pub use source::Source; -pub use template::{Adapter, Template}; +pub use template::Template; use crate::language; diff --git a/src/templating/procedure/mod.rs b/src/templating/procedure/mod.rs index e5b002ae..969cb328 100644 --- a/src/templating/procedure/mod.rs +++ b/src/templating/procedure/mod.rs @@ -5,14 +5,13 @@ //! 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::domain::procedure::adapter::ProcedureAdapter; +use crate::domain::typst::{Data, Render}; +use crate::domain::Adapter; use crate::language; -use crate::templating::template::{Adapter, Template}; -use crate::templating::typst::{Data, Render}; -use adapter::ProcedureAdapter; +use crate::templating::template::Template; /// Procedure template: adapter + renderer composition. pub struct Procedure; diff --git a/src/templating/procedure/renderer.rs b/src/templating/procedure/renderer.rs index b893080e..195d42a4 100644 --- a/src/templating/procedure/renderer.rs +++ b/src/templating/procedure/renderer.rs @@ -6,7 +6,7 @@ use crate::templating::typst; -use super::types::{Document, Node, Response}; +use crate::domain::procedure::types::{Document, Node, Response}; pub(super) fn markup(document: &Document) -> String { let mut out = String::new(); @@ -297,7 +297,7 @@ fn render_responses(out: &mut String, responses: &[Response]) { #[cfg(test)] mod check { - use super::super::types::{Document, Node}; + use crate::domain::procedure::types::{Document, Node}; fn dep(ordinal: &str, title: &str) -> Node { Node::Sequential { diff --git a/src/templating/template.rs b/src/templating/template.rs index 5eaa930a..ceb71f4f 100644 --- a/src/templating/template.rs +++ b/src/templating/template.rs @@ -15,12 +15,3 @@ pub trait Template { /// Serialize the document as a Typst data literal. fn data(&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; -} diff --git a/src/templating/typst.rs b/src/templating/typst.rs index e6bf7a54..70bb6d70 100644 --- a/src/templating/typst.rs +++ b/src/templating/typst.rs @@ -1,9 +1,7 @@ -//! Shared Typst primitives available for use by all templates. +//! Typst rendering primitives for use by template renderers. //! -//! This provides building blocks (headings, steps, roles, responses, etc) -//! that renderers can compose into complete output documents, and data -//! literal helpers that domain types use to serialize themselves as Typst -//! dictionaries. +//! These building blocks (headings, steps, roles, responses, etc) are +//! composed by renderers into complete Typst markup for PDF output. //! //! Note that this is distinct from `highlighting::typst` which renders //! Technique in its original surface language syntax form; this module @@ -76,174 +74,3 @@ pub fn responses(options: &[String]) -> String { out.push_str("\n\n"); out } - -const INDENT: &str = " "; - -/// Stateful builder for accumulating Typst data literals. -pub struct Data { - out: String, - depth: usize, -} - -impl Data { - pub fn new() -> Self { - Data { - out: String::new(), - depth: 0, - } - } - - /// Consume the builder and return the wrapped output. - pub fn finish(self) -> String { - format!("#let technique =\n{}", self.out) - } - - fn pad(&mut self) { - for _ in 0..self.depth { - self.out.push_str(INDENT); - } - } - - /// Open a dictionary: `(`, newline, and increase depth. - pub fn open(&mut self) { - self.pad(); - self.out.push_str("(\n"); - self.depth += 1; - } - - /// Close a dictionary: decrease depth, closing `)`, and newline. - pub fn close(&mut self) { - self.depth -= 1; - self.pad(); - self.out.push_str(")\n"); - } - - /// Emit a `type: "name",` discriminator field and a newline. - pub fn tag(&mut self, name: &str) { - self.pad(); - self.out - .push_str(&format!("type: \"{}\",\n", name)); - } - - /// Emit a field whose value implements `Field`. - pub fn field(&mut self, key: &str, value: &(impl Field + ?Sized)) { - value.emit(self, key); - } - - /// Emit a list field, calling `Render::render` on each item. - pub fn list(&mut self, key: &str, items: &[T]) { - self.pad(); - self.out - .push_str(&format!("{}: (\n", key)); - self.depth += 1; - for item in items { - item.render(self); - } - self.depth -= 1; - self.pad(); - self.out.push_str("),\n"); - } -} - -/// Emit a domain type as a Typst data literal. -pub trait Render { - fn render(&self, data: &mut Data); -} - -impl Render for String { - fn render(&self, data: &mut Data) { - data.pad(); - data.out - .push_str(&format!("\"{}\",\n", escape_string(self))); - } -} - -/// Any type that knows how to emit itself as a `key: value,` pair in a Typst -/// dictionary should implement Field, which can then be used by the Data -/// builder's field() method. -pub trait Field { - fn emit(&self, data: &mut Data, key: &str); -} - -impl Field for str { - fn emit(&self, data: &mut Data, key: &str) { - data.pad(); - data.out - .push_str(&format!("{}: \"{}\",\n", key, escape_string(self))); - } -} - -impl Field for String { - fn emit(&self, data: &mut Data, key: &str) { - self.as_str().emit(data, key); - } -} - -impl Field for Option { - fn emit(&self, data: &mut Data, key: &str) { - data.pad(); - match self { - Some(v) => data - .out - .push_str(&format!("{}: \"{}\",\n", key, escape_string(v))), - None => data - .out - .push_str(&format!("{}: none,\n", key)), - } - } -} - -/// Escape `\` and `"` for Typst string literals. -pub fn escape_string(s: &str) -> String { - s.replace('\\', "\\\\") - .replace('"', "\\\"") -} - -#[cfg(test)] -mod check { - use super::*; - - #[test] - fn escape_string_backslash_and_quote() { - assert_eq!(escape_string(r#"a "b" c\d"#), r#"a \"b\" c\\d"#); - } - - #[test] - fn field_some() { - let mut d = Data::new(); - d.depth = 1; - d.field("title", &Some("Hello".into())); - assert_eq!(d.out, " title: \"Hello\",\n"); - } - - #[test] - fn field_none() { - let mut d = Data::new(); - d.depth = 1; - d.field("title", &None::); - assert_eq!(d.out, " title: none,\n"); - } - - #[test] - fn open_close_tracks_depth() { - let mut d = Data::new(); - d.open(); - assert_eq!(d.depth, 1); - d.close(); - assert_eq!(d.depth, 0); - assert_eq!(d.out, "(\n)\n"); - } - - #[test] - fn nested_dict() { - let mut d = Data::new(); - d.open(); - d.field("name", "outer"); - d.open(); - d.field("name", "inner"); - d.close(); - d.close(); - assert!(d.out.contains(" name: \"outer\",\n")); - assert!(d.out.contains(" name: \"inner\",\n")); - } -} From 78dea01a1762baf91de77e592a54d704416c5d3e Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 20:28:06 +1100 Subject: [PATCH 33/39] Move template rendering logic to Typst --- src/main.rs | 20 +- .../{checklist/mod.rs => checklist.rs} | 12 +- src/templating/checklist.typ | 55 +++ src/templating/checklist/renderer.rs | 160 ------- src/templating/mod.rs | 9 +- .../{procedure/mod.rs => procedure.rs} | 16 +- src/templating/procedure.typ | 149 +++++++ src/templating/procedure/renderer.rs | 392 ------------------ src/templating/source.rs | 6 +- src/templating/template.rs | 3 - src/templating/typst.rs | 76 ---- tests/templating/rendering/mod.rs | 2 +- 12 files changed, 221 insertions(+), 679 deletions(-) rename src/templating/{checklist/mod.rs => checklist.rs} (71%) create mode 100644 src/templating/checklist.typ delete mode 100644 src/templating/checklist/renderer.rs rename src/templating/{procedure/mod.rs => procedure.rs} (54%) create mode 100644 src/templating/procedure.typ delete mode 100644 src/templating/procedure/renderer.rs delete mode 100644 src/templating/typst.rs diff --git a/src/main.rs b/src/main.rs index 8c48346d..7ea199c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -356,20 +356,12 @@ fn main() { print!("{}", result); } "pdf" => { - let result = match domain { - "source" => templating::render(&Source, &technique), - "checklist" => templating::render(&Checklist, &technique), - "procedure" => templating::render(&Procedure, &technique), - other => { - eprintln!( - "{}: unrecognized domain \"{}\"", - "error".bright_red(), - other - ); - std::process::exit(1); - } - }; - output::via_typst(&filename, &result); + // TODO: wire up new template-based rendering pipeline + eprintln!( + "{}: PDF rendering via templates not yet implemented", + "error".bright_red(), + ); + std::process::exit(1); } _ => panic!("Unrecognized --output value"), } diff --git a/src/templating/checklist/mod.rs b/src/templating/checklist.rs similarity index 71% rename from src/templating/checklist/mod.rs rename to src/templating/checklist.rs index 9e3c65e6..abb7efc7 100644 --- a/src/templating/checklist/mod.rs +++ b/src/templating/checklist.rs @@ -1,27 +1,21 @@ -//! Checklist template — flattens procedures into printable checklists. +//! Checklist domain — 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 renderer; - use crate::domain::checklist::adapter::ChecklistAdapter; use crate::domain::typst::{Data, Render}; use crate::domain::Adapter; use crate::language; use crate::templating::template::Template; -/// Checklist template: adapter + renderer composition. +pub static TEMPLATE: &str = include_str!("checklist.typ"); + pub struct Checklist; impl Template for Checklist { - fn render(&self, document: &language::Document) -> String { - let model = ChecklistAdapter.extract(document); - renderer::markup(&model) - } - fn data(&self, document: &language::Document) -> String { let model = ChecklistAdapter.extract(document); let mut data = Data::new(); diff --git a/src/templating/checklist.typ b/src/templating/checklist.typ new file mode 100644 index 00000000..e41af98b --- /dev/null +++ b/src/templating/checklist.typ @@ -0,0 +1,55 @@ +// Built-in checklist template for Technique. +// +// Expects a `technique` dictionary with shape: +// (sections: ((ordinal, heading, steps: ((ordinal, title, body, role, +// responses, children), ...)), ...)) + +#let check = box(stroke: 0.5pt, width: 0.8em, height: 0.8em) +#let small-check = box(stroke: 0.5pt, width: 0.6em, height: 0.6em) + +#let render-responses(responses) = { + for (i, r) in responses.enumerate() { + if i > 0 [ | ] + small-check + if r.condition != none [ _#r.value #r.condition _] + else [ _#r.value _] + } + if responses.len() > 0 { parbreak() } +} + +#let render-step(step) = { + if step.role != none { + text(weight: "bold")[#step.role] + parbreak() + } + check + if step.ordinal != none [ *#step.ordinal.* ] + if step.title != none [ #step.title] + parbreak() + for para in step.body { + [#para] + parbreak() + } + render-responses(step.responses) + for child in step.children { + render-step(child) + } +} + +#let render(technique) = [ + #set page(margin: 1.5cm) + #set text(size: 10pt) + + #for section in technique.sections [ + #if section.ordinal != none and section.heading != none [ + == #section.ordinal. #section.heading + ] else if section.ordinal != none [ + == #section.ordinal. + ] else if section.heading != none [ + == #section.heading + ] + #for step in section.steps { + render-step(step) + } + ] +] diff --git a/src/templating/checklist/renderer.rs b/src/templating/checklist/renderer.rs deleted file mode 100644 index 12974375..00000000 --- a/src/templating/checklist/renderer.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Format checklist domain types into Typst. - -use crate::templating::typst; - -use crate::domain::checklist::types::{Document, Section, Step}; - -pub fn markup(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'); -} - -/// 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)); - } - - 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); - } -} - -#[cfg(test)] -mod check { - use crate::domain::checklist::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 = super::markup(&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 = super::markup(&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 = super::markup(&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 = super::markup(&doc); - assert!(out.contains("Yes")); - assert!(out.contains("No if complications")); - } -} diff --git a/src/templating/mod.rs b/src/templating/mod.rs index 7824ca28..c76d8ff7 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -1,14 +1,12 @@ //! Render Technique documents into formatted output. //! -//! The **Template** trait acts as a top-level interface providing `render()` -//! for Typst markup (PDF path) and `data()` for Typst data literals. Each +//! The **Template** trait provides `data()` for Typst data literals. Each //! domain template composes an adapter from `crate::domain` internally. mod checklist; mod procedure; mod source; mod template; -pub mod typst; pub use checklist::Checklist; pub use procedure::Procedure; @@ -17,11 +15,6 @@ pub use template::Template; use crate::language; -/// Render a Technique document using the specified template. -pub fn render(template: &impl Template, document: &language::Document) -> String { - template.render(document) -} - /// Serialize a Technique document as a Typst data literal. pub fn data(template: &impl Template, document: &language::Document) -> String { template.data(document) diff --git a/src/templating/procedure/mod.rs b/src/templating/procedure.rs similarity index 54% rename from src/templating/procedure/mod.rs rename to src/templating/procedure.rs index 969cb328..4d93d21b 100644 --- a/src/templating/procedure/mod.rs +++ b/src/templating/procedure.rs @@ -1,27 +1,21 @@ -//! Renders procedures preserving the full hierarchy described by the source +//! Procedure domain — preserves 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 +//! Unlike the checklist (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 renderer; - use crate::domain::procedure::adapter::ProcedureAdapter; use crate::domain::typst::{Data, Render}; use crate::domain::Adapter; use crate::language; use crate::templating::template::Template; -/// Procedure template: adapter + renderer composition. +pub static TEMPLATE: &str = include_str!("procedure.typ"); + pub struct Procedure; impl Template for Procedure { - fn render(&self, document: &language::Document) -> String { - let model = ProcedureAdapter.extract(document); - renderer::markup(&model) - } - fn data(&self, document: &language::Document) -> String { let model = ProcedureAdapter.extract(document); let mut data = Data::new(); diff --git a/src/templating/procedure.typ b/src/templating/procedure.typ new file mode 100644 index 00000000..acafc20b --- /dev/null +++ b/src/templating/procedure.typ @@ -0,0 +1,149 @@ +// Built-in procedure template for Technique. +// +// Expects a `technique` dictionary with shape: +// (title, description, body: ((type: "section"|"procedure"|"sequential" +// |"parallel"|"attribute", ...children), ...)) + +#let render-responses(responses) = { + for r in responses { + if r.condition != none [- _#r.value #r.condition _] + else [- _#r.value _] + } + if responses.len() > 0 { parbreak() } +} + +#let render-invocations(invocations) = { + if invocations.len() > 0 { + text(size: 7pt)[`#invocations.join(", ")`] + linebreak() + } +} + +#let ordinal-start(children) = { + let first = children.at(0, default: none) + if first != none and first.type == "sequential" { + let o = first.at("ordinal", default: "a") + let c = o.codepoints().at(0, default: "a") + if "abcdefghijklmnopqrstuvwxyz".contains(c) { + "abcdefghijklmnopqrstuvwxyz".position(c) + 1 + } else { 1 } + } else { 1 } +} + +#let render-child(node) = { + if node.type == "sequential" or node.type == "parallel" { + if node.at("title", default: none) != none [+ #node.title] + } +} + +#let render-role(node) = { + [- *#node.name*] + if node.children.len() > 0 { + let start = ordinal-start(node.children) + pad(left: 20pt)[ + #set par(leading: 0.5em) + #set enum(numbering: "a.", start: start, spacing: 0.8em) + #for child in node.children { + if child.type == "attribute" { + render-role(child) + } else { + render-child(child) + } + } + ] + } +} + +#let render-node(node) = { + if node.type == "section" { + if node.at("heading", default: none) != none { + text(size: 14pt)[*#node.ordinal.* #h(8pt) *#node.heading*] + } else { + text(size: 14pt)[*#node.ordinal.*] + } + parbreak() + for child in node.children { render-node(child) } + + } else if node.type == "procedure" { + text(size: 7pt)[`#node.name`] + linebreak() + if node.at("title", default: none) != none { + text(size: 11pt)[*#node.title*] + parbreak() + } + for para in node.description { + [#para] + parbreak() + } + if node.children.len() > 0 { + pad(left: 8pt)[#for child in node.children { render-node(child) }] + } + + } else if node.type == "sequential" or node.type == "parallel" { + render-invocations(node.invocations) + let ordinal = if node.type == "sequential" { node.ordinal } else { none } + if ordinal != none and node.at("title", default: none) != none [ + *#ordinal.* #h(4pt) *#node.title* + ] else if ordinal != none [ + *#ordinal.* + ] else if node.at("title", default: none) != none [ + *#node.title* + ] + parbreak() + for para in node.body { + [#para] + parbreak() + } + render-responses(node.responses) + if node.children.len() > 0 { + pad(left: 16pt)[#for child in node.children { render-node(child) }] + } + + } else if node.type == "attribute" { + render-role(node) + } +} + +#let has-sections(body) = { + body.any(n => n.type == "section") +} + +#let render-outline(body) = { + grid(columns: (auto, 1fr), column-gutter: 6pt, row-gutter: 0.3em, + ..body.filter(n => n.type == "section").map(n => { + let heading = n.at("heading", default: none) + ([#n.ordinal.], [#if heading != none { heading }]) + }).flatten() + ) +} + +#let render(technique) = [ + #set page(margin: 1.5cm) + #set par(justify: false) + #show text: set text(size: 9pt, font: "TeX Gyre Heros") + + #block(width: 100%, stroke: 0.1pt, inset: 10pt)[ + #if technique.at("title", default: none) != none [ + #text(size: 15pt)[*#technique.title*] + + ] + #if technique.description.len() > 0 or has-sections(technique.body) [ + _Overview_ + + #for para in technique.description [ + #para + ] + #if has-sections(technique.body) { + render-outline(technique.body) + } + ] + #block(width: 100%, fill: rgb("#006699"), inset: 5pt)[#text(fill: white)[*Procedure*]] + + #for (i, node) in technique.body.enumerate() { + render-node(node) + if i + 1 < technique.body.len() and node.type == "section" { + line(length: 100%, stroke: (thickness: 0.5pt, paint: rgb("#003366"), dash: ("dot", 2pt, 4pt, 2pt))) + } + } + ] +] diff --git a/src/templating/procedure/renderer.rs b/src/templating/procedure/renderer.rs deleted file mode 100644 index 195d42a4..00000000 --- a/src/templating/procedure/renderer.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! 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::typst; - -use crate::domain::procedure::types::{Document, Node, Response}; - -pub(super) fn markup(document: &Document) -> String { - 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 { - out.push_str(&format!( - "#text(size: 15pt)[*{}*]\n\n", - typst::escape(title) - )); - } - - 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"); - - // 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 { - 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"); - } - } - } - - out.push_str("]\n"); - out -} - -/// True if any top-level node is a Section. -fn has_sections(body: &[Node]) -> bool { - body.iter() - .any(|n| if let Node::Section { .. } = n { true } else { false }) -} - -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::Sequential { .. } | Node::Parallel { .. } => { - render_step(out, node); - } - Node::Attribute { name, children } => { - render_role(out, name, children); - } - } -} - -fn render_section(out: &mut String, ordinal: &str, heading: Option<&str>, children: &[Node]) { - match heading { - Some(heading) => { - out.push_str(&format!( - "#text(size: 14pt)[*{}.* #h(8pt) *{}*]\n\n", - ordinal, - typst::escape(heading) - )); - } - 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 description { - out.push_str(&typst::escape(para)); - out.push_str("\n\n"); - } - - if !children.is_empty() { - out.push_str("#pad(left: 8pt)[\n"); - for child in children { - render_node(out, child); - } - out.push_str("]\n"); - } -} - -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 children { - render_child(out, child); - } - out.push_str("]\n"); - } -} - -/// Convert the first child's letter ordinal to a numeric start value. -fn ordinal_start(children: &[Node]) -> u32 { - if let Some(Node::Sequential { ordinal, .. }) = children.first() { - if let Some(c) = ordinal - .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, node: &Node) { - match node { - Node::Sequential { title, .. } | Node::Parallel { title, .. } => { - if let Some(t) = title { - out.push_str(&format!("+ {}\n", typst::escape(t))); - } - } - Node::Attribute { name, children } => { - render_role(out, name, children); - } - _ => {} - } -} - -fn render_step(out: &mut String, node: &Node) { - let (ordinal, title, body, invocations, responses, children) = match node { - Node::Sequential { - ordinal, - title, - body, - invocations, - responses, - children, - } => ( - Some(ordinal.as_str()), - title, - body, - invocations, - responses, - children, - ), - Node::Parallel { - title, - body, - invocations, - responses, - children, - } => (None, title, body, invocations, responses, children), - _ => return, - }; - - // Invocations on the line before the step heading - render_invocations(out, invocations); - - // Step heading - match (ordinal, 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 body { - out.push_str(&typst::escape(para)); - out.push_str("\n\n"); - } - - if !responses.is_empty() { - render_responses(out, responses); - } - - if !children.is_empty() { - out.push_str("#pad(left: 16pt)[\n"); - 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::domain::procedure::types::{Document, Node}; - - fn dep(ordinal: &str, title: &str) -> Node { - Node::Sequential { - ordinal: ordinal.into(), - title: Some(title.into()), - body: Vec::new(), - invocations: Vec::new(), - responses: Vec::new(), - children: Vec::new(), - } - } - - fn par(title: &str) -> Node { - Node::Parallel { - title: Some(title.into()), - body: Vec::new(), - invocations: Vec::new(), - responses: Vec::new(), - children: Vec::new(), - } - } - - #[test] - fn document_title_in_block() { - let doc = Document { - title: Some("Emergency Procedure".into()), - description: Vec::new(), - body: Vec::new(), - }; - let out = super::markup(&doc); - assert!(out.contains("*Emergency Procedure*")); - } - - #[test] - fn dependent_step_shows_ordinal() { - let doc = Document { - title: None, - description: Vec::new(), - body: vec![dep("4", "Engineering Design")], - }; - let out = super::markup(&doc); - assert!(out.contains("*4.*")); - assert!(out.contains("*Engineering Design*")); - } - - #[test] - fn parallel_step_has_title() { - let doc = Document { - title: None, - description: Vec::new(), - body: vec![par("Check exits")], - }; - let out = super::markup(&doc); - assert!(out.contains("Check exits")); - } - - #[test] - fn role_group_wraps_children() { - let doc = Document { - title: None, - description: Vec::new(), - body: vec![Node::Attribute { - name: "programmers".into(), - children: vec![dep("a", "define_interfaces")], - }], - }; - let out = super::markup(&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(), - body: vec![Node::Section { - ordinal: "III".into(), - heading: Some("Implementation".into()), - children: Vec::new(), - }], - }; - let out = super::markup(&doc); - assert!(out.contains("*III.*")); - assert!(out.contains("*Implementation*")); - } -} diff --git a/src/templating/source.rs b/src/templating/source.rs index 3f724311..7da05b7b 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -18,13 +18,9 @@ const WIDTH: u8 = 70; pub struct Source; impl Template for Source { - fn render(&self, document: &Document) -> String { + fn data(&self, document: &Document) -> String { let mut out = String::from(PREAMBLE); out.push_str(&render(&Typst, document, WIDTH)); out } - - fn data(&self, document: &Document) -> String { - self.render(document) - } } diff --git a/src/templating/template.rs b/src/templating/template.rs index ceb71f4f..954cbed0 100644 --- a/src/templating/template.rs +++ b/src/templating/template.rs @@ -9,9 +9,6 @@ use crate::language; /// that delegates directly to the code formatting logic. pub trait Template { - /// Render the document as complete Typst markup (for PDF generation). - fn render(&self, document: &language::Document) -> String; - /// Serialize the document as a Typst data literal. fn data(&self, document: &language::Document) -> String; } diff --git a/src/templating/typst.rs b/src/templating/typst.rs deleted file mode 100644 index 70bb6d70..00000000 --- a/src/templating/typst.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Typst rendering primitives for use by template renderers. -//! -//! These building blocks (headings, steps, roles, responses, etc) are -//! composed by renderers into complete Typst markup for PDF output. -//! -//! 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 { - 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(text: &str) -> String { - format!("{}\n\n", escape(text)) -} - -/// Step with checkbox, optional ordinal, and text. -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)); - } - if let Some(t) = text { - out.push_str(&escape(t)); - } - out.push_str("\n\n"); - out -} - -/// Role attribution header. -pub fn role(name: &str) -> String { - format!("#text(weight: \"bold\")[{}]\n\n", name) -} - -/// Response options with small checkboxes. -pub fn responses(options: &[String]) -> String { - if options.is_empty() { - return String::new(); - } - let mut out = String::new(); - 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 -} diff --git a/tests/templating/rendering/mod.rs b/tests/templating/rendering/mod.rs index d2457307..25270db1 100644 --- a/tests/templating/rendering/mod.rs +++ b/tests/templating/rendering/mod.rs @@ -34,7 +34,7 @@ fn check_directory(dir: &Path, template: &impl templating::Template) { let doc = parsing::parse(file, &source) .unwrap_or_else(|e| panic!("Failed to parse {:?}: {:?}", file, e)); - let output = templating::render(template, &doc); + let output = templating::data(template, &doc); if output.is_empty() { failures.push(file.clone()); From aa8f4535a92fc785c6a59a63bbacf6d08c7b0fef Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 21:40:35 +1100 Subject: [PATCH 34/39] Pass template and data to Typst process --- src/domain/typst.rs | 37 ++++++++++++++++++-------- src/main.rs | 47 +++++++++++++++++--------------- src/output/mod.rs | 53 ++++++++++++++++++++++++++++--------- src/templating/checklist.rs | 4 +++ src/templating/procedure.rs | 4 +++ src/templating/template.rs | 6 +++++ 6 files changed, 106 insertions(+), 45 deletions(-) diff --git a/src/domain/typst.rs b/src/domain/typst.rs index 36781861..eeba65f6 100644 --- a/src/domain/typst.rs +++ b/src/domain/typst.rs @@ -21,29 +21,38 @@ impl Data { } } - /// Consume the builder and return the wrapped output. + /// Consume the builder and return the data as a `#let technique = ...` + /// binding. The trailing comma from `close()` is stripped so the + /// top-level assignment is valid Typst. pub fn finish(self) -> String { - format!("#let technique =\n{}", self.out) + let out = self + .out + .trim_end() + .trim_end_matches(','); + format!("#let technique = {}\n", out) } fn pad(&mut self) { for _ in 0..self.depth { - self.out.push_str(INDENT); + self.out + .push_str(INDENT); } } /// Open a dictionary: `(`, newline, and increase depth. pub fn open(&mut self) { self.pad(); - self.out.push_str("(\n"); + self.out + .push_str("(\n"); self.depth += 1; } - /// Close a dictionary: decrease depth, closing `)`, and newline. + /// Close a dictionary: decrease depth, closing `),` and newline. pub fn close(&mut self) { self.depth -= 1; self.pad(); - self.out.push_str(")\n"); + self.out + .push_str("),\n"); } /// Emit a `type: "name",` discriminator field and a newline. @@ -69,7 +78,8 @@ impl Data { } self.depth -= 1; self.pad(); - self.out.push_str("),\n"); + self.out + .push_str("),\n"); } } @@ -103,7 +113,8 @@ impl Field for str { impl Field for String { fn emit(&self, data: &mut Data, key: &str) { - self.as_str().emit(data, key); + self.as_str() + .emit(data, key); } } @@ -159,7 +170,7 @@ mod check { assert_eq!(d.depth, 1); d.close(); assert_eq!(d.depth, 0); - assert_eq!(d.out, "(\n)\n"); + assert_eq!(d.out, "(\n),\n"); } #[test] @@ -171,7 +182,11 @@ mod check { d.field("name", "inner"); d.close(); d.close(); - assert!(d.out.contains(" name: \"outer\",\n")); - assert!(d.out.contains(" name: \"inner\",\n")); + assert!(d + .out + .contains(" name: \"outer\",\n")); + assert!(d + .out + .contains(" name: \"inner\",\n")); } } diff --git a/src/main.rs b/src/main.rs index 7ea199c4..c030e6c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -337,32 +337,37 @@ fn main() { debug!(domain); - // Select domain and render - match output.as_str() { - "typst" => { - let result = match domain { - "source" => templating::data(&Source, &technique), - "checklist" => templating::data(&Checklist, &technique), - "procedure" => templating::data(&Procedure, &technique), - other => { - eprintln!( - "{}: unrecognized domain \"{}\"", - "error".bright_red(), - other - ); - std::process::exit(1); - } - }; - print!("{}", result); - } - "pdf" => { - // TODO: wire up new template-based rendering pipeline + // Select domain + let template: &dyn templating::Template = match domain { + "source" => &Source, + "checklist" => &Checklist, + "procedure" => &Procedure, + other => { eprintln!( - "{}: PDF rendering via templates not yet implemented", + "{}: unrecognized domain \"{}\"", "error".bright_red(), + other ); std::process::exit(1); } + }; + + let tmpl = template.typst(); + let data = template.data(&technique); + + match output.as_str() { + "typst" => { + if let Some(t) = tmpl { + println!("{}", t); + } + print!("{}", data); + if tmpl.is_some() { + println!("\n#render(technique)"); + } + } + "pdf" => { + output::via_typst(filename, tmpl, &data, Path::new(".")); + } _ => panic!("Unrecognized --output value"), } } diff --git a/src/output/mod.rs b/src/output/mod.rs index e2f18844..4df8329a 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -6,10 +6,17 @@ use std::path::Path; use std::process::{Command, Stdio}; use tracing::{debug, info}; -pub fn via_typst(filename: &Path, markup: &str) { +/// Compile a Typst document piped via stdin to a PDF file. +/// +/// The template content, data literal, and render call are written +/// sequentially to the process's stdin. If `template` is `None` (as +/// with Source), `data` is already a complete Typst document. +/// +/// The `root` path is passed as `--root` to Typst, controlling where +/// relative imports resolve from. For built-in templates pass `"."`. +pub fn via_typst(filename: &Path, template: Option<&str>, data: &str, root: &Path) { 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.", @@ -28,29 +35,49 @@ pub fn via_typst(filename: &Path, markup: &str) { let mut child = Command::new("typst") .arg("compile") + .arg("--root") + .arg(root) .arg("-") - .arg(target) + .arg(&target) .stdin(Stdio::piped()) .spawn() - .expect("Failed to start external Typst process"); + .unwrap_or_else(|e| { + eprintln!("{}: failed to start typst: {}", "error".bright_red(), e); + std::process::exit(1); + }); - // Write the markup to the process's stdin let mut stdin = child .stdin .take() .unwrap(); + if let Some(tmpl) = template { + stdin + .write_all(tmpl.as_bytes()) + .expect("Failed attempting to write"); + stdin + .write_all(b"\n") + .expect("Failed attempting to write"); + } stdin - .write(markup.as_bytes()) - .expect("Write document to child process"); + .write_all(data.as_bytes()) + .expect("Write data"); + if template.is_some() { + stdin + .write_all(b"\n#render(technique)\n") + .expect("Failed attempting to write"); + } drop(stdin); - // Wait for the process to complete - let output = child - .wait_with_output() - .expect("Failed to read stdout"); + let status = child + .wait() + .expect("Failed to wait for Typst process"); + + if !status.success() { + eprintln!("{}: typst compile failed", "error".bright_red()); + std::process::exit(1); + } - // Log the output - debug!("Process output: {:?}", output); + debug!("Wrote {}", target.display()); } diff --git a/src/templating/checklist.rs b/src/templating/checklist.rs index abb7efc7..08517a61 100644 --- a/src/templating/checklist.rs +++ b/src/templating/checklist.rs @@ -22,4 +22,8 @@ impl Template for Checklist { model.render(&mut data); data.finish() } + + fn typst(&self) -> Option<&str> { + Some(TEMPLATE) + } } diff --git a/src/templating/procedure.rs b/src/templating/procedure.rs index 4d93d21b..70c49ee8 100644 --- a/src/templating/procedure.rs +++ b/src/templating/procedure.rs @@ -22,4 +22,8 @@ impl Template for Procedure { model.render(&mut data); data.finish() } + + fn typst(&self) -> Option<&str> { + Some(TEMPLATE) + } } diff --git a/src/templating/template.rs b/src/templating/template.rs index 954cbed0..73ce238c 100644 --- a/src/templating/template.rs +++ b/src/templating/template.rs @@ -11,4 +11,10 @@ use crate::language; pub trait Template { /// Serialize the document as a Typst data literal. fn data(&self, document: &language::Document) -> String; + + /// Return the Typst source for this template, if any. Templates that + /// generate complete Typst documents directly (like Source) return `None`. + fn typst(&self) -> Option<&str> { + None + } } From b3d40a86907f7949dc41b8a4c70f8d17595b7005 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 21:54:49 +1100 Subject: [PATCH 35/39] Adopt template pattern for source renderer --- src/domain/mod.rs | 1 + src/domain/source/adapter.rs | 32 ++++++++++++++++++++++++++++ src/domain/source/mod.rs | 2 ++ src/domain/source/types.rs | 33 +++++++++++++++++++++++++++++ src/main.rs | 8 ++----- src/output/mod.rs | 29 +++++++++++-------------- src/templating/checklist.rs | 4 ++-- src/templating/procedure.rs | 4 ++-- src/templating/source.rs | 38 +++++++++++++++++---------------- src/templating/source.typ | 41 ++++++++++++++++++++++++++++++++++++ src/templating/template.rs | 7 ++---- 11 files changed, 149 insertions(+), 50 deletions(-) create mode 100644 src/domain/source/adapter.rs create mode 100644 src/domain/source/mod.rs create mode 100644 src/domain/source/types.rs create mode 100644 src/templating/source.typ diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 2c88eeeb..434c77f9 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -13,6 +13,7 @@ mod adapter; pub mod checklist; pub mod engine; pub mod procedure; +pub mod source; pub mod typst; pub use adapter::Adapter; diff --git a/src/domain/source/adapter.rs b/src/domain/source/adapter.rs new file mode 100644 index 00000000..cfd8b6e6 --- /dev/null +++ b/src/domain/source/adapter.rs @@ -0,0 +1,32 @@ +//! Projects the AST into the source domain model. +//! +//! This runs the code formatter to produce syntax-tagged fragments, +//! then wraps them as domain types for serialization. + +use crate::domain::Adapter; +use crate::formatting::formatter::format_with_renderer; +use crate::language; + +use super::types::{Document, Fragment}; + +const WIDTH: u8 = 70; + +pub struct SourceAdapter; + +impl Adapter for SourceAdapter { + type Model = Document; + + fn extract(&self, document: &language::Document) -> Document { + let fragments = format_with_renderer(document, WIDTH); + + Document { + fragments: fragments + .into_iter() + .map(|(syntax, content)| Fragment { + syntax: format!("{:?}", syntax), + content: content.into_owned(), + }) + .collect(), + } + } +} diff --git a/src/domain/source/mod.rs b/src/domain/source/mod.rs new file mode 100644 index 00000000..35135afb --- /dev/null +++ b/src/domain/source/mod.rs @@ -0,0 +1,2 @@ +pub mod adapter; +pub mod types; diff --git a/src/domain/source/types.rs b/src/domain/source/types.rs new file mode 100644 index 00000000..077031a4 --- /dev/null +++ b/src/domain/source/types.rs @@ -0,0 +1,33 @@ +//! Domain types for source code display. +//! +//! A source document is a flat sequence of syntax-tagged fragments, +//! produced by the code formatter. Each fragment carries a syntax tag +//! (e.g. "Declaration", "Keyword") and a content string. + +use crate::domain::typst::{Data, Render}; + +pub struct Document { + pub fragments: Vec, +} + +pub struct Fragment { + pub syntax: String, + pub content: String, +} + +impl Render for Document { + fn render(&self, data: &mut Data) { + data.open(); + data.list("fragments", &self.fragments); + data.close(); + } +} + +impl Render for Fragment { + fn render(&self, data: &mut Data) { + data.open(); + data.field("syntax", &self.syntax); + data.field("content", &self.content); + data.close(); + } +} diff --git a/src/main.rs b/src/main.rs index c030e6c0..18a524a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -357,13 +357,9 @@ fn main() { match output.as_str() { "typst" => { - if let Some(t) = tmpl { - println!("{}", t); - } + println!("{}", tmpl); print!("{}", data); - if tmpl.is_some() { - println!("\n#render(technique)"); - } + println!("\n#render(technique)"); } "pdf" => { output::via_typst(filename, tmpl, &data, Path::new(".")); diff --git a/src/output/mod.rs b/src/output/mod.rs index 4df8329a..dac1905c 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -9,12 +9,11 @@ use tracing::{debug, info}; /// Compile a Typst document piped via stdin to a PDF file. /// /// The template content, data literal, and render call are written -/// sequentially to the process's stdin. If `template` is `None` (as -/// with Source), `data` is already a complete Typst document. +/// sequentially to the process's stdin. /// /// The `root` path is passed as `--root` to Typst, controlling where /// relative imports resolve from. For built-in templates pass `"."`. -pub fn via_typst(filename: &Path, template: Option<&str>, data: &str, root: &Path) { +pub fn via_typst(filename: &Path, template: &str, data: &str, root: &Path) { info!("Printing file: {}", filename.display()); if filename.to_str() == Some("-") { @@ -51,22 +50,18 @@ pub fn via_typst(filename: &Path, template: Option<&str>, data: &str, root: &Pat .take() .unwrap(); - if let Some(tmpl) = template { - stdin - .write_all(tmpl.as_bytes()) - .expect("Failed attempting to write"); - stdin - .write_all(b"\n") - .expect("Failed attempting to write"); - } + stdin + .write_all(template.as_bytes()) + .expect("Failed attempting to write"); + stdin + .write_all(b"\n") + .expect("Failed attempting to write"); stdin .write_all(data.as_bytes()) - .expect("Write data"); - if template.is_some() { - stdin - .write_all(b"\n#render(technique)\n") - .expect("Failed attempting to write"); - } + .expect("Failed attempting to write"); + stdin + .write_all(b"\n#render(technique)\n") + .expect("Failed attempting to write"); drop(stdin); diff --git a/src/templating/checklist.rs b/src/templating/checklist.rs index 08517a61..9f9dc782 100644 --- a/src/templating/checklist.rs +++ b/src/templating/checklist.rs @@ -23,7 +23,7 @@ impl Template for Checklist { data.finish() } - fn typst(&self) -> Option<&str> { - Some(TEMPLATE) + fn typst(&self) -> &str { + TEMPLATE } } diff --git a/src/templating/procedure.rs b/src/templating/procedure.rs index 70c49ee8..070464db 100644 --- a/src/templating/procedure.rs +++ b/src/templating/procedure.rs @@ -23,7 +23,7 @@ impl Template for Procedure { data.finish() } - fn typst(&self) -> Option<&str> { - Some(TEMPLATE) + fn typst(&self) -> &str { + TEMPLATE } } diff --git a/src/templating/source.rs b/src/templating/source.rs index 7da05b7b..e7b5b68e 100644 --- a/src/templating/source.rs +++ b/src/templating/source.rs @@ -1,26 +1,28 @@ -//! 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. +//! Source domain — renders Technique source code with syntax highlighting. +//! +//! The source domain model is a flat sequence of syntax-tagged fragments +//! produced by the code formatter. The Typst template maps each syntax +//! tag to a colour and weight. -use crate::highlighting::{render, Typst}; -use crate::language::Document; +use crate::domain::source::adapter::SourceAdapter; +use crate::domain::typst::{Data, Render}; +use crate::domain::Adapter; +use crate::language; +use crate::templating::template::Template; -use super::Template; - -static PREAMBLE: &str = r#" -#show text: set text(font: "Inconsolata") -#show raw: set block(breakable: true) -"#; - -const WIDTH: u8 = 70; +pub static TEMPLATE: &str = include_str!("source.typ"); pub struct Source; impl Template for Source { - fn data(&self, document: &Document) -> String { - let mut out = String::from(PREAMBLE); - out.push_str(&render(&Typst, document, WIDTH)); - out + fn data(&self, document: &language::Document) -> String { + let model = SourceAdapter.extract(document); + let mut data = Data::new(); + model.render(&mut data); + data.finish() + } + + fn typst(&self) -> &str { + TEMPLATE } } diff --git a/src/templating/source.typ b/src/templating/source.typ new file mode 100644 index 00000000..83116d52 --- /dev/null +++ b/src/templating/source.typ @@ -0,0 +1,41 @@ +// Built-in source template for Technique. +// +// Expects a `technique` dictionary with shape: +// (fragments: ((syntax, content), ...)) +// +// Each fragment carries a syntax tag and a content string. +// The syntax tag determines the colour and weight applied. + +#let render-fragment(f) = { + if f.syntax == "Newline" { linebreak() } + else if f.syntax == "Header" { text(fill: rgb(0x75, 0x50, 0x7b), raw(f.content)) } + else if f.syntax == "Declaration" { text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(f.content)) } + else if f.syntax == "Forma" { text(fill: rgb(0x8f, 0x59, 0x02), weight: "bold", raw(f.content)) } + else if f.syntax == "StepItem" { text(weight: "bold", raw(f.content)) } + else if f.syntax == "CodeBlock" { text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(f.content)) } + else if f.syntax == "Variable" { text(fill: rgb(0x72, 0x9f, 0xcf), weight: "bold", raw(f.content)) } + else if f.syntax == "String" { text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(f.content)) } + else if f.syntax == "Numeric" { text(fill: rgb(0xad, 0x7f, 0xa8), weight: "bold", raw(f.content)) } + else if f.syntax == "Response" { text(fill: rgb(0xf5, 0x79, 0x00), weight: "bold", raw(f.content)) } + else if f.syntax == "Invocation" { text(fill: rgb(0x3b, 0x5d, 0x7d), weight: "bold", raw(f.content)) } + else if f.syntax == "Title" { text(weight: "bold", raw(f.content)) } + else if f.syntax == "Keyword" { text(fill: rgb(0x75, 0x50, 0x7b), weight: "bold", raw(f.content)) } + else if f.syntax == "Function" { text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(f.content)) } + else if f.syntax == "Multiline" { text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(f.content)) } + else if f.syntax == "Label" { text(fill: rgb(0x60, 0x98, 0x9a), weight: "bold", raw(f.content)) } + else if f.syntax == "Operator" { text(fill: red, raw(f.content)) } + else if f.syntax == "Quote" { text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(f.content)) } + else if f.syntax == "Language" { text(fill: rgb(0xc4, 0xa0, 0x00), weight: "bold", raw(f.content)) } + else if f.syntax == "Attribute" { text(weight: "bold", raw(f.content)) } + else if f.syntax == "Structure" { text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(f.content)) } + else { raw(f.content) } +} + +#let render(technique) = [ + #show text: set text(font: "Inconsolata") + #show raw: set block(breakable: true) + + #for f in technique.fragments { + render-fragment(f) + } +] diff --git a/src/templating/template.rs b/src/templating/template.rs index 73ce238c..f82c7691 100644 --- a/src/templating/template.rs +++ b/src/templating/template.rs @@ -12,9 +12,6 @@ pub trait Template { /// Serialize the document as a Typst data literal. fn data(&self, document: &language::Document) -> String; - /// Return the Typst source for this template, if any. Templates that - /// generate complete Typst documents directly (like Source) return `None`. - fn typst(&self) -> Option<&str> { - None - } + /// Return the Typst source for this template. + fn typst(&self) -> &str; } From e0dad19e162b29457be3f618b14a09c7e8a539e8 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 22:01:04 +1100 Subject: [PATCH 36/39] Use hash lookups rather than if/else ladder --- src/templating/source.typ | 52 ++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/src/templating/source.typ b/src/templating/source.typ index 83116d52..1f8e9d68 100644 --- a/src/templating/source.typ +++ b/src/templating/source.typ @@ -6,29 +6,37 @@ // Each fragment carries a syntax tag and a content string. // The syntax tag determines the colour and weight applied. +#let palette = ( + Neutral: (c) => raw(c), + Indent: (c) => raw(c), + Newline: (_) => linebreak(), + Header: (c) => text(fill: rgb(0x75, 0x50, 0x7b), raw(c)), + Declaration: (c) => text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(c)), + Description: (c) => raw(c), + Forma: (c) => text(fill: rgb(0x8f, 0x59, 0x02), weight: "bold", raw(c)), + StepItem: (c) => text(weight: "bold", raw(c)), + CodeBlock: (c) => text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)), + Variable: (c) => text(fill: rgb(0x72, 0x9f, 0xcf), weight: "bold", raw(c)), + Section: (c) => raw(c), + String: (c) => text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(c)), + Numeric: (c) => text(fill: rgb(0xad, 0x7f, 0xa8), weight: "bold", raw(c)), + Response: (c) => text(fill: rgb(0xf5, 0x79, 0x00), weight: "bold", raw(c)), + Invocation: (c) => text(fill: rgb(0x3b, 0x5d, 0x7d), weight: "bold", raw(c)), + Title: (c) => text(weight: "bold", raw(c)), + Keyword: (c) => text(fill: rgb(0x75, 0x50, 0x7b), weight: "bold", raw(c)), + Function: (c) => text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(c)), + Multiline: (c) => text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(c)), + Label: (c) => text(fill: rgb(0x60, 0x98, 0x9a), weight: "bold", raw(c)), + Operator: (c) => text(fill: red, raw(c)), + Quote: (c) => text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)), + Language: (c) => text(fill: rgb(0xc4, 0xa0, 0x00), weight: "bold", raw(c)), + Attribute: (c) => text(weight: "bold", raw(c)), + Structure: (c) => text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(c)), +) + #let render-fragment(f) = { - if f.syntax == "Newline" { linebreak() } - else if f.syntax == "Header" { text(fill: rgb(0x75, 0x50, 0x7b), raw(f.content)) } - else if f.syntax == "Declaration" { text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(f.content)) } - else if f.syntax == "Forma" { text(fill: rgb(0x8f, 0x59, 0x02), weight: "bold", raw(f.content)) } - else if f.syntax == "StepItem" { text(weight: "bold", raw(f.content)) } - else if f.syntax == "CodeBlock" { text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(f.content)) } - else if f.syntax == "Variable" { text(fill: rgb(0x72, 0x9f, 0xcf), weight: "bold", raw(f.content)) } - else if f.syntax == "String" { text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(f.content)) } - else if f.syntax == "Numeric" { text(fill: rgb(0xad, 0x7f, 0xa8), weight: "bold", raw(f.content)) } - else if f.syntax == "Response" { text(fill: rgb(0xf5, 0x79, 0x00), weight: "bold", raw(f.content)) } - else if f.syntax == "Invocation" { text(fill: rgb(0x3b, 0x5d, 0x7d), weight: "bold", raw(f.content)) } - else if f.syntax == "Title" { text(weight: "bold", raw(f.content)) } - else if f.syntax == "Keyword" { text(fill: rgb(0x75, 0x50, 0x7b), weight: "bold", raw(f.content)) } - else if f.syntax == "Function" { text(fill: rgb(0x34, 0x65, 0xa4), weight: "bold", raw(f.content)) } - else if f.syntax == "Multiline" { text(fill: rgb(0x4e, 0x9a, 0x06), weight: "bold", raw(f.content)) } - else if f.syntax == "Label" { text(fill: rgb(0x60, 0x98, 0x9a), weight: "bold", raw(f.content)) } - else if f.syntax == "Operator" { text(fill: red, raw(f.content)) } - else if f.syntax == "Quote" { text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(f.content)) } - else if f.syntax == "Language" { text(fill: rgb(0xc4, 0xa0, 0x00), weight: "bold", raw(f.content)) } - else if f.syntax == "Attribute" { text(weight: "bold", raw(f.content)) } - else if f.syntax == "Structure" { text(fill: rgb(0x99, 0x99, 0x99), weight: "bold", raw(f.content)) } - else { raw(f.content) } + let styler = palette.at(f.syntax, default: (c) => raw(c)) + styler(f.content) } #let render(technique) = [ From 56d4a5be041f4c3ed568303d447ba3e6153f2b2d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 22:40:43 +1100 Subject: [PATCH 37/39] Drop root requirement --- src/main.rs | 2 +- src/output/mod.rs | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 18a524a5..d359c98a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -362,7 +362,7 @@ fn main() { println!("\n#render(technique)"); } "pdf" => { - output::via_typst(filename, tmpl, &data, Path::new(".")); + output::via_typst(filename, &preamble, &data); } _ => panic!("Unrecognized --output value"), } diff --git a/src/output/mod.rs b/src/output/mod.rs index dac1905c..55de3c07 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -10,10 +10,7 @@ use tracing::{debug, info}; /// /// The template content, data literal, and render call are written /// sequentially to the process's stdin. -/// -/// The `root` path is passed as `--root` to Typst, controlling where -/// relative imports resolve from. For built-in templates pass `"."`. -pub fn via_typst(filename: &Path, template: &str, data: &str, root: &Path) { +pub fn via_typst(filename: &Path, template: &str, data: &str) { info!("Printing file: {}", filename.display()); if filename.to_str() == Some("-") { @@ -34,8 +31,6 @@ pub fn via_typst(filename: &Path, template: &str, data: &str, root: &Path) { let mut child = Command::new("typst") .arg("compile") - .arg("--root") - .arg(root) .arg("-") .arg(&target) .stdin(Stdio::piped()) From 2f48b5821de0c4b4e7965fc33e023d2b2fe37987 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 23:00:42 +1100 Subject: [PATCH 38/39] Implement handling of --template command-line option --- src/main.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index d359c98a..24702664 100644 --- a/src/main.rs +++ b/src/main.rs @@ -352,12 +352,31 @@ fn main() { } }; - let tmpl = template.typst(); let data = template.data(&technique); + // If --template is given, use the user-supplied file (expected to + // be a .typ file containing Typst template code) ; otherwise + // inline the built-in template. + let preamble: String = match submatches.get_one::("template") { + Some(path) => { + if !Path::new(path).exists() { + eprintln!( + "{}: template file not found: {}", + "error".bright_red(), + path + ); + std::process::exit(1); + } + format!("#import \"{}\": render", path) + } + None => template + .typst() + .to_string(), + }; + match output.as_str() { "typst" => { - println!("{}", tmpl); + println!("{}", preamble); print!("{}", data); println!("\n#render(technique)"); } From 9dd4e9da640940915c44901eed17c6e59dca31df Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 11 Mar 2026 23:04:45 +1100 Subject: [PATCH 39/39] Bump version --- Cargo.lock | 299 ++++++++++++++++++++--------------------------------- Cargo.toml | 2 +- 2 files changed, 114 insertions(+), 187 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e8d21a7..7e95063b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -43,22 +43,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -69,15 +69,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bstr" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "serde", @@ -85,24 +85,24 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.5.48" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -113,9 +113,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" @@ -164,7 +164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -178,9 +178,9 @@ dependencies = [ [[package]] name = "globset" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", @@ -191,9 +191,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.23" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" dependencies = [ "crossbeam-deque", "globset", @@ -207,15 +207,15 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "lazy_static" @@ -225,21 +225,21 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.176" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lsp-server" @@ -278,17 +278,17 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -299,45 +299,45 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.41" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "regex" -version = "1.11.3" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -347,9 +347,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -358,29 +358,23 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "same-file" version = "1.0.6" @@ -422,15 +416,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -467,9 +461,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.106" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -478,7 +472,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.5.0" +version = "0.5.1" dependencies = [ "clap", "ignore", @@ -524,9 +518,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -535,9 +529,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -546,9 +540,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -567,9 +561,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -585,9 +579,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "utf8parse" @@ -617,23 +611,14 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] name = "windows-link" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" - -[[package]] -name = "windows-sys" -version = "0.52.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" @@ -641,143 +626,85 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.4", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.61.1" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "windows-targets" -version = "0.52.6" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 4bf6c04d..c7a6b5b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.5.0" +version = "0.5.1" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ]