use crate::{CoreError, Diagnostic, Location, Outcome, Severity, Span};
use camino::Utf8Path;
use cooklang::{
error::{SourceDiag, SourceReport},
Converter, CooklangParser, Extensions, Recipe,
};
use std::sync::LazyLock;
pub static PARSER: LazyLock<CooklangParser> =
LazyLock::new(|| CooklangParser::new(Extensions::empty(), Converter::default()));
pub fn parse_recipe(text: &str, name: &str, scale: f64) -> Result<Outcome<Recipe>, CoreError> {
parse_recipe_at(text, name, scale, None)
}
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)
}
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)),
Err(report) => Err(parse_error(&report)),
}
}
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()
}
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,
};
let span = diag.labels.first().map(|(span, _)| span.range().into());
Diagnostic {
severity,
message: diag.message.to_string(),
location: location_for(file, span),
hints: diag.hints.iter().map(|h| h.to_string()).collect(),
}
}
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";
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() {
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
);
}
#[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:?}"),
}
}
}
#[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() {
let text = ">> title: Old Style\n\nBoil @water{}.\n";
let outcome = parse_recipe(text, "old", 1.0).expect("parses despite warning");
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());
}
#[test]
fn every_error_is_converted_with_error_severity() {
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"
);
}
#[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:?}"
);
}
#[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() {
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");
assert!(
!rendered.contains('\u{1b}'),
"CoreError::Parse.rendered must carry no escape codes: {rendered:?}"
);
}
other => panic!("expected CoreError::Parse, got {other:?}"),
}
}
#[test]
fn spans_are_byte_offsets_into_the_source() {
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:?}"
);
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 };
assert_eq!(location_for(None, None), None);
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:?}"
);
}
#[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}"
);
}
}