From f50db8f4723843e8e4e8e19ecebd269f1ea96b96 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 13:11:30 +1000 Subject: [PATCH 1/2] Allow tablets in bare code blocks to form ingredients --- src/domain/recipe/adapter.rs | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/domain/recipe/adapter.rs b/src/domain/recipe/adapter.rs index eed669e..07a4b31 100644 --- a/src/domain/recipe/adapter.rs +++ b/src/domain/recipe/adapter.rs @@ -124,6 +124,20 @@ fn collect_ingredients(items: &mut Vec, scope: &language::Scope, pla return; } + // Bare tablet (CodeBlock containing a Tablet expression) + if let Some(pairs) = scope.tablet() { + for pair in pairs { + items.push(Ingredient { + label: pair + .label + .to_string(), + quantity: format_value(&pair.value), + source: place.map(String::from), + }); + } + return; + } + // Steps may contain tablet children if scope.is_step() { for child in scope.children() { @@ -410,6 +424,42 @@ turkey : () -> Ingredients assert_eq!(doc.ingredients[0].items[1].quantity, "2 pieces"); } + #[test] + fn ingredients_from_bare_tablet_under_place() { + let doc = extract(trim( + r#" +dinner : + +# Dinner + + 1. Get ingredients + +shopping : + + ^grocer + { + [ + "Pasta" = 500 g + ] + } + "#, + )); + assert_eq!( + doc.ingredients + .len(), + 1 + ); + assert_eq!( + doc.ingredients[0] + .items + .len(), + 1 + ); + assert_eq!(doc.ingredients[0].items[0].label, "Pasta"); + assert_eq!(doc.ingredients[0].items[0].quantity, "500 g"); + assert_eq!(doc.ingredients[0].items[0].source, Some("grocer".into())); + } + #[test] fn method_steps_from_role_scoped_procedures() { let doc = extract(trim( From 6bcdfdf328d75897b5a6adff33ab50532a4347a4 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 27 May 2026 14:36:23 +1000 Subject: [PATCH 2/2] Include descriptions in checklist domain --- src/domain/checklist/adapter.rs | 391 +++++++++++++++++--------------- src/domain/checklist/types.rs | 39 ++-- src/domain/checklist/typst.rs | 74 +++--- src/templating/checklist.typ | 40 ++-- 4 files changed, 306 insertions(+), 238 deletions(-) diff --git a/src/domain/checklist/adapter.rs b/src/domain/checklist/adapter.rs index c113876..e152822 100644 --- a/src/domain/checklist/adapter.rs +++ b/src/domain/checklist/adapter.rs @@ -1,13 +1,15 @@ //! 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. +//! Each top-level procedure becomes an `Item::Procedure` carrying its own +//! direct steps. Section chunks found within a procedure's body float up as +//! sibling `Item::Section`s alongside the procedure. Sections contain either +//! sub-procedures or steps. If the document has no procedures at all, its +//! top-level scopes (sections or bare steps) become items directly. use crate::domain::Adapter; use crate::language; -use super::types::{Document, Prose, Response, Section, Step}; +use super::types::{Document, Item, Procedure, Prose, Response, Section, Step}; pub struct ChecklistAdapter; @@ -20,139 +22,107 @@ impl Adapter for ChecklistAdapter { } fn extract(document: &language::Document) -> Document { - let mut extracted = Document::new(); - - let mut procedures = document.procedures(); - - if let Some(first) = procedures.next() { - extracted.name = Some( - first + let mut items = Vec::new(); + let mut had_procedures = false; + + for procedure in document.procedures() { + had_procedures = true; + let (steps, sections) = split_procedure_body(procedure); + items.push(Item::Procedure(Procedure { + name: procedure .name() .to_string(), - ); - extracted.title = first - .title() - .map(String::from); - extract_procedure(&mut extracted, first); - } - - for procedure in procedures { - extract_procedure(&mut extracted, procedure); + title: procedure + .title() + .map(String::from), + description: procedure + .description() + .map(|p| Prose::parse(&p.content())) + .collect(), + steps, + })); + for section in sections { + items.push(Item::Section(section)); + } } - if extracted - .sections - .is_empty() - { - // Handle top-level SectionChunks (no procedures) + if !had_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 scope + .section_info() + .is_some() + { + items.push(Item::Section(section_from_scope(scope))); + } else { + items.extend( + steps_from_scope(scope, None) + .into_iter() + .map(Item::Step), + ); } } + } + + Document { items } +} - // Handle bare top-level steps (no sections, no procedures) - if extracted - .sections - .is_empty() +fn split_procedure_body(procedure: &language::Procedure) -> (Vec, Vec
) { + let mut steps = Vec::new(); + let mut sections = Vec::new(); + for scope in procedure.steps() { + if scope + .section_info() + .is_some() { - 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, - }); - } + sections.push(section_from_scope(scope)); + } else { + steps.extend(steps_from_scope(scope, None)); } } + (steps, sections) +} - extracted +fn section_from_scope(scope: &language::Scope) -> Section { + let (numeral, title) = scope + .section_info() + .expect("scope is a section"); + let mut items = Vec::new(); + if let Some(body) = scope.body() { + for p in body.procedures() { + items.push(Item::Procedure(extract_subprocedure(p))); + } + for s in body.steps() { + items.extend( + steps_from_scope(s, None) + .into_iter() + .map(Item::Step), + ); + } + } + Section { + ordinal: Some(numeral.to_string()), + heading: title.map(|para| para.text()), + items, + } } -fn extract_procedure(content: &mut Document, procedure: &language::Procedure) { +fn extract_subprocedure(procedure: &language::Procedure) -> Procedure { + let mut steps = Vec::new(); for scope in procedure.steps() { - if let Some((numeral, title)) = scope.section_info() { - let mut steps = Vec::new(); - if let Some(body) = scope.body() { - for p in body.procedures() { - let title = p - .title() - .map(String::from) - .unwrap_or_else(|| { - p.name() - .to_string() - }); - let children: Vec = p - .steps() - .flat_map(|s| steps_from_scope(s, None)) - .collect(); - steps.push(Step { - name: Some( - p.name() - .to_string(), - ), - ordinal: None, - title: Some(title), - body: Vec::new(), - role: None, - responses: Vec::new(), - children, - }); - } - } - content - .sections - .push(Section { - ordinal: Some(numeral.to_string()), - heading: title.map(|para| para.text()), - steps, - }); - } else { - if content - .sections - .is_empty() - { - content - .sections - .push(Section { - ordinal: None, - heading: None, - steps: Vec::new(), - }); - } - content - .sections - .last_mut() - .unwrap() - .steps - .extend(steps_from_scope(&scope, None)); - } + steps.extend(steps_from_scope(scope, None)); + } + Procedure { + name: procedure + .name() + .to_string(), + title: procedure + .title() + .map(String::from), + description: procedure + .description() + .map(|p| Prose::parse(&p.content())) + .collect(), + steps, } } @@ -161,7 +131,6 @@ fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ve return vec![step_from_scope(scope, inherited_role)]; } - // AttributeBlock — extract role and process children let roles: Vec<_> = scope .roles() .collect(); @@ -175,52 +144,9 @@ fn steps_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ve .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(); @@ -254,7 +180,6 @@ fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ste }; Step { - name: None, ordinal: scope .ordinal() .map(String::from), @@ -270,6 +195,7 @@ fn step_from_scope(scope: &language::Scope, inherited_role: Option<&str>) -> Ste mod check { use std::path::Path; + use crate::domain::checklist::types::Item; use crate::domain::Adapter; use crate::parsing; @@ -286,8 +212,29 @@ mod check { ChecklistAdapter.extract(&doc) } + fn as_procedure(item: &Item) -> &super::Procedure { + match item { + Item::Procedure(p) => p, + _ => panic!("expected procedure"), + } + } + + fn as_section(item: &Item) -> &super::Section { + match item { + Item::Section(s) => s, + _ => panic!("expected section"), + } + } + + fn as_step(item: &Item) -> &super::Step { + match item { + Item::Step(s) => s, + _ => panic!("expected step"), + } + } + #[test] - fn procedure_title_becomes_document_title() { + fn single_procedure_with_steps() { let doc = extract(trim( r#" preflight : @@ -297,18 +244,23 @@ preflight : 1. Fasten seatbelt "#, )); - assert_eq!(doc.name, Some("preflight".into())); - assert_eq!(doc.title, Some("Pre-flight Checks".into())); assert_eq!( - doc.sections + doc.items + .len(), + 1 + ); + let proc = as_procedure(&doc.items[0]); + assert_eq!(proc.name, "preflight"); + assert_eq!(proc.title, Some("Pre-flight Checks".into())); + assert_eq!( + proc.steps .len(), 1 ); - assert_eq!(doc.sections[0].heading, None); } #[test] - fn role_flattened_onto_children() { + fn role_flattened_onto_steps() { let doc = extract(trim( r#" checks : @@ -318,7 +270,8 @@ checks : 2. Mark surgical site "#, )); - let steps = &doc.sections[0].steps; + let proc = as_procedure(&doc.items[0]); + let steps = &proc.steps; assert_eq!(steps.len(), 2); assert_eq!(steps[0].role, Some("surgeon".into())); assert_eq!(steps[1].role, Some("surgeon".into())); @@ -334,7 +287,8 @@ checks : 'Yes' | 'No' if complications "#, )); - let step = &doc.sections[0].steps[0]; + let proc = as_procedure(&doc.items[0]); + let step = &proc.steps[0]; assert_eq!( step.responses .len(), @@ -347,7 +301,7 @@ checks : } #[test] - fn invocation_only_step_has_content() { + fn sibling_procedures_are_peers() { let doc = extract(trim( r#" main : @@ -361,7 +315,88 @@ ensure_safety : - Check exits "#, )); - let steps = &doc.sections[0].steps; - assert_eq!(steps[0].title, Some("ensure_safety".into())); + assert_eq!( + doc.items + .len(), + 2 + ); + let first = as_procedure(&doc.items[0]); + assert_eq!(first.name, "main"); + let second = as_procedure(&doc.items[1]); + assert_eq!(second.name, "ensure_safety"); + assert_eq!(second.title, Some("Safety First".into())); + assert_eq!( + second + .steps + .len(), + 1 + ); + } + + #[test] + fn no_procedure_sections_of_steps() { + let doc = extract(trim( + r#" +% technique v1 +& checklist + +I. Morning + + 1. Eat breakfast + 2. Brush teeth + +II. Evening + + 1. Dinner + 2. Sleep + "#, + )); + assert_eq!( + doc.items + .len(), + 2 + ); + let s1 = as_section(&doc.items[0]); + assert_eq!(s1.ordinal, Some("I".into())); + assert_eq!(s1.heading, Some("Morning".into())); + assert_eq!( + s1.items + .len(), + 2 + ); + let _ = as_step(&s1.items[0]); + } + + #[test] + fn procedure_with_sections_of_subprocedures() { + let doc = extract(trim( + r#" +outer : + +I. Setup + +setup_machine : +# Setup Machine + 1. Plug in + "#, + )); + assert_eq!( + doc.items + .len(), + 2 + ); + let outer = as_procedure(&doc.items[0]); + assert_eq!(outer.name, "outer"); + assert_eq!( + outer + .steps + .len(), + 0 + ); + let section = as_section(&doc.items[1]); + assert_eq!(section.ordinal, Some("I".into())); + let sub = as_procedure(§ion.items[0]); + assert_eq!(sub.name, "setup_machine"); + assert_eq!(sub.title, Some("Setup Machine".into())); } } diff --git a/src/domain/checklist/types.rs b/src/domain/checklist/types.rs index 9e5203f..f280cf9 100644 --- a/src/domain/checklist/types.rs +++ b/src/domain/checklist/types.rs @@ -1,37 +1,46 @@ //! 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 sequence of items, where an item is a step, a named +//! procedure with optional description and its own steps, or a section +//! grouping further items under an ordinal heading. pub use crate::domain::engine::{Inline, Prose}; -/// A checklist is a document of sections containing steps. +/// A checklist: a sequence of top-level items. pub struct Document { - pub name: Option, - pub title: Option, - pub sections: Vec
, + pub items: Vec, } impl Document { pub fn new() -> Self { - Document { - name: None, - title: None, - sections: Vec::new(), - } + Document { items: Vec::new() } } } -/// A section within a checklist. +/// A top-level entry in a checklist. +pub enum Item { + Step(Step), + Procedure(Procedure), + Section(Section), +} + +/// A named subroutine: name, optional title, optional description, and steps. +pub struct Procedure { + pub name: String, + pub title: Option, + pub description: Vec, + pub steps: Vec, +} + +/// A grouping with an ordinal heading; contains procedures or steps. pub struct Section { pub ordinal: Option, pub heading: Option, - pub steps: Vec, + pub items: Vec, } -/// A step within a checklist section. +/// A checkbox action. pub struct Step { - pub name: Option, pub ordinal: Option, pub title: Option, pub body: Vec, diff --git a/src/domain/checklist/typst.rs b/src/domain/checklist/typst.rs index 603052b..f661ed0 100644 --- a/src/domain/checklist/typst.rs +++ b/src/domain/checklist/typst.rs @@ -2,33 +2,41 @@ use crate::domain::serialize::{render_prose_list, Markup, Render}; -use super::types::{Document, Response, Section, Step}; +use super::types::{Document, Item, Procedure, Response, Section, Step}; impl Render for Document { fn render(&self, out: &mut Markup) { - if self - .name - .is_some() - || self - .title - .is_some() + out.call("render-document"); + if !self + .items + .is_empty() { - out.call("render-document"); - out.param_opt("name", &self.name); - out.param_opt("title", &self.title); - out.close(); + out.content_open("children"); + for item in &self.items { + item.render(out); + } + out.content_close(); } - for section in &self.sections { - section.render(out); + out.close(); + } +} + +impl Render for Item { + fn render(&self, out: &mut Markup) { + match self { + Item::Step(s) => s.render(out), + Item::Procedure(p) => p.render(out), + Item::Section(s) => s.render(out), } } } -impl Render for Section { +impl Render for Procedure { fn render(&self, out: &mut Markup) { - out.call("render-section"); - out.param_opt("ordinal", &self.ordinal); - out.param_opt("heading", &self.heading); + out.call("render-procedure"); + out.param("name", &self.name); + out.param_opt("title", &self.title); + render_prose_list(out, "description", &self.description); if !self .steps .is_empty() @@ -43,20 +51,30 @@ impl Render for Section { } } -impl Render for Step { +impl Render for Section { fn render(&self, out: &mut Markup) { - if self - .name - .is_some() + out.call("render-section"); + out.param_opt("ordinal", &self.ordinal); + out.param_opt("heading", &self.heading); + if !self + .items + .is_empty() { - out.call("render-procedure"); - out.param_opt("name", &self.name); - out.param_opt("title", &self.title); - } else { - out.call("render-step"); - out.param_opt("ordinal", &self.ordinal); - out.param_opt("title", &self.title); + out.content_open("children"); + for item in &self.items { + item.render(out); + } + out.content_close(); } + out.close(); + } +} + +impl Render for Step { + fn render(&self, out: &mut Markup) { + out.call("render-step"); + out.param_opt("ordinal", &self.ordinal); + out.param_opt("title", &self.title); render_prose_list(out, "body", &self.body); out.param_opt("role", &self.role); if !self diff --git a/src/templating/checklist.typ b/src/templating/checklist.typ index dea4af9..1d72dda 100644 --- a/src/templating/checklist.typ +++ b/src/templating/checklist.typ @@ -7,34 +7,40 @@ #let check = box(stroke: 0.5pt, width: 0.8em, height: 0.8em, baseline: 0.05em) -#let render-document(name: none, title: none) = { - if title != none { - std.heading(level: 1, numbering: none, title) - } else if name != none { - std.heading(level: 1, numbering: none, raw(name)) +#let render-description(description: ()) = { + for para in description { + para + parbreak() } } +#let render-document(children: none) = { + if children != none { children } +} + #let render-section(ordinal: none, heading: none, children: none) = { if ordinal != none and heading != none { - std.heading(level: 2, numbering: none, [#ordinal. #heading]) + std.heading(level: 1, numbering: none, [#ordinal. #heading]) } else if ordinal != none { - std.heading(level: 2, numbering: none, [#ordinal.]) + std.heading(level: 1, numbering: none, [#ordinal.]) } else if heading != none { - std.heading(level: 2, numbering: none, heading) + std.heading(level: 1, numbering: none, heading) } if children != none { children } } -#let render-procedure(name: none, title: none, body: (), role: none, responses: none, children: none) = { - block(above: 0.8em, below: 0.6em, { - if title != none { - std.heading(level: 3, numbering: none, title) - } else if name != none { - std.heading(level: 3, numbering: none, raw(name)) - } - }) - if children != none { children } +#let render-procedure(name: none, title: none, description: (), children: none) = { + if title != none { + std.heading(level: 2, numbering: none, title) + } else if name != none { + std.heading(level: 2, numbering: none, raw(name)) + } + if description.len() > 0 { + block(above: 1.2em, below: 1.2em, render-description(description: description)) + } + if children != none { + block(above: 1.2em, children) + } } #let render-response(value: none, condition: none) = {