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 stripped = strip_block_comments(input)?;
let lines = collect_lines(&stripped);
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 strip_block_comments(input: &str) -> Result<String, ShoppingListError> {
let mut result = String::with_capacity(input.len());
let mut chars = input.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c == '[' && input[i..].starts_with("[-") {
chars.next(); let open_offset = i;
let mut terminated = false;
loop {
match chars.next() {
Some((_, '\n')) => result.push('\n'),
Some((j, '-')) if input[j..].starts_with("-]") => {
chars.next(); terminated = true;
break;
}
Some(_) => {} None => break,
}
}
if !terminated {
return Err(ShoppingListError::Parse {
span: Span::new(open_offset, input.len()),
message: "unterminated block comment (missing `-]`)".to_string(),
});
}
result.push(' ');
} else {
result.push(c);
}
}
Ok(result)
}
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 normalize_whitespace(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
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 = normalize_whitespace(&content[..brace_start]);
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((normalize_whitespace(content), None))
}
} else {
Ok((normalize_whitespace(content), 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 = normalize_whitespace(&content[..brace_start]);
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((normalize_whitespace(content), None))
}
} else {
Ok((normalize_whitespace(content), 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(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CheckEntry {
Checked(String),
Unchecked(String),
}
pub fn parse_checked(input: &str) -> Vec<CheckEntry> {
let mut entries = Vec::new();
for line in input.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(name) = line.strip_prefix("+ ") {
let name = name.trim();
if !name.is_empty() {
entries.push(CheckEntry::Checked(name.to_string()));
}
} else if let Some(name) = line.strip_prefix("- ") {
let name = name.trim();
if !name.is_empty() {
entries.push(CheckEntry::Unchecked(name.to_string()));
}
}
}
entries
}
pub fn checked_set(entries: &[CheckEntry]) -> std::collections::HashSet<String> {
use std::collections::HashMap;
let mut state: HashMap<String, bool> = HashMap::new();
for entry in entries {
match entry {
CheckEntry::Checked(name) => {
state.insert(name.to_lowercase(), true);
}
CheckEntry::Unchecked(name) => {
state.insert(name.to_lowercase(), false);
}
}
}
state
.into_iter()
.filter_map(|(name, checked)| if checked { Some(name) } else { None })
.collect()
}
pub fn write_check_entry(entry: &CheckEntry, mut w: impl std::io::Write) -> std::io::Result<()> {
match entry {
CheckEntry::Checked(name) => writeln!(w, "+ {name}"),
CheckEntry::Unchecked(name) => writeln!(w, "- {name}"),
}
}
pub fn compact_checked<'a, I>(entries: &[CheckEntry], current_ingredients: I) -> Vec<CheckEntry>
where
I: IntoIterator<Item = &'a str>,
{
let current = checked_set(entries);
let list_ingredients: std::collections::HashSet<String> = current_ingredients
.into_iter()
.map(|n| n.to_lowercase())
.collect();
let mut compacted: Vec<CheckEntry> = current
.into_iter()
.filter(|name| list_ingredients.contains(name))
.map(CheckEntry::Checked)
.collect();
compacted.sort_by(|a, b| {
let name_of = |e: &CheckEntry| match e {
CheckEntry::Checked(n) | CheckEntry::Unchecked(n) => n.clone(),
};
name_of(a).cmp(&name_of(b))
});
compacted
}
pub fn collect_ingredient_names(list: &ShoppingList) -> Vec<String> {
let mut names = Vec::new();
collect_ingredient_names_from_items(&list.items, &mut names);
names
}
fn collect_ingredient_names_from_items(items: &[ShoppingListItem], names: &mut Vec<String>) {
for item in items {
match item {
ShoppingListItem::Ingredient(i) => names.push(i.name.clone()),
ShoppingListItem::Recipe(r) => {
collect_ingredient_names_from_items(&r.children, names);
}
}
}
}
#[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 block_comment() {
let input = "salt\n[- this is a\nblock comment -]\npepper";
let list = parse(input).unwrap();
assert_eq!(list.items.len(), 2);
match &list.items[0] {
ShoppingListItem::Ingredient(i) => assert_eq!(i.name, "salt"),
_ => panic!("expected ingredient"),
}
match &list.items[1] {
ShoppingListItem::Ingredient(i) => assert_eq!(i.name, "pepper"),
_ => panic!("expected ingredient"),
}
}
#[test]
fn inline_block_comment() {
let list = parse("salt [- seasoning -] pepper").unwrap();
assert_eq!(list.items.len(), 1);
match &list.items[0] {
ShoppingListItem::Ingredient(i) => assert_eq!(i.name, "salt pepper"),
_ => panic!("expected ingredient"),
}
}
#[test]
fn unterminated_block_comment_errors() {
let err = parse("salt [- seasoning\npepper").unwrap_err();
assert!(matches!(err, ShoppingListError::Parse { .. }));
}
#[test]
fn adjacent_block_comment_does_not_glue_tokens() {
let list = parse("foo[- note -]bar").unwrap();
assert_eq!(list.items.len(), 1);
match &list.items[0] {
ShoppingListItem::Ingredient(i) => assert_eq!(i.name, "foo bar"),
_ => 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 { .. }));
}
#[test]
fn parse_checked_empty() {
let entries = parse_checked("");
assert!(entries.is_empty());
}
#[test]
fn parse_checked_basic() {
let input = "+ salt\n+ avocados\n+ olive oil\n- avocados\n+ avocados\n";
let entries = parse_checked(input);
assert_eq!(entries.len(), 5);
assert_eq!(entries[0], CheckEntry::Checked("salt".into()));
assert_eq!(entries[3], CheckEntry::Unchecked("avocados".into()));
}
#[test]
fn checked_set_last_wins() {
let input = "+ salt\n+ avocados\n+ olive oil\n- avocados\n+ avocados\n";
let entries = parse_checked(input);
let set = checked_set(&entries);
assert!(set.contains("salt"));
assert!(set.contains("avocados"));
assert!(set.contains("olive oil"));
assert_eq!(set.len(), 3);
}
#[test]
fn checked_set_uncheck_wins() {
let input = "+ salt\n- salt\n";
let entries = parse_checked(input);
let set = checked_set(&entries);
assert!(!set.contains("salt"));
}
#[test]
fn checked_set_case_insensitive() {
let input = "+ Salt\n";
let entries = parse_checked(input);
let set = checked_set(&entries);
assert!(set.contains("salt"));
}
#[test]
fn compact_removes_stale() {
let list = parse("salt\npepper\n").unwrap();
let names = collect_ingredient_names(&list);
let entries = parse_checked("+ salt\n+ garlic\n");
let compacted = compact_checked(&entries, names.iter().map(String::as_str));
assert_eq!(compacted.len(), 1);
assert!(matches!(&compacted[0], CheckEntry::Checked(n) if n == "salt"));
}
#[test]
fn compact_accepts_arbitrary_name_iter() {
let entries = parse_checked("+ salt\n+ pepper\n+ garlic\n");
let names = ["salt", "pepper"];
let compacted = compact_checked(&entries, names.iter().copied());
assert_eq!(compacted.len(), 2);
let kept: Vec<&str> = compacted
.iter()
.map(|e| match e {
CheckEntry::Checked(n) | CheckEntry::Unchecked(n) => n.as_str(),
})
.collect();
assert!(kept.contains(&"salt"));
assert!(kept.contains(&"pepper"));
assert!(!kept.contains(&"garlic"));
}
#[test]
fn compact_is_case_insensitive_against_names() {
let entries = parse_checked("+ Salt\n");
let compacted = compact_checked(&entries, ["SALT"].iter().copied());
assert_eq!(compacted.len(), 1);
}
#[test]
fn write_check_entry_roundtrip() {
let entries = vec![
CheckEntry::Checked("salt".into()),
CheckEntry::Unchecked("pepper".into()),
];
let mut buf = Vec::new();
for e in &entries {
write_check_entry(e, &mut buf).unwrap();
}
let output = String::from_utf8(buf).unwrap();
assert_eq!(output, "+ salt\n- pepper\n");
let parsed = parse_checked(&output);
assert_eq!(parsed, entries);
}
}