module Roux.ParserSpec (spec) where import Data.List.NonEmpty (NonEmpty ((:|))) import qualified Data.List.NonEmpty as NE import Data.Ratio ((%)) import qualified Data.Text.IO as TIO import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe) import Data.CookLang import Roux.Parser (parseCookFile) spec :: Spec spec = do describe "parseCookFile" $ do it "parses a single step of plain text" $ do let input = "Hello, world." expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep (Step [StepText "Hello, world."]) :| [] } :| [] } parseCookFile input `shouldBe` expected it "parses multiple steps separated by a blank line" $ do let input = "Wash the potatoes.\n\nPeel and dice them." expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep (Step [StepText "Wash the potatoes."]) :| [ SecStep (Step [StepText "Peel and dice them."]) ] } :| [] } parseCookFile input `shouldBe` expected it "handles empty input by returning an empty recipe" $ do let expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep (Step []) :| [] } :| [] } parseCookFile "" `shouldBe` expected it "trims leading and trailing whitespace from each block" $ do let input = "\n\n Mix well. \n\n" expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep (Step [StepText "Mix well."]) :| [] } :| [] } parseCookFile input `shouldBe` expected it "parses a full-line comment" $ do let input = "-- TODO add source" expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecComment "TODO add source" :| [] } :| [] } parseCookFile input `shouldBe` expected it "parses a note block" $ do let input = "> Don't burn the roux!" expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecNote "Don't burn the roux!" :| [] } :| [] } parseCookFile input `shouldBe` expected it "parses a section header" $ do let input = "= Dough" expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Just "Dough" , sectionBody = SecStep (Step []) :| [] } :| [] } parseCookFile input `shouldBe` expected it "groups items before a section header into an unnamed section" $ do let input = "Mix flour and water.\n\n= Filling\n\nCombine cheese and spinach." expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep (Step [StepText "Mix flour and water."]) :| [] } :| [ Section { sectionName = Just "Filling" , sectionBody = SecStep (Step [StepText "Combine cheese and spinach."]) :| [] } ] } parseCookFile input `shouldBe` expected describe "Ingredients" $ do it "parses a simple ingredient with quantity" $ do let input = "@eggs{3}" expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep ( Step [ StepIngredient (Ingredient "eggs" (Just (Quantity 3 Nothing False)) Nothing) ] ) :| [] } :| [] } parseCookFile input `shouldBe` expected it "parses an ingredient with quantity and unit" $ do let input = "@flour{125%g}" expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep ( Step [ StepIngredient (Ingredient "flour" (Just (Quantity 125 (Just "g") False)) Nothing) ] ) :| [] } :| [] } parseCookFile input `shouldBe` expected it "parses an ingredient without quantity (single word)" $ do let input = "drizzle of @oil" expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep ( Step [ StepText "drizzle of " , StepIngredient (Ingredient "oil" Nothing Nothing) ] ) :| [] } :| [] } parseCookFile input `shouldBe` expected it "parses a multi-word ingredient with empty braces" $ do let input = "@sea salt{}" expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep ( Step [ StepIngredient (Ingredient "sea salt" Nothing Nothing) ] ) :| [] } :| [] } parseCookFile input `shouldBe` expected it "parses a multi-word ingredient with quantity and unit" $ do let input = "@sea salt{1%pinch}" expected = Right Recipe { recipeMetadata = emptyMetadata , recipeSections = Section { sectionName = Nothing , sectionBody = SecStep ( Step [ StepIngredient (Ingredient "sea salt" (Just (Quantity 1 (Just "pinch") False)) Nothing) ] ) :| [] } :| [] } parseCookFile input `shouldBe` expected describe "Comments" $ do it "parses an end-of-line comment within a step" $ do let input = "Mash until smooth -- alternatively, boil 'em first" case parseCookFile input of Left err -> expectationFailure ("parse failed: " <> err) Right recipe -> do let body = NE.toList (sectionBody (NE.head (recipeSections recipe))) case body of [SecStep (Step items)] -> do let expected = [ StepText "Mash until smooth " , StepEndComment "alternatively, boil 'em first" ] items `shouldBe` expected other -> expectationFailure ("expected single SecStep, got " <> show other) it "parses an inline block comment" $ do let input = "Slowly add @milk{4%cup} [- TODO change units to litres -], keep mixing" case parseCookFile input of Left err -> expectationFailure ("parse failed: " <> err) Right recipe -> do let body = NE.toList (sectionBody (NE.head (recipeSections recipe))) case body of [SecStep (Step items)] -> do let expected = [ StepText "Slowly add " , StepIngredient (Ingredient "milk" (Just (Quantity 4 (Just "cup") False)) Nothing) , StepText " " , StepComment " TODO change units to litres " , StepText ", keep mixing" ] items `shouldBe` expected other -> expectationFailure ("expected single SecStep, got " <> show other) describe "EasyPancakes example" $ do let pancakeInput = "-- TODO add source\n" <> "\n" <> "Crack the @eggs{3} into a blender, then add the @flour{125%g}, @milk{250%ml} and @sea salt{1%pinch}, and blitz until smooth.\n" <> "\n" <> "Pour into a #bowl and leave to stand for ~{15%minutes}.\n" <> "\n" <> "Melt the butter (or a drizzle of @oil if you want to be a bit healthier) in a #large non-stick frying pan{} on a medium heat, then tilt the pan so the butter coats the surface.\n" <> "\n" <> "Pour in 1 ladle of batter and tilt again, so that the batter spreads all over the base, then cook for 1 to 2 minutes, or until it starts to come away from the sides.\n" <> "\n" <> "Once golden underneath, flip the pancake over and cook for 1 further minute, or until cooked through.\n" <> "\n" <> "Serve straightaway with your favourite topping.\n" it "parses into 1 section with 7 body items" $ do case parseCookFile pancakeInput of Left err -> expectationFailure ("parse failed: " <> err) Right recipe -> do let section = NE.head (recipeSections recipe) body = NE.toList (sectionBody section) length body `shouldBe` 7 it "first body item is the full-line comment" $ do case parseCookFile pancakeInput of Left err -> expectationFailure ("parse failed: " <> err) Right recipe -> do let body = NE.toList (sectionBody (NE.head (recipeSections recipe))) case body of (SecComment t : _) -> t `shouldBe` "TODO add source" other -> expectationFailure ("expected SecComment first, got " <> show other) it "first step parses ingredients correctly" $ do case parseCookFile pancakeInput of Left err -> expectationFailure ("parse failed: " <> err) Right recipe -> do let body = NE.toList (sectionBody (NE.head (recipeSections recipe))) case body of (_ : SecStep (Step items) : _) -> do let expected = [ StepText "Crack the " , StepIngredient (Ingredient "eggs" (Just (Quantity 3 Nothing False)) Nothing) , StepText " into a blender, then add the " , StepIngredient (Ingredient "flour" (Just (Quantity 125 (Just "g") False)) Nothing) , StepText ", " , StepIngredient (Ingredient "milk" (Just (Quantity 250 (Just "ml") False)) Nothing) , StepText " and " , StepIngredient (Ingredient "sea salt" (Just (Quantity 1 (Just "pinch") False)) Nothing) , StepText ", and blitz until smooth." ] items `shouldBe` expected _ -> expectationFailure ("expected SecStep as second body item, got body=" <> show body) it "second step parses cookware and timer" $ do case parseCookFile pancakeInput of Left err -> expectationFailure ("parse failed: " <> err) Right recipe -> do let body = NE.toList (sectionBody (NE.head (recipeSections recipe))) case body of (_ : _ : SecStep (Step items) : _) -> do let expected = [ StepText "Pour into a " , StepCookware (Cookware "bowl") , StepText " and leave to stand for " , StepTimer (Timer Nothing (Duration 15 (Just "minutes"))) , StepText "." ] items `shouldBe` expected _ -> expectationFailure ("expected SecStep as third body item, got body=" <> show body) describe "Quantities" $ do it "parses compound quantity (1,1/2) with unit attached" $ do let input = "@water{1,1/2cups}" case parseCookFile input of Left err -> expectationFailure ("parse failed: " <> err) Right recipe -> do let items = stepItems recipe items `shouldBe` [ StepIngredient (Ingredient "water" (Just (Quantity (3 % 2) (Just "cups") False)) Nothing) ] it "parses quantity with trailing unit (no %)" $ do let input = "@gelatine{3tsp}" case parseCookFile input of Left err -> expectationFailure ("parse failed: " <> err) Right recipe -> do let items = stepItems recipe items `shouldBe` [ StepIngredient (Ingredient "gelatine" (Just (Quantity 3 (Just "tsp") False)) Nothing) ] describe "Recipe references" $ do it "parses a recipe reference" $ do let input = "Pour over with @./sauces/Hollandaise{150%g}." case parseCookFile input of Left err -> expectationFailure ("parse failed: " <> err) Right recipe -> do let items = stepItems recipe let expected = [ StepText "Pour over with " , StepRecipeRef (RecipeRef "./sauces/Hollandaise" (Just (Quantity 150 (Just "g") False))) , StepText "." ] items `shouldBe` expected describe "Full recipe files" $ do let readExample name = do content <- TIO.readFile ("cooklang-examples/" <> name <> ".cook") case parseCookFile content of Left err -> expectationFailure (name <> ": " <> err) Right _ -> pure () it "EasyPancakes parses without error" $ do readExample "EasyPancakes" it "FriedRice parses without error" $ do readExample "FriedRice" it "CoffeeSouffle parses without error" $ do readExample "CoffeeSouffle" it "OlivierSalad parses without error" $ do readExample "OlivierSalad" -- | Extract step items from the first step of an unnamed section. stepItems :: Recipe -> [StepItem] stepItems r = let s = NE.head (recipeSections r) in case NE.head (sectionBody s) of SecStep step -> unStep step _ -> []