cookcli-core 0.38.0

Recipe, shopping list, pantry and report operations for Cooklang, extracted from CookCLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
//! Recipe parsing and conversion of `cooklang` reports into [`Diagnostic`]s.
//!
//! `cooklang` parses leniently, so a recipe can parse successfully and still
//! have something to say about itself. The CLI used to log those warnings and
//! drop them; here they come back to the caller in the [`Outcome`].

use crate::{CoreError, Diagnostic, Location, Outcome, Severity, Span};
use camino::Utf8Path;
use cooklang::{
    error::{SourceDiag, SourceReport},
    Converter, CooklangParser, Extensions, Recipe,
};
use std::sync::LazyLock;

/// The shared parser. Matches CookCLI's configuration exactly: no extensions,
/// default converter for unit support.
pub static PARSER: LazyLock<CooklangParser> =
    LazyLock::new(|| CooklangParser::new(Extensions::empty(), Converter::default()));

/// Parse recipe text, scale it, and collect diagnostics.
///
/// `name` identifies the recipe in error messages. `scale` is the scaling
/// factor; pass `1.0` to leave quantities alone.
///
/// Returns [`CoreError::Parse`] when the recipe has errors, since no recipe can
/// be produced in that case. Warnings come back in the [`Outcome`].
///
/// # Scale
///
/// `scale` must be finite; NaN and infinity give [`CoreError::InvalidScale`].
/// Zero and negative factors are *accepted*, matching what the CLI does today.
pub fn parse_recipe(text: &str, name: &str, scale: f64) -> Result<Outcome<Recipe>, CoreError> {
    parse_recipe_at(text, name, scale, None)
}

/// As [`parse_recipe`], but attributing diagnostics to a specific file path.
///
/// Pass `file` when the text came from disk, so that diagnostics point at
/// something the caller can open. An editor parsing an unsaved buffer passes
/// `None` and still gets spans.
pub fn parse_recipe_at(
    text: &str,
    name: &str,
    scale: f64,
    file: Option<&Utf8Path>,
) -> Result<Outcome<Recipe>, CoreError> {
    if !scale.is_finite() {
        return Err(CoreError::InvalidScale { scale });
    }

    let mut outcome = parse_unscaled(text, name, file)?;
    outcome.value.scale(scale, PARSER.converter());
    Ok(outcome)
}

/// Parse recipe text and collect diagnostics, without scaling it.
///
/// Deliberately separate from `parse_recipe_at(.., 1.0, ..)`:
/// [`cooklang::Recipe::scale`] re-fits units even at a factor of one, so with a
/// unit database behind it `1500 ml` comes back as `1.5 l`. "Scale by one" and
/// "do not scale" are therefore different operations, and shopping-list
/// reference expansion needs the latter — it applies its own `scale_to_target`
/// afterwards, and a fit in between would change the numbers the user sees.
///
/// That fit is invisible today: `cooklang`'s `bundled_units` feature is off, so
/// quantities keep the units they were authored in and there is no database to
/// fit against. The two functions stay separate anyway, because the difference
/// is in `scale`'s contract rather than in today's unit configuration.
pub(crate) fn parse_unscaled(
    text: &str,
    name: &str,
    file: Option<&Utf8Path>,
) -> Result<Outcome<Recipe>, CoreError> {
    let parsed = PARSER.parse(text);
    let display_path = file.map_or_else(|| name.to_string(), |p| p.to_string());
    let parse_error = |report: &SourceReport| CoreError::Parse {
        name: name.to_string(),
        diagnostics: collect_diagnostics(report, file),
        rendered: render_report(report, &display_path, text, false),
    };

    if parsed.report().has_errors() {
        return Err(parse_error(parsed.report()));
    }
    let diagnostics = collect_diagnostics(parsed.report(), file);

    match parsed.into_result() {
        Ok((recipe, _)) => Ok(Outcome::with_diagnostics(recipe, diagnostics)),
        // `into_result` fails when `is_valid()` is false, which is
        // `has_output() && !has_errors()`. We have just ruled out errors, but
        // cooklang does not promise output is present, so this arm is
        // reachable in principle. Returning beats panicking: this crate is
        // called from a NAPI addon, where a panic crosses into JavaScript.
        Err(report) => Err(parse_error(&report)),
    }
}

/// Render a parse report the way the CLI prints it, with source line context.
///
/// `ansi` controls colour. The CLI passes `true` for terminal output; the
/// report stored in [`CoreError::Parse`] is always rendered with `false`.
///
/// Takes the report rather than the parse result so that it also renders
/// reports from metadata-only parses.
pub fn render_report(
    report: &SourceReport,
    display_path: &str,
    content: &str,
    ansi: bool,
) -> String {
    let mut buf = Vec::new();
    report.write(display_path, content, ansi, &mut buf).ok();
    String::from_utf8_lossy(&buf).into_owned()
}

