From ffb1931c7d0aa89099e470620f237ad13d2c11f3 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Mon, 18 May 2026 23:02:53 -0400 Subject: [PATCH] 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 --- src/Roux/Parser.hs | 30 ++++++++++++++++++++++++------ test/Roux/ParserSpec.hs | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/Roux/Parser.hs b/src/Roux/Parser.hs index 120caa8..6389c9e 100644 --- a/src/Roux/Parser.hs +++ b/src/Roux/Parser.hs @@ -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 = diff --git a/test/Roux/ParserSpec.hs b/test/Roux/ParserSpec.hs index 13169ac..a3e34fe 100644 --- a/test/Roux/ParserSpec.hs +++ b/test/Roux/ParserSpec.hs @@ -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}."