Skip to content

Commit bf31446

Browse files
committed
feat: lenient aisle parser
1 parent dd9a84c commit bf31446

2 files changed

Lines changed: 218 additions & 35 deletions

File tree

bindings/src/lib.rs

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::sync::Arc;
22

3-
use cooklang::aisle::parse as parse_aisle_config_original;
3+
use cooklang::aisle::parse_lenient;
44

55
pub mod aisle;
66
pub mod model;
@@ -24,9 +24,7 @@ pub fn parse_metadata(input: String, scaling_factor: f64) -> CooklangMetadata {
2424
let mut metadata = CooklangMetadata::new();
2525
let parser = cooklang::CooklangParser::canonical();
2626

27-
let (parsed, _warnings) = parser.parse(&input)
28-
.into_result()
29-
.unwrap();
27+
let (parsed, _warnings) = parser.parse(&input).into_result().unwrap();
3028

3129
let scaled = parsed.scale(scaling_factor, parser.converter());
3230

@@ -76,7 +74,27 @@ pub fn parse_aisle_config(input: String) -> Arc<AisleConf> {
7674
let mut categories: Vec<AisleCategory> = Vec::new();
7775
let mut cache: AisleReverseCategory = AisleReverseCategory::default();
7876

79-
let parsed = parse_aisle_config_original(&input).unwrap();
77+
// Use the lenient parser that handles duplicates as warnings
78+
let result = parse_lenient(&input);
79+
let parsed = match result.into_result() {
80+
Ok((parsed, warnings)) => {
81+
// Log warnings if any
82+
if warnings.has_warnings() {
83+
for diag in warnings.iter() {
84+
eprintln!("Warning: {}", diag);
85+
}
86+
}
87+
parsed
88+
}
89+
Err(report) => {
90+
// Log errors
91+
for diag in report.iter() {
92+
eprintln!("Error: {}", diag);
93+
}
94+
// Return empty config on error
95+
Default::default()
96+
}
97+
};
8098

8199
let _ = &(parsed).categories.iter().for_each(|c| {
82100
let category = into_category(c);
@@ -132,7 +150,7 @@ mod tests {
132150
a test @step @salt{1%mg} more text
133151
"#
134152
.to_string(),
135-
1.0
153+
1.0,
136154
);
137155

138156
assert_eq!(
@@ -209,7 +227,7 @@ source: https://google.com
209227
a test @step @salt{1%mg} more text
210228
"#
211229
.to_string(),
212-
1.0
230+
1.0,
213231
);
214232

215233
assert_eq!(
@@ -357,7 +375,7 @@ dried oregano
357375
Cook @onions{3%large} until brown
358376
"#
359377
.to_string(),
360-
1.0
378+
1.0,
361379
);
362380

363381
let first_section = recipe
@@ -416,7 +434,7 @@ add @tomatoes{400%g}
416434
simmer for 10 minutes
417435
"#
418436
.to_string(),
419-
1.0
437+
1.0,
420438
);
421439
let first_section = recipe
422440
.sections
@@ -481,7 +499,7 @@ Mix @flour{200%g} and @water{50%ml} together until smooth.
481499
Combine @cheese{100%g} and @spinach{50%g}, then season to taste.
482500
"#
483501
.to_string(),
484-
1.0
502+
1.0,
485503
);
486504

487505
let mut sections = recipe.sections.into_iter();

src/aisle.rs

Lines changed: 190 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ use serde::{Deserialize, Serialize};
1111
use thiserror::Error;
1212

1313
use crate::{
14-
error::{CowStr, Label, RichError},
14+
error::{CowStr, Label, RichError, SourceDiag, SourceReport, Stage},
1515
span::Span,
16+
PassResult,
1617
};
1718

1819
/// Represents a aisle configuration file
@@ -96,8 +97,12 @@ impl AisleConf<'_> {
9697
}
9798
}
9899

99-
/// Parse an [`AisleConf`] with the cooklang shopping list format
100-
pub fn parse(input: &str) -> Result<AisleConf, AisleConfError> {
100+
/// Core parsing logic that can either return errors or collect warnings
101+
fn parse_core<'i>(
102+
input: &'i str,
103+
lenient: bool,
104+
mut report: Option<&mut SourceReport>,
105+
) -> Result<AisleConf<'i>, AisleConfError> {
101106
let mut categories: Vec<Category> = Vec::new();
102107
let mut current_category: Option<Category> = None;
103108

