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 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" ] 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' diff --git a/examples/prototype/DatabaseUpgrade.tq b/examples/prototype/DatabaseUpgrade.tq new file mode 100644 index 00000000..29013fe6 --- /dev/null +++ b/examples/prototype/DatabaseUpgrade.tq @@ -0,0 +1,75 @@ +% technique v1 +& procedure + +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/editor/server.rs b/src/editor/server.rs index ca57b576..996df2c4 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::highlighting; 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 = highlighting::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..eb016ecf --- /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() + } +} diff --git a/src/highlighting/mod.rs b/src/highlighting/mod.rs new file mode 100644 index 00000000..ec78eee8 --- /dev/null +++ b/src/highlighting/mod.rs @@ -0,0 +1,9 @@ +//! 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; diff --git a/src/formatting/renderer.rs b/src/highlighting/renderer.rs similarity index 59% rename from src/formatting/renderer.rs rename to src/highlighting/renderer.rs index 0ec994ad..ef8ebfb8 100644 --- a/src/formatting/renderer.rs +++ b/src/highlighting/renderer.rs @@ -1,53 +1,8 @@ //! Renderers for colourizing Technique language +use crate::formatting::*; 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() - } -} - /// 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 /// pairs). Then second we apply the specified renderer to each pair to result diff --git a/src/rendering/terminal.rs b/src/highlighting/terminal.rs similarity index 99% rename from src/rendering/terminal.rs rename to src/highlighting/terminal.rs index bade4992..66fbeed3 100644 --- a/src/rendering/terminal.rs +++ b/src/highlighting/terminal.rs @@ -1,7 +1,7 @@ //! Renderers for colourizing Technique language +use crate::formatting::*; use owo_colors::OwoColorize; -use technique::formatting::*; /// Embellish fragments with ANSI escapes to create syntax highlighting in /// terminal output. diff --git a/src/rendering/typst.rs b/src/highlighting/typst.rs similarity index 99% rename from src/rendering/typst.rs rename to src/highlighting/typst.rs index 31dff068..6d92c555 100644 --- a/src/rendering/typst.rs +++ b/src/highlighting/typst.rs @@ -1,7 +1,7 @@ //! Renderers for colourizing Technique language +use crate::formatting::*; use std::borrow::Cow; -use technique::formatting::*; /// Add markup around syntactic elements for use when including /// Technique source in Typst documents. diff --git a/src/lib.rs b/src/lib.rs index 929e66c3..84777d26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ pub mod formatting; +pub mod highlighting; pub mod language; pub mod parsing; pub mod regex; +pub mod templating; diff --git a/src/main.rs b/src/main.rs index 434193d4..d9a77461 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,24 +1,23 @@ 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::highlighting::{self, Terminal}; use technique::parsing; +use technique::templating::{self, Checklist, Procedure, Source}; mod editor; +mod output; mod problem; -mod rendering; #[derive(Eq, Debug, PartialEq)] enum Output { Native, - Typst, Silent, } @@ -106,23 +105,30 @@ 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("Which kind of diagnostic output to print when rendering.") + .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") + .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."), ) .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( @@ -251,9 +257,9 @@ fn main() { let result; if raw_output || std::io::stdout().is_terminal() { - result = formatting::render(&Terminal, &technique, wrap_width); + result = highlighting::render(&Terminal, &technique, wrap_width); } else { - result = formatting::render(&Identity, &technique, wrap_width); + result = highlighting::render(&Identity, &technique, wrap_width); } print!("{}", result); @@ -262,13 +268,8 @@ 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); + debug!(output); let filename = submatches .get_one::("filename") @@ -309,20 +310,48 @@ fn main() { } }; - let result = formatting::render(&Typst, &technique, 70); + // 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 { + "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 { - 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"), } - - rendering::via_typst(&filename, &result); } Some(("language", _)) => { debug!("Starting Language Server"); diff --git a/src/rendering/mod.rs b/src/output/mod.rs similarity index 59% rename from src/rendering/mod.rs rename to src/output/mod.rs index f2d92501..e2f18844 100644 --- a/src/rendering/mod.rs +++ b/src/output/mod.rs @@ -1,28 +1,12 @@ +//! 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}; -mod terminal; -mod typst; - -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) { +pub fn via_typst(filename: &Path, markup: &str) { info!("Printing file: {}", filename.display()); // Verify that the file actually exists @@ -50,31 +34,12 @@ pub(crate) 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/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/templating/checklist/adapter.rs b/src/templating/checklist/adapter.rs new file mode 100644 index 00000000..5b5c63ca --- /dev/null +++ b/src/templating/checklist/adapter.rs @@ -0,0 +1,299 @@ +//! 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) + } +} + +fn extract(document: &language::Document) -> Document { + let mut extracted = Document::new(); + + for procedure in document.procedures() { + extract_procedure(&mut extracted, procedure); + } + + if extracted + .sections + .is_empty() + { + // 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, + }); + } + } + } + + // 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, + }); + } + } + } + + 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, + }); + } +} + +fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Vec { + if scope.is_step() { + return vec![step_from_scope(scope, inherited_role)]; + } + + // 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(); + } + + // 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(), + }]; + + 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)); + } + + let paragraphs: Vec = scope + .description() + .map(|p| p.content()) + .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, + } +} + +#[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/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..bda64048 --- /dev/null +++ b/src/templating/checklist/renderer.rs @@ -0,0 +1,164 @@ +//! 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'); +} + +/// 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::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/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/engine.rs b/src/templating/engine.rs new file mode 100644 index 00000000..9e7d7a3d --- /dev/null +++ b/src/templating/engine.rs @@ -0,0 +1,399 @@ +//! 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, +}; + +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, + } + } + + /// 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 only the text content of this paragraph. + pub fn text(&self) -> String { + let mut result = String::new(); + for d in &self.0 { + Self::append_text(&mut result, d); + } + result + } + + /// 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 + } + + /// 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; + } + 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>) { + match descriptive { + Descriptive::Text(text) => { + if !result.is_empty() && !result.ends_with(' ') { + result.push(' '); + } + result.push_str(text); + } + 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) => { + Self::extract_expression_invocations(targets, expr); + } + Descriptive::Binding(inner, _) => { + Self::extract_invocations(targets, inner); + } + _ => {} + } + } + + fn extract_expression_invocations( + targets: &mut Vec<&'i str>, + expr: &crate::language::Expression<'i>, + ) { + match expr { + crate::language::Expression::Application(inv) => { + targets.push(Self::invocation_name(inv)); + } + crate::language::Expression::Repeat(inner) => { + Self::extract_expression_invocations(targets, inner); + } + crate::language::Expression::Foreach(_, inner) => { + Self::extract_expression_invocations(targets, inner); + } + crate::language::Expression::Binding(inner, _) => { + Self::extract_expression_invocations(targets, inner); + } + _ => {} + } + } + + 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, + } + } +} + +#[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"); + } +} diff --git a/src/templating/mod.rs b/src/templating/mod.rs new file mode 100644 index 00000000..9d595748 --- /dev/null +++ b/src/templating/mod.rs @@ -0,0 +1,37 @@ +//! 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; +mod procedure; +mod source; +mod template; +pub mod typst; + +pub use checklist::Checklist; +pub use procedure::Procedure; +pub use source::Source; +pub use template::{Adapter, Renderer, 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) +} diff --git a/src/templating/procedure/adapter.rs b/src/templating/procedure/adapter.rs new file mode 100644 index 00000000..60269e77 --- /dev/null +++ b/src/templating/procedure/adapter.rs @@ -0,0 +1,357 @@ +//! Projects the parser's AST into a domain model suitable for procedures. +//! +//! 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, Node, Response, 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(); + + 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.content()) + .collect(); + + for scope in first.steps() { + doc.body + .extend(nodes_from_scope(scope)); + } + + for procedure in procedures { + doc.body + .push(node_from_procedure(procedure)); + } + } + + // Handle bare top-level steps (no procedures) + if doc + .body + .is_empty() + { + for scope in document.steps() { + doc.body + .extend(nodes_from_scope(scope)); + } + } + + doc +} + +fn node_from_procedure(procedure: &language::Procedure) -> Node { + let mut children = Vec::new(); + for scope in procedure.steps() { + children.extend(nodes_from_scope(scope)); + } + + Node::Procedure { + name: procedure + .name() + .to_string(), + title: procedure + .title() + .map(String::from), + description: procedure + .description() + .map(|p| p.content()) + .collect(), + children, + } +} + +/// Extract nodes from a scope, handling different scope types. +fn nodes_from_scope(scope: &language::Scope) -> Vec { + if scope.is_step() { + return vec![node_from_step(scope)]; + } + + // AttributeBlock — role group with children + let roles: Vec<_> = scope + .roles() + .collect(); + if !roles.is_empty() { + let name = roles.join(" + "); + let mut children = Vec::new(); + for child in scope.children() { + children.extend(nodes_from_scope(child)); + } + return vec![Node::Attribute { name, children }]; + } + + // 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() { + 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 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(); + + 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(nodes_from_scope(subscope)); + } + + 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() { + Some((first, rest)) => (Some(first.clone()), rest.to_vec()), + None => (None, Vec::new()), + }; + + Node::Step { + kind, + ordinal: scope + .ordinal() + .map(String::from), + title, + body, + invocations, + responses, + children, + } +} + +#[cfg(test)] +mod check { + use std::path::Path; + + use crate::parsing; + use crate::templating::template::Adapter; + + use super::ProcedureAdapter; + use super::super::types::{Node, 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. + "#, + )); + 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"); + } + } else { + panic!("expected Step"); + } + } + + #[test] + fn dependent_step_has_ordinal() { + let doc = extract(trim( + r#" +checks : + + 1. First step + 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")); + } else { + panic!("expected Step"); + } + } + + #[test] + fn parallel_step_no_ordinal() { + let doc = extract(trim( + r#" +checks : + + - First item + - 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); + } else { + panic!("expected Step"); + } + } + + #[test] + fn invocation_only_step_has_content() { + let doc = extract(trim( + r#" +main : + + 1. + +ensure_safety : + +# Safety First + + - Check exits + "#, + )); + if let Node::Step { title, .. } = &doc.body[0] { + assert_eq!(title.as_deref(), Some("ensure_safety")); + } else { + 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.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"); + } + + 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/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..708a21e1 --- /dev/null +++ b/src/templating/procedure/renderer.rs @@ -0,0 +1,373 @@ +//! 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; + +use super::types::{Document, Node, Response, 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 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| 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); + } + 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::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; + } + } + } + } + 1 +} + +/// Render items nested under a role group (substeps). +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))); + } + } + Node::Attribute { name, children } => { + render_role(out, name, children); + } + _ => {} + } +} + +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, 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::templating::template::Renderer; + + use super::ProcedureRenderer; + use super::super::types::{Document, Node, StepKind}; + + 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) -> Node { + Node::Step { + kind: StepKind::Parallel, + ordinal: None, + 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 = ProcedureRenderer.render(&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 = 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(), + body: vec![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(), + body: vec![Node::Attribute { + name: "programmers".into(), + 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(); + 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 = ProcedureRenderer.render(&doc); + assert!(out.contains("*III.*")); + assert!(out.contains("*Implementation*")); + } +} diff --git a/src/templating/procedure/types.rs b/src/templating/procedure/types.rs new file mode 100644 index 00000000..26c76f46 --- /dev/null +++ b/src/templating/procedure/types.rs @@ -0,0 +1,63 @@ +//! Domain types for a procedure. +//! +//! 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: 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 body: Vec, +} + +impl Document { + pub fn new() -> Self { + Document { + title: None, + description: Vec::new(), + body: Vec::new(), + } + } +} + +/// 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). +pub enum StepKind { + Dependent, + Parallel, +} + +/// A response option with an optional condition. +pub struct Response { + pub value: String, + pub condition: Option, +} diff --git a/src/templating/source.rs b/src/templating/source.rs new file mode 100644 index 00000000..31a0070f --- /dev/null +++ b/src/templating/source.rs @@ -0,0 +1,32 @@ +//! 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::highlighting::{render, Typst}; +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, +} + +impl Source { + pub fn new(width: u8) -> Self { + Source { width } + } +} + +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 + } +} diff --git a/src/templating/template.rs b/src/templating/template.rs new file mode 100644 index 00000000..39d1486e --- /dev/null +++ b/src/templating/template.rs @@ -0,0 +1,30 @@ +//! Traits for the templating pipeline. + +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. + +pub trait Template { + 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..95a612a0 --- /dev/null +++ b/src/templating/typst.rs @@ -0,0 +1,77 @@ +//! 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 `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/formatting/golden.rs b/tests/formatting/golden.rs index ad7a48b8..11a0370c 100644 --- a/tests/formatting/golden.rs +++ b/tests/formatting/golden.rs @@ -1,7 +1,8 @@ use std::fs; use std::path::Path; -use technique::formatting::*; +use technique::formatting::Identity; +use technique::highlighting::render; use technique::parsing; /// Golden test for the format command 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); +}