Skip to main content

recipe_parser/
parser.rs

1use std::fmt::Display;
2use winnow::ascii::{line_ending, multispace0, multispace1, space0, space1};
3use winnow::combinator::{alt, cut_err, delimited, not, opt, preceded, repeat};
4use winnow::error::{ContextError, ParseError, StrContext, StrContextValue};
5use winnow::token::{any, rest, take_until, take_while};
6use winnow::{LocatingSlice, ModalResult, Parser};
7
8type Input<'a> = LocatingSlice<&'a str>;
9
10/// Parses a valid string from the input.
11///
12/// This function takes a mutable reference to a string slice and parses a valid string from it.
13/// A valid string can contain alphanumeric characters as well as certain symbols and spaces.
14/// The function returns a `PResult` containing the parsed valid string.
15fn parse_valid_string<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
16    let spaces_and_symbols = "\t /-_@.,%#'";
17    take_while(1.., move |c: char| {
18        c.is_alphanumeric() || spaces_and_symbols.contains(c)
19    })
20    .parse_next(input)
21}
22
23/// Parse comments in the form of:
24///
25/// ```recp
26/// /* */
27/// ```
28fn parse_comment<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
29    delimited(
30        "/*",
31        cut_err(take_until(0.., "*/"))
32            .context(StrContext::Expected(StrContextValue::StringLiteral("*/")))
33            .map(|v: &str| v.trim()),
34        ("*/", space0),
35    )
36    .parse_next(input)
37}
38
39/// Parse curly braces delimited utf-8
40///
41/// ```recp
42/// {salt}
43/// {tomatoes}
44/// ```
45fn parse_curly<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
46    delimited(
47        "{",
48        parse_valid_string.map(|v| v.trim()),
49        cut_err("}").context(StrContext::Expected(StrContextValue::CharLiteral('}'))),
50        // "}"
51    )
52    .parse_next(input)
53}
54
55/// The amount of an ingredient must be numeric
56/// with a few symbols allowed.
57///
58/// ```recp
59/// 1
60/// 3.2
61/// 3,2
62/// 3_000_000
63/// 2/3
64/// ```
65fn parse_quantity<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
66    let spaces_and_symbols = ".,/_";
67
68    cut_err(
69        take_while(1.., move |c: char| {
70            c.is_numeric() || spaces_and_symbols.contains(c)
71        })
72        .verify(|s: &str| {
73            // NEXT: Can this be improved?
74            let has_repeated_symbols = s
75                .as_bytes()
76                .windows(2)
77                .any(|v| v[0] == v[1] && spaces_and_symbols.contains(char::from(v[0])));
78            let last_char = &s[s.len() - 1..];
79            !spaces_and_symbols.contains(last_char) && !has_repeated_symbols
80        }),
81    )
82    .context(StrContext::Expected(StrContextValue::Description(
83        "a quantity value, like 3, 1.2, 1/2 or 1_000",
84    )))
85    .parse_next(input)
86}
87
88/// Parse units like kg, kilograms, pinch, etc.
89fn parse_unit<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
90    parse_valid_string.parse_next(input)
91}
92
93/// Ingredient amounts are surrounded by parenthesis
94fn parse_ingredient_amount<'a>(
95    input: &mut Input<'a>,
96) -> ModalResult<(Option<&'a str>, Option<&'a str>)> {
97    delimited(
98        ("(", space0),
99        (
100            opt(parse_quantity),
101            opt(preceded(space0, parse_unit.map(|v| v.trim()))),
102        ),
103        cut_err(")").context(StrContext::Expected(StrContextValue::CharLiteral(')'))), // cut_err(")"),
104    )
105    // .context(StrContext::Expected(StrContextValue::CharLiteral('}')))
106    .parse_next(input)
107}
108
109/// Ingredients come in these formats:
110///
111/// ```recp
112/// {quinoa}(200gr)
113/// {tomatoes}(2)
114/// {sweet potatoes}(2)
115/// ```
116fn parse_ingredient<'a>(
117    input: &mut Input<'a>,
118) -> ModalResult<(&'a str, Option<(Option<&'a str>, Option<&'a str>)>)> {
119    (parse_curly, opt(parse_ingredient_amount)).parse_next(input)
120}
121
122/// Materials format:
123///
124/// ```recp
125/// &{pot}
126/// &{small jar}
127/// &{stick}
128/// ```
129fn parse_material<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
130    preceded("&", parse_curly).parse_next(input)
131}
132
133/// Materials format:
134///
135/// ```recp
136/// t{25 minutes}
137/// t{10 sec}
138/// ```
139fn parse_timer<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
140    preceded("t", parse_curly).parse_next(input)
141}
142
143/// Parse a reference to another recipe
144///
145/// ```recp
146/// @{woile/special-tomato-sauce}
147/// @{woile/special-tomato-sauce}(100 ml)
148/// ```
149fn parse_recipe_ref<'a>(
150    input: &mut Input<'a>,
151) -> ModalResult<(&'a str, Option<(Option<&'a str>, Option<&'a str>)>)> {
152    preceded("@", (parse_curly, opt(parse_ingredient_amount))).parse_next(input)
153}
154
155fn parse_word<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
156    repeat(
157        1..,
158        (
159            not(alt((
160                " ", "\t", "\r", "\n", "'", "`", "{", "t{", "&{", "@{",
161            ))),
162            any,
163        ),
164    )
165    .map(|()| ())
166    .take()
167    .parse_next(input)
168}
169
170fn parse_metadata<'a>(input: &mut Input<'a>) -> ModalResult<(&'a str, &'a str)> {
171    preceded(
172        (">>", space0),
173        (
174            take_while(1.., |c| c != ':'),
175            preceded((":", space0), take_until(0.., "\n")),
176        ),
177    )
178    .parse_next(input)
179}
180
181/// The backstory is separated by `---`, and it consumes till the end
182/// ```recp
183/// my recipe bla with {ingredient1}
184/// ---
185/// This recipe was given by my grandma
186/// ```
187fn parse_backstory<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
188    preceded(
189        delimited(
190            preceded(line_ending, multispace0),
191            "---",
192            preceded(line_ending, multispace0),
193        ),
194        rest,
195    )
196    .parse_next(input)
197}
198
199/// Symbols that create conflict when parsing
200///
201/// If you have a recipe like:
202///
203/// ```recp
204/// Take the l'{ingredient}
205/// ```
206///
207/// There's a word connected to the ingredient by a symbol, in order to be prevent
208/// the `parse_word` from ingesting the chunk `l'{ingredient}` as a word, we need
209/// to tell `parse_word` to stop at this character and also we need to catch it here.
210fn parse_special_symbols<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> {
211    alt(("(", "'", "`")).parse_next(input)
212}
213/* ****************
214* The main parser
215**************** */
216
217/// A recipe string is parsed into many of these tokens
218#[derive(Debug, Clone, Eq, PartialEq, Hash)]
219#[cfg_attr(feature = "serde", derive(serde::Serialize))]
220#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
221// if you use `zod` for example, using a tag makes it easy to use an undiscriminated union
222#[cfg_attr(feature = "serde", serde(tag = "token", content = "content"))]
223#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
224#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
225pub enum Token<'a> {
226    /// Relevant information of a recipe that doesn't make the recipe itself
227    ///
228    /// Example:
229    /// ```recp
230    /// >> name: Salad
231    /// ```
232    Metadata {
233        key: &'a str,
234        value: &'a str,
235    },
236
237    /// Represents a recipe ingredient
238    ///
239    /// Example
240    ///
241    /// ```recp
242    /// {tomato}(1 kg)
243    /// ```
244    Ingredient {
245        name: &'a str,
246        quantity: Option<&'a str>,
247        unit: Option<&'a str>,
248    },
249
250    /// Link to another recipe
251    ///
252    /// Example
253    ///
254    /// ```recp
255    /// @{path/recipe}(30 ml)
256    /// ```
257    RecipeRef {
258        name: &'a str,
259        quantity: Option<&'a str>,
260        unit: Option<&'a str>,
261    },
262
263    /// Mark for a timer
264    ///
265    /// Example
266    ///
267    /// ```recp
268    /// t{25 minutes}
269    /// ```
270    Timer(&'a str),
271
272    /// Mark for a material required
273    ///
274    /// Example
275    ///
276    /// ```recp
277    /// &{blender}
278    /// ```
279    Material(&'a str),
280
281    Word(&'a str),
282    Space(&'a str),
283    Comment(&'a str),
284
285    /// Information, story or notes about a recipe
286    ///
287    /// Example
288    ///
289    /// ```recp
290    /// my recipe
291    /// ---
292    /// shared by my best friend
293    /// ```
294    Backstory(&'a str),
295}
296
297impl Display for Token<'_> {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        match self {
300            Token::Ingredient {
301                name,
302                quantity: _,
303                unit: _,
304            } => write!(f, "{}", name),
305            Token::RecipeRef {
306                name,
307                quantity: _,
308                unit: _,
309            } => write!(f, "\"{}\"", name),
310            Token::Backstory(v)
311            | Token::Timer(v)
312            | Token::Material(v)
313            | Token::Word(v)
314            | Token::Space(v) => {
315                write!(f, "{}", v)
316            }
317            Token::Metadata { key: _, value: _ } => Ok(()),
318            Token::Comment(_) => Ok(()),
319        }
320    }
321}
322
323pub fn recipe_value<'a>(input: &mut Input<'a>) -> ModalResult<Token<'a>> {
324    alt((
325        parse_metadata.map(|(key, value)| Token::Metadata { key, value }),
326        parse_material.map(|m| Token::Material(m)),
327        parse_timer.map(|t| Token::Timer(t)),
328        // Because ingredient doesn't have a prefix before the curly braces, e.g: `&{}`
329        // it must always be parsed after timer and material
330        parse_ingredient.map(|(name, amount)| {
331            let mut quantity = None;
332            let mut unit = None;
333            if let Some((_quantity, _unit)) = amount {
334                quantity = _quantity;
335                unit = _unit;
336            };
337
338            Token::Ingredient {
339                name,
340                quantity,
341                unit,
342            }
343        }),
344        parse_recipe_ref.map(|(name, amount)| {
345            let mut quantity = None;
346            let mut unit = None;
347            if let Some((_quantity, _unit)) = amount {
348                quantity = _quantity;
349                unit = _unit;
350            };
351
352            Token::RecipeRef {
353                name,
354                quantity,
355                unit,
356            }
357        }),
358        parse_backstory.map(|v| Token::Backstory(v)),
359        parse_comment.map(|v| Token::Comment(v)),
360        parse_special_symbols.map(|v| Token::Word(v)),
361        parse_word.map(|v| Token::Word(v)),
362        space1.map(|v| Token::Space(v)),
363        multispace1.map(|v| Token::Space(v)),
364    ))
365    .parse_next(input)
366}
367
368pub fn recipe<'a>(input: &mut Input<'a>) -> ModalResult<Vec<Token<'a>>> {
369    repeat(0.., recipe_value).parse_next(input)
370}
371
372/// Parse recipe tokens from a string
373///
374/// Example:
375///
376/// ```
377/// use recipe_parser::parse;
378///
379/// let input = "Take the {potato}(1) and boil it";
380/// let result = parse(input).expect("recipe could not be parsed");
381///
382/// println!("{result:?}");
383/// ```
384pub fn parse(input: &str) -> Result<Vec<Token<'_>>, ParseError<LocatingSlice<&str>, ContextError>> {
385    let input = LocatingSlice::new(input);
386    recipe.parse(input)
387}
388
389#[cfg(test)]
390mod test {
391    use super::*;
392    use rstest::*;
393
394    #[rstest]
395    #[case("salt", "salt")]
396    #[case("sweet potato", "sweet potato")]
397    #[case("ToMaToeS", "ToMaToeS")]
398    #[case("1/2 lemon", "1/2 lemon")]
399    #[case("my-best-sauce", "my-best-sauce")]
400    #[case("1.2", "1.2")]
401    #[case("1,2", "1,2")]
402    #[case("1_200", "1_200")]
403    #[case("@woile", "@woile")]
404    #[case("10%", "10%")]
405    #[case("#vegan", "#vegan")]
406    #[case("mango's", "mango's")]
407    fn test_parse_valid_string(#[case] input: String, #[case] expected: &str) {
408        let mut input = LocatingSlice::new(input.as_str());
409        let valid_str = parse_valid_string(&mut input).unwrap();
410        assert_eq!(valid_str, expected)
411    }
412
413    #[rstest]
414    #[case("/* */", "")]
415    #[case("/* hello */", "hello")]
416    #[case("/* multi\nline\ncomment */", "multi\nline\ncomment")]
417    fn test_parse_comment_ok(#[case] input: String, #[case] expected: &str) {
418        let mut input = LocatingSlice::new(input.as_str());
419        let comment = parse_comment(&mut input).expect("failed to parse comment");
420        assert_eq!(comment, expected)
421    }
422
423    #[test]
424    fn test_parse_comment_wrong() {
425        let mut input = LocatingSlice::new("/* unclosed");
426        let res = parse_comment(&mut input);
427        assert!(res.is_err());
428
429        let err = res.unwrap_err();
430        println!("{:?}", err);
431        assert!(matches!(err, winnow::error::ErrMode::Cut(_)));
432    }
433
434    #[rstest]
435    #[case("{salt}", "salt")]
436    #[case("{black pepper}", "black pepper")]
437    #[case("{smashed potatoes}", "smashed potatoes")]
438    #[case("{15 minutes}", "15 minutes")]
439    #[case("{   15 minutes  }", "15 minutes")]
440    fn test_parse_curly_ok(#[case] input: String, #[case] expected: &str) {
441        let mut input = LocatingSlice::new(input.as_str());
442        let content = parse_curly(&mut input).expect("to work");
443        assert_eq!(expected, content);
444    }
445
446    #[test]
447    fn test_parse_curly_wrong() {
448        let mut input = LocatingSlice::new("{}");
449        let res = parse_curly(&mut input);
450        assert!(res.is_err());
451
452        let mut input = LocatingSlice::new("{unclosed");
453        let res = parse_curly(&mut input);
454        assert!(res.is_err());
455
456        let err = res.unwrap_err();
457        assert!(matches!(err, winnow::error::ErrMode::Cut(_)));
458    }
459
460    #[rstest]
461    #[case("200", "200")]
462    #[case("2.1", "2.1")]
463    #[case("2_1", "2_1")]
464    #[case("2,1", "2,1")]
465    #[case("2.1", "2.1")]
466    #[case("1/2", "1/2")]
467    #[case(".2", ".2")]
468    fn test_parse_quantity_ok(#[case] input: String, #[case] expected: &str) {
469        let mut input = LocatingSlice::new(input.as_str());
470        let content = parse_quantity(&mut input).expect("to work");
471        assert_eq!(expected, content);
472    }
473
474    #[rstest]
475    #[case("2.")]
476    #[case("2..0")]
477    #[case("2,,0")]
478    #[case("2//0")]
479    fn test_parse_quantity_invalid(#[case] input: String) {
480        // TODO: Add verify function to validate the last char
481        let mut input = LocatingSlice::new(input.as_str());
482        let res = parse_quantity(&mut input);
483        let err = res.unwrap_err();
484        assert!(matches!(err, winnow::error::ErrMode::Cut(_)));
485    }
486
487    #[rstest]
488    #[case("(200gr)", (Some("200"), Some("gr")))]
489    #[case("(1/2)", (Some("1/2"), None))]
490    #[case("(100 gr)", (Some("100"), Some("gr")))]
491    #[case("(10 ml)", (Some("10"), Some("ml")))]
492    #[case("( 10 ml )", (Some("10"), Some("ml")))]
493    #[case("(1.5 cups)", (Some("1.5"), Some("cups")))]
494    fn test_parse_ingredient_amount_ok(
495        #[case] input: String,
496        #[case] expected: (Option<&str>, Option<&str>),
497    ) {
498        let mut input = LocatingSlice::new(input.as_str());
499        let content = parse_ingredient_amount(&mut input).expect("to work");
500        assert_eq!(expected, content);
501    }
502
503    #[rstest]
504    #[case("()")]
505    #[case("(unclosed")]
506    fn test_parse_ingredient_amount_invalid_quantity(#[case] input: String) {
507        let mut input = LocatingSlice::new(input.as_str());
508        let res = parse_ingredient_amount(&mut input);
509        match res {
510            Ok(_) => {
511                // should fail the test
512                assert!(false);
513            }
514            Err(e) => match e {
515                winnow::error::ErrMode::Cut(err) => {
516                    println!("{}", err);
517                    assert_eq!(
518                        "expected a quantity value, like 3, 1.2, 1/2 or 1_000",
519                        err.to_string()
520                    );
521                    assert!(true);
522                }
523                _ => {
524                    assert!(false);
525                }
526            },
527        }
528    }
529
530    #[rstest]
531    #[case("(1.5")]
532    fn test_parse_ingredient_amount_invalid_amount(#[case] input: String) {
533        let mut input = LocatingSlice::new(input.as_str());
534        let res = parse_ingredient_amount(&mut input);
535        match res {
536            Ok(_) => {
537                // should fail the test
538                assert!(false);
539            }
540            Err(e) => match e {
541                winnow::error::ErrMode::Cut(err) => {
542                    println!("{}", err);
543                    assert_eq!("expected `)`", err.to_string());
544                    assert!(true);
545                }
546                _ => {
547                    assert!(false);
548                }
549            },
550        }
551    }
552
553    #[rstest]
554    #[case("{sweet potato}(200gr)", "sweet potato", Some((Some("200"),Some("gr"))))]
555    #[case("{sweet potato}", "sweet potato", None)]
556    fn test_parse_ingredient_ok(
557        #[case] input: String,
558        #[case] expected_ingredient: &str,
559        #[case] expected_amount: Option<(Option<&str>, Option<&str>)>,
560    ) {
561        let mut input = LocatingSlice::new(input.as_str());
562        let (ingredient, amount) = parse_ingredient(&mut input).unwrap();
563        assert_eq!(expected_ingredient, ingredient);
564        assert_eq!(expected_amount, amount);
565    }
566
567    #[rstest]
568    #[case("&{pot}", "pot")]
569    #[case("&{small jar}", "small jar")]
570    #[case("&{stick}", "stick")]
571    #[case("&{bricks}", "bricks")]
572    fn test_parse_material_ok(#[case] input: String, #[case] expected: &str) {
573        let mut input = LocatingSlice::new(input.as_str());
574        let material = parse_material(&mut input).expect("Failed to parse material");
575        assert_eq!(material, expected)
576    }
577
578    #[rstest]
579    #[case("t{1 minute}", "1 minute")]
580    fn test_parse_timer_ok(#[case] input: String, #[case] expected: &str) {
581        let mut input = LocatingSlice::new(input.as_str());
582        let timer = parse_timer(&mut input).expect("Failed to parse timer");
583        assert_eq!(timer, expected)
584    }
585
586    #[rstest]
587    #[case("@{woile/tomato-sauce}(200gr)", "woile/tomato-sauce", Some((Some("200"),Some("gr"))))]
588    #[case("@{woile/tomato-sauce}", "woile/tomato-sauce", None)]
589    #[case("@{special stew}", "special stew", None)]
590    fn test_parse_recipe_ok(
591        #[case] input: String,
592        #[case] expected_recipe: &str,
593        #[case] expected_amount: Option<(Option<&str>, Option<&str>)>,
594    ) {
595        let mut input = LocatingSlice::new(input.as_str());
596        let (recipe, amount) = parse_recipe_ref(&mut input).unwrap();
597        assert_eq!(expected_recipe, recipe);
598        assert_eq!(expected_amount, amount);
599    }
600
601    #[rstest]
602    #[case(">> tags: vegan\n", ("tags", "vegan"))]
603    #[case(">> key: pepe\n", ("key", "pepe"))]
604    #[case(">>key: pepe\n", ("key", "pepe"))]
605    #[case(">>    key: pepe\n", ("key", "pepe"))]
606    #[case(">>    key:     pepe\n", ("key", "pepe"))]
607    #[case(">>    key:\t\tpepe\n", ("key", "pepe"))]
608    #[case(">>    key:pepe\n", ("key", "pepe"))]
609    fn test_parse_metadata_ok(#[case] input: String, #[case] expected: (&str, &str)) {
610        let mut input = LocatingSlice::new(input.as_str());
611        let metadata = parse_metadata(&mut input).expect("Failed to parse metadata");
612        assert_eq!(metadata, expected)
613    }
614
615    #[rstest]
616    #[case("\n---\nwhat a backstory", "what a backstory")]
617    #[case("\n   ---\nwhat a backstory", "what a backstory")]
618    #[case("\n   ---\n\nwhat a backstory", "what a backstory")]
619    #[case("\n   ---\n\nthis is **markdown**", "this is **markdown**")]
620    #[case("\n   ---\n\nthis is [markdown](url)", "this is [markdown](url)")]
621    fn test_parse_backstory_ok(#[case] input: String, #[case] expected: &str) {
622        let mut input = LocatingSlice::new(input.as_str());
623        let backsotry = parse_backstory(&mut input).expect("failed to parse backstory");
624        assert_eq!(backsotry, expected)
625    }
626
627    #[rstest]
628    #[case("\n---    \nwhat a backstory")]
629    fn test_parse_backstory_fail(#[case] input: String) {
630        let mut input = LocatingSlice::new(input.as_str());
631        let out = parse_backstory(&mut input);
632        assert!(out.is_err())
633    }
634
635    #[rstest]
636    #[case(" ", Token::Space(" "))]
637    #[case("{holis}(100 gr)", Token::Ingredient { name: "holis", quantity: Some("100"), unit: Some("gr") })]
638    fn test_recipe_value_ok(#[case] input: &str, #[case] expected: Token) {
639        let mut input = LocatingSlice::new(input);
640        let token = recipe_value(&mut input).expect("failed to parse token");
641        assert_eq!(token, expected)
642    }
643
644    #[test]
645    fn test_recipe_ok() {
646        let input = "Boil the quinoa for t{5 minutes} in a &{pot}.\nPut the boiled {quinoa}(200gr) in the base of the bowl.";
647        let expected = "Boil the quinoa for 5 minutes in a pot.\nPut the boiled quinoa in the base of the bowl.";
648        let recipe = recipe
649            .parse(LocatingSlice::new(input))
650            .expect("parse failed");
651        let fmt_recipe = recipe
652            .iter()
653            .fold(String::new(), |acc, val| format!("{acc}{val}"));
654        println!("{}", fmt_recipe);
655
656        assert_eq!(expected, fmt_recipe);
657        println!("{:?}", recipe);
658    }
659
660    #[rstest]
661    #[case(" ", vec![Token::Space(" ")])]
662    #[case("\n\nhello", vec![Token::Space("\n\n"), Token::Word("hello")])]
663    #[case("hello\n", vec![Token::Word("hello"), Token::Space("\n")])]
664    #[case(">> tags: hello\n\nhello", vec![Token::Metadata {key: "tags", value: "hello"}, Token::Space("\n\n"), Token::Word("hello")])]
665    #[case(">> source: https://hello.com\n>> tags: hello\n", vec![Token::Metadata {key: "source", value: "https://hello.com"}, Token::Space("\n"), Token::Metadata {key: "tags", value: "hello"}, Token::Space("\n")])]
666    #[case("{holis}(100 gr)", vec![Token::Ingredient { name: "holis", quantity: Some("100"), unit: Some("gr") }])]
667    fn test_recipe_cases_ok(#[case] input: &str, #[case] expected: Vec<Token>) {
668        let mut input = LocatingSlice::new(input);
669        let token = recipe(&mut input).expect("failed to parse token");
670        assert_eq!(token, expected)
671    }
672
673    #[rstest]
674    #[case("Foo. ")]
675    #[case("Foo.")]
676    #[case("Foo, bar")]
677    #[case("Foo,bar")]
678    #[case("Foo,")]
679    #[case("Foo, ")]
680    #[case("Foo,\n")]
681    #[case("Foo.\n")]
682    #[case("Foo.\nfoo")]
683    #[case("Foo,\nfoo")]
684    fn test_symbol_parsing(#[case] input: &str) {
685        let recipe_result = parse(input);
686
687        assert!(recipe_result.is_ok());
688    }
689
690    #[test]
691    fn test_parse_ok() {
692        let input = "Boil the quinoa for t{5 minutes} in a &{pot}.\nPut the boiled {quinoa}(200gr) in the base of the bowl.";
693        let expected = "Boil the quinoa for 5 minutes in a pot.\nPut the boiled quinoa in the base of the bowl.";
694        let recipe = parse(input).expect("parse failed");
695        let fmt_recipe = recipe
696            .iter()
697            .fold(String::new(), |acc, val| format!("{acc}{val}"));
698        println!("{}", fmt_recipe);
699
700        assert_eq!(expected, fmt_recipe);
701        println!("{:?}", recipe);
702    }
703
704    #[rstest]
705    #[case("l'{ingredient}", vec![
706        Token::Word("l"),
707        Token::Word("'"),
708        Token::Ingredient {
709            name: "ingredient",
710            quantity: None,
711            unit: None,
712        },
713    ])]
714    #[case("l'&{material}", vec![
715        Token::Word("l"),
716        Token::Word("'"),
717        Token::Material("material"),
718    ])]
719    #[case("l't{mytimer}", vec![
720        Token::Word("l"),
721        Token::Word("'"),
722        Token::Timer("mytimer"),
723    ])]
724    #[case("l`{ingredient}", vec![
725        Token::Word("l"),
726        Token::Word("`"),
727        Token::Ingredient {
728            name: "ingredient",
729            quantity: None,
730            unit: None,
731        },
732    ])]
733    #[case("l`&{material}", vec![
734        Token::Word("l"),
735        Token::Word("`"),
736        Token::Material("material"),
737    ])]
738    #[case("l`t{mytimer}", vec![
739        Token::Word("l"),
740        Token::Word("`"),
741        Token::Timer("mytimer"),
742    ])]
743    #[case("foot{timer}", vec![
744        Token::Word("foo"),
745        Token::Timer("timer")
746    ])]
747    #[case("煮{白飯}(100 g)", vec![
748        Token::Word("煮"),
749        Token::Ingredient {
750            name: "白飯",
751            quantity: Some("100"),
752            unit: Some("g"),
753        },
754    ])]
755    #[case("煮t{mytimer}", vec![
756        Token::Word("煮"),
757        Token::Timer("mytimer"),
758    ])]
759    fn test_parse_chunks_without_spaces(#[case] input: &str, #[case] expected: Vec<Token>) {
760        let recipe = parse(input).expect("parse failed");
761
762        println!("{:?}", recipe);
763
764        assert_eq!(expected, recipe);
765    }
766
767    #[test]
768    fn test_parse_with_backstory_ok() {
769        let input = "Foo. \n---\nA backstory";
770        let expected = vec![
771            Token::Word("Foo."),
772            Token::Space(" "),
773            Token::Backstory("A backstory"),
774        ];
775        let recipe = parse(input).expect("parse failed");
776
777        println!("{:?}", recipe);
778
779        assert_eq!(expected, recipe);
780        println!("{:?}", recipe);
781    }
782
783    #[test]
784    #[cfg(feature = "serde")]
785    fn test_token_serialization_works() {
786        let token = Token::Ingredient {
787            name: "quinoa",
788            quantity: Some("200"),
789            unit: Some("gr"),
790        };
791
792        let serialized = serde_json::to_string(&token).expect("failed to serialize");
793        println!("{}", serialized);
794    }
795
796    #[test]
797    #[cfg(feature = "serde")]
798    fn test_token_serialization_creates_right_payload() {
799        let token = Token::Ingredient {
800            name: "quinoa",
801            quantity: Some("200"),
802            unit: Some("gr"),
803        };
804
805        let serialized = serde_json::to_string(&token).expect("failed to serialize");
806        assert_eq!(
807            serialized,
808            r#"{"token":"Ingredient","content":{"name":"quinoa","quantity":"200","unit":"gr"}}"#
809        );
810    }
811
812    #[test]
813    #[cfg(feature = "serde")]
814    fn test_token_serialization_creates_right_payload_single_string() {
815        let token = Token::Word("holis");
816
817        let serialized = serde_json::to_string(&token).expect("failed to serialize");
818        assert_eq!(serialized, r#"{"token":"Word","content":"holis"}"#);
819    }
820
821    #[test]
822    #[cfg(feature = "schemars")]
823    fn test_token_json_schema_generation() {
824        use schemars::schema_for;
825        let schema = schema_for!(Token);
826        println!("{}", serde_json::to_string_pretty(&schema).unwrap());
827    }
828}