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
+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)