diff --git a/Cargo.lock b/Cargo.lock index bca15c5b..af5991fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,7 +472,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.5.2" +version = "0.5.3" dependencies = [ "clap", "ignore", diff --git a/Cargo.toml b/Cargo.toml index c8150367..7cfa01b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.5.2" +version = "0.5.3" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] diff --git a/examples/prototype/AirlockPowerdown.tq b/examples/prototype/AirlockPowerdown.tq index fab5e1d9..b43e965e 100644 --- a/examples/prototype/AirlockPowerdown.tq +++ b/examples/prototype/AirlockPowerdown.tq @@ -1,6 +1,6 @@ % technique v1 ! PD; © 2003 National Aeronautics and Space Administration, Canadian Space Agency, European Space Agency, and Others -& nasa-flight-plan,v4.0 +& nasa-esa-iss,v4.0 emergency_procedures : diff --git a/src/domain/engine.rs b/src/domain/engine.rs index 88c0e9a3..42b5bed7 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -13,8 +13,8 @@ //! projecting these into domain-specific models. use crate::language::{ - Attribute, Descriptive, Document, Element, Expression, Pair, Paragraph, Procedure, Response, - Scope, Target, Technique, + Attribute, Descriptive, Document, Element, Expression, Numeric, Pair, Paragraph, Piece, + Procedure, Response, Scope, Target, Technique, }; impl<'i> Document<'i> { @@ -318,7 +318,19 @@ fn render_expression(expr: &Expression) -> String { id.0.to_string() } Expression::Binding(inner, _) => render_expression(inner), - _ => String::new(), + Expression::String(pieces) => { + let mut result = String::new(); + for piece in pieces { + match piece { + Piece::Text(t) => result.push_str(t), + Piece::Interpolation(e) => result.push_str(&render_expression(e)), + } + } + result + } + Expression::Number(Numeric::Scientific(q)) => q.to_string(), + Expression::Number(Numeric::Integral(n)) => n.to_string(), + Expression::Tablet(_) => String::new(), } } diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 8599eda2..3e5170ff 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -12,6 +12,7 @@ mod adapter; pub mod checklist; pub mod engine; +pub mod nasa_esa_iss; pub mod procedure; pub mod recipe; pub(crate) mod serialize; diff --git a/src/domain/nasa_esa_iss/adapter.rs b/src/domain/nasa_esa_iss/adapter.rs new file mode 100644 index 00000000..630cecce --- /dev/null +++ b/src/domain/nasa_esa_iss/adapter.rs @@ -0,0 +1,129 @@ +//! Projects the parser's AST into the NASA/ESA ISS Crew Procedure domain +//! model. +//! +//! This isn't really a serious example (because the procedure template used +//! by NASA and ESA for ISS operations is a bit ridiculous), but it shows +//! Technique is usable in many procedural domains. +//! +//! Delegates to the procedure adapter for initial extraction, then +//! post-processes the result: invocation-only steps are replaced by +//! their target procedures (with ordinals transferred), producing +//! the flat numbered-procedure layout used in ISS flight documents. +//! Domain-specific builtins like `cmd()` are recognized and their +//! expressions reformatted for template rendering. + +use std::collections::HashMap; + +use crate::domain::procedure::adapter::ProcedureAdapter; +use crate::domain::procedure::types::{Document, Node}; +use crate::domain::Adapter; +use crate::language; + +pub struct NasaEsaIssAdapter; + +impl Adapter for NasaEsaIssAdapter { + type Model = Document; + + fn extract(&self, document: &language::Document) -> Document { + let mut doc = ProcedureAdapter.extract(document); + inline_procedures(&mut doc); + rewrite_builtins(&mut doc.body); + doc + } +} + +// -- Procedure inlining ------------------------------------------------------ + +/// Replace invocation-only steps with their target Procedure nodes, +/// transferring the step's ordinal into the procedure's name field +/// (which the template renders as the step number). +fn inline_procedures(doc: &mut Document) { + let ordinals: HashMap = collect_ordinals(&doc.body) + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(); + + for node in &mut doc.body { + if let Node::Procedure { name, .. } = node { + if let Some(ord) = ordinals.get(name.as_str()) { + *name = ord.clone(); + } + } + } + + doc.body.retain(|node| { + !matches!(node, Node::Sequential { title: None, invocations, .. } if !invocations.is_empty()) + }); +} + +/// Collect ordinals from invocation-only steps: procedure_name -> ordinal. +fn collect_ordinals(nodes: &[Node]) -> HashMap<&str, String> { + let mut map = HashMap::new(); + for node in nodes { + if let Node::Sequential { + ordinal, + title: None, + invocations, + .. + } = node + { + if invocations.len() == 1 { + map.insert(invocations[0].as_str(), ordinal.clone()); + } + } + } + map +} + +// -- Builtin functions ------------------------------------------------------- + +/// Rewrite domain-specific builtin expressions in CodeBlock nodes. +/// `cmd(Inhibit)` becomes `cmd Inhibit` for template styling. +fn rewrite_builtins(nodes: &mut [Node]) { + for node in nodes.iter_mut() { + match node { + Node::CodeBlock { + expression, + children, + .. + } => { + *expression = rewrite_expression(expression); + rewrite_builtins(children); + } + Node::Sequential { children, .. } + | Node::Parallel { children, .. } + | Node::Section { children, .. } + | Node::Procedure { children, .. } + | Node::Attribute { children, .. } => { + rewrite_builtins(children); + } + } + } +} + +/// Rewrite a single expression string if it matches a builtin pattern. +fn rewrite_expression(expr: &str) -> String { + if let Some(arg) = expr + .strip_prefix("cmd(") + .and_then(|s| s.strip_suffix(')')) + { + return format!("cmd {}", arg); + } + // foreach node in seq(1, 6) -> foreach node 1 2 3 4 5 6 + if let Some(rest) = expr.strip_prefix("foreach ") { + if let Some((var, seq_expr)) = rest.split_once(" in ") { + if let Some((start, end)) = parse_seq(seq_expr) { + let values: Vec = (start..=end).map(|n| n.to_string()).collect(); + return format!("foreach {} {}", var.trim(), values.join(" ")); + } + } + } + expr.to_string() +} + +/// Parse `seq(A, B)` into a (start, end) pair. +fn parse_seq(s: &str) -> Option<(i64, i64)> { + let inner = s.strip_prefix("seq(")?.strip_suffix(')')?; + let (a, b) = inner.split_once(", ")?; + Some((a.trim().parse().ok()?, b.trim().parse().ok()?)) +} diff --git a/src/domain/nasa_esa_iss/mod.rs b/src/domain/nasa_esa_iss/mod.rs new file mode 100644 index 00000000..54f0d460 --- /dev/null +++ b/src/domain/nasa_esa_iss/mod.rs @@ -0,0 +1 @@ +pub mod adapter; diff --git a/src/main.rs b/src/main.rs index 1a1f5b00..62781deb 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::highlighting::{self, Terminal}; use technique::parsing; -use technique::templating::{self, Checklist, Procedure, Recipe, Source}; +use technique::templating::{self, Checklist, NasaEsaIss, Procedure, Recipe, Source}; mod editor; mod output; @@ -124,7 +124,7 @@ fn main() { Arg::new("domain") .short('d') .long("domain") - .value_parser(["checklist", "procedure", "recipe", "source"]) + .value_parser(["checklist", "nasa-esa-iss", "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."), ) @@ -348,6 +348,7 @@ fn main() { let template: &dyn templating::Template = match domain { "source" => &Source, "checklist" => &Checklist, + "nasa-esa-iss" => &NasaEsaIss, "procedure" => &Procedure, "recipe" => &Recipe, other => { diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index 409661df..0e27ee3d 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -95,11 +95,11 @@ fn header_domain() { let result = input.read_domain_line(); assert_eq!(result, Ok(Some("checklist"))); - input.initialize("& nasa-flight-plan,v4.0"); + input.initialize("& nasa-esa-iss,v4.0"); assert!(is_domain_line(input.source)); let result = input.read_domain_line(); - assert_eq!(result, Ok(Some("nasa-flight-plan,v4.0"))); + assert_eq!(result, Ok(Some("nasa-esa-iss,v4.0"))); } // now we test incremental parsing diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 5a397550..0b9e03a9 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -110,7 +110,7 @@ to be used when rendering the Technique. Common domains include renderer.style(crate::formatting::Syntax::Header, "CC-BY 4.0"), renderer.style(crate::formatting::Syntax::Header, "Proprietary"), renderer.style(crate::formatting::Syntax::Header, "checklist"), - renderer.style(crate::formatting::Syntax::Header, "nasa-flight-plan,v4.0"), + renderer.style(crate::formatting::Syntax::Header, "nasa-esa-iss,v4.0"), renderer.style(crate::formatting::Syntax::Header, "recipe") ), ) diff --git a/src/templating/mod.rs b/src/templating/mod.rs index f0fcb8c5..a9d4f932 100644 --- a/src/templating/mod.rs +++ b/src/templating/mod.rs @@ -5,12 +5,14 @@ //! internally. mod checklist; +mod nasa_esa_iss; mod procedure; mod recipe; mod source; mod template; pub use checklist::Checklist; +pub use nasa_esa_iss::NasaEsaIss; pub use procedure::Procedure; pub use recipe::Recipe; pub use source::Source; diff --git a/src/templating/nasa_esa_iss.rs b/src/templating/nasa_esa_iss.rs new file mode 100644 index 00000000..0a0c0ef7 --- /dev/null +++ b/src/templating/nasa_esa_iss.rs @@ -0,0 +1,30 @@ +//! NASA/ESA ISS Crew Procedure domain — renders Technique documents in the +//! style of ISS crew procedures, with bordered tables, role designators, and +//! structured command/verification layout. + +use crate::domain::nasa_esa_iss::adapter::NasaEsaIssAdapter; +use crate::domain::serialize::{Markup, Render}; +use crate::domain::Adapter; +use crate::language; +use crate::templating::template::Template; + +pub static TEMPLATE: &str = include_str!("nasa_esa_iss.typ"); + +pub struct NasaEsaIss; + +impl Template for NasaEsaIss { + fn markup(&self, document: &language::Document) -> String { + let model = NasaEsaIssAdapter.extract(document); + let mut out = Markup::new(); + model.render(&mut out); + out.finish() + } + + fn typst(&self) -> &str { + TEMPLATE + } + + fn domain(&self) -> &str { + "nasa-esa-iss" + } +} diff --git a/src/templating/nasa_esa_iss.typ b/src/templating/nasa_esa_iss.typ new file mode 100644 index 00000000..081bc8ad --- /dev/null +++ b/src/templating/nasa_esa_iss.typ @@ -0,0 +1,211 @@ +// NASA/ESA ISS Crew Procedure domain template for Technique. +// +// Renders procedures in the style of ISS flight plan documents: +// bordered table layout, role designators in left margin, underlined +// procedure titles, boxed display names, bold commands, and +// verification checks. + +// -- Formatting functions ---------------------------------------------------- + +#let render-document(source: none, name: none, title: none, description: (), children: none) = [ + #if title != none [ + #text(size: 11pt, weight: "bold")[#upper(title)] + #v(0.5em) + ] + #block(width: 100%, stroke: 0.75pt, { + // Column header + block(width: 100%, fill: rgb("#e8e8e8"), inset: 4pt, above: 0pt, + text(size: 9pt, weight: "bold", align(center, [PROCEDURE]))) + + // Notes from description + if description.len() > 0 { + block(width: 100%, inset: (x: 10pt, y: 6pt), { + align(center, text(size: 8pt, weight: "bold", underline([NOTE]))) + v(2pt) + for para in description { + text(size: 8pt, para) + parbreak() + } + }) + line(length: 100%, stroke: 0.5pt) + } + + // Body content + if children != none { + block(width: 100%, inset: (x: 10pt, top: 6pt, bottom: 12pt), children) + } + }) +] + +#let render-outline(sections: ()) = {} + +#let render-section(ordinal: none, heading: none, children: none) = { + std.heading(level: 1, numbering: none, + [#ordinal. #h(8pt) #if heading != none { heading }]) + if children != none { children } +} + +#let section-divider() = { + line(length: 100%, stroke: 0.5pt) +} + +#let render-procedure(name: none, title: none, description: (), children: none) = { + let ordinal-width = 1.6em + block(above: 0.8em, below: 0.8em, { + set par(hanging-indent: ordinal-width + 0.2em) + box(width: ordinal-width, text(size: 9pt, weight: "bold", [#name.])) + h(0.2em) + if title != none { + text(size: 9pt, weight: "bold", underline(offset: 2pt, title)) + } + }) + pad(left: ordinal-width + 0.2em, { + for para in description { + block(above: 0.2em, below: 0.2em, { + set text(size: 8pt) + para + }) + } + if children != none { + children + } + }) +} + +#let render-step(ordinal: none, title: none, body: (), invocations: (), responses: none, children: none) = { + let ordinal-width = 1.2em + + block(above: 0.4em, below: 0.4em, breakable: false, { + set par(spacing: 0.5em, hanging-indent: ordinal-width + 0.2em) + + if ordinal != none { + box(width: ordinal-width, text(size: 8pt, [#ordinal.])) + h(0.2em) + } else if title != none or invocations.len() > 0 { + box(width: ordinal-width)[\u{2013}] + h(0.2em) + } + + if title != none { + text(size: 8pt, title) + } + + if invocations.len() > 0 { + if title != none { h(4pt) } + invocations.map(i => { + text(size: 8pt, fill: rgb("#666666"), raw("<")) + text(size: 8pt, fill: rgb("#3b5d7d"), raw(i)) + text(size: 8pt, fill: rgb("#666666"), raw(">")) + }).join(text(fill: rgb("#666666"), raw(", "))) + } + + if body.len() > 0 { + parbreak() + for para in body { + text(size: 8pt, [#para]) + parbreak() + } + } + + if responses != none { + v(2pt) + responses + } + + if children != none { + pad(left: 14pt, children) + } + }) +} + +#let render-response(value: none, condition: none) = { + h(4pt) + text(size: 8pt, [\u{221a}]) + text(size: 8pt, { + if condition != none [ #value -- #condition ] + else [ #value ] + }) +} + +#let render-attribute(name: none, children: none) = { + block(above: 0.3em, below: 0.3em, { + text(size: 8pt, weight: "bold", upper(name)) + if children != none { + pad(left: 8pt, children) + } + }) +} + +#let render-code-block(expression: none, body: (), responses: none, children: none) = { + if expression != none and expression.starts-with("foreach ") { + // "foreach var 1 2 3 4 5 6" — render as numbered boxes + let parts = expression.split(" ") + let var = parts.at(1) + let values = parts.slice(2) + + block(above: 0.3em, below: 0.6em, { + text(size: 8pt, [\[]) + text(size: 8pt, weight: "bold", var) + text(size: 8pt, [\] = ]) + h(2pt) + for val in values { + box(baseline: 15%, stroke: 0.5pt, inset: (x: 3pt, y: 1pt), text(size: 8pt, val)) + h(1pt) + } + }) + + // Loop body with left bracket + block(above: 0.2em, below: 0.2em, + stroke: (left: 0.5pt), inset: (left: 8pt), { + if responses != none { responses } + if children != none { children } + }) + + block(above: 0.1em, text(size: 8pt, [Repeat])) + } else { + if expression != none { + block(above: 0.3em, below: 0.3em, { + if expression.starts-with("cmd ") { + let arg = expression.slice(4) + text(size: 8pt, weight: "bold", [cmd ]) + text(size: 8pt, arg) + } else { + text(size: 8pt, fill: rgb("#444444"), raw(expression)) + } + }) + } + if body.len() > 0 { + pad(left: 14pt, { + for line in body { + text(size: 8pt, fill: rgb("#4e9a06"), weight: "bold", raw(line)) + linebreak() + } + }) + } + if responses != none or children != none { + pad(left: 14pt, { + if responses != none { + responses + } + if children != none { + children + } + }) + } + } +} + +// -- Default template -------------------------------------------------------- + +#let template(body) = { + set page( + paper: "a5", + margin: (top: 1.5cm, bottom: 1.5cm, left: 1.5cm, right: 1.5cm), + numbering: "1", + number-align: center + bottom, + ) + set par(justify: false) + set text(size: 8pt, font: "Liberation Sans") + + body +}