/// Convert every entry of a `cooklang` report into a [`Diagnostic`].
pub(crate) fn collect_diagnostics(
    report: &SourceReport,
    file: Option<&Utf8Path>,
) -> Vec<Diagnostic> {
    report
        .iter()
        .map(|diag| convert_diagnostic(diag, file))
        .collect()
}

fn convert_diagnostic(diag: &SourceDiag, file: Option<&Utf8Path>) -> Diagnostic {
    let severity = match diag.severity {
        cooklang::error::Severity::Error => Severity::Error,
        cooklang::error::Severity::Warning => Severity::Warning,
    };

    // `labels` is ordered most- to least-important, so the first one is the
    // main location. Diagnostics about the recipe as a whole have none.
    let span = diag.labels.first().map(|(span, _)| span.range().into());

    Diagnostic {
        severity,
        message: diag.message.to_string(),
        location: location_for(file, span),
        // Often a ready-to-apply replacement, which is exactly the payload
        // the CLI's `warn!` used to discard.
        hints: diag.hints.iter().map(|h| h.to_string()).collect(),
    }
}

/// Build a [`Location`] from whichever of file and span are known.
///
/// A span alone is still worth reporting: an editor parsing an unsaved buffer
/// has no path but still wants to underline the offending text. Only when
/// neither is known is there no location at all.
fn location_for(file: Option<&Utf8Path>, span: Option<Span>) -> Option<Location> {
    if file.is_none() && span.is_none() {
        return None;
    }
    Some(Location {
        file: file.map(ToOwned::to_owned),
        span,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::diagnostic::{Severity, Span};
    use cooklang::quantity::Value;

    const GOOD: &str = "Boil @water{2%cups} for ~{5%minutes}.\n";

    /// The numeric value of an ingredient's quantity, ignoring its unit.
    ///
    /// Asserting on the value rather than the formatted quantity matters:
    /// cooklang re-fits units when scaling, so `2 cups` can render as `4 c`
    /// and a string comparison would be testing the formatter, not the maths.
    fn quantity_value(recipe: &Recipe, index: usize) -> f64 {
        match recipe.ingredients[index]
            .quantity
            .as_ref()
            .expect("ingredient has a quantity")
            .value()
        {
            Value::Number(n) => n.value(),
            other => panic!("expected a numeric quantity, got {other:?}"),
        }
    }

    #[test]
    fn parses_a_clean_recipe_without_diagnostics() {
        let outcome = parse_recipe(GOOD, "simple", 1.0).expect("parses");
        assert_eq!(outcome.value.ingredients.len(), 1);
        assert!(outcome.diagnostics.is_empty());
    }

    #[test]
    fn scaling_multiplies_quantities_by_exactly_the_factor() {
        // GOOD declares `@water{2%cups}`.
        assert_eq!(
            quantity_value(&parse_recipe(GOOD, "s", 1.0).unwrap().value, 0),
            2.0
        );
        assert_eq!(
            quantity_value(&parse_recipe(GOOD, "s", 2.0).unwrap().value, 0),
            4.0
        );
        assert_eq!(
            quantity_value(&parse_recipe(GOOD, "s", 0.5).unwrap().value, 0),
            1.0
        );
    }

    /// The reason `parse_unscaled` exists: scaling by one is not a no-op in
    /// `cooklang`'s contract, because it re-fits units.
    ///
    /// Re-fitting needs a unit database and `bundled_units` is off, so both
    /// paths leave `1500 ml` alone today. What this pins is that neither of
    /// them *changes* the authored quantity; the `scale(1.0)` assertion is the
    /// one to revisit if the unit database ever comes back.
    #[test]
    fn neither_scaling_by_one_nor_parse_unscaled_disturbs_authored_units() {
        let text = "Pour @milk{1500%ml}.\n";

        let scaled = parse_recipe(text, "milk", 1.0).expect("parses").value;
        let quantity = scaled.ingredients[0].quantity.as_ref().unwrap();
        assert_eq!(
            (quantity.value().to_string(), quantity.unit()),
            ("1500".to_string(), Some("ml")),
            "with no unit database, scale(1.0) has nothing to re-fit"
        );

        let untouched = parse_unscaled(text, "milk", None).expect("parses").value;
        let quantity = untouched.ingredients[0].quantity.as_ref().unwrap();
        assert_eq!(
            (quantity.value().to_string(), quantity.unit()),
            ("1500".to_string(), Some("ml")),
            "parse_unscaled must leave the authored quantity alone"
        );
    }

    #[test]
    fn non_finite_scale_is_rejected() {
        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
            match parse_recipe(GOOD, "simple", bad) {
                Err(CoreError::InvalidScale { scale }) => {
                    assert_eq!(scale.is_nan(), bad.is_nan());
                }
                other => panic!("expected InvalidScale for {bad}, got {other:?}"),
            }
        }
    }

    /// Zero and negative scale are accepted, because the CLI accepts them.
    /// Rejecting them would be a behaviour change smuggled into a refactor.
    #[test]
    fn zero_and_negative_scale_are_accepted() {
        assert_eq!(
            quantity_value(&parse_recipe(GOOD, "s", 0.0).unwrap().value, 0),
            0.0
        );
        assert_eq!(
            quantity_value(&parse_recipe(GOOD, "s", -1.0).unwrap().value, 0),
            -2.0
        );
    }

    #[test]
    fn warnings_are_returned_not_swallowed() {
        // Deprecated `>>` metadata parses successfully but warns.
        let text = ">> title: Old Style\n\nBoil @water{}.\n";
        let outcome = parse_recipe(text, "old", 1.0).expect("parses despite warning");

        // Every diagnostic must be a Warning — not merely "at least one is",
        // which would still hold if errors were mislabelled as warnings.
        assert!(!outcome.diagnostics.is_empty(), "expected a diagnostic");
        for d in &outcome.diagnostics {
            assert_eq!(
                d.severity,
                Severity::Warning,
                "deprecated syntax is a warning, got {d:?}"
            );
        }
        assert!(!outcome.has_errors());
    }

    /// Pins the severity mapping in both directions at once, so that inverting
    /// it cannot pass. Also pins that *every* diagnostic is converted, not
    /// just the first.
    #[test]
    fn every_error_is_converted_with_error_severity() {
        // Two empty ingredient names: two errors, at two distinct spans.
        let text = "Add @{1%tsp} and @{2%tsp} to the pot.\n";
        let Err(CoreError::Parse { diagnostics, .. }) = parse_recipe(text, "broken", 1.0) else {
            panic!("expected a parse error");
        };

        assert_eq!(
            diagnostics.len(),
            2,
            "both errors must survive: {diagnostics:?}"
        );
        for d in &diagnostics {
            assert_eq!(
                d.severity,
                Severity::Error,
                "cooklang error must map to Error"
            );
        }

        let spans: Vec<_> = diagnostics
            .iter()
            .map(|d| d.location.as_ref().unwrap().span.unwrap())
            .collect();
        assert_eq!(
            spans,
            vec![Span { start: 5, end: 5 }, Span { start: 18, end: 18 }],
            "each diagnostic keeps its own span, in source order"
        );
    }

    /// `labels` is ordered most- to least-important, so the *first* is the
    /// primary location. Taking the last would underline the wrong text.
    #[test]
    fn the_first_label_wins_when_a_diagnostic_has_several() {
        let text = ">> title: A\n>> title: B\n\nBoil @water{}.\n";
        let outcome = parse_recipe(text, "dup", 1.0).expect("parses");

        let duplicate = outcome
            .diagnostics
            .iter()
            .find(|d| d.message.contains("duplicate") || d.message.contains("Duplicate"))
            .unwrap_or(&outcome.diagnostics[0]);

        assert_eq!(
            duplicate.location.as_ref().unwrap().span,
            Some(Span { start: 2, end: 11 }),
            "expected the first label's span, not a later one: {duplicate:?}"
        );
    }

    /// Hints are quick fixes, and the CLI's `warn!` threw them away.
    #[test]
    fn hints_are_captured() {
        let text = ">> title: A\n>> title: B\n\nBoil @water{}.\n";
        let outcome = parse_recipe(text, "dup", 1.0).expect("parses");

        let hints: Vec<&String> = outcome.diagnostics.iter().flat_map(|d| &d.hints).collect();
        assert!(
            !hints.is_empty(),
            "expected at least one hint, got {:?}",
            outcome.diagnostics
        );
        assert!(
            hints.iter().any(|h| h.contains("---")),
            "expected a ready-to-apply frontmatter fix, got {hints:?}"
        );
    }

    #[test]
    fn parse_errors_carry_diagnostics_and_rendered_output() {
        // An ingredient with a quantity but no name is a hard parse error.
        let text = "Add @{1%tsp} to the pot.\n";
        match parse_recipe(text, "broken", 1.0) {
            Err(CoreError::Parse {
                name,
                diagnostics,
                rendered,
            }) => {
                assert_eq!(name, "broken");
                assert_eq!(diagnostics.len(), 1);
                assert_eq!(diagnostics[0].severity, Severity::Error);
                assert!(!rendered.is_empty(), "rendered report should be populated");
                // The stored report is documented as ANSI-free.
                assert!(
                    !rendered.contains('\u{1b}'),
                    "CoreError::Parse.rendered must carry no escape codes: {rendered:?}"
                );
            }
            other => panic!("expected CoreError::Parse, got {other:?}"),
        }
    }

    /// Offsets must be byte offsets, since that is what `Span` documents and
    /// what an editor slicing a `str` needs.
    #[test]
    fn spans_are_byte_offsets_into_the_source() {
        // `é` is two bytes but one char, so the empty ingredient name sits at
        // byte offset 8 and char offset 7. The two disagree, which is the
        // point of using this text.
        let text = "Sauté @{1%tsp} it.\n";
        assert_eq!(text.find('{'), Some(8));
        assert_eq!(text.chars().position(|c| c == '{'), Some(7));

        let Err(CoreError::Parse { diagnostics, .. }) = parse_recipe(text, "broken", 1.0) else {
            panic!("expected a parse error");
        };

        let span = diagnostics[0]
            .location
            .as_ref()
            .expect("location set")
            .span
            .expect("span set");
        assert!(
            text.get(span.start..span.end).is_some(),
            "span {span:?} does not fall on char boundaries of {text:?}"
        );
        // Byte offset, not the char offset 7.
        assert_eq!(span, Span { start: 8, end: 8 });
    }

    #[test]
    fn parse_recipe_at_attributes_diagnostics_to_the_file() {
        let text = ">> title: Old Style\n\nBoil @water{}.\n";
        let file = Utf8Path::new("recipes/old.cook");
        let outcome = parse_recipe_at(text, "old", 1.0, Some(file)).expect("parses");

        let location = outcome.diagnostics[0]
            .location
            .as_ref()
            .expect("location set");
        assert_eq!(location.file.as_deref(), Some(file));
        assert!(location.span.is_some(), "warning should carry a span");
    }

    #[test]
    fn location_is_built_from_whichever_parts_are_known() {
        let file = Utf8Path::new("soup.cook");
        let span = Span { start: 1, end: 4 };

        // Neither known: no location at all, rather than an empty one.
        assert_eq!(location_for(None, None), None);

        // A span with no file still locates the problem for an unsaved buffer.
        assert_eq!(
            location_for(None, Some(span)),
            Some(Location {
                file: None,
                span: Some(span)
            })
        );

        assert_eq!(
            location_for(Some(file), None),
            Some(Location {
                file: Some(file.to_owned()),
                span: None
            })
        );

        assert_eq!(
            location_for(Some(file), Some(span)),
            Some(Location {
                file: Some(file.to_owned()),
                span: Some(span)
            })
        );
    }

    #[test]
    fn render_report_includes_the_display_path_and_source_context() {
        let text = "Add @{1%tsp} to the pot.\n";
        let parsed = PARSER.parse(text);
        let rendered = render_report(parsed.report(), "recipes/broken.cook", text, false);

        assert!(
            rendered.contains("recipes/broken.cook"),
            "report should name the file: {rendered}"
        );
        assert!(
            rendered.contains("Add @{1%tsp} to the pot."),
            "report should quote the source line: {rendered}"
        );
    }

    #[test]
    fn render_report_honours_the_ansi_flag() {
        let text = "Add @{1%tsp} to the pot.\n";
        let parsed = PARSER.parse(text);

        let plain = render_report(parsed.report(), "broken.cook", text, false);
        let coloured = render_report(parsed.report(), "broken.cook", text, true);

        assert!(
            !plain.contains('\u{1b}'),
            "ansi=false must produce no escape codes: {plain:?}"
        );
        assert!(
            coloured.contains('\u{1b}'),
            "ansi=true must produce escape codes: {coloured:?}"
        );
    }

    /// `render_report` takes a report, not a parse result, so it also serves
    /// metadata-only parses.
    #[test]
    fn render_report_works_for_a_metadata_parse() {
        let text = ">> title: Old Style\n\nBoil @water{}.\n";
        let parsed = PARSER.parse_metadata(text);
        let rendered = render_report(parsed.report(), "old.cook", text, false);
        assert!(
            rendered.contains("old.cook"),
            "metadata report should render: {rendered}"
        );
    }
}