diff --git a/scripts/build b/scripts/build new file mode 100755 index 0000000..2948440 --- /dev/null +++ b/scripts/build @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +echo "Formatting with fourmolu..." +./hs fourmolu --mode inplace app/ src/ test/ + +echo "Linting with hlint..." +./hs hlint app/ src/ test/ + +echo "Building..." +./hs stack build --copy-bins --local-bin-path /work/build diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit new file mode 100755 index 0000000..d026924 --- /dev/null +++ b/scripts/git-hooks/pre-commit @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Pre-commit hook: format staged Haskell files with fourmolu. +# Installed by: scripts/install-hooks +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# Get staged .hs files (handles filenames with spaces) +staged=$(git -C "$PROJECT_DIR" diff --cached --name-only --diff-filter=ACM -- '*.hs') +if [ -z "$staged" ]; then + exit 0 +fi + +# Collect the files into an array +mapfile -t files <<< "$staged" + +echo "--- Formatting staged Haskell files with fourmolu..." +if ! "$PROJECT_DIR/hs" fourmolu --mode inplace "${files[@]}"; then + echo "ERROR: fourmolu formatting failed" >&2 + exit 1 +fi + +# Re-stage any files that were modified by formatting +changed=0 +for f in "${files[@]}"; do + if ! git -C "$PROJECT_DIR" diff --quiet "$f"; then + git -C "$PROJECT_DIR" add "$f" + changed=$((changed + 1)) + fi +done + +if [ "$changed" -gt 0 ]; then + echo "--- Formatted $changed file(s) — re-staged automatically" +fi diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 0000000..2521131 --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Pre-push hook: check formatting and hlint before pushing. +# Installed by: scripts/install-hooks +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +echo "--- Checking formatting with fourmolu..." +if ! "$PROJECT_DIR/hs" fourmolu --mode check app/ src/ test/; then + echo "" + echo "ERROR: Formatting check failed. Run './scripts/build' to auto-format." + echo " (fourmolu --mode inplace app/ src/ test/)" + exit 1 +fi +echo "OK" + +echo "--- Linting with hlint..." +if ! "$PROJECT_DIR/hs" hlint app/ src/ test/; then + echo "" + echo "ERROR: Hlint found warnings. Fix them before pushing." + exit 1 +fi +echo "OK" diff --git a/scripts/install-hooks b/scripts/install-hooks new file mode 100755 index 0000000..017ea69 --- /dev/null +++ b/scripts/install-hooks @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Install git hooks for the roux-server project. +# Run once: ./scripts/install-hooks +set -euo pipefail + +HOOK_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/git-hooks" && pwd)" +# Determine git hooks directory — supports both regular repos and worktrees +GIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.git" +if [ -f "$GIT_DIR" ]; then + # Worktree: .git is a file pointing to the real gitdir + GIT_DIR="$(cut -d' ' -f2 "$GIT_DIR")" +fi +HOOK_DST="${GIT_DIR}/hooks" + +mkdir -p "$HOOK_DST" +echo "Installing git hooks from $HOOK_SRC -> $HOOK_DST" + +for hook in "$HOOK_SRC"/*; do + name=$(basename "$hook") + ln -sf "$hook" "$HOOK_DST/$name" + echo " $name installed" +done + +echo "Done." diff --git a/scripts/run b/scripts/run new file mode 100755 index 0000000..547018b --- /dev/null +++ b/scripts/run @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +./hs stack exec roux-server -- "$@" diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..b075853 --- /dev/null +++ b/scripts/test @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +echo "Checking formatting with fourmolu..." +./hs fourmolu --mode check app/ src/ test/ + +echo "Linting with hlint..." +./hs hlint app/ src/ test/ + +echo "Running tests..." +./hs stack test diff --git a/src/Roux.hs b/src/Roux.hs index cf1c37c..bc1281c 100644 --- a/src/Roux.hs +++ b/src/Roux.hs @@ -1,9 +1,8 @@ -{- | Top-level re-exports and application setup for Roux. --} -module Roux - ( app - , module X - ) where +-- | Top-level re-exports and application setup for Roux. +module Roux ( + app, + module X, +) where import qualified Network.HTTP.Types as HTTP import qualified Network.Wai as Wai diff --git a/src/Roux/Parser.hs b/src/Roux/Parser.hs index 9c2ffe1..52f8634 100644 --- a/src/Roux/Parser.hs +++ b/src/Roux/Parser.hs @@ -4,9 +4,9 @@ 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 +module Roux.Parser ( + parseCookFile, +) where import Data.List.NonEmpty (NonEmpty ((:|))) import Data.Text (Text) @@ -14,26 +14,30 @@ 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). +{- | 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 [] :| []) :| [] - } + Right + Recipe + { recipeMetadata = emptyMetadata + , recipeSections = Section Nothing (Step [] :| []) :| [] + } (p : ps) -> - Right Recipe - { recipeMetadata = emptyMetadata - , recipeSections = Section Nothing (stepFromText p :| fmap stepFromText 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. +{- | 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 20bfe26..b8b741c 100644 --- a/src/Roux/Types.hs +++ b/src/Roux/Types.hs @@ -26,29 +26,29 @@ StepItem StepBreak — \\ at end of line @ -} -module Roux.Types - ( -- * Recipe - Recipe (..) +module Roux.Types ( + -- * Recipe + Recipe (..), - -- * Sections and steps - , Section (..) - , Step (..) + -- * Sections and steps + Section (..), + Step (..), - -- * Inline elements - , StepItem (..) - , Ingredient (..) - , Cookware (..) - , Timer (..) - , RecipeRef (..) + -- * Inline elements + StepItem (..), + Ingredient (..), + Cookware (..), + Timer (..), + RecipeRef (..), - -- * Quantities and durations - , Quantity (..) - , Duration (..) + -- * Quantities and durations + Quantity (..), + Duration (..), - -- * Metadata - , Metadata (..) - , emptyMetadata - ) where + -- * Metadata + Metadata (..), + emptyMetadata, +) where import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) @@ -70,16 +70,18 @@ data Recipe = Recipe -- Sections and steps -- --------------------------------------------------------------------------- --- | 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 one +or more cooking steps. +-} data Section = Section - { sectionName :: !(Maybe Text) + { sectionName :: !(Maybe Text) , sectionSteps :: !(NonEmpty Step) } deriving stock (Eq, Show) --- | A single cooking step — one paragraph of text in the Cooklang file, --- separated from other steps by a blank line. +{- | A single cooking step — one paragraph of text in the Cooklang file, +separated from other steps by a blank line. +-} newtype Step = Step { unStep :: [StepItem] } @@ -101,38 +103,42 @@ data StepItem | StepBreak deriving stock (Eq, Show) --- | An ingredient reference. --- --- @\@salt\@ground black pepper{}\@potato{2}\@bacon strips{1%kg}@ +{- | An ingredient reference. + +@\@salt\@ground black pepper{}\@potato{2}\@bacon strips{1%kg}@ +-} data Ingredient = Ingredient - { ingName :: !Text - , ingQuantity :: !(Maybe Quantity) + { ingName :: !Text + , ingQuantity :: !(Maybe Quantity) , ingPreparation :: !(Maybe Text) } deriving stock (Eq, Show) --- | A piece of cookware. --- --- @\#pot\#potato masher{}@ +{- | A piece of cookware. + +@\#pot\#potato masher{}@ +-} newtype Cookware = Cookware { cwName :: Text } deriving stock (Eq, Show) --- | A timer. --- --- @\{25%minutes\}\~eggs{3%minutes}@ +{- | A timer. + +@\{25%minutes\}\~eggs{3%minutes}@ +-} data Timer = Timer - { timerName :: !(Maybe Text) + { timerName :: !(Maybe Text) , timerDuration :: !Duration } deriving stock (Eq, Show) --- | A reference to another recipe, signalled by a path starting with @.\/@. --- --- @\@.\/sauces\/Hollandaise{150%g}@ +{- | A reference to another recipe, signalled by a path starting with @.\/@. + +@\@.\/sauces\/Hollandaise{150%g}@ +-} data RecipeRef = RecipeRef - { refPath :: !FilePath + { refPath :: !FilePath , refQuantity :: !(Maybe Quantity) } deriving stock (Eq, Show) @@ -141,21 +147,28 @@ data RecipeRef = RecipeRef -- Quantities and durations -- --------------------------------------------------------------------------- --- | An ingredient quantity with optional unit and scaling lock. --- --- @{2}\{1%kg}\{1\/2%tbsp}\{=1%tsp}@ +{- | An ingredient quantity with optional unit and scaling lock. + +@{2}\{1%kg}\{1\/2%tbsp}\{=1%tsp}@ +-} data Quantity = Quantity - { quantityAmount :: !Rational -- ^ e.g. @1\/2@, @3@, @3\/4@ - , quantityUnit :: !(Maybe Text) -- ^ e.g. @\"kg\"@, @\"tbsp\"@ - , quantityFixed :: !Bool -- ^ @{=1%tsp}@ — locked from scaling + { quantityAmount :: !Rational + -- ^ e.g. @1\/2@, @3@, @3\/4@ + , quantityUnit :: !(Maybe Text) + -- ^ e.g. @\"kg\"@, @\"tbsp\"@ + , quantityFixed :: !Bool + -- ^ @{=1%tsp}@ — locked from scaling } deriving stock (Eq, Show) --- | A timer duration — structurally similar to 'Quantity' but semantically --- distinct. Timers are not scaled with serving size. +{- | A timer duration — structurally similar to 'Quantity' but semantically +distinct. Timers are not scaled with serving size. +-} data Duration = Duration - { durationAmount :: !Rational -- ^ e.g. @25@, @3@ - , durationUnit :: !(Maybe Text) -- ^ e.g. @\"minutes\"@, @\"hours\"@ + { durationAmount :: !Rational + -- ^ e.g. @25@, @3@ + , durationUnit :: !(Maybe Text) + -- ^ e.g. @\"minutes\"@, @\"hours\"@ } deriving stock (Eq, Show) @@ -163,26 +176,27 @@ data Duration = Duration -- Metadata -- --------------------------------------------------------------------------- --- | Metadata parsed from YAML front matter (the @---@ … @---@ block at the --- top of a @.cook@ file). --- --- Canonical metadata keys are lifted to dedicated fields; everything else --- lives in 'metaCustom'. +{- | Metadata parsed from YAML front matter (the @---@ … @---@ block at the +top of a @.cook@ file). + +Canonical metadata keys are lifted to dedicated fields; everything else +lives in 'metaCustom'. +-} data Metadata = Metadata - { metaTitle :: !(Maybe Text) - , metaServings :: !(Maybe (Int, Maybe Text)) - , metaAuthor :: !(Maybe Text) - , metaSource :: !(Maybe Text) - , metaTotalTime :: !(Maybe Duration) - , metaPrepTime :: !(Maybe Duration) - , metaCookTime :: !(Maybe Duration) - , metaDifficulty :: !(Maybe Text) - , metaCuisine :: !(Maybe Text) - , metaDiet :: ![Text] - , metaTags :: ![Text] - , metaImage :: !(Maybe Text) + { metaTitle :: !(Maybe Text) + , metaServings :: !(Maybe (Int, Maybe Text)) + , metaAuthor :: !(Maybe Text) + , metaSource :: !(Maybe Text) + , metaTotalTime :: !(Maybe Duration) + , metaPrepTime :: !(Maybe Duration) + , metaCookTime :: !(Maybe Duration) + , metaDifficulty :: !(Maybe Text) + , metaCuisine :: !(Maybe Text) + , metaDiet :: ![Text] + , metaTags :: ![Text] + , metaImage :: !(Maybe Text) , metaDescription :: !(Maybe Text) - , metaCustom :: !(Map Text Text) + , metaCustom :: !(Map Text Text) } deriving stock (Eq, Show) @@ -190,18 +204,18 @@ data Metadata = Metadata 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 + { 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 + , metaCustom = Map.empty } diff --git a/test/Roux/ParserSpec.hs b/test/Roux/ParserSpec.hs index 172cf3f..eabff09 100644 --- a/test/Roux/ParserSpec.hs +++ b/test/Roux/ParserSpec.hs @@ -12,62 +12,66 @@ spec = 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."] - } - :| [] - } - :| [] - } + 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."]} - ] - } - :| [] - } + 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 = []} :| [] - } - :| [] - } + 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."]} :| [] - } - :| [] - } + Right + Recipe + { recipeMetadata = emptyMetadata + , recipeSections = + Section + { sectionName = Nothing + , sectionSteps = + Step{unStep = [StepText "Mix well."]} :| [] + } + :| [] + } parseCookFile input `shouldBe` expected