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}."