1use 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
15pub static PARSER: LazyLock<CooklangParser> =
18 LazyLock::new(|| CooklangParser::new(Extensions::empty(), Converter::default()));
19
20pub fn parse_recipe(text: &str, name: &str, scale: f64) -> Result<Outcome<Recipe>, CoreError> {
33 parse_recipe_at(text, name, scale, None)
34}
35
36pub 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
56pub(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 Err(report) => Err(parse_error(&report)),
95 }
96}
97
98pub 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
116pub(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 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 hints: diag.hints.iter().map(|h| h.to_string()).collect(),
144 }
145}
146
147fn 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 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 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 #[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 #[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 let text = ">> title: Old Style\n\nBoil @water{}.\n";
269 let outcome = parse_recipe(text, "old", 1.0).expect("parses despite warning");
270
271 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 #[test]
288 fn every_error_is_converted_with_error_severity() {
289 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 #[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 #[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 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 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 #[test]
384 fn spans_are_byte_offsets_into_the_source() {
385 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 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 assert_eq!(location_for(None, None), None);
431
432 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 #[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}