Phase 5: Compound quantities, trailing-unit format, and edge cases

- Add splitAmountUnit to parse quantities with unit attached without
  % separator (e.g., @water{1,1/2cups}, @gelatine{3tsp})
- Add isNumericChar helper for detecting numeric quantity characters
- Add compound quantity test (1,1/2 cups = 3/2)
- Add no-separator unit test (3tsp)
- All 26 tests passing, all 4 example files parse correctly
This commit is contained in:
2026-05-18 23:02:53 -04:00
parent c68d80e9c1
commit ffb1931c7d
2 changed files with 46 additions and 6 deletions
+24 -6
View File
@@ -9,6 +9,7 @@ module Roux.Parser (
) where
import Control.Monad (join)
import Data.Char (isDigit)
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Text (Text)
import qualified Data.Text as T
@@ -200,19 +201,36 @@ pQuantityBody = do
Just q -> return (Just q)
Nothing -> P.parserFail ("invalid quantity: " <> content)
-- | Parse a quantity text like @2@, @1%kg@, @=1%tsp@, @1\/2%tbsp@.
{- | Parse a quantity text like @2@, @1%kg@, @=1%tsp@, @1\/2%tbsp@, or
@1,1\/2cups@ (unit without @%@ separator).
-}
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)
(amountPart, unitPart) = case T.break (== '%') rest of
(a, u)
| T.null u -> splitAmountUnit a
| otherwise -> (a, Just (T.strip (T.drop 1 u)))
in case parseRational (T.strip amountPart) of
Just a -> Just (Quantity a unitPart fixed)
Nothing -> Nothing
{- | Split text into amount and optional trailing unit (no @%@ separator).
Handles @"1,1\/2cups"@ → @("1,1\/2", Just "cups")@.
-}
splitAmountUnit :: Text -> (Text, Maybe Text)
splitAmountUnit t =
let (numeric, trailing) = T.span isNumericChar t
in if T.null trailing
then (numeric, Nothing)
else (numeric, Just (T.strip trailing))
-- | Characters that can appear in a quantity number.
isNumericChar :: Char -> Bool
isNumericChar c = isDigit c || c `elem` ['.', ',', '/', ' ', '-', '+']
-- | Parse a rational number from text. Handles @1\/2@, @3@, @1,1\/2@.
parseRational :: Text -> Maybe Rational
parseRational t =
+22
View File
@@ -2,6 +2,7 @@ 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)
@@ -371,6 +372,27 @@ spec = do
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}."