1use 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 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) = §ion.name {
55 writeln!(w, "== {name} ==")?;
56 } else if index > 0 {
57 writeln!(w, "====")?;
58 }
59 for content in §ion.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
155fn 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 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}