Add fourmolu + hlint with scripts and git hooks (Atlas-style)

- Create scripts/build, scripts/test, scripts/run, scripts/install-hooks
- Create git-hooks/pre-commit (auto-format staged .hs files)
- Create git-hooks/pre-push (check formatting + hlint before push)
- Handle git worktrees correctly in hook installation
- Run fourmolu on all source files to fix existing formatting
This commit is contained in:
2026-05-18 22:24:59 -04:00
parent 235adce596
commit 9759a4c3a3
10 changed files with 278 additions and 148 deletions
Executable
+12
View File
@@ -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
+34
View File
@@ -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
+23
View File
@@ -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"
+24
View File
@@ -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."
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
./hs stack exec roux-server -- "$@"
Executable
+12
View File
@@ -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
+5 -6
View File
@@ -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
+21 -17
View File
@@ -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]
+95 -81
View File
@@ -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
}
+48 -44
View File
@@ -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