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:
@@ -60,6 +60,9 @@ tests:
|
|||||||
main: Spec.hs
|
main: Spec.hs
|
||||||
source-dirs: test
|
source-dirs: test
|
||||||
dependencies:
|
dependencies:
|
||||||
|
- hspec
|
||||||
- roux-server
|
- roux-server
|
||||||
- tasty
|
- tasty
|
||||||
|
- tasty-golden
|
||||||
|
- tasty-hspec
|
||||||
- tasty-hunit
|
- tasty-hunit
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ build-type: Simple
|
|||||||
library
|
library
|
||||||
exposed-modules:
|
exposed-modules:
|
||||||
Roux
|
Roux
|
||||||
|
Roux.Parser
|
||||||
Roux.Types
|
Roux.Types
|
||||||
other-modules:
|
other-modules:
|
||||||
Paths_roux_server
|
Paths_roux_server
|
||||||
@@ -78,6 +79,7 @@ test-suite roux-server-test
|
|||||||
type: exitcode-stdio-1.0
|
type: exitcode-stdio-1.0
|
||||||
main-is: Spec.hs
|
main-is: Spec.hs
|
||||||
other-modules:
|
other-modules:
|
||||||
|
Roux.ParserSpec
|
||||||
Paths_roux_server
|
Paths_roux_server
|
||||||
autogen-modules:
|
autogen-modules:
|
||||||
Paths_roux_server
|
Paths_roux_server
|
||||||
@@ -94,11 +96,14 @@ test-suite roux-server-test
|
|||||||
, bytestring
|
, bytestring
|
||||||
, containers
|
, containers
|
||||||
, directory
|
, directory
|
||||||
|
, hspec
|
||||||
, http-types
|
, http-types
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, parsec
|
, parsec
|
||||||
, roux-server
|
, roux-server
|
||||||
, tasty
|
, tasty
|
||||||
|
, tasty-golden
|
||||||
|
, tasty-hspec
|
||||||
, tasty-hunit
|
, tasty-hunit
|
||||||
, text
|
, text
|
||||||
, time
|
, time
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -47,10 +47,12 @@ module Roux.Types
|
|||||||
|
|
||||||
-- * Metadata
|
-- * Metadata
|
||||||
, Metadata (..)
|
, Metadata (..)
|
||||||
|
, emptyMetadata
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.List.NonEmpty (NonEmpty)
|
import Data.List.NonEmpty (NonEmpty)
|
||||||
import Data.Map.Strict (Map)
|
import Data.Map.Strict (Map)
|
||||||
|
import qualified Data.Map.Strict as Map
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -183,3 +185,23 @@ data Metadata = Metadata
|
|||||||
, metaCustom :: !(Map Text Text)
|
, metaCustom :: !(Map Text Text)
|
||||||
}
|
}
|
||||||
deriving stock (Eq, Show)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,10 +1,15 @@
|
|||||||
module Main (main) where
|
module Main (main) where
|
||||||
|
|
||||||
import Test.Tasty (defaultMain, testGroup)
|
import Test.Tasty (defaultMain, testGroup)
|
||||||
|
import Test.Tasty.Hspec (testSpec)
|
||||||
|
|
||||||
|
import qualified Roux.ParserSpec
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main =
|
main = do
|
||||||
|
parserTests <- testSpec "Parser" Roux.ParserSpec.spec
|
||||||
defaultMain $
|
defaultMain $
|
||||||
testGroup
|
testGroup
|
||||||
"roux-server"
|
"roux-server"
|
||||||
[ ]
|
[ parserTests
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user