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
+12
View File
@@ -0,0 +1,12 @@
-- make caramel
Crack the @egg yoke{1} into a bowl, then add the @condenced milk{125%g} and @instant coffee{3tsp}, and mix until a nice caramel is formed. Do not allow to bubble.
Pour into a #bowl and leave to cool down for ~{15%minutes}.
heat @water{1,1/2cups} and @gelatine{3tsp} then stir till gelatine is dissolved completely.Once gelatine is colled down mix it with the caramel.
Beat @eggwhite{1} until formy. Then mix the whites bit by bit to the the gelatinecaramel mixture.
Pour into a #puddingbowl and leave in fridge for 2 hours minimum. Add some @cashew{60%g} and enjoy!
+13
View File
@@ -0,0 +1,13 @@
-- TODO add source
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.
Pour into a #bowl and leave to stand for ~{15%minutes}.
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.
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.
Once golden underneath, flip the pancake over and cook for 1 further minute, or until cooked through.
Serve straightaway with your favourite topping.
+19
View File
@@ -0,0 +1,19 @@
-- TODO add source
Mix together @oyster sauce{1%tbsp}, @soy sauce{5%tbsp} and @sesame oil{5%tsp}, set aside.
Heat @peanut oil{1%tbsp} in a wok or heavy based skillet/fry pan over medium heat. Add @eggs{2%items} and cook until scrambled, then remove onto plate.
Increase heat to high and add chopped @bacon{75%cup}, excess fat trimmed. Cook until golden, then remove onto plate with egg - about 2 minutes.
Drain off excess bacon fat.
Add remaining @peanut oil{2%tbsp}, minced @garlic{2%gloves} and @ginger{3%tsp}. Return wok to stove on high heat. Stir fry as it comes up to heat, dont let it burn. When garlic starts to sizzle, add finally chopped @onion{2%medium} and stir fry for 2 minutes until golden.
Add @Chinese cooking wine{1%tbsp} and @sugar{2%tsp} and let it simmer rapidly, stirring, for 20 seconds, until mostly evaporated.
Add @prawns{150%g} and stir fry for 1 minute to heat through if pre-cooked, cook longer to cook if raw.
Add refrigerated overnight @cooked rice{3%cups}, Sauce, eggs, bacon and thinly sliced @scallions{5%items}. Stir fry for 2 minutes until rice is hot - around 2 minutes.
Transfer to serving plate, sprinkle with thinly sliced @scallions{2%items} and serve.
+27
View File
@@ -0,0 +1,27 @@
-- TODO add source
Zero step is cook @corn beef{1%kg}. Put into a large pan and simmer for ~{2%hours}.
The first step is to cook your @potatoes{3%medium} and @carrots{3%medium}. I used a steamer, but you can always go the traditional route and boil them. In either case, peel the carrots but not the potatoes.
Steam the potatoes for ~{30%minutes} to start with, and then add the peeled carrots. Continue steaming for 10-15 more minutes, or until the potatoes and carrots are firm but tender when poked.
Meanwhile, cook your @frozen peas{1%cup} according to package directions. I use the kind that can be steamed in the package in the microwave. When they are done, set them aside to cool.
When the potatoes and carrots are done, allow them to cool to the point that you can handle them easily.
Peel the potatoes. Using your fingers or the back of a knife, gently scrape the thin layer of skin off of the potatoes. Dice them into 1cm cube-ish shapes and put them into a medium serving bowl.
Next, dice your carrots. I've heard it said that a Soviet housewife could be judged on her housekeeping skills by how finely she could dice vegetables for her soups and salads. I, however, won't judge you. In fact, if you chop your potatoes and carrots a little larger, I would probably even thank you. I happen to like chunky salads.
Toss the carrots and a cup of steamed peas into the bowl with the potatoes.
Peel and dice your hardboiled @eggs{4}. Again, I know some like to have their salads with finely diced ingredients, but I don't. So dice them however you like.
Chop @pickles{6} finely. I used small snacking dill pickles, so I needed to use six of them. If you have larger pickles, try using three and see if that is enough for you.
Add the meat if using and mix everything together gently before you add the @mayonnaise{1%cup}.
Stir in one cup of mayo to start with, and add more if you think that the salad needs more binding together.
Cover the salad and chill for at least one hour or overnight to allow the flavors to come together. And of course, garnish with finelly chopped @dill{1%tbsp}. This is a Russian salad, after all.
+184 -23
View File
@@ -1,8 +1,8 @@
{- | Parser for Cooklang (.cook) recipe files.
Currently a minimal placeholder that splits input into paragraphs and
treats them as plain-text steps. Proper inline parsing (ingredients,
cookware, timers, …) and metadata support will be added incrementally.
Parses the full file structure: full-line comments, notes, section headers,
and steps. Inline element parsing (ingredients, cookware, timers, …) is
added incrementally in follow-up commits.
-}
module Roux.Parser (
parseCookFile,
@@ -11,33 +11,194 @@ module Roux.Parser (
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Text (Text)
import qualified Data.Text as T
import qualified Text.Parsec as P
import Text.Parsec.Text (Parser)
import Roux.Types
{- | Parse a complete @.cook@ file into a 'Recipe'.
-- ---------------------------------------------------------------------------
-- Internal raw-block types (parser only)
-- ---------------------------------------------------------------------------
Returns 'Left' with an error message on invalid input (currently never
— the placeholder always succeeds).
{- | The result of classifying a top-level block before building the final
'SectionBodyItem' tree.
-}
data RawBlock
= RawStep !Step
| RawComment !Text
| RawNote !Text
| RawSectionHeader !Text
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Entry point
-- ---------------------------------------------------------------------------
-- | Parse a complete @.cook@ file into a 'Recipe'.
parseCookFile :: Text -> Either String Recipe
parseCookFile input =
let paragraphs = filter (not . T.null) (map T.strip (T.splitOn "\n\n" (T.strip input)))
in case paragraphs of
let trimmed = T.strip input
rawBlocks = splitByBlankLines trimmed
nonEmpty = filter (not . T.null) rawBlocks
in case nonEmpty of
[] ->
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections = Section Nothing (Step [] :| []) :| []
}
(p : ps) ->
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections = Section Nothing (stepFromText p :| fmap stepFromText ps) :| []
}
Right emptyRecipe
blocks ->
case traverse classifyBlock blocks of
Left err -> Left err
Right items -> Right (buildRecipe items)
{- | Turn a paragraph of text into a single 'Step' containing nothing but
'StepText'. This is a placeholder until inline parsing is implemented.
-- ---------------------------------------------------------------------------
-- File-level structure
-- ---------------------------------------------------------------------------
{- | Split text into blocks separated by one or more blank lines,
preserving each block's internal newlines.
-}
stepFromText :: Text -> Step
stepFromText t = Step [StepText t]
splitByBlankLines :: Text -> [Text]
splitByBlankLines = map T.strip . T.splitOn "\n\n"
-- | Classify a single top-level block.
classifyBlock :: Text -> Either String RawBlock
classifyBlock block
| "--" `T.isPrefixOf` trimmed =
Right (RawComment (T.strip (T.drop 2 trimmed)))
| ">" `T.isPrefixOf` trimmed =
Right (RawNote (T.strip (T.drop 1 trimmed)))
| Just name <- stripSectionHeader trimmed =
Right (RawSectionHeader name)
| otherwise =
case P.parse (pStepBlock <* P.eof) "" trimmed of
Left err -> Left (show err)
Right step -> Right (RawStep step)
where
trimmed = T.strip block
{- | Try to extract a section name from header markup.
Recognises @= Name =@, @= Name@, @== Name ==@, etc. One or more @=@
signs on each side, with optional whitespace inside.
-}
stripSectionHeader :: Text -> Maybe Text
stripSectionHeader t =
let stripped = T.strip t
in case T.break (/= '=') stripped of
-- At least one leading =
(leadingEq, afterEq)
| not (T.null leadingEq) ->
let afterSpace = T.strip afterEq
in case T.break (== '=') afterSpace of
-- No trailing = signs -> the whole thing is the name
(name, trailingEqs)
| T.all (== '=') (T.strip trailingEqs)
, not (T.null (T.strip name)) ->
Just (T.strip name)
_ -> Nothing
_ -> Nothing
-- ---------------------------------------------------------------------------
-- Step-level parser (Parsec)
-- ---------------------------------------------------------------------------
-- | Parse a single step block (plain text only for now).
pStepBlock :: Parser Step
pStepBlock = do
items <- P.many pStepItem
return (Step items)
-- | Parse one inline item within a step.
pStepItem :: Parser StepItem
pStepItem =
pLineBreak
P.<|> pText
-- | End-of-line backslash line break.
pLineBreak :: Parser StepItem
pLineBreak = do
_ <- P.char '\\'
P.optional (P.char '\r')
_ <- P.newline
return StepBreak
-- | Plain text (everything up to a special character or newline).
pText :: Parser StepItem
pText = do
chars <- P.many1 (P.noneOf ['\\', '\n'])
return (StepText (T.pack chars))
-- ---------------------------------------------------------------------------
-- Recipe construction
-- ---------------------------------------------------------------------------
{- | Build a 'Recipe' from classified blocks, grouping into sections at
'RawSectionHeader' boundaries.
-}
buildRecipe :: [RawBlock] -> Recipe
buildRecipe items =
case sections of
[] -> emptyRecipe
(s : ss) ->
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections = s :| ss
}
where
sections = groupSections items
{- | Group a flat list of 'RawBlock' values into sections, splitting at
'RawSectionHeader' boundaries.
-}
groupSections :: [RawBlock] -> [Section]
groupSections [] = []
groupSections items =
case span (not . isSectionHeader) items of
-- No section header: everything goes into an unnamed section
(body, []) ->
[Section Nothing (makeNonEmpty (map rawToBody body))]
-- Items before the first header → unnamed section, then named
(before, (RawSectionHeader name) : rest) ->
let pre =
if null before
then []
else [Section Nothing (makeNonEmpty (map rawToBody before))]
named = collectNamedSection name rest
in pre ++ named
-- Unreachable: 'span' never returns a non-header as the first item
-- of the second component, but GHC doesn't know that.
_ -> error "groupSections: impossible"
-- | Is this block a section header?
isSectionHeader :: RawBlock -> Bool
isSectionHeader (RawSectionHeader _) = True
isSectionHeader _ = False
-- | Collect a named section: from the header to the next header (or end).
collectNamedSection :: Text -> [RawBlock] -> [Section]
collectNamedSection name items =
case span (not . isSectionHeader) items of
(body, []) ->
[Section (Just name) (makeNonEmpty (map rawToBody body))]
(body, (RawSectionHeader nextName) : rest) ->
Section (Just name) (makeNonEmpty (map rawToBody body))
: collectNamedSection nextName rest
_ -> error "collectNamedSection: impossible"
-- | Convert a 'RawBlock' to a 'SectionBodyItem'.
rawToBody :: RawBlock -> SectionBodyItem
rawToBody (RawStep s) = SecStep s
rawToBody (RawComment t) = SecComment t
rawToBody (RawNote t) = SecNote t
rawToBody (RawSectionHeader _) = error "rawToBody: section header should not appear here"
-- | Convert a list to 'NonEmpty', using a single empty step as default.
makeNonEmpty :: [SectionBodyItem] -> NonEmpty SectionBodyItem
makeNonEmpty [] = SecStep (Step []) :| []
makeNonEmpty (x : xs) = x :| xs
-- | An empty recipe (no metadata, one unnamed section with one empty step).
emptyRecipe :: Recipe
emptyRecipe =
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections = Section Nothing (SecStep (Step []) :| []) :| []
}
+29 -16
View File
@@ -10,28 +10,34 @@ Recipe
Section
Maybe Text — optional section name ("Dough", "Filling")
NonEmpty Step — at least one step per section
NonEmpty SectionBodyItem — interleaved steps, comments, and notes
SectionBodyItem
SecStep Step — a cooking step (paragraph)
SecComment Text — full-line @--@ comment
SecNote Text — @>@ note / commentary
Step
[StepItem] — sequence of inline elements
[StepItem]
StepItem
StepText — plain text
StepIngredient — \@name{amount%unit}(preparation)
StepCookware — \#name{}
StepTimer — \~name{duration%unit}
StepRecipeRef — \@.\/path\/to\/recipe{amount}
StepNote — \> note / commentary
StepComment — [- comment -]
StepBreak — \\ at end of line
StepText — plain text
StepIngredient — \@name{amount%unit}(preparation)
StepCookware — \#name{}
StepTimer — \~name{duration%unit}
StepRecipeRef — \@.\/path\/to\/recipe{amount}
StepEndComment — @-- end of line comment@
StepComment — @[- block comment -]@
StepBreak — \\ at end of line
@
-}
module Roux.Types (
-- * Recipe
Recipe (..),
-- * Sections and steps
-- * Sections and body items
Section (..),
SectionBodyItem (..),
Step (..),
-- * Inline elements
@@ -67,18 +73,25 @@ data Recipe = Recipe
deriving stock (Eq, Show)
-- ---------------------------------------------------------------------------
-- Sections and steps
-- Sections and body items
-- ---------------------------------------------------------------------------
{- | A named or unnamed section within a recipe. Each section contains one
or more cooking steps.
{- | A named or unnamed section within a recipe. Each section contains
interleaved steps, full-line comments, and notes.
-}
data Section = Section
{ sectionName :: !(Maybe Text)
, sectionSteps :: !(NonEmpty Step)
, sectionBody :: !(NonEmpty SectionBodyItem)
}
deriving stock (Eq, Show)
-- | An item that appears at the top-level of a section (between blank lines).
data SectionBodyItem
= SecStep !Step
| SecComment !Text
| SecNote !Text
deriving stock (Eq, Show)
{- | A single cooking step — one paragraph of text in the Cooklang file,
separated from other steps by a blank line.
-}
@@ -98,7 +111,7 @@ data StepItem
| StepCookware !Cookware
| StepTimer !Timer
| StepRecipeRef !RecipeRef
| StepNote !Text
| StepEndComment !Text
| StepComment !Text
| StepBreak
deriving stock (Eq, Show)
+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)