Skip to main content

cooklang_format/
cooklang_source.rs

1// This file includes a substantial portion of code from
2// https://github.com/Zheoni/cooklang-chef
3//
4// The original code is licensed under the MIT License, a copy of which
5// is provided below in addition to our project's license.
6//
7//
8
9// MIT License
10
11// Copyright (c) 2023 Francisco J. Sanchez
12
13// Permission is hereby granted, free of charge, to any person obtaining a copy
14// of this software and associated documentation files (the "Software"), to deal
15// in the Software without restriction, including without limitation the rights
16// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17// copies of the Software, and to permit persons to whom the Software is
18// furnished to do so, subject to the following conditions:
19
20// The above copyright notice and this permission notice shall be included in all
21// copies or substantial portions of the Software.
22
23// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29// SOFTWARE.
30
31//! Format a recipe as cooklang
32//!
33//! Named `cooklang_source` rather than `cooklang`: at this crate's root that
34//! name belongs to the re-exported `cooklang` parser crate, and the two cannot
35//! share a scope. `cookcli-core` aliases it back to `format::cooklang`.
36
37use 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
48/// Write `recipe` back out as Cooklang source.
49///
50/// Metadata is emitted as YAML front-matter; steps are re-wrapped to the
51/// terminal width without breaking a component across lines.
52pub 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    // TODO if the recipe has been scaled and multiple servings are defined
64    // it can lead to the recipe not parsing.
65    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) = &section.name {
93        writeln!(w, "== {name} ==")?;
94    } else if index > 0 {
95        writeln!(w, "====")?;
96    }
97    for content in &section.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                    // `path` already carries the leading `.` — a reference's
117                    // components always begin with one, because `@./sauce{}`
118                    // parses as components `["."]`. Prepending another `./`
119                    // here, as this used to, made the formatter emit
120                    // `@././sub/sauce{}`; that reparses as components
121                    // `[".", ".", "sub"]`, so the next pass prepended one more
122                    // and the prefix grew without bound on every rewrite.
123                    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
194// This prevents spliting a multi word component in two lines, because that's
195// invalid.
196fn 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        // Take the whitespace that follows the component along with it.
216        //
217        // A `textwrap::Word` carries the whitespace that *trails* it, and that
218        // trailing whitespace is what gets dropped when the line breaks there.
219        // Emitting the component on its own left it with none, so the space
220        // after it fell to the following word instead — as an empty word whose
221        // whitespace is that space — and when the break landed at exactly that
222        // point the space moved to the start of the wrapped line. Reparsed, a
223        // leading space is ordinary step text, so the next format pass added
224        // another, and another: the formatter was not idempotent, and
225        // rewriting a collection through it corrupted every step that happened
226        // to wrap just after a component
227        // (<https://github.com/cooklang/cookcli/issues/414>).
228        //
229        // `Word::from` splits trailing whitespace off into the `whitespace`
230        // field for us, so extending the slice is the whole fix.
231        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    // `super::*` already brings `std::fmt::Write` into scope for `write!`.
307    use crate::test_support::parse_recipe;
308
309    /// Exercises the things this formatter can get wrong: multi-word names
310    /// (which need `{}` to stay one component), a note, an aliased name, an
311    /// ingredient with no quantity, cookware with and without an amount,
312    /// named and unnamed timers, several steps, a text block, and both an
313    /// unnamed and a named section.
314    ///
315    /// The long step is deliberate: it must wrap, and it places a multi-word
316    /// component near the wrap point, which is exactly what
317    /// [`component_word_separator`] exists to protect. Splitting
318    /// `@caster sugar{2%tbsp}` across two lines produces text that no longer
319    /// parses as one ingredient.
320    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    /// A structural summary of everything this formatter has to preserve.
340    /// Comparing summaries rather than `Recipe` values keeps the failure
341    /// message readable and ignores spans, which legitimately move.
342    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 &section.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    /// The step as a reader sees it, with components replaced by their names.
394    /// Whitespace is collapsed because the formatter re-wraps steps, so line
395    /// breaks legitimately land in different places.
396    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    /// The formatter's whole contract: what it writes must parse back into
429    /// the same recipe. This catches escaping, `{}` placement and wrapping
430    /// regressions in one assertion.
431    #[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    /// Formatting is idempotent, so that rewriting a stored `.cook` file does
457    /// not churn it.
458    ///
459    /// It was not: a step whose wrap fell just after a component came out with
460    /// a leading space, that space reparsed as ordinary step text, and the next
461    /// pass added another — one space after pass 1, two after pass 2, three
462    /// after pass 3, without bound. Any workflow that rewrites recipes through
463    /// the formatter — normalising a collection, an editor's "format document",
464    /// a pre-commit hook — degraded the source a little more on each run
465    /// (<https://github.com/cooklang/cookcli/issues/414>).
466    ///
467    /// `FIXTURE`'s long step is built to reach this: it wraps, and it puts a
468    /// multi-word component near the wrap point.
469    #[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    /// The defect this guards grew by one space per pass, so two passes is the
477    /// smallest case that shows it and proves nothing about the fourth. Run it
478    /// out far enough that an accumulating change cannot hide.
479    #[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(&current, "r", 1.0).unwrap().value);
485            assert_eq!(first, current, "pass {pass} differed from the first");
486        }
487    }
488
489    /// The specific shape that broke: a step that wraps immediately after a
490    /// component must not start the next line with a space.
491    #[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    /// A recipe reference is re-emitted with `/` between its components on
507    /// every platform.
508    ///
509    /// This writer produces Cooklang *source*, so the separator is syntax, not
510    /// presentation: joining with `std::path::MAIN_SEPARATOR` wrote
511    /// `@.\sub\sauce{}` on Windows, which does not parse back as the same
512    /// reference (<https://github.com/cooklang/cookcli/issues/442>). The
513    /// assertion holds trivially on Unix, where the platform separator was
514    /// already `/`; it is the Windows leg of the CI matrix that it guards.
515    #[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        // And it still means the same thing when read back.
533        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    /// A reference survives repeated rewrites unchanged.
542    ///
543    /// It did not: a reference's components already begin with `.`, and the
544    /// writer prepended a second `./` on top, so each pass added one more —
545    /// `@./sub/sauce{}`, `@././sub/sauce{}`, `@./././sub/sauce{}`. Every pass
546    /// still resolved to the same file, so nothing failed; the source just got
547    /// steadily uglier each time a collection was reformatted.
548    ///
549    /// Unlike [`formatting_is_idempotent`], which covers the wrapping defect,
550    /// this holds for every pass and is not ignored.
551    #[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    /// Metadata survives as YAML front-matter, not as the deprecated `>>`
566    /// syntax that would warn on reparse.
567    #[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    /// A recipe with no metadata must not emit an empty front-matter block,
588    /// which would reparse as a stray text step.
589    #[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}