@@ -126,18 +131,45 @@ pub fn parse(input: &str) -> Result<AisleConf, AisleConfError> {
126131
if line.starts_with('[') && line.ends_with(']') {
127132
let name = &line[1..line.len() - 1];
128133
if name.contains('|') {
129-
return Err(AisleConfError::Parse {
130-
span: calc_span(name),
131-
message: "Invalid category name".to_string(),
132-
});
134+
if lenient {
135+
if let Some(report) = report.as_mut() {
136+
let warning = SourceDiag::warning(
137+
"Invalid category name: contains '|' character",
138+
(
139+
calc_span(name),
140+
Some("category names cannot contain '|'".into()),
141+
),
142+
Stage::Parse,
143+
);
144+
report.push(warning);
145+
}
146+
continue;
147+
} else {
148+
return Err(AisleConfError::Parse {
149+
span: calc_span(name),
150+
message: "Invalid category name".to_string(),
151+
});
152+
}
133153
}
134154

135155
if let Some(&other) = used_categories.get(name) {
136-
return Err(AisleConfError::DuplicateCategory {
137-
name: name.to_string(),
138-
first_span: calc_span(other),
139-
second_span: calc_span(name),
140-
});
156+
if lenient {
157+
if let Some(report) = report.as_mut() {
158+
let warning = SourceDiag::warning(
159+
format!("Duplicate category: '{}'", name),
160+
(calc_span(name), Some("duplicate found here".into())),
161+
Stage::Parse,
162+
);
163+
report.push(warning);
164+
}
165+
continue;
166+
} else {
167+
return Err(AisleConfError::DuplicateCategory {
168+
name: name.to_string(),
169+
first_span: calc_span(other),
170+
second_span: calc_span(name),
171+
});
172+
}
141173
}
142174

143175
used_categories.insert(name);
@@ -154,23 +186,52 @@ pub fn parse(input: &str) -> Result<AisleConf, AisleConfError> {
154186
for mut n in line.split('|') {
155187
n = n.trim();
156188
if let Some(&other) = used_names.get(n) {
157-
return Err(AisleConfError::DuplicateIngredient {
158-
name: n.to_string(),
159-
first_span: calc_span(other),
160-
second_span: calc_span(n),
161-
});
189+
if lenient {
190+
if let Some(report) = report.as_mut() {
191+
let warning = SourceDiag::warning(
192+
format!("Duplicate ingredient: '{}'", n),
193+
(calc_span(n), Some("duplicate found here".into())),
194+
Stage::Parse,
195+
);
196+
report.push(warning);
197+
}
198+
continue;
199+
} else {
200+
return Err(AisleConfError::DuplicateIngredient {
201+
name: n.to_string(),
202+
first_span: calc_span(other),
203+
second_span: calc_span(n),
204+
});
205+
}
162206
}
163207
used_names.insert(n);
164208
names.push(n);
165209
}
166-
let names = line.split('|').map(str::trim).collect();
167-
if let Some(cat) = &mut current_category {
168-
cat.ingredients.push(Ingredient { names });
169-
} else {
170-
return Err(AisleConfError::Parse {
171-
span: calc_span(line),
172-
message: "Expected category".to_string(),
173-
});
210+
211+
// Only add ingredient if it has at least one name
212+
if !names.is_empty() {
213+
if let Some(cat) = &mut current_category {
214+
cat.ingredients.push(Ingredient { names });
215+
} else {
216+
if lenient {
217+
if let Some(report) = report.as_mut() {
218+
let warning = SourceDiag::warning(
219+
"Ingredient found before any category",
220+
(
221+
calc_span(line),
222+
Some("add a category before listing ingredients".into()),
223+
),
224+
Stage::Parse,
225+
);
226+
report.push(warning);
227+
}
228+
} else {
229+
return Err(AisleConfError::Parse {
230+
span: calc_span(line),
231+
message: "Expected category".to_string(),
232+
});
233+
}
234+
}
174235
}
175236
}
176237
}
@@ -185,6 +246,39 @@ pub fn parse(input: &str) -> Result<AisleConf, AisleConfError> {
185246
})
186247
}
187248

