Phase 1: Model update + file-level parser (text-only steps)

- Update Roux.Types: add SectionBodyItem (SecStep/SecComment/SecNote),
  StepEndComment, remove StepNote (notes are now section-level)
- Rewrite Roux.Parser using Parsec for inline elements, text-splitting
  for file-level structure
- Handle full-line -- comments, > notes, = Name = section headers
- Handle = Name (w/ and w/o closing =), == Name == variations
- Handle end-of-line backslash line breaks within steps
- Update tests to match new model + 3 EasyPancakes integration tests
- Add expectTextStep helper at module level
This commit is contained in:
2026-05-18 22:41:38 -04:00
parent 9759a4c3a3
commit 30dd268f2c
7 changed files with 417 additions and 52 deletions
+133 -13
View File
@@ -1,13 +1,14 @@
module Roux.ParserSpec (spec) where
import Data.List.NonEmpty (NonEmpty ((:|)))
import Test.Hspec (Spec, describe, it, shouldBe)
import qualified Data.List.NonEmpty as NE
import Test.Hspec (Expectation, Spec, describe, expectationFailure, it, shouldBe)
import Roux.Parser (parseCookFile)
import Roux.Types
spec :: Spec
spec =
spec = do
describe "parseCookFile" $ do
it "parses a single step of plain text" $ do
let input = "Hello, world."
@@ -18,10 +19,9 @@ spec =
, recipeSections =
Section
{ sectionName = Nothing
, sectionSteps =
Step
{ unStep = [StepText "Hello, world."]
}
, sectionBody =
SecStep
(Step [StepText "Hello, world."])
:| []
}
:| []
@@ -37,9 +37,10 @@ spec =
, recipeSections =
Section
{ sectionName = Nothing
, sectionSteps =
Step{unStep = [StepText "Wash the potatoes."]}
:| [ Step{unStep = [StepText "Peel and dice them."]}
, sectionBody =
SecStep (Step [StepText "Wash the potatoes."])
:| [ SecStep
(Step [StepText "Peel and dice them."])
]
}
:| []
@@ -54,13 +55,13 @@ spec =
, recipeSections =
Section
{ sectionName = Nothing
, sectionSteps = Step{unStep = []} :| []
, sectionBody = SecStep (Step []) :| []
}
:| []
}
parseCookFile "" `shouldBe` expected
it "trims leading and trailing whitespace from each step" $ do
it "trims leading and trailing whitespace from each block" $ do
let input = "\n\n Mix well. \n\n"
expected =
Right
@@ -69,9 +70,128 @@ spec =
, recipeSections =
Section
{ sectionName = Nothing
, sectionSteps =
Step{unStep = [StepText "Mix well."]} :| []
, sectionBody =
SecStep (Step [StepText "Mix well."]) :| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses a full-line comment" $ do
let input = "-- TODO add source"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody = SecComment "TODO add source" :| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses a note block" $ do
let input = "> Don't burn the roux!"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody = SecNote "Don't burn the roux!" :| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses a section header" $ do
let input = "= Dough"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Just "Dough"
, sectionBody = SecStep (Step []) :| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "groups items before a section header into an unnamed section" $ do
let input = "Mix flour and water.\n\n= Filling\n\nCombine cheese and spinach."
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep
(Step [StepText "Mix flour and water."])
:| []
}
:| [ Section
{ sectionName = Just "Filling"
, sectionBody =
SecStep
(Step [StepText "Combine cheese and spinach."])
:| []
}
]
}
parseCookFile input `shouldBe` expected
describe "EasyPancakes example" $ do
let pancakeInput =
"-- TODO add source\n"
<> "\n"
<> "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.\n"
<> "\n"
<> "Pour into a #bowl and leave to stand for ~{15%minutes}.\n"
<> "\n"
<> "Melt the butter (or a drizzle of @oil if you want to be a bit healthier) in a #large non-stick frying pan{} on a medium heat, then tilt the pan so the butter coats the surface.\n"
<> "\n"
<> "Pour in 1 ladle of batter and tilt again, so that the batter spreads all over the base, then cook for 1 to 2 minutes, or until it starts to come away from the sides.\n"
<> "\n"
<> "Once golden underneath, flip the pancake over and cook for 1 further minute, or until cooked through.\n"
<> "\n"
<> "Serve straightaway with your favourite topping.\n"
it "parses into 1 section with 7 body items" $ do
case parseCookFile pancakeInput of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let section = NE.head (recipeSections recipe)
body = NE.toList (sectionBody section)
length body `shouldBe` 7
it "first body item is the full-line comment" $ do
case parseCookFile pancakeInput of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let body = NE.toList (sectionBody (NE.head (recipeSections recipe)))
case body of
(SecComment t : _) -> t `shouldBe` "TODO add source"
other -> expectationFailure ("expected SecComment first, got " <> show other)
it "remaining 6 items are text-only steps" $ do
case parseCookFile pancakeInput of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let body = NE.toList (sectionBody (NE.head (recipeSections recipe)))
case body of
(_comment : steps) -> mapM_ expectTextStep steps
[] -> expectationFailure "empty body"
-- | Check that a body item is a 'SecStep' containing a single 'StepText'.
expectTextStep :: SectionBodyItem -> Expectation
expectTextStep (SecStep (Step [StepText _])) = pure ()
expectTextStep other =
expectationFailure
("expected SecStep with single StepText, got " <> show other)