use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::error::{CowStr, Label, RichError};
use crate::span::Span;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ShoppingList {
pub items: Vec<ShoppingListItem>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ShoppingListItem {
Recipe(RecipeItem),
Ingredient(IngredientItem),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RecipeItem {
pub path: String,
pub multiplier: Option<f64>,
pub children: Vec<ShoppingListItem>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IngredientItem {
pub name: String,
pub quantity: Option<String>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ShoppingListError {
#[error("Error parsing shopping list: {message}")]
Parse { span: Span, message: String },
#[error("Invalid multiplier: {message}")]
InvalidMultiplier { span: Span, message: String },
#[error("Invalid indentation at line")]
InvalidIndentation { span: Span },
}
impl RichError for ShoppingListError {
fn labels(&self) -> std::borrow::Cow<'_, [Label]> {
use crate::error::label;
match self {
ShoppingListError::Parse { span, .. } => vec![label!(span)],
ShoppingListError::InvalidMultiplier { span, .. } => {
vec![label!(span, "invalid multiplier here")]
}
ShoppingListError::InvalidIndentation { span } => {
vec![label!(span, "unexpected indentation")]
}
}
.into()
}
fn hints(&self) -> std::borrow::Cow<'_, [CowStr]> {
match self {
ShoppingListError::InvalidIndentation { .. } => {
vec!["Use 2 spaces per indentation level".into()]
}
ShoppingListError::InvalidMultiplier { .. } => {
vec!["Multiplier must be a number, e.g. {2} or {0.5}".into()]
}
_ => vec![],
}
.into()
}
fn severity(&self) -> crate::error::Severity {
crate::error::Severity::Error
}
}
pub fn parse(input: &str) -> Result<ShoppingList, ShoppingListError> {
let lines = collect_lines(input);
let items = parse_items(&lines, 0, 0, lines.len())?;
Ok(ShoppingList { items })
}
struct ParsedLine<'a> {
content: &'a str,
indent: usize,
offset: usize,
}
fn collect_lines(input: &str) -> Vec<ParsedLine<'_>> {
let mut lines = Vec::new();
let mut offset = 0;
for line in input.split('\n') {
let line_len = line.len();
let line = line.trim_end_matches('\r');
let content = match line.split_once("//") {
Some((before, _)) => before,
None => line,
};
let indent = content.len() - content.trim_start_matches(' ').len();
let content = content.trim();
if !content.is_empty() {
lines.push(ParsedLine {
content,
indent,
offset,
});
}
offset += line_len + 1;
}
lines
}
fn parse_items(
lines: &[ParsedLine<'_>],
base_indent: usize,
start: usize,
end: usize,
) -> Result<Vec<ShoppingListItem>, ShoppingListError> {
let mut items = Vec::new();
let mut i = start;
while i < end {
let line = &lines[i];
if line.indent < base_indent {
break;
}
if line.indent != base_indent {
return Err(ShoppingListError::InvalidIndentation {
span: Span::new(line.offset, line.offset + line.content.len()),
});
}
if line.content.starts_with("./") {
let (path, multiplier) = parse_recipe_line(line)?;
let child_indent = base_indent + 2;
let child_start = i + 1;
let mut child_end = child_start;
while child_end < end && lines[child_end].indent >= child_indent {
child_end += 1;
}
let children = if child_start < child_end {
parse_items(lines, child_indent, child_start, child_end)?
} else {
Vec::new()
};
items.push(ShoppingListItem::Recipe(RecipeItem {
path,
multiplier,
children,
}));
i = child_end;
} else {
let (name, quantity) = parse_ingredient_line(line)?;
items.push(ShoppingListItem::Ingredient(IngredientItem {
name,
quantity,
}));
i += 1;
}
}
Ok(items)
}
fn parse_recipe_line(line: &ParsedLine<'_>) -> Result<(String, Option<f64>), ShoppingListError> {
let content = &line.content[2..];
if let Some(brace_start) = content.rfind('{') {
if content.ends_with('}') {
let path = content[..brace_start].trim().to_string();
let multiplier_str = &content[brace_start + 1..content.len() - 1];
let multiplier: f64 =
multiplier_str
.parse()
.map_err(|_| ShoppingListError::InvalidMultiplier {
span: Span::new(
line.offset + 2 + brace_start + 1,
line.offset + 2 + content.len() - 1,
),
message: format!("'{multiplier_str}' is not a valid number"),
})?;
Ok((path, Some(multiplier)))
} else {
Ok((content.trim().to_string(), None))
}
} else {
Ok((content.trim().to_string(), None))
}
}
fn parse_ingredient_line(
line: &ParsedLine<'_>,
) -> Result<(String, Option<String>), ShoppingListError> {
let content = line.content;
if let Some(brace_start) = content.rfind('{') {
if content.ends_with('}') {
let name = content[..brace_start].trim().to_string();
let quantity = content[brace_start + 1..content.len() - 1].to_string();
if name.is_empty() {
return Err(ShoppingListError::Parse {
span: Span::new(line.offset, line.offset + content.len()),
message: "ingredient name cannot be empty".to_string(),
});
}
Ok((name, Some(quantity)))
} else {
Ok((content.trim().to_string(), None))
}
} else {
Ok((content.trim().to_string(), None))
}
}
pub fn write(list: &ShoppingList, mut w: impl std::io::Write) -> std::io::Result<()> {
write_items(&list.items, 0, &mut w)
}
fn write_items(
items: &[ShoppingListItem],
depth: usize,
w: &mut impl std::io::Write,
) -> std::io::Result<()> {
let indent = " ".repeat(depth);
for item in items {
match item {
ShoppingListItem::Recipe(recipe) => {
write!(w, "{indent}./{}", recipe.path)?;
if let Some(m) = recipe.multiplier {
if m.fract() == 0.0 {
write!(w, "{{{}}}", m as i64)?;
} else {
write!(w, "{{{m}}}")?;
}
}
writeln!(w)?;
write_items(&recipe.children, depth + 1, w)?;
}
ShoppingListItem::Ingredient(ingredient) => {
write!(w, "{indent}{}", ingredient.name)?;
if let Some(q) = &ingredient.quantity {
write!(w, "{{{q}}}")?;
}
writeln!(w)?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input() {
let list = parse("").unwrap();
assert!(list.items.is_empty());
}
#[test]
fn single_recipe() {
let list = parse("./Breakfast/Easy Pancakes{2}").unwrap();
assert_eq!(list.items.len(), 1);
match &list.items[0] {
ShoppingListItem::Recipe(r) => {
assert_eq!(r.path, "Breakfast/Easy Pancakes");
assert_eq!(r.multiplier, Some(2.0));
assert!(r.children.is_empty());
}
_ => panic!("expected recipe"),
}
}
#[test]
fn recipe_without_multiplier() {
let list = parse("./Breakfast/Easy Pancakes").unwrap();
match &list.items[0] {
ShoppingListItem::Recipe(r) => {
assert_eq!(r.path, "Breakfast/Easy Pancakes");
assert_eq!(r.multiplier, None);
}
_ => panic!("expected recipe"),
}
}
#[test]
fn single_ingredient_with_quantity() {
let list = parse("free hand ingredient{4%l}").unwrap();
assert_eq!(list.items.len(), 1);
match &list.items[0] {
ShoppingListItem::Ingredient(i) => {
assert_eq!(i.name, "free hand ingredient");
assert_eq!(i.quantity.as_deref(), Some("4%l"));
}
_ => panic!("expected ingredient"),
}
}
#[test]
fn bare_ingredient() {
let list = parse("salt").unwrap();
assert_eq!(list.items.len(), 1);
match &list.items[0] {
ShoppingListItem::Ingredient(i) => {
assert_eq!(i.name, "salt");
assert_eq!(i.quantity, None);
}
_ => panic!("expected ingredient"),
}
}
#[test]
fn comment_lines() {
let input = "// this is a comment\nsalt\n// another comment";
let list = parse(input).unwrap();
assert_eq!(list.items.len(), 1);
match &list.items[0] {
ShoppingListItem::Ingredient(i) => assert_eq!(i.name, "salt"),
_ => panic!("expected ingredient"),
}
}
#[test]
fn inline_comment() {
let list = parse("salt // for seasoning").unwrap();
match &list.items[0] {
ShoppingListItem::Ingredient(i) => {
assert_eq!(i.name, "salt");
assert_eq!(i.quantity, None);
}
_ => panic!("expected ingredient"),
}
}
#[test]
fn nested_recipes() {
let input = "\
./Breakfast/Easy Pancakes{2}
./Some/Nested Recipe{2}
./Another Nested{1}";
let list = parse(input).unwrap();
assert_eq!(list.items.len(), 1);
match &list.items[0] {
ShoppingListItem::Recipe(r) => {
assert_eq!(r.path, "Breakfast/Easy Pancakes");
assert_eq!(r.multiplier, Some(2.0));
assert_eq!(r.children.len(), 2);
match &r.children[0] {
ShoppingListItem::Recipe(nested) => {
assert_eq!(nested.path, "Some/Nested Recipe");
assert_eq!(nested.multiplier, Some(2.0));
}
_ => panic!("expected nested recipe"),
}
}
_ => panic!("expected recipe"),
}
}
#[test]
fn full_example() {
let input = "\
./Breakfast/Easy Pancakes{2}
./Some/Nested Recipe{2}
free hand ingredient{4%l}
salt";
let list = parse(input).unwrap();
assert_eq!(list.items.len(), 3);
assert!(matches!(&list.items[0], ShoppingListItem::Recipe(_)));
assert!(matches!(&list.items[1], ShoppingListItem::Ingredient(_)));
assert!(matches!(&list.items[2], ShoppingListItem::Ingredient(_)));
match &list.items[0] {
ShoppingListItem::Recipe(r) => {
assert_eq!(r.children.len(), 1);
}
_ => unreachable!(),
}
}
#[test]
fn deeply_nested() {
let input = "\
./Top{1}
./Mid{2}
./Deep{3}";
let list = parse(input).unwrap();
match &list.items[0] {
ShoppingListItem::Recipe(top) => {
assert_eq!(top.path, "Top");
match &top.children[0] {
ShoppingListItem::Recipe(mid) => {
assert_eq!(mid.path, "Mid");
match &mid.children[0] {
ShoppingListItem::Recipe(deep) => {
assert_eq!(deep.path, "Deep");
assert_eq!(deep.multiplier, Some(3.0));
}
_ => panic!("expected deep recipe"),
}
}
_ => panic!("expected mid recipe"),
}
}
_ => panic!("expected top recipe"),
}
}
#[test]
fn recipe_with_child_ingredients() {
let input = "\
./Pancakes{2}
flour{500%g}
milk{200%ml}";
let list = parse(input).unwrap();
match &list.items[0] {
ShoppingListItem::Recipe(r) => {
assert_eq!(r.children.len(), 2);
match &r.children[0] {
ShoppingListItem::Ingredient(i) => {
assert_eq!(i.name, "flour");
assert_eq!(i.quantity.as_deref(), Some("500%g"));
}
_ => panic!("expected ingredient child"),
}
}
_ => panic!("expected recipe"),
}
}
#[test]
fn empty_lines_ignored() {
let input = "\
./Recipe{1}
salt
pepper";
let list = parse(input).unwrap();
assert_eq!(list.items.len(), 3);
}
#[test]
fn fractional_multiplier() {
let list = parse("./Recipe{0.5}").unwrap();
match &list.items[0] {
ShoppingListItem::Recipe(r) => {
assert_eq!(r.multiplier, Some(0.5));
}
_ => panic!("expected recipe"),
}
}
#[test]
fn write_roundtrip() {
let input = "\
./Breakfast/Easy Pancakes{2}
./Some/Nested Recipe{2}
free hand ingredient{4%l}
salt
";
let list = parse(input).unwrap();
let mut buf = Vec::new();
write(&list, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
let list2 = parse(&output).unwrap();
assert_eq!(list, list2);
}
#[test]
fn write_deep_nesting() {
let input = "\
./Top{1}
./Mid{2}
./Deep{3}
ingredient{1%kg}
";
let list = parse(input).unwrap();
let mut buf = Vec::new();
write(&list, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
let list2 = parse(&output).unwrap();
assert_eq!(list, list2);
}
#[test]
fn write_empty() {
let list = ShoppingList::default();
let mut buf = Vec::new();
write(&list, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert_eq!(output, "");
}
#[test]
fn error_invalid_multiplier() {
let err = parse("./Recipe{abc}").unwrap_err();
assert!(matches!(err, ShoppingListError::InvalidMultiplier { .. }));
}
#[test]
fn error_empty_ingredient_name() {
let err = parse("{4%l}").unwrap_err();
assert!(matches!(err, ShoppingListError::Parse { .. }));
}
#[test]
fn error_bad_indentation() {
let input = "./Recipe{1}\n ./Bad{2}"; let err = parse(input).unwrap_err();
assert!(matches!(err, ShoppingListError::InvalidIndentation { .. }));
}
}