249+
/// Parse an [`AisleConf`] with the cooklang shopping list format
250+
pub fn parse(input: &str) -> Result<AisleConf, AisleConfError> {
251+
parse_core(input, false, None)
252+
}
253+
254+
/// Parse aisle configuration with lenient handling of duplicates
255+
///
256+
/// This function returns a [`PassResult`] which includes both the parsed configuration
257+
/// and any warnings that occurred during parsing. Duplicate ingredients will be
258+
/// reported as warnings rather than errors.
259+
///
260+
/// # Examples
261+
///
262+
/// ```
263+
/// let aisle_conf = r#"
264+
/// [fruit and vegetables]
265+
/// potato
266+
/// apple
267+
/// "#;
268+
///
269+
/// let result = cooklang::aisle::parse_lenient(aisle_conf);
270+
/// let (parsed, warnings) = result.into_result().unwrap();
271+
///
272+
/// assert_eq!(parsed.categories.len(), 1);
273+
/// assert_eq!(parsed.categories[0].ingredients.len(), 2);
274+
/// ```
275+
pub fn parse_lenient(input: &str) -> PassResult<AisleConf> {
276+
let mut report = SourceReport::empty();
277+
278+
let conf = parse_core(input, true, Some(&mut report)).expect("lenient parsing should never fail");
279+
PassResult::new(Some(conf), report)
280+
}
281+
188282
/// Write an [`AisleConf`] in the cooklang shopping list format
189283
pub fn write(conf: &AisleConf, mut write: impl std::io::Write) -> std::io::Result<()> {
190284
let w = &mut write;
@@ -500,4 +594,75 @@ tuna|chicken of the sea
500594
let got2 = parse(&serialized).unwrap();
501595
assert_eq!(got, got2);
502596
}
597+
598+
#[test]
599+
fn parse_lenient_with_duplicates() {
600+
let input = r#"
601+
[dairy]
602+
milk
603+
cheese
604+
605+
[produce]
606+
apple
607+
apple
608+
banana
609+
610+
[meat]
611+
chicken
612+
apple
613+
"#;
614+
let result = parse_lenient(input);
615+
let (parsed, warnings) = result.into_result().unwrap();
616+
617+
// Should have warnings but still parse successfully
618+
assert!(warnings.has_warnings());
619+
// Count warnings
620+
let warning_count = warnings.iter().count();
621+
assert_eq!(warning_count, 2); // Two duplicate 'apple' entries
622+
623+
// Check that duplicates were skipped
624+
assert_eq!(parsed.categories.len(), 3);
625+
626+
// Check produce category only has apple once
627+
let produce = &parsed.categories[1];
628+
assert_eq!(produce.name, "produce");
629+
assert_eq!(produce.ingredients.len(), 2); // apple and banana
630+
assert_eq!(produce.ingredients[0].names, vec!["apple"]);
631+
assert_eq!(produce.ingredients[1].names, vec!["banana"]);
632+
633+
// Check meat category doesn't have apple
634+
let meat = &parsed.categories[2];
635+
assert_eq!(meat.name, "meat");
636+
assert_eq!(meat.ingredients.len(), 1); // only chicken
637+
assert_eq!(meat.ingredients[0].names, vec!["chicken"]);
638+
}
639+
640+
#[test]
641+
fn parse_lenient_with_all_error_types() {
642+
let input = r#"
643+
orphan ingredient
644+
[dairy|invalid]
645+
milk
646+
[dairy]
647+
cheese
648+
[produce]
649+
apple
650+
"#;
651+
let result = parse_lenient(input);
652+
let (parsed, warnings) = result.into_result().unwrap();
653+
654+
// Should have warnings but still parse successfully
655+
assert!(warnings.has_warnings());
656+
let warning_count = warnings.iter().count();
657+
assert_eq!(warning_count, 3); // orphan ingredient, invalid category, duplicate category
658+
659+
// Check that we got the valid parts
660+
assert_eq!(parsed.categories.len(), 2);
661+
assert_eq!(parsed.categories[0].name, "dairy");
662+
assert_eq!(parsed.categories[0].ingredients.len(), 1);
663+
assert_eq!(parsed.categories[0].ingredients[0].names, vec!["cheese"]);
664+
assert_eq!(parsed.categories[1].name, "produce");
665+
assert_eq!(parsed.categories[1].ingredients.len(), 1);
666+
assert_eq!(parsed.categories[1].ingredients[0].names, vec!["apple"]);
667+
}
503668
}

0 commit comments

Comments
 (0)