use std::{fmt::Write, io};
use cooklang::{
metadata::Metadata,
model::{Item, Section, Step},
parser::Modifiers,
quantity::Quantity,
Recipe,
};
use regex::Regex;
pub fn print_cooklang(recipe: &Recipe, mut writer: impl io::Write) -> io::Result<()> {
let w = &mut writer;
metadata(w, &recipe.metadata)?;
writeln!(w)?;
sections(w, recipe)?;
Ok(())
}
fn metadata(w: &mut impl io::Write, metadata: &Metadata) -> io::Result<()> {
if metadata.map.is_empty() {
return Ok(());
}
let map = metadata.map.clone();
const FRONTMATTER_FENCE: &str = "---";
writeln!(w, "{FRONTMATTER_FENCE}")?;
serde_yaml::to_writer(&mut *w, &map)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
writeln!(w, "{FRONTMATTER_FENCE}\n")?;
Ok(())
}
fn sections(w: &mut impl io::Write, recipe: &Recipe) -> io::Result<()> {
for (index, section) in recipe.sections.iter().enumerate() {
w_section(w, section, recipe, index)?;
}
Ok(())
}
fn w_section(
w: &mut impl io::Write,
section: &Section,
recipe: &Recipe,
index: usize,
) -> io::Result<()> {
if let Some(name) = §ion.name {
writeln!(w, "== {name} ==")?;
} else if index > 0 {
writeln!(w, "====")?;
}
for content in §ion.content {
match content {
cooklang::Content::Step(step) => w_step(w, step, recipe)?,
cooklang::Content::Text(text) => w_text_block(w, text)?,
}
writeln!(w)?;
}
Ok(())
}
fn w_step(w: &mut impl io::Write, step: &Step, recipe: &Recipe) -> io::Result<()> {
let mut step_str = String::new();
for item in &step.items {
match item {
Item::Text { value } => step_str.push_str(value),
&Item::Ingredient { index } => {
let igr = &recipe.ingredients[index];
let name = if let Some(reference) = &igr.reference {
reference.path(crate::REFERENCE_SEPARATOR)
} else {
igr.name.clone()
};
ComponentFormatter {
kind: ComponentKind::Ingredient,
modifiers: igr.modifiers(),
name: Some(&name),
alias: igr.alias.as_deref(),
quantity: igr.quantity.as_ref(),
note: igr.note.as_deref(),
}
.format(&mut step_str)
}
&Item::Cookware { index } => {
let cw = &recipe.cookware[index];
ComponentFormatter {
kind: ComponentKind::Cookware,
modifiers: cw.modifiers(),
name: Some(&cw.name),
alias: cw.alias.as_deref(),
quantity: cw.quantity.as_ref(),
note: None,
}
.format(&mut step_str)
}
&Item::Timer { index } => {
let t = &recipe.timers[index];
ComponentFormatter {
kind: ComponentKind::Timer,
modifiers: Modifiers::empty(),
name: t.name.as_deref(),
alias: None,
quantity: t.quantity.as_ref(),
note: None,
}
.format(&mut step_str)
}
&Item::InlineQuantity { index } => {
let q = &recipe.inline_quantities[index];
write!(&mut step_str, "{}", q.value()).expect("writing to a String is infallible");
if let Some(u) = q.unit() {
step_str.push_str(u);
}
}
}
}
let width = textwrap::termwidth().min(80);
let options = textwrap::Options::new(width)
.word_separator(textwrap::WordSeparator::Custom(component_word_separator));
let lines = textwrap::wrap(step_str.trim(), options);
for line in lines {
writeln!(w, "{line}")?;
}
Ok(())
}
fn w_text_block(w: &mut impl io::Write, text: &str) -> io::Result<()> {
let width = textwrap::termwidth().min(80);
let indent = "> ";
let options = textwrap::Options::new(width)
.initial_indent(indent)
.subsequent_indent(indent);
let lines = textwrap::wrap(text.trim(), options);
for line in lines {
writeln!(w, "{line}")?;
}
Ok(())
}
fn component_word_separator<'a>(
line: &'a str,
) -> Box<dyn Iterator<Item = textwrap::core::Word<'a>> + 'a> {
use textwrap::core::Word;
let re = {
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
RE.get_or_init(|| regex::Regex::new(r"[@#~][^@#~]*\{[^\}]*\}").unwrap())
};
let mut words = vec![];
let mut last_added = 0;
let default_separator = textwrap::WordSeparator::new();
for component in re.find_iter(line) {
if last_added < component.start() {
words.extend(default_separator.find_words(&line[last_added..component.start()]));
}
let trailing = line[component.end()..]
.find(|c: char| !c.is_whitespace())
.map_or(line.len(), |offset| component.end() + offset);
words.push(Word::from(&line[component.start()..trailing]));
last_added = trailing;
}
if last_added < line.len() {
words.extend(default_separator.find_words(&line[last_added..]));
}
Box::new(words.into_iter())
}
struct ComponentFormatter<'a> {
kind: ComponentKind,
modifiers: Modifiers,
name: Option<&'a str>,
alias: Option<&'a str>,
quantity: Option<&'a Quantity>,
note: Option<&'a str>,
}
enum ComponentKind {
Ingredient,
Cookware,
Timer,
}
impl ComponentFormatter<'_> {
fn format(self, w: &mut String) {
w.push(match self.kind {
ComponentKind::Ingredient => '@',
ComponentKind::Cookware => '#',
ComponentKind::Timer => '~',
});
for m in self.modifiers {
w.push(match m {
Modifiers::RECIPE => '@',
Modifiers::HIDDEN => '-',
Modifiers::OPT => '?',
Modifiers::REF => '&',
Modifiers::NEW => '+',
_ => panic!("Unknown modifier: {m:?}"),
});
}
let mut multi_word = false;
if let Some(name) = self.name {
if name.chars().any(|c| !c.is_alphanumeric()) {
multi_word = true;
}
w.push_str(name);
if let Some(alias) = self.alias {
multi_word = true;
w.push('|');
w.push_str(alias);
}
}
if let Some(q) = self.quantity {
w.push('{');
w.push_str(&q.value().to_string());
if let Some(unit) = q.unit() {
write!(w, "%{unit}").unwrap();
}
w.push('}');
} else if multi_word {
w.push_str("{}");
}
if let Some(note) = self.note {
write!(w, "({note})").unwrap();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::parse_recipe;
const FIXTURE: &str = "\
---
title: Round Trip
servings: 4
---
Mix @plain flour{200%g} with @whole milk{250%ml} in a #large mixing bowl{} \
and beat until the batter is completely smooth, then fold in @caster \
sugar{2%tbsp} and a pinch of @fine sea salt{}.
Simmer ~gently{10%minutes} in a #pan{2}, then rest ~{5%minutes}.
> Rest the dough somewhere warm, covered.
== Finishing ==
Dust with @icing sugar{1%tbsp}(sifted) using a #fine sieve.
";
fn shape(recipe: &Recipe) -> String {
let mut s = String::new();
for i in &recipe.ingredients {
writeln!(
s,
"ingredient name={:?} alias={:?} qty={:?} note={:?} modifiers={:?}",
i.name,
i.alias,
i.quantity.as_ref().map(ToString::to_string),
i.note,
i.modifiers()
)
.unwrap();
}
for c in &recipe.cookware {
writeln!(
s,
"cookware name={:?} qty={:?} note={:?}",
c.name,
c.quantity.as_ref().map(ToString::to_string),
c.note
)
.unwrap();
}
for t in &recipe.timers {
writeln!(
s,
"timer name={:?} qty={:?}",
t.name,
t.quantity.as_ref().map(ToString::to_string)
)
.unwrap();
}
for section in &recipe.sections {
writeln!(s, "section name={:?}", section.name).unwrap();
for content in §ion.content {
match content {
cooklang::Content::Step(step) => writeln!(
s,
" step {} text={:?}",
step.number,
step_text(recipe, step)
)
.unwrap(),
cooklang::Content::Text(t) => writeln!(s, " text {:?}", t.trim()).unwrap(),
}
}
}
s
}
fn step_text(recipe: &Recipe, step: &Step) -> String {
let mut s = String::new();
for item in &step.items {
match item {
Item::Text { value } => s.push_str(value),
&Item::Ingredient { index } => {
s.push_str(recipe.ingredients[index].display_name().as_ref())
}
&Item::Cookware { index } => s.push_str(&recipe.cookware[index].name),
&Item::Timer { index } => {
let t = &recipe.timers[index];
if let Some(name) = &t.name {
s.push_str(name);
}
if let Some(q) = &t.quantity {
write!(s, "{q}").unwrap();
}
}
&Item::InlineQuantity { index } => {
write!(s, "{}", recipe.inline_quantities[index]).unwrap()
}
}
}
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn format_to_string(recipe: &Recipe) -> String {
let mut buf = Vec::new();
print_cooklang(recipe, &mut buf).expect("formats");
String::from_utf8(buf).expect("utf-8")
}
#[test]
fn output_reparses_into_an_equivalent_recipe() {
let original = parse_recipe(FIXTURE, "round trip", 1.0).expect("fixture parses");
assert!(
original.diagnostics.is_empty(),
"fixture should parse cleanly: {:?}",
original.diagnostics
);
let rendered = format_to_string(&original.value);
let reparsed = parse_recipe(&rendered, "round trip", 1.0)
.unwrap_or_else(|e| panic!("formatter emitted unparseable cooklang:\n{rendered}\n{e}"));
assert!(
reparsed.diagnostics.is_empty(),
"reparse warned: {:?}\n--- rendered ---\n{rendered}",
reparsed.diagnostics
);
assert_eq!(
shape(&original.value),
shape(&reparsed.value),
"round trip changed the recipe\n--- rendered ---\n{rendered}"
);
}
#[test]
fn formatting_is_idempotent() {
let once = format_to_string(&parse_recipe(FIXTURE, "r", 1.0).unwrap().value);
let twice = format_to_string(&parse_recipe(&once, "r", 1.0).unwrap().value);
assert_eq!(once, twice, "second format pass differed");
}
#[test]
fn formatting_is_stable_over_many_passes() {
let first = format_to_string(&parse_recipe(FIXTURE, "r", 1.0).unwrap().value);
let mut current = first.clone();
for pass in 2..=6 {
current = format_to_string(&parse_recipe(¤t, "r", 1.0).unwrap().value);
assert_eq!(first, current, "pass {pass} differed from the first");
}
}
#[test]
fn a_wrap_just_after_a_component_leaves_no_leading_space() {
let rendered = format_to_string(&parse_recipe(FIXTURE, "r", 1.0).unwrap().value);
assert!(
rendered.lines().count() > 1,
"fixture must wrap for this to mean anything: {rendered}"
);
for line in rendered.lines() {
assert!(
!line.starts_with(' '),
"step lines must not be indented: {line:?}\n--- rendered ---\n{rendered}"
);
}
}
#[test]
fn a_reference_is_written_with_forward_slashes() {
let source = "Make @./sub/sauce{200%ml} and stir.\n";
let recipe = parse_recipe(source, "r", 1.0)
.expect("fixture parses")
.value;
let rendered = format_to_string(&recipe);
assert!(
rendered.contains("@./sub/sauce{"),
"expected a forward-slash reference, got: {rendered}"
);
assert!(
!rendered.contains('\\'),
"a reference path must never carry a backslash: {rendered}"
);
let reparsed = parse_recipe(&rendered, "r", 1.0).expect("re-parses").value;
let reference = reparsed.ingredients[0]
.reference
.as_ref()
.expect("still a recipe reference");
assert_eq!(reference.path(crate::REFERENCE_SEPARATOR), "./sub/sauce");
}
#[test]
fn a_reference_does_not_accumulate_a_prefix_across_passes() {
let mut source = "Make @./sub/sauce{200%ml} and stir.\n".to_string();
for pass in 1..=4 {
let recipe = parse_recipe(&source, "r", 1.0).expect("parses").value;
let rendered = format_to_string(&recipe);
assert!(
rendered.contains("@./sub/sauce{"),
"pass {pass} changed the reference: {rendered}"
);
source = rendered;
}
}
#[test]
fn metadata_round_trips_as_front_matter() {
let rendered = format_to_string(&parse_recipe(FIXTURE, "r", 1.0).unwrap().value);
assert!(
rendered.starts_with("---\n"),
"expected YAML front-matter, got: {rendered}"
);
assert!(!rendered.contains(">> "), "must not emit `>>` metadata");
let reparsed = parse_recipe(&rendered, "r", 1.0).unwrap().value;
assert_eq!(
reparsed.metadata.get("title").and_then(|v| v.as_str()),
Some("Round Trip")
);
assert_eq!(
reparsed.metadata.get("servings").and_then(|v| v.as_u64()),
Some(4)
);
}
#[test]
fn a_recipe_without_metadata_emits_no_front_matter() {
let rendered =
format_to_string(&parse_recipe("Boil @water{1%l}.\n", "r", 1.0).unwrap().value);
assert!(
!rendered.contains("---"),
"expected no front-matter fence: {rendered:?}"
);
assert_eq!(rendered.trim(), "Boil @water{1%l}.");
}
}