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:
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/work/personal/roux/personal-recipes
|
||||||
@@ -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
@@ -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
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user