cooklang_to_cooklang/
lib.rs

1//! Format a recipe as cooklang
2
3use std::{fmt::Write, io};
4
5use cooklang::{
6    metadata::{CooklangValueExt, Metadata},
7    model::{Item, Section, Step},
8    parser::{IntermediateData, Modifiers},
9    quantity::{Quantity, QuantityValue},
10    IngredientReferenceTarget, Recipe,
11};
12use regex::Regex;
13
14pub fn print_cooklang<D, V: QuantityValue>(
15    recipe: &Recipe<D, V>,
16    mut writer: impl io::Write,
17) -> io::Result<()> {
18    let w = &mut writer;
19
20    metadata(w, &recipe.metadata)?;
21    writeln!(w)?;
22    sections(w, recipe)?;
23
24    Ok(())
25}
26
27fn metadata(w: &mut impl io::Write, metadata: &Metadata) -> io::Result<()> {
28    // TODO if the recipe has been scaled and multiple servings are defined
29    // it can lead to the recipe not parsing.
30
31    for (key, value) in &metadata.map {
32        if let Some(key) = key.as_str() {
33            if let Some(val) = value.as_str_like() {
34                writeln!(w, ">> {key}: {val}")?;
35            }
36        }
37    }
38    Ok(())
39}
40
41fn sections<D, V: QuantityValue>(w: &mut impl io::Write, recipe: &Recipe<D, V>) -> io::Result<()> {
42    for (index, section) in recipe.sections.iter().enumerate() {
43        w_section(w, section, recipe, index)?;
44    }
45    Ok(())
46}
47
48fn w_section<D, V: QuantityValue>(
49    w: &mut impl io::Write,
50    section: &Section,
51    recipe: &Recipe<D, V>,
52    index: usize,
53) -> io::Result<()> {
54    if let Some(name) = &section.name {
55        writeln!(w, "== {name} ==")?;
56    } else if index > 0 {
57        writeln!(w, "====")?;
58    }
59    for content in &section.content {
60        match content {
61            cooklang::Content::Step(step) => w_step(w, step, recipe)?,
62            cooklang::Content::Text(text) => w_text_block(w, text)?,
63        }
64        writeln!(w)?;
65    }
66    Ok(())
67}
68
69fn w_step<D, V: QuantityValue>(
70    w: &mut impl io::Write,
71    step: &Step,
72    recipe: &Recipe<D, V>,
73) -> io::Result<()> {
74    let mut step_str = String::new();
75    for item in &step.items {
76        match item {
77            Item::Text { value } => step_str.push_str(value),
78            &Item::Ingredient { index } => {
79                let igr = &recipe.ingredients[index];
80
81                let intermediate_data = igr
82                    .relation
83                    .references_to()
84                    .and_then(|(index, target)| calculate_intermediate_data(index, target));
85
86                ComponentFormatter {
87                    kind: ComponentKind::Ingredient,
88                    modifiers: igr.modifiers(),
89                    intermediate_data,
90                    name: Some(&igr.name),
91                    alias: igr.alias.as_deref(),
92                    quantity: igr.quantity.as_ref(),
93                    note: igr.note.as_deref(),
94                }
95                .format(&mut step_str)
96            }
97            &Item::Cookware { index } => {
98                let cw = &recipe.cookware[index];
99                ComponentFormatter {
100                    kind: ComponentKind::Cookware,
101                    modifiers: cw.modifiers(),
102                    intermediate_data: None,
103                    name: Some(&cw.name),
104                    alias: cw.alias.as_deref(),
105                    quantity: cw.quantity.clone().map(|v| Quantity::new(v, None)).as_ref(),
106                    note: None,
107                }
108                .format(&mut step_str)
109            }
110            &Item::Timer { index } => {
111                let t = &recipe.timers[index];
112                ComponentFormatter {
113                    kind: ComponentKind::Timer,
114                    modifiers: Modifiers::empty(),
115                    intermediate_data: None,
116                    name: t.name.as_deref(),
117                    alias: None,
118                    quantity: t.quantity.as_ref(),
119                    note: None,
120                }
121                .format(&mut step_str)
122            }
123            &Item::InlineQuantity { index } => {
124                let q = &recipe.inline_quantities[index];
125                write!(&mut step_str, "{}", q.value()).unwrap();
126                if let Some(u) = q.unit() {
127                    step_str.push_str(u);
128                }
129            }
130        }
131    }
132    let width = textwrap::termwidth().min(80);
133    let options = textwrap::Options::new(width)
134        .word_separator(textwrap::WordSeparator::Custom(component_word_separator));
135    let lines = textwrap::wrap(step_str.trim(), options);
136    for line in lines {
137        writeln!(w, "{line}")?;
138    }
139    Ok(())
140}
141
142fn w_text_block(w: &mut impl io::Write, text: &str) -> io::Result<()> {
143    let width = textwrap::termwidth().min(80);
144    let indent = "> ";
145    let options = textwrap::Options::new(width)
146        .initial_indent(indent)
147        .subsequent_indent(indent);
148    let lines = textwrap::wrap(text.trim(), options);
149    for line in lines {
150        writeln!(w, "{line}")?;
151    }
152    Ok(())
153}
154
155// This prevents spliting a multi word component in two lines, because that's
156// invalid.
157fn component_word_separator<'a>(
158    line: &'a str,
159) -> Box<dyn Iterator<Item = textwrap::core::Word<'a>> + 'a> {
160    use textwrap::core::Word;
161
162    let re = {
163        static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
164        RE.get_or_init(|| regex::Regex::new(r"[@#~][^@#~]*\{[^\}]*\}").unwrap())
165    };
166
167    let mut words = vec![];
168    let mut last_added = 0;
169    let default_separator = textwrap::WordSeparator::new();
170
171    for component in re.find_iter(line) {
172        if last_added < component.start() {
173            words.extend(default_separator.find_words(&line[last_added..component.start()]));
174        }
175        words.push(Word::from(&line[component.range()]));
176        last_added = component.end();
177    }
178    if last_added < line.len() {
179        words.extend(default_separator.find_words(&line[last_added..]));
180    }
181    Box::new(words.into_iter())
182}
183
184struct ComponentFormatter<'a, V: QuantityValue> {
185    kind: ComponentKind,
186    modifiers: Modifiers,
187    intermediate_data: Option<IntermediateData>,
188    name: Option<&'a str>,
189    alias: Option<&'a str>,
190    quantity: Option<&'a Quantity<V>>,
191    note: Option<&'a str>,
192}
193
194enum ComponentKind {
195    Ingredient,
196    Cookware,
197    Timer,
198}
199
200impl<'a, V: QuantityValue> ComponentFormatter<'a, V> {
201    fn format(self, w: &mut String) {
202        w.push(match self.kind {
203            ComponentKind::Ingredient => '@',
204            ComponentKind::Cookware => '#',
205            ComponentKind::Timer => '~',
206        });
207        for m in self.modifiers {
208            w.push(match m {
209                Modifiers::RECIPE => '@',
210                Modifiers::HIDDEN => '-',
211                Modifiers::OPT => '?',
212                Modifiers::REF => '&',
213                Modifiers::NEW => '+',
214                _ => panic!("Unknown modifier: {:?}", m),
215            });
216            if m == Modifiers::REF && self.intermediate_data.is_some() {
217                use cooklang::parser::IntermediateRefMode::*;
218                use cooklang::parser::IntermediateTargetKind::*;
219                let IntermediateData {
220                    ref_mode,
221                    target_kind,
222                    val,
223                } = self.intermediate_data.unwrap();
224                let repr = match (target_kind, ref_mode) {
225                    (Step, Number) => format!("{val}"),
226                    (Step, Relative) => format!("~{val}"),
227                    (Section, Number) => format!("={val}"),
228                    (Section, Relative) => format!("=~{val}"),
229                };
230                w.push_str(&format!("({repr})"));
231            }
232        }
233        let mut multi_word = false;
234        if let Some(name) = self.name {
235            if name.chars().any(|c| !c.is_alphanumeric()) {
236                multi_word = true;
237            }
238            w.push_str(name);
239            if let Some(alias) = self.alias {
240                multi_word = true;
241                w.push('|');
242                w.push_str(alias);
243            }
244        }
245        if let Some(q) = self.quantity {
246            w.push('{');
247            w.push_str(&q.value().to_string());
248            if let Some(unit) = q.unit() {
249                write!(w, "%{}", unit).unwrap();
250            }
251            w.push('}');
252        } else if multi_word {
253            w.push_str("{}");
254        }
255        if let Some(note) = self.note {
256            write!(w, "({note})").unwrap();
257        }
258    }
259}
260
261fn calculate_intermediate_data(
262    index: usize,
263    target: IngredientReferenceTarget,
264) -> Option<IntermediateData> {
265    use cooklang::parser::IntermediateRefMode::*;
266    use cooklang::parser::IntermediateTargetKind::*;
267
268    // TODO maybe use relative references for "close enough" references?
269    let d = match target {
270        IngredientReferenceTarget::Ingredient => return None,
271        IngredientReferenceTarget::Step => IntermediateData {
272            ref_mode: Number,
273            target_kind: Step,
274            val: index as i16,
275        },
276        IngredientReferenceTarget::Section => IntermediateData {
277            ref_mode: Number,
278            target_kind: Section,
279            val: index as i16,
280        },
281    };
282
283    Some(d)
284}