-- Copyright Isaac Van Doren 2022-present module Text.Cook (Result (..), Content (..), Category (..), Item (..), Step, Metadata, parseCook) where import Control.Monad (void, when) import Data.Void (Void) import Text.Megaparsec (MonadParsec (try), Parsec, errorBundlePretty, many, noneOf, optional, parse, some, (<|>)) import Text.Megaparsec.Char (char, digitChar, hspace, newline, space, string) import Text.Printf (printf) type Parser = Parsec Void String type Metadata = (String, String) type Step = [Content] -- | Represents the different types of content that can be appear in a recipe. The strings may be empty. data Content = -- | Plain text Text String | -- | An ingredient annotation with label, quantity, and unit Ingredient String String String | -- | A timer annotation with label, quantity, and unit Timer String String String | -- | A cookware annotation with label and quantity Cookware String String deriving (Show, Eq) -- | An item on a shopping list data Item = Item String String deriving (Show, Eq) -- | A section on a shopping list data Category = Category String [Item] deriving (Show, Eq) -- | The result of parsing a .cook file data Result = -- | A recipe Recipe [Metadata] [Step] | -- | A shopping list 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