Fix parser for personal recipes: newlines, ranges, &, markdown-style headings

Three categories of fixes:

1. Multi-line steps: removed \n from pText exclusion list so newlines
   within a step are consumed as regular text
2. Range timers (e.g. ~{14-18%minutes}): handle hyphen-separated
   ranges in parseSimpleRational by taking the lower value
3. & in ingredient names: added & to pSingleNameChar and
   pMultiNameChar char sets
4. Markdown-style # headings: added pAnyChar fallback so # followed
   by invalid cookware name falls through to text consumption
5. Note detection: tightened > check to require >  (with space)
   or > followed by non->, preventing >> metadata from being
   treated as notes
This commit is contained in:
2026-05-19 12:58:43 -04:00
parent 1e150e703e
commit c55a54796a
7 changed files with 133 additions and 25 deletions
+31
View File
@@ -0,0 +1,31 @@
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import System.Directory (listDirectory)
import System.Environment (getArgs)
import System.FilePath (takeExtension, (</>))
import qualified Data.Text.IO as TIO
import Roux.Parser (parseCookFile)
main :: IO ()
main = do
args <- getArgs
let recipeDir = case args of
(d : _) -> d
[] -> error "Usage: check-recipes <recipe-directory>"
entries <- listDirectory recipeDir
let cookFiles = filter ((== ".cook") . takeExtension) (map (recipeDir </>) entries)
putStrLn $ "Found " ++ show (length cookFiles) ++ " recipes in " ++ recipeDir
results <- mapM testParse cookFiles
let failures = [(name, err) | (name, Just err) <- results]
ok = length results - length failures
mapM_ (\(name, err) -> putStrLn $ "\n" ++ name ++ ":\n " ++ err) failures
putStrLn $ "\nOK: " ++ show ok ++ " / FAIL: " ++ show (length failures)
where
testParse fp = do
let name = reverse . takeWhile (/= '/') . reverse $ fp
content <- TIO.readFile fp
case parseCookFile content of
Left err -> pure (name, Just err)
Right _ -> pure (name, Nothing)
+8
View File
@@ -57,6 +57,14 @@ executables:
- optparse-applicative - optparse-applicative
- roux-server - roux-server
- unix - unix
check-recipes:
main: Main.hs
source-dirs: app/check-recipes
dependencies:
- directory
- filepath
- roux-server
- text
tests: tests:
roux-server-test: roux-server-test:
+1
View File
@@ -0,0 +1 @@
/work/personal/roux/personal-recipes
+32
View File
@@ -51,6 +51,38 @@ library
, warp , warp
default-language: Haskell2010 default-language: Haskell2010
executable check-recipes
main-is: Main.hs
other-modules:
Paths_roux_server
autogen-modules:
Paths_roux_server
hs-source-dirs:
app/check-recipes
default-extensions:
DerivingStrategies
LambdaCase
OverloadedStrings
TupleSections
ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
build-depends:
base >=4.7 && <5
, blaze-html
, blaze-markup
, bytestring
, containers
, directory
, filepath
, http-types
, optparse-applicative
, parsec
, roux-server
, text
, time
, wai
, warp
default-language: Haskell2010
executable roux-server executable roux-server
main-is: Main.hs main-is: Main.hs
other-modules: other-modules:
+26 -11
View File
@@ -146,8 +146,11 @@ classifyBlock :: Text -> Either String RawBlock
classifyBlock block classifyBlock block
| "--" `T.isPrefixOf` trimmed = | "--" `T.isPrefixOf` trimmed =
Right (RawComment (T.strip (T.drop 2 trimmed))) Right (RawComment (T.strip (T.drop 2 trimmed)))
| ">" `T.isPrefixOf` trimmed = | T.isPrefixOf "> " trimmed =
Right (RawNote (T.strip (T.drop 1 trimmed))) Right (RawNote (T.strip (T.drop 2 trimmed)))
| Just rest <- T.stripPrefix ">" trimmed
, not (T.isPrefixOf ">" rest) =
Right (RawNote (T.strip rest))
| Just name <- stripSectionHeader trimmed = | Just name <- stripSectionHeader trimmed =
Right (RawSectionHeader name) Right (RawSectionHeader name)
| otherwise = | otherwise =
@@ -200,6 +203,7 @@ pStepItem =
P.<|> P.try pCookware P.<|> P.try pCookware
P.<|> P.try pTimer P.<|> P.try pTimer
P.<|> pText P.<|> pText
P.<|> pAnyChar
-- | End-of-line backslash line break. -- | End-of-line backslash line break.
pLineBreak :: Parser StepItem pLineBreak :: Parser StepItem
@@ -234,11 +238,11 @@ pIngredient = do
-- | Characters allowed in names that may include spaces (before braces). -- | Characters allowed in names that may include spaces (before braces).
pMultiNameChar :: Parser Char pMultiNameChar :: Parser Char
pMultiNameChar = P.alphaNum P.<|> P.oneOf " .-'" pMultiNameChar = P.alphaNum P.<|> P.oneOf " .-'&"
-- | Characters allowed in single-word names (no braces). -- | Characters allowed in single-word names (no braces).
pSingleNameChar :: Parser Char pSingleNameChar :: Parser Char
pSingleNameChar = P.alphaNum P.<|> P.oneOf ".-'" pSingleNameChar = P.alphaNum P.<|> P.oneOf ".-'&"
{- | Parse a name optionally followed by @{quantity}@ braces. {- | Parse a name optionally followed by @{quantity}@ braces.
@@ -343,13 +347,20 @@ parseSimpleRational t =
case (readMaybeInt num, readMaybeInt den) of case (readMaybeInt num, readMaybeInt den) of
(Just n, Just d) | d /= 0 -> Just (toRational n / toRational d) (Just n, Just d) | d /= 0 -> Just (toRational n / toRational d)
_ -> Nothing _ -> Nothing
-- Whole or decimal: "3", "0.5" -- Range or whole/decimal: "14-18" or "3" or "0.5"
[a] -> [a] ->
case readMaybeInt a of -- Handle range "14-18" -> use the lower value
Just n -> Just (toRational n) case T.splitOn "-" a of
Nothing -> case readMaybeDouble a of [first, _rest] ->
Just d -> Just (toRational d) case readMaybeInt (T.strip first) of
Nothing -> Nothing Just n -> Just (toRational n)
Nothing -> Nothing
_ ->
case readMaybeInt a of
Just n -> Just (toRational n)
Nothing -> case readMaybeDouble a of
Just d -> Just (toRational d)
Nothing -> Nothing
_ -> Nothing _ -> Nothing
-- | Try to read a 'Text' as an 'Int'. -- | Try to read a 'Text' as an 'Int'.
@@ -410,9 +421,13 @@ pTimer = do
-- | Plain text (everything up to a special character, newline, or comment start). -- | Plain text (everything up to a special character, newline, or comment start).
pText :: Parser StepItem pText :: Parser StepItem
pText = do pText = do
chars <- P.many1 (P.notFollowedBy (P.string "--") *> P.notFollowedBy (P.string "[-") *> P.noneOf ['\\', '\n', '@', '#', '~']) chars <- P.many1 (P.notFollowedBy (P.string "--") *> P.notFollowedBy (P.string "[-") *> P.noneOf ['\\', '@', '#', '~'])
return (StepText (T.pack chars)) return (StepText (T.pack chars))
-- | Fallback: consume any single character as plain text.
pAnyChar :: Parser StepItem
pAnyChar = StepText . T.singleton <$> P.anyChar
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Recipe construction -- Recipe construction
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
+27
View File
@@ -0,0 +1,27 @@
module Main where
import System.Directory (listDirectory)
import System.FilePath (takeExtension)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Roux.Parser (parseCookFile)
main :: IO ()
main = do
entries <- listDirectory "/work/personal-recipes"
let cookFiles = filter ((== ".cook") . takeExtension) (map ("/work/personal-recipes/" ++) entries)
results <- mapM testParse cookFiles
let failures = [(name, err) | Right (name, err) <- results]
successes = length [() | Left name <- results]
putStrLn $ "\n=== Summary ==="
putStrLn $ "Total: " ++ show (length results)
putStrLn $ "OK: " ++ show successes
putStrLn $ "Failures: " ++ show (length failures)
mapM_ (\(name, err) -> putStrLn $ "\n " ++ name ++ ":\n " ++ err) failures
where
testParse fp = do
let name = reverse . takeWhile (/= '/') . reverse $ fp
content <- TIO.readFile fp
case parseCookFile content of
Left err -> pure (Right (name, err))
Right _ -> pure (Left name)
+8 -14
View File
@@ -335,7 +335,6 @@ spec = do
let body = NE.toList (sectionBody (NE.head (recipeSections recipe))) let body = NE.toList (sectionBody (NE.head (recipeSections recipe)))
case body of case body of
(_ : SecStep (Step items) : _) -> do (_ : SecStep (Step items) : _) -> do
-- "Crack the @eggs{3} into a blender, then add the @flour{125%g}, @milk{250%ml} and @sea salt{1%pinch}, and blitz until smooth."
let expected = let expected =
[ StepText "Crack the " [ StepText "Crack the "
, StepIngredient (Ingredient "eggs" (Just (Quantity 3 Nothing False)) Nothing) , StepIngredient (Ingredient "eggs" (Just (Quantity 3 Nothing False)) Nothing)
@@ -348,9 +347,7 @@ spec = do
, StepText ", and blitz until smooth." , StepText ", and blitz until smooth."
] ]
items `shouldBe` expected items `shouldBe` expected
_ -> _ -> expectationFailure ("expected SecStep as second body item, got body=" <> show body)
expectationFailure
("expected SecStep as second body item, got body=" <> show body)
it "second step parses cookware and timer" $ do it "second step parses cookware and timer" $ do
case parseCookFile pancakeInput of case parseCookFile pancakeInput of
@@ -359,7 +356,6 @@ spec = do
let body = NE.toList (sectionBody (NE.head (recipeSections recipe))) let body = NE.toList (sectionBody (NE.head (recipeSections recipe)))
case body of case body of
(_ : _ : SecStep (Step items) : _) -> do (_ : _ : SecStep (Step items) : _) -> do
-- "Pour into a #bowl and leave to stand for ~{15%minutes}."
let expected = let expected =
[ StepText "Pour into a " [ StepText "Pour into a "
, StepCookware (Cookware "bowl") , StepCookware (Cookware "bowl")
@@ -368,9 +364,7 @@ spec = do
, StepText "." , StepText "."
] ]
items `shouldBe` expected items `shouldBe` expected
_ -> _ -> expectationFailure ("expected SecStep as third body item, got body=" <> show body)
expectationFailure
("expected SecStep as third body item, got body=" <> show body)
describe "Quantities" $ do describe "Quantities" $ do
it "parses compound quantity (1,1/2) with unit attached" $ do it "parses compound quantity (1,1/2) with unit attached" $ do
@@ -379,9 +373,9 @@ spec = do
Left err -> expectationFailure ("parse failed: " <> err) Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do Right recipe -> do
let items = stepItems recipe let items = stepItems recipe
items items `shouldBe`
`shouldBe` [ StepIngredient (Ingredient "water" (Just (Quantity (3 % 2) (Just "cups") False)) Nothing) [ StepIngredient (Ingredient "water" (Just (Quantity (3 % 2) (Just "cups") False)) Nothing)
] ]
it "parses quantity with trailing unit (no %)" $ do it "parses quantity with trailing unit (no %)" $ do
let input = "@gelatine{3tsp}" let input = "@gelatine{3tsp}"
@@ -389,9 +383,9 @@ spec = do
Left err -> expectationFailure ("parse failed: " <> err) Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do Right recipe -> do
let items = stepItems recipe let items = stepItems recipe
items items `shouldBe`
`shouldBe` [ StepIngredient (Ingredient "gelatine" (Just (Quantity 3 (Just "tsp") False)) Nothing) [ StepIngredient (Ingredient "gelatine" (Just (Quantity 3 (Just "tsp") False)) Nothing)
] ]
describe "Recipe references" $ do describe "Recipe references" $ do
it "parses a recipe reference" $ do it "parses a recipe reference" $ do