cooklang-hs (empty) → 0.1.0.0
raw patch · 5 files changed
+551/−0 lines, 5 filesdep +basedep +hspecdep +megaparsec
Dependencies added: base, hspec, megaparsec
Files
- Main.hs +10/−0
- README.md +8/−0
- Spec.hs +349/−0
- Text/Cook.hs +143/−0
- cooklang-hs.cabal +41/−0
+ Main.hs view
@@ -0,0 +1,10 @@+module Main (main) where++import Text.Cook ( parseCook )++main :: IO ()+main = do+ input <- getContents+ case parseCook input of+ Left parseError -> putStrLn parseError+ Right result -> print result
+ README.md view
@@ -0,0 +1,8 @@+# cooklang-hs+This library allows for parsing `.cook` files in Haskell.++Check out [cooklang](https://cooklang.org/).++To give it a try on MacOS or Linux do `cabal run cooklang-hs < myRecipe.cook`.++Feel free to open an issue or pull request!
+ Spec.hs view
@@ -0,0 +1,349 @@+-- Copyright Isaac Van Doren 2022-present++import Test.Hspec ( hspec, describe, it, shouldBe )+import Text.Cook ( parseCook, Content(..), Result(..), Category(..), Item(..) )+++main :: IO ()+main = hspec $ do+ describe "TDD" $ do+ describe "metadata" $ do+ it "basic metadata" $ do+ parseCook ">> source: https://isaacvando.com" `shouldBe` Right (Recipe [("source", "https://isaacvando.com")] [])++ it "two metadata" $ do+ parseCook ">> first: metadata\n>> second: metadata" `shouldBe` Right (Recipe [("first", "metadata"), ("second", "metadata")] [])++ it "multi word key" $ do+ parseCook ">> multi word: value" `shouldBe` Right (Recipe [("multi word", "value")] [])++ it "simple extra spacing" $ do+ parseCook ">> \t key: value \t\t\t\t \n" `shouldBe` Right (Recipe [("key", "value")] [])++ it "multiple words with extra spaces" $ do+ parseCook ">> key: this is the value " `shouldBe` Right (Recipe [("key", "this is the value")] [])++ it "no spaces" $ do+ parseCook ">>foo:bar" `shouldBe` Right (Recipe [("foo", "bar")] [])++ describe "step" $ do+ it "basic step" $ do+ parseCook "this is a step" `shouldBe` Right (Recipe [] [[Text "this is a step"]])++ it "step beginning with >" $ do+ parseCook "> sneaky step" `shouldBe` Right (Recipe [] [[Text "> sneaky step"]])++ it "two steps" $ do+ parseCook "step one\nstep two" `shouldBe` Right (Recipe [] [[Text "step one"], [Text "step two"]])++ it "space around step" $ do+ parseCook "\n\nfoo\n\t" `shouldBe` Right (Recipe [] [[Text "foo"]])++ describe "ingredient" $ do+ it "basic ingredient" $ do+ parseCook "add @apples" `shouldBe` Right (Recipe [] [[Text "add ", Ingredient "apples" "some" ""]])++ it "ingredient with longer sentence" $ do+ parseCook "add lots of delicious @apples" `shouldBe` Right (Recipe [] [[Text "add lots of delicious ", Ingredient "apples" "some" ""]])++ it "multi word ingredient" $ do+ parseCook "@honey crisp apples{}" `shouldBe` Right (Recipe [] [[Ingredient "honey crisp apples" "some" ""]])++ it "extra spacing" $ do+ parseCook "@ \thoney \t crisp\tapples {}" `shouldBe` Right (Recipe [] [[Ingredient "honey crisp apples" "some" ""]])++ it "ingredient with quantity" $ do+ parseCook "@honey crisp apples {a million}" `shouldBe` Right (Recipe [] [[Ingredient "honey crisp apples" "a million" ""]])++ it "ingredient with quantity and unit" $ do+ parseCook "@chives {10 % bushels}" `shouldBe` Right (Recipe [] [[Ingredient "chives" "10" "bushels"]])++ it "ingredient with quantity and unit and no spaces" $ do+ parseCook "@eggs{0.98%oz}" `shouldBe` Right (Recipe [] [[Ingredient "eggs" "0.98" "oz"]])++ describe "cookware" $ do+ it "basic cookware" $ do+ parseCook "#pot" `shouldBe` Right (Recipe [] [[Cookware "pot" "1"]])++ it "multiword cookware" $ do+ parseCook "#stock pot{}" `shouldBe` Right (Recipe [] [[Cookware "stock pot" "1"]])++ it "cookware with quantity" $ do+ parseCook "# cast iron skillet {10}" `shouldBe` Right (Recipe [] [[Cookware "cast iron skillet" "10"]])++ describe "timer" $ do+ it "basic timer" $ do+ parseCook "~{10 minutes}" `shouldBe` Right (Recipe [] [[Timer "" "10 minutes" ""]])++ it "timer with label" $ do+ parseCook "~stewed apples {10 minutes}" `shouldBe` Right (Recipe [] [[Timer "stewed apples" "10 minutes" ""]])++ it "timer with amount and unit" $ do+ parseCook "~grapefruit{2%hours}" `shouldBe` Right (Recipe [] [[Timer "grapefruit" "2" "hours"]])++ it "timer without unit" $ do+ parseCook "~apple" `shouldBe` Right (Recipe [] [[Timer "apple" "" ""]])++ -- I'm not a big fan of this behavior as it loses information and could change the aesthetic qualities of a recipe+ -- if fractions are desired but I am complying with the canonical tests. + describe "fractions" $ do+ it "basic no spaces" $ do+ parseCook "@parsley{1/2%lbs}" `shouldBe` Right (Recipe [] [[Ingredient "parsley" "0.5" "lbs"]])++ it "repeating decimal" $ do+ parseCook "@food{1/3}" `shouldBe` Right (Recipe [] [[Ingredient "food" "0.33" ""]])++ it "bigger number" $ do+ parseCook "@grapefruit{9999/5}" `shouldBe` Right (Recipe [] [[Ingredient "grapefruit" "1999.8" ""]])++ it "leading zeros don't count" $ do+ parseCook "#pot{08/3}" `shouldBe` Right (Recipe [] [[Cookware "pot" "08/3"]])++ it "division by zero doesn't error" $ do+ parseCook "~foo{10/0}" `shouldBe` Right (Recipe [] [[Timer "foo" "10/0" ""]])++ describe "general step content" $ do+ it "single ingredient followed by timer" $ do+ parseCook "@food ~timer{10 minutes}" `shouldBe` Right (Recipe [] [[Ingredient "food" "some" "", Text " ", Timer "timer" "10 minutes" ""]])++ it "single cookware followed by ingredient" $ do+ parseCook "#pan @two foods{}" `shouldBe` Right (Recipe [] [[Cookware "pan" "1", Text " ", Ingredient "two foods" "some" ""]])++ it "spaces for single word ingredient" $ do+ parseCook "@ apple" `shouldBe` Right (Recipe [] [[Ingredient "apple" "some" ""]])++ it "spaces for single word cookware" $ do+ parseCook "# pot" `shouldBe` Right (Recipe [] [[Cookware "pot" "1"]])++ it "spaces for single word timer" $ do+ parseCook "~ banana" `shouldBe` Right (Recipe [] [[Timer "banana" "" ""]])++ it "timer and cookware" $ do+ parseCook "~apples and bananas in a #pot" `shouldBe` Right (Recipe [] [[Timer "apples" "" "", Text " and bananas in a ", Cookware "pot" "1"]])++ describe "comments" $ do+ it "single line comment" $ do+ parseCook "-- this is a comment" `shouldBe` Right (Recipe [] [])++ it "block comment" $ do+ parseCook "[- single line block comment -]" `shouldBe` Right (Recipe [] [])++ it "multi line block comment" $ do+ parseCook "[- multi line \n block \n comment \n-]" `shouldBe` Right (Recipe [] [])++ it "block comment containing a - and ]" $ do+ parseCook "[- this ] is just - a comment ] with -]" `shouldBe` Right (Recipe [] [])++ it "block comment containing - adjacent to end" $ do+ parseCook "[-- comment --]" `shouldBe` Right (Recipe [] [])++ it "step followed by inline comment" $ do+ parseCook "i'm a step -- i'm a comment" `shouldBe` Right (Recipe [] [[Text "i'm a step "]])++ it "step, inline comment, step" $ do+ parseCook "i'm a step -- i'm a comment\nI'm another step" `shouldBe` Right (Recipe [] [[Text "i'm a step "], [Text "I'm another step"]])++ it "ingredient followed by inline comment" $ do+ parseCook "@cayenne pepper{} -- i'm a comment" `shouldBe` Right (Recipe [] [[Ingredient "cayenne pepper" "some" "", Text " "]])++ it "step followed by block comment" $ do+ parseCook "i'm a step [- i'm a comment -]" `shouldBe` Right (Recipe [] [[Text "i'm a step "]])++ it "metadata followed by inline comment" $ do+ parseCook ">> key: value -- i'm a comment" `shouldBe` Right (Recipe [("key","value")] [])++ it "metadata followed by block comment" $ do+ parseCook ">> key: value [-i'm a comment-]" `shouldBe` Right (Recipe [("key","value")] [])++ describe "general" $ do+ it "empty input" $ do+ parseCook "" `shouldBe` Right (Recipe [] [])++ it "step and metadata" $ do+ parseCook "step one\n>> key: value" `shouldBe` Right (Recipe [("key","value")] [[Text "step one"]])+++ let fullRecipeText = unlines [+ "-- this recipe is delicious!",+ "[- spinach is optional but a good addition -]",+ ">> source: https://isaacvando.com",+ ">> time: 1 hr 20 min",+ ">> title: Grandma's Quiche",+ "\n",+ "Preheat the #oven to 325 degrees.",+ "In a medium bowl, mix the @eggs{3}, @sour cream{1/2%cup}, @shredded cheese{1/3%cup}, @crushed french fried onions{1/3%cup}, and @chopped spinach{}.",+ "Mix ingredients thoroughly and add to the @pie crust{1}.",+ "\t\t\t\t\n\n\n",+ "Bake for an ~{hour} or until the top is nicely brown and a toothpick comes out clean.",+ "Serve with @fruit and enjoy!",+ "\n\n",+ "[- the",+ "end -]"+ ]++ let fullRecipe = Recipe [("source", "https://isaacvando.com"), ("time", "1 hr 20 min"), ("title", "Grandma's Quiche")]+ [[Text "Preheat the ", Cookware "oven" "1", Text " to 325 degrees."]+ , [Text "In a medium bowl, mix the ", Ingredient "eggs" "3" "", Text ", ", Ingredient "sour cream" "0.5" "cup",+ Text ", ", Ingredient "shredded cheese" "0.33" "cup", Text ", ", Ingredient "crushed french fried onions" "0.33" "cup",+ Text ", and ", Ingredient "chopped spinach" "some" "", Text "."]+ , [Text "Mix ingredients thoroughly and add to the ", Ingredient "pie crust" "1" "", Text "."]+ , [Text "Bake for an ", Timer "" "hour" "", Text " or until the top is nicely brown and a toothpick comes out clean."]+ , [Text "Serve with ", Ingredient "fruit" "some" "", Text " and enjoy!"]]++ it "full recipe" $ do+ parseCook fullRecipeText `shouldBe` Right fullRecipe++ describe "shopping list" $ do+ it "basic" $ do+ parseCook "[Aldi]\napples" `shouldBe` Right (Grouping [Category "Aldi" [Item "apples" ""]])++ it "multiple stores" $ do+ parseCook "[Costco]\nfoo\nbar\n\n[Walmart]\nbaz" `shouldBe` Right (Grouping [Category "Costco" [Item "foo" "", Item "bar" ""], Category "Walmart" [Item "baz" ""]])++ it "markdown link" $ do+ parseCook "[recipes]\n[here](https://isaacvando.com)" `shouldBe` Right (Grouping [Category "recipes" [Item "[here](https://isaacvando.com)" ""]])++ it "synonym" $ do+ parseCook "[fruit]\nstrawberry|berry of the straw" `shouldBe` Right (Grouping [Category "fruit" [Item "strawberry" "berry of the straw"]])++ it "extra space" $ do+ parseCook "[ Dinner food ] \n \t apples | NYC \n\n\n\n [lunch food \t] \n banana pie " + `shouldBe` Right (Grouping [Category "Dinner food" [Item "apples" "NYC"], Category "lunch food" [Item "banana pie" ""]])++ it "no item" $ do+ parseCook "[title]" `shouldBe` Right (Grouping [Category "title" []])++ -- generated by GenerateCanonical.hs from canonical.yaml from https://github.com/cooklang/spec/blob/main/tests/canonical.yaml+ describe "Canonical" $ do+ it "BasicDirection" $ do+ parseCook "Add a bit of chilli" `shouldBe` Right (Recipe [] [[Text "Add a bit of chilli"]])++ it "Comments" $ do+ parseCook "-- testing comments" `shouldBe` Right (Recipe [] [])++ it "CommentsAfterIngredients" $ do+ parseCook "@thyme{2%springs} -- testing comments\nand some text" `shouldBe` Right (Recipe [] [[Ingredient "thyme" "2" "springs",Text " "],[Text "and some text"]])++ it "CommentsWithIngredients" $ do+ parseCook "-- testing comments\n@thyme{2%springs}" `shouldBe` Right (Recipe [] [[Ingredient "thyme" "2" "springs"]])++ it "DirectionsWithDegrees" $ do+ parseCook "Heat oven up to 200°C" `shouldBe` Right (Recipe [] [[Text "Heat oven up to 200\176C"]])++ it "DirectionsWithNumbers" $ do+ parseCook "Heat 5L of water" `shouldBe` Right (Recipe [] [[Text "Heat 5L of water"]])++ it "DirectionWithIngrident" $ do+ parseCook "Add @chilli{3%items}, @ginger{10%g} and @milk{1%l}." `shouldBe` Right (Recipe [] [[Text "Add ",Ingredient "chilli" "3" "items",Text ", ",Ingredient "ginger" "10" "g",Text " and ",Ingredient "milk" "1" "l",Text "."]])++ it "EquipmentMultipleWords" $ do+ parseCook "Fry in #frying pan{}" `shouldBe` Right (Recipe [] [[Text "Fry in ",Cookware "frying pan" "1"]])++ it "EquipmentMultipleWordsWithLeadingNumber" $ do+ parseCook "Fry in #7-inch nonstick frying pan{ }" `shouldBe` Right (Recipe [] [[Text "Fry in ",Cookware "7-inch nonstick frying pan" "1"]])++ it "EquipmentMultipleWordsWithSpaces" $ do+ parseCook "Fry in #frying pan{ }" `shouldBe` Right (Recipe [] [[Text "Fry in ",Cookware "frying pan" "1"]])++ it "EquipmentOneWord" $ do+ parseCook "Simmer in #pan for some time" `shouldBe` Right (Recipe [] [[Text "Simmer in ",Cookware "pan" "1",Text " for some time"]])++ it "EquipmentQuantity" $ do+ parseCook "#frying pan{2}" `shouldBe` Right (Recipe [] [[Cookware "frying pan" "2"]])++ it "EquipmentQuantityOneWord" $ do+ parseCook "#frying pan{three}" `shouldBe` Right (Recipe [] [[Cookware "frying pan" "three"]])++ it "EquipmentQuantityMultipleWords" $ do+ parseCook "#frying pan{two small}" `shouldBe` Right (Recipe [] [[Cookware "frying pan" "two small"]])++ it "Fractions" $ do+ parseCook "@milk{1/2%cup}" `shouldBe` Right (Recipe [] [[Ingredient "milk" "0.5" "cup"]])++ it "FractionsInDirections" $ do+ parseCook "knife cut about every 1/2 inches" `shouldBe` Right (Recipe [] [[Text "knife cut about every 1/2 inches"]])++ it "FractionsLike" $ do+ parseCook "@milk{01/2%cup}" `shouldBe` Right (Recipe [] [[Ingredient "milk" "01/2" "cup"]])++ it "FractionsWithSpaces" $ do+ parseCook "@milk{1 / 2 %cup}" `shouldBe` Right (Recipe [] [[Ingredient "milk" "0.5" "cup"]])++ it "IngredientMultipleWordsWithLeadingNumber" $ do+ parseCook "Top with @1000 island dressing{ }" `shouldBe` Right (Recipe [] [[Text "Top with ",Ingredient "1000 island dressing" "some" ""]])++ it "IngredientWithEmoji" $ do+ parseCook "Add some @🧂" `shouldBe` Right (Recipe [] [[Text "Add some ",Ingredient "\129474" "some" ""]])++ it "IngridentExplicitUnits" $ do+ parseCook "@chilli{3%items}" `shouldBe` Right (Recipe [] [[Ingredient "chilli" "3" "items"]])++ it "IngridentExplicitUnitsWithSpaces" $ do+ parseCook "@chilli{ 3 % items }" `shouldBe` Right (Recipe [] [[Ingredient "chilli" "3" "items"]])++ it "IngridentImplicitUnits" $ do+ parseCook "@chilli{3}" `shouldBe` Right (Recipe [] [[Ingredient "chilli" "3" ""]])++ it "IngridentNoUnits" $ do+ parseCook "@chilli" `shouldBe` Right (Recipe [] [[Ingredient "chilli" "some" ""]])++ it "IngridentNoUnitsNotOnlyString" $ do+ parseCook "@5peppers" `shouldBe` Right (Recipe [] [[Ingredient "5peppers" "some" ""]])++ it "IngridentWithNumbers" $ do+ parseCook "@tipo 00 flour{250%g}" `shouldBe` Right (Recipe [] [[Ingredient "tipo 00 flour" "250" "g"]])++ it "IngridentWithoutStopper" $ do+ parseCook "@chilli cut into pieces" `shouldBe` Right (Recipe [] [[Ingredient "chilli" "some" "",Text " cut into pieces"]])++ it "Metadata" $ do+ parseCook ">> sourced: babooshka" `shouldBe` Right (Recipe [("sourced","babooshka")] [])++ it "MetadataBreak" $ do+ parseCook "hello >> sourced: babooshka" `shouldBe` Right (Recipe [] [[Text "hello >> sourced: babooshka"]])++ it "MetadataMultiwordKey" $ do+ parseCook ">> cooking time: 30 mins" `shouldBe` Right (Recipe [("cooking time","30 mins")] [])++ it "MetadataMultiwordKeyWithSpaces" $ do+ parseCook ">>cooking time :30 mins" `shouldBe` Right (Recipe [("cooking time","30 mins")] [])++ it "MultiLineDirections" $ do+ parseCook "Add a bit of chilli\n\n\nAdd a bit of hummus" `shouldBe` Right (Recipe [] [[Text "Add a bit of chilli"],[Text "Add a bit of hummus"]])++ it "MultipleLines" $ do+ parseCook ">> Prep Time: 15 minutes\n>> Cook Time: 30 minutes" `shouldBe` Right (Recipe [("Prep Time","15 minutes"),("Cook Time","30 minutes")] [])++ it "MultiWordIngrident" $ do+ parseCook "@hot chilli{3}" `shouldBe` Right (Recipe [] [[Ingredient "hot chilli" "3" ""]])++ it "MultiWordIngridentNoAmount" $ do+ parseCook "@hot chilli{}" `shouldBe` Right (Recipe [] [[Ingredient "hot chilli" "some" ""]])++ it "MutipleIngridentsWithoutStopper" $ do+ parseCook "@chilli cut into pieces and @garlic" `shouldBe` Right (Recipe [] [[Ingredient "chilli" "some" "",Text " cut into pieces and ",Ingredient "garlic" "some" ""]])++ it "QuantityAsText" $ do+ parseCook "@thyme{few%springs}" `shouldBe` Right (Recipe [] [[Ingredient "thyme" "few" "springs"]])++ it "QuantityDigitalString" $ do+ parseCook "@water{7 k }" `shouldBe` Right (Recipe [] [[Ingredient "water" "7 k" ""]])++ it "Servings" $ do+ parseCook ">> servings: 1|2|3" `shouldBe` Right (Recipe [("servings","1|2|3")] [])++ it "SlashInText" $ do+ parseCook "Preheat the oven to 200℃/Fan 180°C." `shouldBe` Right (Recipe [] [[Text "Preheat the oven to 200\8451/Fan 180\176C."]])++ it "TimerDecimal" $ do+ parseCook "Fry for ~{1.5%minutes}" `shouldBe` Right (Recipe [] [[Text "Fry for ",Timer "" "1.5" "minutes"]])++ it "TimerFractional" $ do+ parseCook "Fry for ~{1/2%hour}" `shouldBe` Right (Recipe [] [[Text "Fry for ",Timer "" "0.5" "hour"]])++ it "TimerInteger" $ do+ parseCook "Fry for ~{10%minutes}" `shouldBe` Right (Recipe [] [[Text "Fry for ",Timer "" "10" "minutes"]])++ it "TimerWithName" $ do+ parseCook "Fry for ~potato{42%minutes}" `shouldBe` Right (Recipe [] [[Text "Fry for ",Timer "potato" "42" "minutes"]])+
+ Text/Cook.hs view
@@ -0,0 +1,143 @@+-- Copyright Isaac Van Doren 2022-present++module Text.Cook (Result(..), Content(..), Category(..), Item(..), Step, Metadata, parseCook) where++import Text.Megaparsec (optional, some, noneOf, many, errorBundlePretty, parse, MonadParsec(try), (<|>), Parsec)+import Text.Megaparsec.Char (hspace, char, string, space, digitChar, newline)+import Data.Void (Void)+import Control.Monad (void, when)+import Text.Printf (printf)++type Parser = Parsec Void String++type Metadata = (String, String)++type Step = [Content]+data Content = Text String+ | Ingredient String String String -- label quantity units+ | Timer String String String -- label quantity units+ | Cookware String String -- label quantity+ deriving (Show, Eq)++data Item = Item String String+ deriving (Show, Eq)+data Category = Category String [Item] + deriving (Show, Eq)++data Result = Recipe [Metadata] [Step] | Grouping [Category]+ deriving (Show, Eq)++-- | Parse a .cook file+parseCook :: String -> Either String Result+parseCook input = case parse cookFile "" (simplify input) of+ Left bundle -> Left $ errorBundlePretty bundle+ Right result -> Right result++simplify :: String -> String+simplify = inlineComments . blockComments+ where+ inlineComments = unlines . map f . lines+ where+ f ('-':'-':_) = ""+ f (x:xs) = x : f xs+ f "" = ""+ blockComments :: String -> String+ blockComments ('[':'-':xs) = blockComments (consume xs)+ where + consume ('-':']':ys) = ys+ consume (_:ys) = consume ys+ consume "" = ""+ blockComments (x:xs) = x : blockComments xs+ blockComments "" = ""++cookFile :: Parser Result+cookFile = grouping <|> recipe++recipe :: Parser Result+recipe = do+ pieces <- space *> many (metadata <* space <|> step <* space)+ return $ foldr go (Recipe [] []) pieces+ where + go (Recipe m s) (Recipe m' s') = Recipe (m ++ m') (s ++ s')+ go x y = error $ "Not expecting " ++ show x ++ " and " ++ show y ++ " in recipe parser."++grouping :: Parser Result+grouping = Grouping <$> some (category <* many newline)++category :: Parser Category+category = do+ title <- hspace *> char '[' *> some (noneOf "]") <* char ']' <* hspace <* optional newline+ items <- many item+ return $ Category (norm title) items++item :: Parser Item+item = do+ first <- some (noneOf "\n|")+ second <- optional (char '|') *> many (noneOf "\n") <* newline+ return $ Item (norm first) (norm second)++metadata :: Parser Result+metadata = do+ void $ string ">>" <* hspace+ key <- some $ noneOf ":"+ void $ char ':' <* hspace+ value <- some $ word <* hspace+ return $ Recipe [(norm key, unwords value)] []++step :: Parser Result+step = do+ content <- some $ (ingredient <|> cookware <|> timer <|> text)+ return $ Recipe [] [content]++text :: Parser Content+text = Text <$> (some (noneOf "@#~\n"))++ingredient :: Parser Content+ingredient = char '@' *> hspace *>+ (try (do+ content <- some $ noneOf "#~@\n{"+ void $ char '{'+ (amount, units) <- quantity+ void $ char '}'+ return $ Ingredient (norm content) (case amount of "" -> "some"; x -> x) units)+ <|> fmap (\x -> Ingredient x "some" "") word)++cookware :: Parser Content+cookware = char '#' *> hspace *>+ (try (do+ content <- some $ noneOf "#~@\n{"+ (amount, _) <- char '{' *> quantity <* char '}'+ return $ Cookware (norm content) (case amount of "" -> "1"; x -> x))+ <|> fmap (\x -> Cookware x "1") word)++timer :: Parser Content+timer = char '~' *> hspace *>+ (try (do+ timerLabel <- many $ noneOf "#~@\n{"+ void $ char '{'+ (amount, units) <- quantity+ void $ char '}'+ return $ Timer (norm timerLabel) amount units)+ <|> fmap (\x -> Timer x "" "") word)++quantity :: Parser (String, String)+quantity = do+ amount <- try fraction <|> many (noneOf "\n%}")+ void $ hspace *> optional (char '%') <* hspace+ units <- many $ noneOf "\n}"+ return (norm amount, norm units)++fraction :: Parser String+fraction = do+ n1 <- some digitChar <* hspace <* char '/' <* hspace+ n2 <- some digitChar+ when (all (== '0') n2 || head n1 == '0') (fail "not a vaild fraction")+ return $ let x = printf "%.2f" ((read n1 / read n2) :: Double)+ in if last x == '0' then init x else x++word :: Parser String+word = some $ noneOf " \t\n"++-- removes extra whitespace+norm :: String -> String+norm = unwords . words
+ cooklang-hs.cabal view
@@ -0,0 +1,41 @@+cabal-version: 2.4+name: cooklang-hs+version: 0.1.0.0+synopsis: Parser for the Cook markup language+description: Parser for the Cook markup language. See more about Cook at https://cooklang.org/+license: MIT+author: Isaac Van Doren+maintainer: 69181572+isaacvando@users.noreply.github.com+copyright: Isaac Van Doren 2022-present+category: Parsing+homepage: https://github.com/isaacvando/cooklang-hs+bug-reports: https://github.com/isaacvando/cooklang-hs/issues+extra-doc-files: README.md++source-repository head+ type: git+ location: https://github.com/isaacvando/cooklang-hs.git++executable cooklang-hs+ main-is: Main.hs+ other-modules: Text.Cook+ build-depends: base+ , megaparsec + hs-source-dirs: .+ default-language: Haskell2010++library+ exposed-modules: Text.Cook+ build-depends: base ^>=4.16.4.0+ , megaparsec >= 9.1.0 && < 9.7+ default-language: Haskell2010++test-suite test-cooklang-hs+ type: exitcode-stdio-1.0+ main-is: Spec.hs+ other-modules: Text.Cook+ build-depends: base+ , megaparsec+ , hspec+ hs-source-dirs: .+ default-language: Haskell2010