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:
+184
-23
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user