@@ -11,8 +11,9 @@ use serde::{Deserialize, Serialize};
1111use thiserror:: Error ;
1212
1313use 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
189283pub 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