diff --git a/08-haskell-adt/README.md b/08-haskell-adt/README.md new file mode 100644 index 0000000..c679ce8 --- /dev/null +++ b/08-haskell-adt/README.md @@ -0,0 +1,25 @@ +# 08-haskell-adt + +This example shows intermediate Haskell data modeling with algebraic data types. + +It includes: + +- sum types for targets, modes, and output style, +- a record type for the full build plan, +- pattern matching in both parsing and description logic, and +- a test suite run by `nix flake check`. + +Useful commands: + +```bash +nix develop +cabal run +cabal run -- executable release quiet +cabal test + +nix build +./result/bin/mini-plan executable release quiet + +nix run . -- executable release quiet +nix flake check +``` diff --git a/08-haskell-adt/app/Main.hs b/08-haskell-adt/app/Main.hs new file mode 100644 index 0000000..7d9b954 --- /dev/null +++ b/08-haskell-adt/app/Main.hs @@ -0,0 +1,13 @@ +module Main where + +import MiniPlan.Build (describePlan, parsePlan) +import System.Environment (getArgs) +import System.Exit (die) + +main :: IO () +main = do + args <- getArgs + + case parsePlan args of + Left err -> die err + Right plan -> putStrLn (describePlan plan) diff --git a/08-haskell-adt/flake.lock b/08-haskell-adt/flake.lock new file mode 100644 index 0000000..dfdfdf9 --- /dev/null +++ b/08-haskell-adt/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1776548001, + "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/08-haskell-adt/flake.nix b/08-haskell-adt/flake.nix new file mode 100644 index 0000000..c78b3ab --- /dev/null +++ b/08-haskell-adt/flake.nix @@ -0,0 +1,38 @@ +{ + # Builds a small Haskell project that focuses on algebraic data types, + # record syntax, and pattern matching. + description = "A Haskell project for algebraic data types and pattern matching"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs, ... }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + inherit (pkgs) haskellPackages; + project = haskellPackages.callCabal2nix "mini-plan" ./. { }; + checkedProject = pkgs.haskell.lib.doCheck project; + in + { + packages.${system}.default = project; + + apps.${system}.default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/mini-plan"; + meta.description = "Run the algebraic data type example."; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ + haskellPackages.ghc + pkgs.cabal-install + pkgs.haskell-language-server + ]; + }; + + checks.${system}.test-suite = checkedProject; + }; +} diff --git a/08-haskell-adt/mini-plan.cabal b/08-haskell-adt/mini-plan.cabal new file mode 100644 index 0000000..e07be0d --- /dev/null +++ b/08-haskell-adt/mini-plan.cabal @@ -0,0 +1,27 @@ +cabal-version: 2.4 +name: mini-plan +version: 0.1.0.0 +build-type: Simple + +library + exposed-modules: MiniPlan.Build + hs-source-dirs: src + build-depends: base >=4.14 && <5 + default-language: Haskell2010 + +executable mini-plan + main-is: Main.hs + hs-source-dirs: app + build-depends: + base >=4.14 && <5, + mini-plan + default-language: Haskell2010 + +test-suite mini-plan-test + type: exitcode-stdio-1.0 + main-is: Main.hs + hs-source-dirs: test + build-depends: + base >=4.14 && <5, + mini-plan + default-language: Haskell2010 diff --git a/08-haskell-adt/src/MiniPlan/Build.hs b/08-haskell-adt/src/MiniPlan/Build.hs new file mode 100644 index 0000000..0139661 --- /dev/null +++ b/08-haskell-adt/src/MiniPlan/Build.hs @@ -0,0 +1,74 @@ +module MiniPlan.Build where + +data Target + = Library + | Executable + | TestSuite + deriving (Eq, Show) + +data Mode + = Debug + | Release + deriving (Eq, Show) + +data Output + = Quiet + | Verbose + deriving (Eq, Show) + +data BuildPlan = BuildPlan + { target :: Target + , mode :: Mode + , output :: Output + } + deriving (Eq, Show) + +defaultPlan :: BuildPlan +defaultPlan = BuildPlan Library Debug Verbose + +describePlan :: BuildPlan -> String +describePlan plan = + case plan of + BuildPlan Library Debug Verbose -> + "compile the library in debug mode with verbose logs" + BuildPlan Library Release _ -> + "compile the library in release mode" + BuildPlan Executable Debug _ -> + "build the executable in debug mode" + BuildPlan Executable Release Verbose -> + "build the executable in release mode with verbose logs" + BuildPlan Executable Release Quiet -> + "build the executable in release mode quietly" + BuildPlan TestSuite planMode planOutput -> + "run the test suite in " ++ modeLabel planMode ++ " mode" ++ outputSuffix planOutput + +modeLabel :: Mode -> String +modeLabel Debug = "debug" +modeLabel Release = "release" + +outputSuffix :: Output -> String +outputSuffix Quiet = " quietly" +outputSuffix Verbose = " with verbose logs" + +parsePlan :: [String] -> Either String BuildPlan +parsePlan [] = Right defaultPlan +parsePlan [targetArg, modeArg, outputArg] = + BuildPlan <$> parseTarget targetArg <*> parseMode modeArg <*> parseOutput outputArg +parsePlan _ = + Left "expected either no arguments or: " + +parseTarget :: String -> Either String Target +parseTarget "library" = Right Library +parseTarget "executable" = Right Executable +parseTarget "test" = Right TestSuite +parseTarget other = Left ("unknown target: " ++ other) + +parseMode :: String -> Either String Mode +parseMode "debug" = Right Debug +parseMode "release" = Right Release +parseMode other = Left ("unknown mode: " ++ other) + +parseOutput :: String -> Either String Output +parseOutput "quiet" = Right Quiet +parseOutput "verbose" = Right Verbose +parseOutput other = Left ("unknown output: " ++ other) diff --git a/08-haskell-adt/test/Main.hs b/08-haskell-adt/test/Main.hs new file mode 100644 index 0000000..8861dc9 --- /dev/null +++ b/08-haskell-adt/test/Main.hs @@ -0,0 +1,27 @@ +module Main where + +import MiniPlan.Build + ( BuildPlan (BuildPlan) + , Mode (Release) + , Output (Quiet) + , Target (Executable) + , defaultPlan + , describePlan + , parsePlan + ) +import System.Exit (die) + +main :: IO () +main = + case + ( parsePlan [] + , parsePlan ["executable", "release", "quiet"] + , describePlan (BuildPlan Executable Release Quiet) + ) of + ( Right parsedDefault + , Right parsedCustom + , "build the executable in release mode quietly" + ) | parsedDefault == defaultPlan + && parsedCustom == BuildPlan Executable Release Quiet -> + putStrLn "test passed" + _ -> die "unexpected plan parsing result" diff --git a/09-haskell-newtype/README.md b/09-haskell-newtype/README.md new file mode 100644 index 0000000..47788ef --- /dev/null +++ b/09-haskell-newtype/README.md @@ -0,0 +1,25 @@ +# 09-haskell-newtype + +This example shows intermediate Haskell domain modeling with `newtype` and smart constructors. + +It includes: + +- `newtype` wrappers for `UserName` and `Email`, +- smart constructors that validate raw input, +- an explicit `Either String` error path, and +- a test suite run by `nix flake check`. + +Useful commands: + +```bash +nix develop +cabal run +cabal run -- learner learner@example.com +cabal test + +nix build +./result/bin/mini-registration learner learner@example.com + +nix run . -- learner learner@example.com +nix flake check +``` diff --git a/09-haskell-newtype/app/Main.hs b/09-haskell-newtype/app/Main.hs new file mode 100644 index 0000000..fc7c916 --- /dev/null +++ b/09-haskell-newtype/app/Main.hs @@ -0,0 +1,23 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Main where + +import Data.Text (Text) +import qualified Data.Text as Text +import qualified Data.Text.IO as Text +import MiniRegistration.Domain (mkRegistration, welcomeMessage) +import System.Environment (getArgs) +import System.Exit (die) + +main :: IO () +main = do + args <- getArgs + (name, email) <- + case args of + [nameArg, emailArg] -> pure (Text.pack nameArg, Text.pack emailArg) + [] -> pure ("learner", "learner@example.com") + _ -> die "expected either no arguments or: " + + case mkRegistration name email of + Left err -> die err + Right registration -> Text.putStrLn (welcomeMessage registration) diff --git a/09-haskell-newtype/flake.lock b/09-haskell-newtype/flake.lock new file mode 100644 index 0000000..dfdfdf9 --- /dev/null +++ b/09-haskell-newtype/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1776548001, + "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/09-haskell-newtype/flake.nix b/09-haskell-newtype/flake.nix new file mode 100644 index 0000000..f59daf7 --- /dev/null +++ b/09-haskell-newtype/flake.nix @@ -0,0 +1,38 @@ +{ + # Builds a small Haskell project that focuses on newtypes, smart + # constructors, and validation with Either. + description = "A Haskell project for newtypes and smart constructors"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs, ... }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + inherit (pkgs) haskellPackages; + project = haskellPackages.callCabal2nix "mini-registration" ./. { }; + checkedProject = pkgs.haskell.lib.doCheck project; + in + { + packages.${system}.default = project; + + apps.${system}.default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/mini-registration"; + meta.description = "Run the newtype and validation example."; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ + haskellPackages.ghc + pkgs.cabal-install + pkgs.haskell-language-server + ]; + }; + + checks.${system}.test-suite = checkedProject; + }; +} diff --git a/09-haskell-newtype/mini-registration.cabal b/09-haskell-newtype/mini-registration.cabal new file mode 100644 index 0000000..a0636a3 --- /dev/null +++ b/09-haskell-newtype/mini-registration.cabal @@ -0,0 +1,31 @@ +cabal-version: 2.4 +name: mini-registration +version: 0.1.0.0 +build-type: Simple + +library + exposed-modules: MiniRegistration.Domain + hs-source-dirs: src + build-depends: + base >=4.14 && <5, + text + default-language: Haskell2010 + +executable mini-registration + main-is: Main.hs + hs-source-dirs: app + build-depends: + base >=4.14 && <5, + mini-registration, + text + default-language: Haskell2010 + +test-suite mini-registration-test + type: exitcode-stdio-1.0 + main-is: Main.hs + hs-source-dirs: test + build-depends: + base >=4.14 && <5, + mini-registration, + text + default-language: Haskell2010 diff --git a/09-haskell-newtype/src/MiniRegistration/Domain.hs b/09-haskell-newtype/src/MiniRegistration/Domain.hs new file mode 100644 index 0000000..fcbfa38 --- /dev/null +++ b/09-haskell-newtype/src/MiniRegistration/Domain.hs @@ -0,0 +1,54 @@ +{-# LANGUAGE OverloadedStrings #-} + +module MiniRegistration.Domain where + +import Data.Char (isAlphaNum) +import Data.Text (Text) +import qualified Data.Text as Text + +newtype UserName = UserName + { unUserName :: Text + } + deriving (Eq, Show) + +newtype Email = Email + { unEmail :: Text + } + deriving (Eq, Show) + +data Registration = Registration + { userName :: UserName + , email :: Email + } + deriving (Eq, Show) + +mkUserName :: Text -> Either String UserName +mkUserName rawName + | Text.length trimmedName < 3 = Left "user name must be at least 3 characters long" + | not (Text.all validUserNameChar trimmedName) = Left "user name must be letters or digits" + | otherwise = Right (UserName trimmedName) + where + trimmedName = Text.strip rawName + validUserNameChar char = isAlphaNum char || char == '_' + +mkEmail :: Text -> Either String Email +mkEmail rawEmail + | Text.count "@" trimmedEmail /= 1 = Left "email must contain exactly one @" + | Text.isSuffixOf "@" trimmedEmail = Left "email must contain a domain" + | not (Text.any (== '.') domainPart) = Left "email domain must contain a dot" + | otherwise = Right (Email trimmedEmail) + where + trimmedEmail = Text.strip rawEmail + domainPart = Text.dropWhile (/= '@') trimmedEmail + +mkRegistration :: Text -> Text -> Either String Registration +mkRegistration rawName rawEmail = + Registration <$> mkUserName rawName <*> mkEmail rawEmail + +welcomeMessage :: Registration -> Text +welcomeMessage registration = + "welcome, " + <> unUserName (userName registration) + <> " (" + <> unEmail (email registration) + <> ")" diff --git a/09-haskell-newtype/test/Main.hs b/09-haskell-newtype/test/Main.hs new file mode 100644 index 0000000..60900ff --- /dev/null +++ b/09-haskell-newtype/test/Main.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Main where + +import MiniRegistration.Domain + ( mkRegistration + , mkUserName + , welcomeMessage + ) +import System.Exit (die) + +main :: IO () +main = + case + ( mkUserName "ha" + , mkRegistration "learner_1" "learner@example.com" + ) of + ( Left _ + , Right registration + ) | welcomeMessage registration == "welcome, learner_1 (learner@example.com)" -> + putStrLn "test passed" + _ -> die "unexpected validation result" diff --git a/10-haskell-effects/README.md b/10-haskell-effects/README.md new file mode 100644 index 0000000..c87da2f --- /dev/null +++ b/10-haskell-effects/README.md @@ -0,0 +1,25 @@ +# 10-haskell-effects + +This example shows intermediate Haskell application structure with `ReaderT` and `Except`. + +It includes: + +- an environment record carried by `ReaderT`, +- an explicit domain error type carried by `Except`, +- `MonadReader` and `MonadError` constraints on library functions, and +- a test suite run by `nix flake check`. + +Useful commands: + +```bash +nix develop +cabal run +cabal run -- haskell +cabal test + +nix build +./result/bin/mini-effects haskell + +nix run . -- haskell +nix flake check +``` diff --git a/10-haskell-effects/app/Main.hs b/10-haskell-effects/app/Main.hs new file mode 100644 index 0000000..bb10bba --- /dev/null +++ b/10-haskell-effects/app/Main.hs @@ -0,0 +1,17 @@ +module Main where + +import MiniEffects.App (defaultEnv, renderError, runGreetingApp) +import System.Environment (getArgs) +import System.Exit (die) + +main :: IO () +main = do + args <- getArgs + let rawName = + case args of + [] -> "learner" + firstArg : _ -> firstArg + + case runGreetingApp defaultEnv rawName of + Left appError -> die (renderError appError) + Right greeting -> putStrLn greeting diff --git a/10-haskell-effects/flake.lock b/10-haskell-effects/flake.lock new file mode 100644 index 0000000..dfdfdf9 --- /dev/null +++ b/10-haskell-effects/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1776548001, + "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/10-haskell-effects/flake.nix b/10-haskell-effects/flake.nix new file mode 100644 index 0000000..9186247 --- /dev/null +++ b/10-haskell-effects/flake.nix @@ -0,0 +1,38 @@ +{ + # Builds a small Haskell project that focuses on ReaderT, Except, and + # mtl-style constraints for application logic. + description = "A Haskell project for ReaderT and Except"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs, ... }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + inherit (pkgs) haskellPackages; + project = haskellPackages.callCabal2nix "mini-effects" ./. { }; + checkedProject = pkgs.haskell.lib.doCheck project; + in + { + packages.${system}.default = project; + + apps.${system}.default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/mini-effects"; + meta.description = "Run the ReaderT and Except example."; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ + haskellPackages.ghc + pkgs.cabal-install + pkgs.haskell-language-server + ]; + }; + + checks.${system}.test-suite = checkedProject; + }; +} diff --git a/10-haskell-effects/mini-effects.cabal b/10-haskell-effects/mini-effects.cabal new file mode 100644 index 0000000..563f0c5 --- /dev/null +++ b/10-haskell-effects/mini-effects.cabal @@ -0,0 +1,31 @@ +cabal-version: 2.4 +name: mini-effects +version: 0.1.0.0 +build-type: Simple + +library + exposed-modules: MiniEffects.App + hs-source-dirs: src + build-depends: + base >=4.14 && <5, + mtl + default-language: Haskell2010 + +executable mini-effects + main-is: Main.hs + hs-source-dirs: app + build-depends: + base >=4.14 && <5, + mini-effects, + mtl + default-language: Haskell2010 + +test-suite mini-effects-test + type: exitcode-stdio-1.0 + main-is: Main.hs + hs-source-dirs: test + build-depends: + base >=4.14 && <5, + mini-effects, + mtl + default-language: Haskell2010 diff --git a/10-haskell-effects/src/MiniEffects/App.hs b/10-haskell-effects/src/MiniEffects/App.hs new file mode 100644 index 0000000..9b5e59d --- /dev/null +++ b/10-haskell-effects/src/MiniEffects/App.hs @@ -0,0 +1,61 @@ +{-# LANGUAGE FlexibleContexts #-} + +module MiniEffects.App where + +import Control.Monad.Except + ( Except + , MonadError + , runExcept + , throwError + ) +import Control.Monad.Reader + ( MonadReader + , ReaderT + , asks + , runReaderT + ) +import Data.Char (isSpace) + +data Env = Env + { greetingPrefix :: String + , maxNameLength :: Int + } + deriving (Eq, Show) + +data AppError + = EmptyName + | NameTooLong Int + deriving (Eq, Show) + +type App = ReaderT Env (Except AppError) + +defaultEnv :: Env +defaultEnv = Env { greetingPrefix = "hello", maxNameLength = 12 } + +trim :: String -> String +trim = dropWhile isSpace . reverse . dropWhile isSpace . reverse + +validateName :: (MonadReader Env m, MonadError AppError m) => String -> m String +validateName rawName + | null cleanedName = throwError EmptyName + | otherwise = do + nameLimit <- asks maxNameLength + if length cleanedName > nameLimit then + throwError (NameTooLong nameLimit) + else + pure cleanedName + where + cleanedName = trim rawName + +buildGreeting :: (MonadReader Env m, MonadError AppError m) => String -> m String +buildGreeting rawName = do + validName <- validateName rawName + prefix <- asks greetingPrefix + pure (prefix ++ ", " ++ validName) + +runGreetingApp :: Env -> String -> Either AppError String +runGreetingApp env rawName = runExcept (runReaderT (buildGreeting rawName) env) + +renderError :: AppError -> String +renderError EmptyName = "name cannot be empty" +renderError (NameTooLong limit) = "name must be at most " ++ show limit ++ " characters long" diff --git a/10-haskell-effects/test/Main.hs b/10-haskell-effects/test/Main.hs new file mode 100644 index 0000000..7210be6 --- /dev/null +++ b/10-haskell-effects/test/Main.hs @@ -0,0 +1,21 @@ +module Main where + +import MiniEffects.App + ( AppError (EmptyName, NameTooLong) + , Env (Env) + , runGreetingApp + ) +import System.Exit (die) + +main :: IO () +main = + case + ( runGreetingApp (Env "hi" 8) "haskell" + , runGreetingApp (Env "hi" 8) " " + , runGreetingApp (Env "hi" 4) "reader" + ) of + ( Right "hi, haskell" + , Left EmptyName + , Left (NameTooLong 4) + ) -> putStrLn "test passed" + _ -> die "unexpected application stack result" diff --git a/notes/010-haskell-adts.md b/notes/010-haskell-adts.md new file mode 100644 index 0000000..a7fb73d --- /dev/null +++ b/notes/010-haskell-adts.md @@ -0,0 +1,51 @@ +# Haskell Algebraic Data Types + +This note covers `08-haskell-adt/`, which models a build plan with sum types, a record type, and pattern matching. + +--- + +## 1. Why This Example Matters + +Haskell programs often start by turning vague strings into precise domain types. + +This example does that with: + +- `Target` as a sum type, +- `Mode` as a sum type, +- `Output` as a sum type, and +- `BuildPlan` as a product type with record fields. + +That is one of the most important intermediate Haskell habits: model the domain first, then write functions over the constructors. + +--- + +## 2. Pattern Matching in Two Places + +The example uses pattern matching in both parsing and behavior: + +- `parseTarget`, `parseMode`, and `parseOutput` turn strings into constructors, and +- `describePlan` matches on the `BuildPlan` value to decide what to print. + +That shows two common styles: + +- pattern matching on one constructor at a time in small helper functions, and +- pattern matching on a whole record value when several fields matter together. + +--- + +## 3. Commands to Try + +```bash +cd 08-haskell-adt + +nix develop +cabal run +cabal run -- executable release quiet +cabal test + +nix build +./result/bin/mini-plan executable release quiet + +nix run . -- executable release quiet +nix flake check +```