Phase 2: Ingredient, cookware, and timer parsing

- Add ingredient parser: @name{quantity%unit}(preparation)
  - Handles single-word names without braces
  - Handles multi-word names with braces
  - Handles quantity (with unit) and preparation text
- Add cookware parser: #name or #multi word name{}
  - Uses shared pNameWithBraces helper
- Add timer parser: ~name{duration%unit} or ~{duration%unit}
  - Separate named/unnamed timer branches for correct brace handling
- Add quantity parsing: {2}, {1%kg}, {1/2%tbsp}, {=1%tsp}
  - Handles simple integers, fractions (1/2), compound (1,1/2)
- Extract shared pNameWithQuantity and pNameWithBraces helpers
- Update EasyPancakes tests to verify ingredient/cookware/timer parsing
- All 17 tests passing
This commit is contained in:
2026-05-18 22:51:24 -04:00
parent 30dd268f2c
commit e63aeedc39
2 changed files with 311 additions and 12 deletions
+161 -2
View File
@@ -8,6 +8,7 @@ module Roux.Parser (
parseCookFile,
) where
import Control.Monad (join)
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Text (Text)
import qualified Data.Text as T
@@ -100,7 +101,7 @@ stripSectionHeader t =
-- Step-level parser (Parsec)
-- ---------------------------------------------------------------------------
-- | Parse a single step block (plain text only for now).
-- | Parse a single step block.
pStepBlock :: Parser Step
pStepBlock = do
items <- P.many pStepItem
@@ -110,6 +111,10 @@ pStepBlock = do
pStepItem :: Parser StepItem
pStepItem =
pLineBreak
P.<|> P.try pRecipeRef
P.<|> P.try pIngredient
P.<|> P.try pCookware
P.<|> P.try pTimer
P.<|> pText
-- | End-of-line backslash line break.
@@ -120,10 +125,164 @@ pLineBreak = do
_ <- P.newline
return StepBreak
-- | Recipe reference: @./path/to/recipe{quantity}
pRecipeRef :: Parser StepItem
pRecipeRef = do
_ <- P.char '@'
_ <- P.char '.'
_ <- P.char '/'
path <- P.many (P.noneOf "{(\n\\")
mqty <- P.optionMaybe (P.between (P.char '{') (P.char '}') pQuantityBody)
return (StepRecipeRef (RecipeRef path (join mqty)))
-- | Ingredient: @name{quantity%unit}(preparation)
pIngredient :: Parser StepItem
pIngredient = do
_ <- P.char '@'
(name, qty) <- pNameWithQuantity
prep <- P.optionMaybe (P.try pPreparation)
return (StepIngredient (Ingredient (T.pack name) qty (fmap T.strip prep)))
{- | Parse a name optionally followed by @{quantity}@ braces.
Used by ingredients. First tries a multi-word name with braces
(e.g. @sea salt{1%pinch}@), then falls back to a single word.
-}
pNameWithQuantity :: Parser ([Char], Maybe Quantity)
pNameWithQuantity =
P.try
( do
name <- P.many (P.noneOf "{(\n\\")
_ <- P.char '{'
qty <- pQuantityBody
_ <- P.char '}'
return (name, qty)
)
P.<|> do
name <- P.many1 (P.alphaNum P.<|> P.oneOf ".-'")
return (name, Nothing)
{- | Parse a name optionally followed by empty @{}@ braces.
Used by cookware to delimit multi-word names (@#potato masher{}@).
-}
pNameWithBraces :: Parser [Char]
pNameWithBraces =
P.try
( do
name <- P.many (P.noneOf "{(\n\\")
_ <- P.char '{'
_ <- P.char '}'
return name
)
P.<|> P.many1 (P.alphaNum P.<|> P.oneOf ".-'")
-- | Parse preparation text inside parentheses: @name(diced)
pPreparation :: Parser Text
pPreparation = do
_ <- P.char '('
content <- P.many (P.noneOf ")")
_ <- P.char ')'
return (T.pack content)
{- | Parse the body of a @{...}@ quantity block.
Returns 'Nothing' for empty braces @{}@.
-}
pQuantityBody :: Parser (Maybe Quantity)
pQuantityBody = do
content <- P.many (P.noneOf "}")
case content of
[] -> return Nothing -- empty braces
_ -> case parseQuantityText (T.pack content) of
Just q -> return (Just q)
Nothing -> P.parserFail ("invalid quantity: " <> content)
-- | Parse a quantity text like @2@, @1%kg@, @=1%tsp@, @1\/2%tbsp@.
parseQuantityText :: Text -> Maybe Quantity
parseQuantityText t =
let (fixed, rest) = case T.uncons t of
Just ('=', rest') -> (True, T.strip rest')
_ -> (False, T.strip t)
(amountStr, unit) = case T.break (== '%') rest of
(a, u) | T.null u -> (a, Nothing)
(a, u) -> (a, Just (T.drop 1 u))
in case parseRational (T.strip amountStr) of
Just a -> Just (Quantity a unit fixed)
Nothing -> Nothing
-- | Parse a rational number from text. Handles @1\/2@, @3@, @1,1\/2@.
parseRational :: Text -> Maybe Rational
parseRational t =
case T.splitOn "," (T.strip t) of
-- Compound: "1,1/2" → 1 + 1/2 = 3/2
[a, b] -> (+) <$> parseSimpleRational a <*> parseSimpleRational b
-- Simple: "1/2" or "3"
[a] -> parseSimpleRational a
_ -> Nothing
-- | Parse a simple rational: @1\/2@, @3@, @0.5@.
parseSimpleRational :: Text -> Maybe Rational
parseSimpleRational t =
case T.splitOn "/" t of
-- Fraction: "1/2"
[num, den] ->
case (readMaybeInt num, readMaybeInt den) of
(Just n, Just d) | d /= 0 -> Just (toRational n / toRational d)
_ -> Nothing
-- Whole or decimal: "3", "0.5"
[a] ->
case readMaybeInt a of
Just n -> Just (toRational n)
Nothing -> case readMaybeDouble a of
Just d -> Just (toRational d)
Nothing -> Nothing
_ -> Nothing
-- | Try to read a 'Text' as an 'Int'.
readMaybeInt :: Text -> Maybe Int
readMaybeInt t = case reads (T.unpack t) of
[(n, "")] -> Just n
_ -> Nothing
-- | Try to read a 'Text' as a 'Double'.
readMaybeDouble :: Text -> Maybe Double
readMaybeDouble t = case reads (T.unpack t) of
[(n, "")] -> Just n
_ -> Nothing
-- | Cookware: #name or #multi word name{}
pCookware :: Parser StepItem
pCookware = P.char '#' *> (StepCookware . Cookware . T.pack <$> pNameWithBraces)
-- | Timer: ~name{duration%unit} or ~{duration%unit}
pTimer :: Parser StepItem
pTimer = do
_ <- P.char '~'
(name, dur) <- P.try pNamedTimer P.<|> pUnnamedTimer
_ <- P.char '}'
return (StepTimer (Timer (fmap T.pack name) dur))
where
pNamedTimer = do
n <- P.many1 (P.noneOf "{")
_ <- P.char '{'
d <- pDurationBody
return (Just n, d)
pUnnamedTimer = do
_ <- P.char '{'
d <- pDurationBody
return (Nothing, d)
pDurationBody = do
content <- P.many (P.noneOf "}")
case content of
[] -> P.parserFail "empty timer duration"
_ -> case parseQuantityText (T.pack content) of
Just q -> return (Duration (quantityAmount q) (quantityUnit q))
Nothing -> P.parserFail ("invalid duration: " <> content)
-- | Plain text (everything up to a special character or newline).
pText :: Parser StepItem
pText = do
chars <- P.many1 (P.noneOf ['\\', '\n'])
chars <- P.many1 (P.noneOf ['\\', '\n', '@', '#', '~'])
return (StepText (T.pack chars))
-- ---------------------------------------------------------------------------