Skip to main content

cookcli_core/
parser.rs

1//! Recipe parsing and conversion of `cooklang` reports into [`Diagnostic`]s.
2//!
3//! `cooklang` parses leniently, so a recipe can parse successfully and still
4//! have something to say about itself. The CLI used to log those warnings and
5//! drop them; here they come back to the caller in the [`Outcome`].
6
7use crate::{CoreError, Diagnostic, Location, Outcome, Severity, Span};
8use camino::Utf8Path;
9use cooklang::{
10    error::{SourceDiag, SourceReport},
11    Converter, CooklangParser, Extensions, Recipe,
12};
13use std::sync::LazyLock;
14
15/// The shared parser. Matches CookCLI's configuration exactly: no extensions,
16/// default converter for unit support.
17pub static PARSER: LazyLock<CooklangParser> =
18    LazyLock::new(|| CooklangParser::new(Extensions::empty(), Converter::default()));
19
20/// Parse recipe text, scale it, and collect diagnostics.
21///
22/// `name` identifies the recipe in error messages. `scale` is the scaling
23/// factor; pass `1.0` to leave quantities alone.
24///
25/// Returns [`CoreError::Parse`] when the recipe has errors, since no recipe can
26/// be produced in that case. Warnings come back in the [`Outcome`].
27///
28/// # Scale
29///
30/// `scale` must be finite; NaN and infinity give [`CoreError::InvalidScale`].
31/// Zero and negative factors are *accepted*, matching what the CLI does today.
32pub fn parse_recipe(text: &str, name: &str, scale: f64) -> Result<Outcome<Recipe>, CoreError> {
33    parse_recipe_at(text, name, scale, None)
34}
35
36/// As [`parse_recipe`], but attributing diagnostics to a specific file path.
37///
38/// Pass `file` when the text came from disk, so that diagnostics point at
39/// something the caller can open. An editor parsing an unsaved buffer passes
40/// `None` and still gets spans.
41pub fn parse_recipe_at(
42    text: &str,
43    name: &str,
44    scale: f64,
45    file: Option<&Utf8Path>,
46) -> Result<Outcome<Recipe>, CoreError> {
47    if !scale.is_finite() {
48        return Err(CoreError::InvalidScale { scale });
49    }
50
51    let mut outcome = parse_unscaled(text, name, file)?;
52    outcome.value.scale(scale, PARSER.converter());
53    Ok(outcome)
54}
55
56/// Parse recipe text and collect diagnostics, without scaling it.
57///
58/// Deliberately separate from `parse_recipe_at(.., 1.0, ..)`:
59/// [`cooklang::Recipe::scale`] re-fits units even at a factor of one, so with a
60/// unit database behind it `1500 ml` comes back as `1.5 l`. "Scale by one" and
61/// "do not scale" are therefore different operations, and shopping-list
62/// reference expansion needs the latter — it applies its own `scale_to_target`
63/// afterwards, and a fit in between would change the numbers the user sees.
64///
65/// That fit is invisible today: `cooklang`'s `bundled_units` feature is off, so
66/// quantities keep the units they were authored in and there is no database to
67/// fit against. The two functions stay separate anyway, because the difference
68/// is in `scale`'s contract rather than in today's unit configuration.
69pub(crate) fn parse_unscaled(
70    text: &str,
71    name: &str,
72    file: Option<&Utf8Path>,
73) -> Result<Outcome<Recipe>, CoreError> {
74    let parsed = PARSER.parse(text);
75    let display_path = file.map_or_else(|| name.to_string(), |p| p.to_string());
76    let parse_error = |report: &SourceReport| CoreError::Parse {
77        name: name.to_string(),
78        diagnostics: collect_diagnostics(report, file),
79        rendered: render_report(report, &display_path, text, false),
80    };
81
82    if parsed.report().has_errors() {
83        return Err(parse_error(parsed.report()));
84    }
85    let diagnostics = collect_diagnostics(parsed.report(), file);
86
87    match parsed.into_result() {
88        Ok((recipe, _)) => Ok(Outcome::with_diagnostics(recipe, diagnostics)),
89        // `into_result` fails when `is_valid()` is false, which is
90        // `has_output() && !has_errors()`. We have just ruled out errors, but
91        // cooklang does not promise output is present, so this arm is
92        // reachable in principle. Returning beats panicking: this crate is
93        // called from a NAPI addon, where a panic crosses into JavaScript.
94        Err(report) => Err(parse_error(&report)),
95    }
96}
97
98/// Render a parse report the way the CLI prints it, with source line context.
99///
100/// `ansi` controls colour. The CLI passes `true` for terminal output; the
101/// report stored in [`CoreError::Parse`] is always rendered with `false`.
102///
103/// Takes the report rather than the parse result so that it also renders
104/// reports from metadata-only parses.
105pub fn render_report(
106    report: &SourceReport,
107    display_path: &str,
108    content: &str,
109    ansi: bool,
110) -> String {
111    let mut buf = Vec::new();
112    report.write(display_path, content, ansi, &mut buf).ok();
113    String::from_utf8_lossy(&buf).into_owned()
114}
115
116/// Convert every entry of a `cooklang` report into a [`Diagnostic`].
117pub(crate) fn collect_diagnostics(
118    report: &SourceReport,
119    file: Option<&Utf8Path>,
120) -> Vec<Diagnostic> {
121    report
122        .iter()
123        .map(|diag| convert_diagnostic(diag, file))
124        .collect()
125}
126
127fn convert_diagnostic(diag: &SourceDiag, file: Option<&Utf8Path>) -> Diagnostic {
128    let severity = match diag.severity {
129        cooklang::error::Severity::Error => Severity::Error,
130        cooklang::error::Severity::Warning => Severity::Warning,
131    };
132
133    // `labels` is ordered most- to least-important, so the first one is the
134    // main location. Diagnostics about the recipe as a whole have none.
135    let span = diag.labels.first().map(|(span, _)| span.range().into());
136
137    Diagnostic {
138        severity,
139        message: diag.message.to_string(),
140        location: location_for(file, span),
141        // Often a ready-to-apply replacement, which is exactly the payload
142        // the CLI's `warn!` used to discard.
143        hints: diag.hints.iter().map(|h| h.to_string()).collect(),
144    }
145}
146
147/// Build a [`Location`] from whichever of file and span are known.
148///
149/// A span alone is still worth reporting: an editor parsing an unsaved buffer
150/// has no path but still wants to underline the offending text. Only when
151/// neither is known is there no location at all.
152fn location_for(file: Option<&Utf8Path>, span: Option<Span>) -> Option<Location> {
153    if file.is_none() && span.is_none() {
154        return None;
155    }
156    Some(Location {
157        file: file.map(ToOwned::to_owned),
158        span,
159    })
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::diagnostic::{Severity, Span};
166    use cooklang::quantity::Value;
167
168    const GOOD: &str = "Boil @water{2%cups} for ~{5%minutes}.\n";
169
170    /// The numeric value of an ingredient's quantity, ignoring its unit.
171    ///
172    /// Asserting on the value rather than the formatted quantity matters:
173    /// cooklang re-fits units when scaling, so `2 cups` can render as `4 c`
174    /// and a string comparison would be testing the formatter, not the maths.
175    fn quantity_value(recipe: &Recipe, index: usize) -> f64 {
176        match recipe.ingredients[index]
177            .quantity
178            .as_ref()
179            .expect("ingredient has a quantity")
180            .value()
181        {
182            Value::Number(n) => n.value(),
183            other => panic!("expected a numeric quantity, got {other:?}"),
184        }
185    }
186
187    #[test]
188    fn parses_a_clean_recipe_without_diagnostics() {
189        let outcome = parse_recipe(GOOD, "simple", 1.0).expect("parses");
190        assert_eq!(outcome.value.ingredients.len(), 1);
191        assert!(outcome.diagnostics.is_empty());
192    }
193
194    #[test]
195    fn scaling_multiplies_quantities_by_exactly_the_factor() {
196        // GOOD declares `@water{2%cups}`.
197        assert_eq!(
198            quantity_value(&parse_recipe(GOOD, "s", 1.0).unwrap().value, 0),
199            2.0
200        );
201        assert_eq!(
202            quantity_value(&parse_recipe(GOOD, "s", 2.0).unwrap().value, 0),
203            4.0
204        );
205        assert_eq!(
206            quantity_value(&parse_recipe(GOOD, "s", 0.5).unwrap().value, 0),
207            1.0
208        );
209    }
210
211    /// The reason `parse_unscaled` exists: scaling by one is not a no-op in
212    /// `cooklang`'s contract, because it re-fits units.
213    ///
214    /// Re-fitting needs a unit database and `bundled_units` is off, so both
215    /// paths leave `1500 ml` alone today. What this pins is that neither of
216    /// them *changes* the authored quantity; the `scale(1.0)` assertion is the
217    /// one to revisit if the unit database ever comes back.
218    #[test]
219    fn neither_scaling_by_one_nor_parse_unscaled_disturbs_authored_units() {
220        let text = "Pour @milk{1500%ml}.\n";
221
222        let scaled = parse_recipe(text, "milk", 1.0).expect("parses").value;
223        let quantity = scaled.ingredients[0].quantity.as_ref().unwrap();
224        assert_eq!(
225            (quantity.value().to_string(), quantity.unit()),
226            ("1500".to_string(), Some("ml")),
227            "with no unit database, scale(1.0) has nothing to re-fit"
228        );
229
230        let untouched = parse_unscaled(text, "milk", None).expect("parses").value;
231        let quantity = untouched.ingredients[0].quantity.as_ref().unwrap();
232        assert_eq!(
233            (quantity.value().to_string(), quantity.unit()),
234            ("1500".to_string(), Some("ml")),
235            "parse_unscaled must leave the authored quantity alone"
236        );
237    }
238
239    #[test]
240    fn non_finite_scale_is_rejected() {
241        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
242            match parse_recipe(GOOD, "simple", bad) {
243                Err(CoreError::InvalidScale { scale }) => {
244                    assert_eq!(scale.is_nan(), bad.is_nan());
245                }
246                other => panic!("expected InvalidScale for {bad}, got {other:?}"),
247            }
248        }
249    }
250
251    /// Zero and negative scale are accepted, because the CLI accepts them.
252    /// Rejecting them would be a behaviour change smuggled into a refactor.
253    #[test]
254    fn zero_and_negative_scale_are_accepted() {
255        assert_eq!(
256            quantity_value(&parse_recipe(GOOD, "s", 0.0).unwrap().value, 0),
257            0.0
258        );
259        assert_eq!(
260            quantity_value(&parse_recipe(GOOD, "s", -1.0).unwrap().value, 0),
261            -2.0
262        );
263    }
264
265    #[test]
266    fn warnings_are_returned_not_swallowed() {
267        // Deprecated `>>` metadata parses successfully but warns.
268        let text = ">> title: Old Style\n\nBoil @water{}.\n";
269        let outcome = parse_recipe(text, "old", 1.0).expect("parses despite warning");
270
271        // Every diagnostic must be a Warning — not merely "at least one is",
272        // which would still hold if errors were mislabelled as warnings.
273        assert!(!outcome.diagnostics.is_empty(), "expected a diagnostic");
274        for d in &outcome.diagnostics {
275            assert_eq!(
276                d.severity,
277                Severity::Warning,
278                "deprecated syntax is a warning, got {d:?}"
279            );
280        }
281        assert!(!outcome.has_errors());
282    }
283
284    /// Pins the severity mapping in both directions at once, so that inverting
285    /// it cannot pass. Also pins that *every* diagnostic is converted, not
286    /// just the first.
287    #[test]
288    fn every_error_is_converted_with_error_severity() {
289        // Two empty ingredient names: two errors, at two distinct spans.
290        let text = "Add @{1%tsp} and @{2%tsp} to the pot.\n";
291        let Err(CoreError::Parse { diagnostics, .. }) = parse_recipe(text, "broken", 1.0) else {
292            panic!("expected a parse error");
293        };
294
295        assert_eq!(
296            diagnostics.len(),
297            2,
298            "both errors must survive: {diagnostics:?}"
299        );
300        for d in &diagnostics {
301            assert_eq!(
302                d.severity,
303                Severity::Error,
304                "cooklang error must map to Error"
305            );
306        }
307
308        let spans: Vec<_> = diagnostics
309            .iter()
310            .map(|d| d.location.as_ref().unwrap().span.unwrap())
311            .collect();
312        assert_eq!(
313            spans,
314            vec![Span { start: 5, end: 5 }, Span { start: 18, end: 18 }],
315            "each diagnostic keeps its own span, in source order"
316        );
317    }
318
319    /// `labels` is ordered most- to least-important, so the *first* is the
320    /// primary location. Taking the last would underline the wrong text.
321    #[test]
322    fn the_first_label_wins_when_a_diagnostic_has_several() {
323        let text = ">> title: A\n>> title: B\n\nBoil @water{}.\n";
324        let outcome = parse_recipe(text, "dup", 1.0).expect("parses");
325
326        let duplicate = outcome
327            .diagnostics
328            .iter()
329            .find(|d| d.message.contains("duplicate") || d.message.contains("Duplicate"))
330            .unwrap_or(&outcome.diagnostics[0]);
331
332        assert_eq!(
333            duplicate.location.as_ref().unwrap().span,
334            Some(Span { start: 2, end: 11 }),
335            "expected the first label's span, not a later one: {duplicate:?}"
336        );
337    }
338
339    /// Hints are quick fixes, and the CLI's `warn!` threw them away.
340    #[test]
341    fn hints_are_captured() {
342        let text = ">> title: A\n>> title: B\n\nBoil @water{}.\n";
343        let outcome = parse_recipe(text, "dup", 1.0).expect("parses");
344
345        let hints: Vec<&String> = outcome.diagnostics.iter().flat_map(|d| &d.hints).collect();
346        assert!(
347            !hints.is_empty(),
348            "expected at least one hint, got {:?}",
349            outcome.diagnostics
350        );
351        assert!(
352            hints.iter().any(|h| h.contains("---")),
353            "expected a ready-to-apply frontmatter fix, got {hints:?}"
354        );
355    }
356
357    #[test]
358    fn parse_errors_carry_diagnostics_and_rendered_output() {
359        // An ingredient with a quantity but no name is a hard parse error.
360        let text = "Add @{1%tsp} to the pot.\n";
361        match parse_recipe(text, "broken", 1.0) {
362            Err(CoreError::Parse {
363                name,
364                diagnostics,
365                rendered,
366            }) => {
367                assert_eq!(name, "broken");
368                assert_eq!(diagnostics.len(), 1);
369                assert_eq!(diagnostics[0].severity, Severity::Error);
370                assert!(!rendered.is_empty(), "rendered report should be populated");
371                // The stored report is documented as ANSI-free.
372                assert!(
373                    !rendered.contains('\u{1b}'),
374                    "CoreError::Parse.rendered must carry no escape codes: {rendered:?}"
375                );
376            }
377            other => panic!("expected CoreError::Parse, got {other:?}"),
378        }
379    }
380
381    /// Offsets must be byte offsets, since that is what `Span` documents and
382    /// what an editor slicing a `str` needs.
383    #[test]
384    fn spans_are_byte_offsets_into_the_source() {
385        // `é` is two bytes but one char, so the empty ingredient name sits at
386        // byte offset 8 and char offset 7. The two disagree, which is the
387        // point of using this text.
388        let text = "Sauté @{1%tsp} it.\n";
389        assert_eq!(text.find('{'), Some(8));
390        assert_eq!(text.chars().position(|c| c == '{'), Some(7));
391
392        let Err(CoreError::Parse { diagnostics, .. }) = parse_recipe(text, "broken", 1.0) else {
393            panic!("expected a parse error");
394        };
395
396        let span = diagnostics[0]
397            .location
398            .as_ref()
399            .expect("location set")
400            .span
401            .expect("span set");
402        assert!(
403            text.get(span.start..span.end).is_some(),
404            "span {span:?} does not fall on char boundaries of {text:?}"
405        );
406        // Byte offset, not the char offset 7.
407        assert_eq!(span, Span { start: 8, end: 8 });
408    }
409
410    #[test]
411    fn parse_recipe_at_attributes_diagnostics_to_the_file() {
412        let text = ">> title: Old Style\n\nBoil @water{}.\n";
413        let file = Utf8Path::new("recipes/old.cook");
414        let outcome = parse_recipe_at(text, "old", 1.0, Some(file)).expect("parses");
415
416        let location = outcome.diagnostics[0]
417            .location
418            .as_ref()
419            .expect("location set");
420        assert_eq!(location.file.as_deref(), Some(file));
421        assert!(location.span.is_some(), "warning should carry a span");
422    }
423
424    #[test]
425    fn location_is_built_from_whichever_parts_are_known() {
426        let file = Utf8Path::new("soup.cook");
427        let span = Span { start: 1, end: 4 };
428
429        // Neither known: no location at all, rather than an empty one.
430        assert_eq!(location_for(None, None), None);
431
432        // A span with no file still locates the problem for an unsaved buffer.
433        assert_eq!(
434            location_for(None, Some(span)),
435            Some(Location {
436                file: None,
437                span: Some(span)
438            })
439        );
440
441        assert_eq!(
442            location_for(Some(file), None),
443            Some(Location {
444                file: Some(file.to_owned()),
445                span: None
446            })
447        );
448
449        assert_eq!(
450            location_for(Some(file), Some(span)),
451            Some(Location {
452                file: Some(file.to_owned()),
453                span: Some(span)
454            })
455        );
456    }
457
458    #[test]
459    fn render_report_includes_the_display_path_and_source_context() {
460        let text = "Add @{1%tsp} to the pot.\n";
461        let parsed = PARSER.parse(text);
462        let rendered = render_report(parsed.report(), "recipes/broken.cook", text, false);
463
464        assert!(
465            rendered.contains("recipes/broken.cook"),
466            "report should name the file: {rendered}"
467        );
468        assert!(
469            rendered.contains("Add @{1%tsp} to the pot."),
470            "report should quote the source line: {rendered}"
471        );
472    }
473
474    #[test]
475    fn render_report_honours_the_ansi_flag() {
476        let text = "Add @{1%tsp} to the pot.\n";
477        let parsed = PARSER.parse(text);
478
479        let plain = render_report(parsed.report(), "broken.cook", text, false);
480        let coloured = render_report(parsed.report(), "broken.cook", text, true);
481
482        assert!(
483            !plain.contains('\u{1b}'),
484            "ansi=false must produce no escape codes: {plain:?}"
485        );
486        assert!(
487            coloured.contains('\u{1b}'),
488            "ansi=true must produce escape codes: {coloured:?}"
489        );
490    }
491
492    /// `render_report` takes a report, not a parse result, so it also serves
493    /// metadata-only parses.
494    #[test]
495    fn render_report_works_for_a_metadata_parse() {
496        let text = ">> title: Old Style\n\nBoil @water{}.\n";
497        let parsed = PARSER.parse_metadata(text);
498        let rendered = render_report(parsed.report(), "old.cook", text, false);
499        assert!(
500            rendered.contains("old.cook"),
501            "metadata report should render: {rendered}"
502        );
503    }
504}