1use std::{fmt::Write, io};
38
39use cooklang::{
40 metadata::Metadata,
41 model::{Item, Section, Step},
42 parser::Modifiers,
43 quantity::Quantity,
44 Recipe,
45};
46use regex::Regex;
47
48pub fn print_cooklang(recipe: &Recipe, mut writer: impl io::Write) -> io::Result<()> {
53 let w = &mut writer;
54
55 metadata(w, &recipe.metadata)?;
56 writeln!(w)?;
57 sections(w, recipe)?;
58
59 Ok(())
60}
61
62fn metadata(w: &mut impl io::Write, metadata: &Metadata) -> io::Result<()> {
63 if metadata.map.is_empty() {
66 return Ok(());
67 }
68
69 let map = metadata.map.clone();
70
71 const FRONTMATTER_FENCE: &str = "---";
72 writeln!(w, "{FRONTMATTER_FENCE}")?;
73 serde_yaml::to_writer(&mut *w, &map)
74 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
75 writeln!(w, "{FRONTMATTER_FENCE}\n")?;
76 Ok(())
77}
78
79fn sections(w: &mut impl io::Write, recipe: &Recipe) -> io::Result<()> {
80 for (index, section) in recipe.sections.iter().enumerate() {
81 w_section(w, section, recipe, index)?;
82 }
83 Ok(())
84}
85
86fn w_section(
87 w: &mut impl io::Write,
88 section: &Section,
89 recipe: &Recipe,
90 index: usize,
91) -> io::Result<()> {
92 if let Some(name) = §ion.name {
93 writeln!(w, "== {name} ==")?;
94 } else if index > 0 {
95 writeln!(w, "====")?;
96 }
97 for content in §ion.content {
98 match content {
99 cooklang::Content::Step(step) => w_step(w, step, recipe)?,
100 cooklang::Content::Text(text) => w_text_block(w, text)?,
101 }
102 writeln!(w)?;
103 }
104 Ok(())
105}
106
107fn w_step(w: &mut impl io::Write, step: &Step, recipe: &Recipe) -> io::Result<()> {
108 let mut step_str = String::new();
109 for item in &step.items {
110 match item {
111 Item::Text { value } => step_str.push_str(value),
112 &Item::Ingredient { index } => {
113 let igr = &recipe.ingredients[index];
114
115 let name = if let Some(reference) = &igr.reference {
116 reference.path(crate::REFERENCE_SEPARATOR)
124 } else {
125 igr.name.clone()
126 };
127
128 ComponentFormatter {
129 kind: ComponentKind::Ingredient,
130 modifiers: igr.modifiers(),
131 name: Some(&name),
132 alias: igr.alias.as_deref(),
133 quantity: igr.quantity.as_ref(),
134 note: igr.note.as_deref(),
135 }
136 .format(&mut step_str)
137 }
138 &Item::Cookware { index } => {
139 let cw = &recipe.cookware[index];
140 ComponentFormatter {
141 kind: ComponentKind::Cookware,
142 modifiers: cw.modifiers(),
143 name: Some(&cw.name),
144 alias: cw.alias.as_deref(),
145 quantity: cw.quantity.as_ref(),
146 note: None,
147 }
148 .format(&mut step_str)
149 }
150 &Item::Timer { index } => {
151 let t = &recipe.timers[index];
152 ComponentFormatter {
153 kind: ComponentKind::Timer,
154 modifiers: Modifiers::empty(),
155 name: t.name.as_deref(),
156 alias: None,
157 quantity: t.quantity.as_ref(),
158 note: None,
159 }
160 .format(&mut step_str)
161 }
162 &Item::InlineQuantity { index } => {
163 let q = &recipe.inline_quantities[index];
164 write!(&mut step_str, "{}", q.value()).expect("writing to a String is infallible");
165 if let Some(u) = q.unit() {
166 step_str.push_str(u);
167 }
168 }
169 }
170 }
171 let width = textwrap::termwidth().min(80);
172 let options = textwrap::Options::new(width)
173 .word_separator(textwrap::WordSeparator::Custom(component_word_separator));
174 let lines = textwrap::wrap(step_str.trim(), options);
175 for line in lines {
176 writeln!(w, "{line}")?;
177 }
178 Ok(())
179}
180
181fn w_text_block(w: &mut impl io::Write, text: &str) -> io::Result<()> {
182 let width = textwrap::termwidth().min(80);
183 let indent = "> ";
184 let options = textwrap::Options::new(width)
185 .initial_indent(indent)
186 .subsequent_indent(indent);
187 let lines = textwrap::wrap(text.trim(), options);
188 for line in lines {
189 writeln!(w, "{line}")?;
190 }
191 Ok(())
192}
193
194fn component_word_separator<'a>(
197 line: &'a str,
198) -> Box<dyn Iterator<Item = textwrap::core::Word<'a>> + 'a> {
199 use textwrap::core::Word;
200
201 let re = {
202 static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
203 RE.get_or_init(|| regex::Regex::new(r"[@#~][^@#~]*\{[^\}]*\}").unwrap())
204 };
205
206 let mut words = vec![];
207 let mut last_added = 0;
208 let default_separator = textwrap::WordSeparator::new();
209
210 for component in re.find_iter(line) {
211 if last_added < component.start() {
212 words.extend(default_separator.find_words(&line[last_added..component.start()]));
213 }
214
215 let trailing = line[component.end()..]
232 .find(|c: char| !c.is_whitespace())
233 .map_or(line.len(), |offset| component.end() + offset);
234 words.push(Word::from(&line[component.start()..trailing]));
235 last_added = trailing;
236 }
237 if last_added < line.len() {
238 words.extend(default_separator.find_words(&line[last_added..]));
239 }
240 Box::new(words.into_iter())
241}
242
243struct ComponentFormatter<'a> {
244 kind: ComponentKind,
245 modifiers: Modifiers,
246 name: Option<&'a str>,
247 alias: Option<&'a str>,
248 quantity: Option<&'a Quantity>,
249 note: Option<&'a str>,
250}
251
252enum ComponentKind {
253 Ingredient,
254 Cookware,
255 Timer,
256}
257
258impl ComponentFormatter<'_> {
259 fn format(self, w: &mut String) {
260 w.push(match self.kind {
261 ComponentKind::Ingredient => '@',
262 ComponentKind::Cookware => '#',
263 ComponentKind::Timer => '~',
264 });
265 for m in self.modifiers {
266 w.push(match m {
267 Modifiers::RECIPE => '@',
268 Modifiers::HIDDEN => '-',
269 Modifiers::OPT => '?',
270 Modifiers::REF => '&',
271 Modifiers::NEW => '+',
272 _ => panic!("Unknown modifier: {m:?}"),
273 });
274 }
275 let mut multi_word = false;
276 if let Some(name) = self.name {
277 if name.chars().any(|c| !c.is_alphanumeric()) {
278 multi_word = true;
279 }
280 w.push_str(name);
281 if let Some(alias) = self.alias {
282 multi_word = true;
283 w.push('|');
284 w.push_str(alias);
285 }
286 }
287 if let Some(q) = self.quantity {
288 w.push('{');
289 w.push_str(&q.value().to_string());
290 if let Some(unit) = q.unit() {
291 write!(w, "%{unit}").unwrap();
292 }
293 w.push('}');
294 } else if multi_word {
295 w.push_str("{}");
296 }
297 if let Some(note) = self.note {
298 write!(w, "({note})").unwrap();
299 }
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::test_support::parse_recipe;
308
309 const FIXTURE: &str = "\
321---
322title: Round Trip
323servings: 4
324---
325
326Mix @plain flour{200%g} with @whole milk{250%ml} in a #large mixing bowl{} \
327and beat until the batter is completely smooth, then fold in @caster \
328sugar{2%tbsp} and a pinch of @fine sea salt{}.
329
330Simmer ~gently{10%minutes} in a #pan{2}, then rest ~{5%minutes}.
331
332> Rest the dough somewhere warm, covered.
333
334== Finishing ==
335
336Dust with @icing sugar{1%tbsp}(sifted) using a #fine sieve.
337";
338
339 fn shape(recipe: &Recipe) -> String {
343 let mut s = String::new();
344 for i in &recipe.ingredients {
345 writeln!(
346 s,
347 "ingredient name={:?} alias={:?} qty={:?} note={:?} modifiers={:?}",
348 i.name,
349 i.alias,
350 i.quantity.as_ref().map(ToString::to_string),
351 i.note,
352 i.modifiers()
353 )
354 .unwrap();
355 }
356 for c in &recipe.cookware {
357 writeln!(
358 s,
359 "cookware name={:?} qty={:?} note={:?}",
360 c.name,
361 c.quantity.as_ref().map(ToString::to_string),
362 c.note
363 )
364 .unwrap();
365 }
366 for t in &recipe.timers {
367 writeln!(
368 s,
369 "timer name={:?} qty={:?}",
370 t.name,
371 t.quantity.as_ref().map(ToString::to_string)
372 )
373 .unwrap();
374 }
375 for section in &recipe.sections {
376 writeln!(s, "section name={:?}", section.name).unwrap();
377 for content in §ion.content {
378 match content {
379 cooklang::Content::Step(step) => writeln!(
380 s,
381 " step {} text={:?}",
382 step.number,
383 step_text(recipe, step)
384 )
385 .unwrap(),
386 cooklang::Content::Text(t) => writeln!(s, " text {:?}", t.trim()).unwrap(),
387 }
388 }
389 }
390 s
391 }
392
393 fn step_text(recipe: &Recipe, step: &Step) -> String {
397 let mut s = String::new();
398 for item in &step.items {
399 match item {
400 Item::Text { value } => s.push_str(value),
401 &Item::Ingredient { index } => {
402 s.push_str(recipe.ingredients[index].display_name().as_ref())
403 }
404 &Item::Cookware { index } => s.push_str(&recipe.cookware[index].name),
405 &Item::Timer { index } => {
406 let t = &recipe.timers[index];
407 if let Some(name) = &t.name {
408 s.push_str(name);
409 }
410 if let Some(q) = &t.quantity {
411 write!(s, "{q}").unwrap();
412 }
413 }
414 &Item::InlineQuantity { index } => {
415 write!(s, "{}", recipe.inline_quantities[index]).unwrap()
416 }
417 }
418 }
419 s.split_whitespace().collect::<Vec<_>>().join(" ")
420 }
421
422 fn format_to_string(recipe: &Recipe) -> String {
423 let mut buf = Vec::new();
424 print_cooklang(recipe, &mut buf).expect("formats");
425 String::from_utf8(buf).expect("utf-8")
426 }
427
428 #[test]
432 fn output_reparses_into_an_equivalent_recipe() {
433 let original = parse_recipe(FIXTURE, "round trip", 1.0).expect("fixture parses");
434 assert!(
435 original.diagnostics.is_empty(),
436 "fixture should parse cleanly: {:?}",
437 original.diagnostics
438 );
439
440 let rendered = format_to_string(&original.value);
441 let reparsed = parse_recipe(&rendered, "round trip", 1.0)
442 .unwrap_or_else(|e| panic!("formatter emitted unparseable cooklang:\n{rendered}\n{e}"));
443 assert!(
444 reparsed.diagnostics.is_empty(),
445 "reparse warned: {:?}\n--- rendered ---\n{rendered}",
446 reparsed.diagnostics
447 );
448
449 assert_eq!(
450 shape(&original.value),
451 shape(&reparsed.value),
452 "round trip changed the recipe\n--- rendered ---\n{rendered}"
453 );
454 }
455
456 #[test]
470 fn formatting_is_idempotent() {
471 let once = format_to_string(&parse_recipe(FIXTURE, "r", 1.0).unwrap().value);
472 let twice = format_to_string(&parse_recipe(&once, "r", 1.0).unwrap().value);
473 assert_eq!(once, twice, "second format pass differed");
474 }
475
476 #[test]
480 fn formatting_is_stable_over_many_passes() {
481 let first = format_to_string(&parse_recipe(FIXTURE, "r", 1.0).unwrap().value);
482 let mut current = first.clone();
483 for pass in 2..=6 {
484 current = format_to_string(&parse_recipe(¤t, "r", 1.0).unwrap().value);
485 assert_eq!(first, current, "pass {pass} differed from the first");
486 }
487 }
488
489 #[test]
492 fn a_wrap_just_after_a_component_leaves_no_leading_space() {
493 let rendered = format_to_string(&parse_recipe(FIXTURE, "r", 1.0).unwrap().value);
494 assert!(
495 rendered.lines().count() > 1,
496 "fixture must wrap for this to mean anything: {rendered}"
497 );
498 for line in rendered.lines() {
499 assert!(
500 !line.starts_with(' '),
501 "step lines must not be indented: {line:?}\n--- rendered ---\n{rendered}"
502 );
503 }
504 }
505
506 #[test]
516 fn a_reference_is_written_with_forward_slashes() {
517 let source = "Make @./sub/sauce{200%ml} and stir.\n";
518 let recipe = parse_recipe(source, "r", 1.0)
519 .expect("fixture parses")
520 .value;
521 let rendered = format_to_string(&recipe);
522
523 assert!(
524 rendered.contains("@./sub/sauce{"),
525 "expected a forward-slash reference, got: {rendered}"
526 );
527 assert!(
528 !rendered.contains('\\'),
529 "a reference path must never carry a backslash: {rendered}"
530 );
531
532 let reparsed = parse_recipe(&rendered, "r", 1.0).expect("re-parses").value;
534 let reference = reparsed.ingredients[0]
535 .reference
536 .as_ref()
537 .expect("still a recipe reference");
538 assert_eq!(reference.path(crate::REFERENCE_SEPARATOR), "./sub/sauce");
539 }
540
541 #[test]
552 fn a_reference_does_not_accumulate_a_prefix_across_passes() {
553 let mut source = "Make @./sub/sauce{200%ml} and stir.\n".to_string();
554 for pass in 1..=4 {
555 let recipe = parse_recipe(&source, "r", 1.0).expect("parses").value;
556 let rendered = format_to_string(&recipe);
557 assert!(
558 rendered.contains("@./sub/sauce{"),
559 "pass {pass} changed the reference: {rendered}"
560 );
561 source = rendered;
562 }
563 }
564
565 #[test]
568 fn metadata_round_trips_as_front_matter() {
569 let rendered = format_to_string(&parse_recipe(FIXTURE, "r", 1.0).unwrap().value);
570 assert!(
571 rendered.starts_with("---\n"),
572 "expected YAML front-matter, got: {rendered}"
573 );
574 assert!(!rendered.contains(">> "), "must not emit `>>` metadata");
575
576 let reparsed = parse_recipe(&rendered, "r", 1.0).unwrap().value;
577 assert_eq!(
578 reparsed.metadata.get("title").and_then(|v| v.as_str()),
579 Some("Round Trip")
580 );
581 assert_eq!(
582 reparsed.metadata.get("servings").and_then(|v| v.as_u64()),
583 Some(4)
584 );
585 }
586
587 #[test]
590 fn a_recipe_without_metadata_emits_no_front_matter() {
591 let rendered =
592 format_to_string(&parse_recipe("Boil @water{1%l}.\n", "r", 1.0).unwrap().value);
593 assert!(
594 !rendered.contains("---"),
595 "expected no front-matter fence: {rendered:?}"
596 );
597 assert_eq!(rendered.trim(), "Boil @water{1%l}.");
598 }
599}