diff --git a/package.yaml b/package.yaml index d627f55..389f898 100644 --- a/package.yaml +++ b/package.yaml @@ -60,6 +60,9 @@ tests: main: Spec.hs source-dirs: test dependencies: + - hspec - roux-server - tasty + - tasty-golden + - tasty-hspec - tasty-hunit diff --git a/roux-server.cabal b/roux-server.cabal index 8607071..7f8bceb 100644 --- a/roux-server.cabal +++ b/roux-server.cabal @@ -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 diff --git a/src/Roux/Parser.hs b/src/Roux/Parser.hs new file mode 100644 index 0000000..9c2ffe1 --- /dev/null +++ b/src/Roux/Parser.hs @@ -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] diff --git a/src/Roux/Types.hs b/src/Roux/Types.hs index 38e9d20..20bfe26 100644 --- a/src/Roux/Types.hs +++ b/src/Roux/Types.hs @@ -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 + } diff --git a/test/Roux/ParserSpec.hs b/test/Roux/ParserSpec.hs new file mode 100644 index 0000000..172cf3f --- /dev/null +++ b/test/Roux/ParserSpec.hs @@ -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 diff --git a/test/Spec.hs b/test/Spec.hs index b58388d..6676ac0 100644 --- a/test/Spec.hs +++ b/test/Spec.hs @@ -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 + ]