Add minimal Cooklang parser with first tests

- Create Roux.Parser with parseCookFile (splits paragraphs, treats
  each as a plain-text step, no metadata parsing yet)
- Add emptyMetadata to Roux.Types for convenience
- Add test dependencies: hspec, tasty-golden, tasty-hspec
- Add ParserSpec with 4 tests covering single step, multiple steps,
  empty input, and whitespace trimming
- Wire ParserSpec into test runner via tasty-hspec
This commit is contained in:
2026-05-18 22:21:32 -04:00
parent e907a03b09
commit 235adce596
6 changed files with 149 additions and 2 deletions
+3
View File
@@ -60,6 +60,9 @@ tests:
main: Spec.hs
source-dirs: test
dependencies:
- hspec
- roux-server
- tasty
- tasty-golden
- tasty-hspec
- tasty-hunit
+5
View File
@@ -17,6 +17,7 @@ build-type: Simple
library
exposed-modules:
Roux
Roux.Parser
Roux.Types
other-modules:
Paths_roux_server
@@ -78,6 +79,7 @@ test-suite roux-server-test
type: exitcode-stdio-1.0
main-is: Spec.hs
other-modules:
Roux.ParserSpec
Paths_roux_server
autogen-modules:
Paths_roux_server
@@ -94,11 +96,14 @@ test-suite roux-server-test
, bytestring
, containers
, directory
, hspec
, http-types
, optparse-applicative
, parsec
, roux-server
, tasty
, tasty-golden
, tasty-hspec
, tasty-hunit
, text
, time
+39
View File
@@ -0,0 +1,39 @@
{- | 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.
-}
module Roux.Parser
( parseCookFile
) where
import Data.List.NonEmpty (NonEmpty ((:|)))
import Data.Text (Text)
import qualified Data.Text as T
import Roux.Types
-- | Parse a complete @.cook@ file into a 'Recipe'.
--
-- Returns 'Left' with an error message on invalid input (currently never
-- — the placeholder always succeeds).
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
[] ->
Right Recipe
{ recipeMetadata = emptyMetadata
, recipeSections = Section Nothing (Step [] :| []) :| []
}
(p : ps) ->
Right Recipe
{ recipeMetadata = emptyMetadata
, recipeSections = Section Nothing (stepFromText p :| fmap stepFromText ps) :| []
}
-- | Turn a paragraph of text into a single 'Step' containing nothing but
-- 'StepText'. This is a placeholder until inline parsing is implemented.
stepFromText :: Text -> Step
stepFromText t = Step [StepText t]
+22
View File
@@ -47,10 +47,12 @@ module Roux.Types
-- * Metadata
, Metadata (..)
, emptyMetadata
) where
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
-- ---------------------------------------------------------------------------
@@ -183,3 +185,23 @@ data Metadata = Metadata
, metaCustom :: !(Map Text Text)
}
deriving stock (Eq, Show)
-- | Metadata with all fields set to their default / empty values.
emptyMetadata :: Metadata
emptyMetadata =
Metadata
{ metaTitle = Nothing
, metaServings = Nothing
, metaAuthor = Nothing
, metaSource = Nothing
, metaTotalTime = Nothing
, metaPrepTime = Nothing
, metaCookTime = Nothing
, metaDifficulty = Nothing
, metaCuisine = Nothing
, metaDiet = []
, metaTags = []
, metaImage = Nothing
, metaDescription = Nothing
, metaCustom = Map.empty
}
+73
View File
@@ -0,0 +1,73 @@
module Roux.ParserSpec (spec) where
import Data.List.NonEmpty (NonEmpty ((:|)))
import Test.Hspec (Spec, describe, it, shouldBe)
import Roux.Parser (parseCookFile)
import Roux.Types
spec :: Spec
spec =
describe "parseCookFile" $ do
it "parses a single step of plain text" $ do
let input = "Hello, world."
expected =
Right Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionSteps =
Step
{ unStep = [StepText "Hello, world."]
}
:| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses multiple steps separated by a blank line" $ do
let input = "Wash the potatoes.\n\nPeel and dice them."
expected =
Right Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionSteps =
Step {unStep = [StepText "Wash the potatoes."]}
:| [ Step {unStep = [StepText "Peel and dice them."]}
]
}
:| []
}
parseCookFile input `shouldBe` expected
it "handles empty input by returning an empty recipe" $ do
let expected =
Right Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionSteps = Step {unStep = []} :| []
}
:| []
}
parseCookFile "" `shouldBe` expected
it "trims leading and trailing whitespace from each step" $ do
let input = "\n\n Mix well. \n\n"
expected =
Right Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionSteps =
Step {unStep = [StepText "Mix well."]} :| []
}
:| []
}
parseCookFile input `shouldBe` expected
+7 -2
View File
@@ -1,10 +1,15 @@
module Main (main) where
import Test.Tasty (defaultMain, testGroup)
import Test.Tasty.Hspec (testSpec)
import qualified Roux.ParserSpec
main :: IO ()
main =
main = do
parserTests <- testSpec "Parser" Roux.ParserSpec.spec
defaultMain $
testGroup
"roux-server"
[ ]
[ parserTests
]