Haskeleton: A Haskell Project Skeleton
I’m new to Haskell. I’ve learned enough to feel comfortable writing programs in it. I can solve code katas like exercism.io, H-99, and Project Euler. Yet I don’t feel comfortable developing software with it. Writing idiomatic, maintainable and well-tested Haskell code remains a mystery to me.
Cabal, the Haskell build tool, provides little guidance. For instance, cabal
init
asks 11 questions and outputs two files totaling 26 lines. That’s not the
best metric, but it shows that you aren’t getting much out of it. To both
improve on that and educate myself, I built Haskeleton, a Haskell project
skeleton.
I hope it replaces cabal init
someday. For the time being, it’s just an
example project. This post will walk you through setting up a project like
Haskeleton and explain the decisions I made along the way.
Setup
There’s no reason to make new software with old technology. To get started,
make sure you have GHC 7.6.3 and Cabal 1.18.0.2 installed. You can get GHC
through The Haskell Platform and the latest version of Cabal with cabal
install cabal-install
.
Now for the hardest part: thinking of a name for your package. I went with “husk”. (If you’re following along, replace that with your package’s name throughout.) Make a directory for your package to get started.
# mkdir husk
# cd husk
You only need one file to make a package: a Cabal file. It describes the package and tells Cabal how to build it. It starts off pretty simple.
-- husk.cabal
name: husk
version: 0.0.0
build-type: Simple
cabal-version: >= 1.18
library
default-language: Haskell2010
The syntax is mix between Haskell and YAML. The package properties at the top describe the package as a whole. In the library section, the build information declares how to build the library. Here’s what they all mean.
name
: The package’s name. This should be unique on Hackage.version
: The package’s version number. I recommend using semantic versioning.build-type
: The type of build. Cabal provides a setup script if this is set toSimple
. For some reason the default isCustom
.cabal-version
: Cabal’s version number. Use the major and minor parts of the version of Cabal used to build the package.default-language
: The version of the Haskell language report. The current state of the art isHaskell2010
.
With all the boilerplate out of the way, we can build the package. Before we do, let’s create a sandbox. It sets up a private environment separate from the rest of your system.
# cabal sandbox init
Writing a default package environment file to
.../husk/cabal.sandbox.config
Creating a new sandbox at .../husk/.cabal-sandbox
Now let’s install the package into the sandbox.
# cabal install
Resolving dependencies...
Configuring husk-0.0.0...
Building husk-0.0.0...
Preprocessing library husk-0.0.0...
In-place registering husk-0.0.0...
Installing library in .../husk-0.0.0
Registering husk-0.0.0...
Installed husk-0.0.0
Alright, it worked! Six lines of code made a valid Cabal package. It doesn’t do anything yet, though. Let’s fix that.
Library
Your library code shouldn’t live at the top level. Create a library
directory
for it. In there, make a file with the same name as your package. For this
example, it doesn’t have to do anything interesting. We’re going to make it
export a function that returns the unit value.
-- library/Husk.hs
module Husk (husk) where
husk :: ()
husk = ()
Just writing the module isn’t enough. You have to let Cabal know about it.
-- husk.cabal
library
exposed-modules: Husk
hs-source-dirs: library
build-depends: base
This adds some new build information to the library:
exposed-modules
: List of modules exposed by the package.hs-source-dirs
: List of directories to search for source files in.build-depends
: A list of needed packages. Every project will depend onbase
, which provides the Prelude.
Now that Cabal’s in the loop, you can fire up a REPL for your package. The modules exposed by the package are already available. You can play around with the functions they export.
# cabal repl
*Husk> :type Husk.husk
Husk.husk :: ()
*Husk> husk
()
Now we’ve got a Cabal package with a library, which is more than cabal init
provides. And we did it with less code!
Executable
Let’s provide an executable that uses the library. It shouldn’t live at the top
level either, so create an executable
directory for it. Unlike the library,
don’t name this after your package. Just call is Main.hs
instead.
-- executable/Main.hs
module Main (main) where
import Husk (husk)
main :: IO ()
main = print husk
As before, Cabal needs to know about this. Create a new section at the bottom of the Cabal file.
-- husk.cabal
executable husk
build-depends: base, husk
default-language: Haskell2010
hs-source-dirs: executable
main-is: Main.hs
The only new property is main-is
. It points to the main entry point for the
executable. After adding that, you can run it!
# cabal run
Preprocessing library husk-0.0.0...
In-place registering husk-0.0.0...
Preprocessing executable 'husk' for husk-0.0.0...
Linking dist/build/husk/husk ...
()
That’s all it takes to make an executable with Cabal.
Documentation
Now that you’ve got a library and an executable, you should document them. There are two things that need documentation: the package itself and the source of the package.
Documenting the package requires adding a few more package properties to the Cabal file. If you’re not going to distribute your package on Hackage, you can skip this step.
-- husk.cabal
copyright: 2014 Taylor Fausak
license: MIT
synopsis: An example package.
To write documentation for the source, you’ll need to learn Haddock. It’s a simple markup language for annotating Haskell source. Here’s how the library looks with comments:
-- library/Husk.hs
-- | An example module.
module Husk (husk) where
{- |
An alias for the unit value.
>>> husk
()
-}
husk :: () -- ^ The unit type.
husk = ()
Now that it’s documented, let’s generate the HTML documentation.
# cabal haddock
Running Haddock for husk-0.0.0...
Preprocessing library husk-0.0.0...
Haddock coverage:
100% ( 2 / 2) in 'Husk'
Documentation created: dist/doc/html/husk/index.html
The output is like what you’d see on Hackage. It’s missing one thing: links to the source. Adding those requires another dependency. It should be optional. Not everyone who installs the package will generate its documentation.
-- husk.cabal
flag documentation
default: False
library
if flag(documentation)
build-depends: hscolour == 1.20.*
Enabling flags for Cabal commands is easy. Add either -fdocumentation
or
--flags=documentation
. Using that flag, let’s regenerate the documentation.
# cabal install --flags=documentation
# cabal haddock --hyperlink-source
Running Haddock for husk-0.0.0...
Running hscolour for husk-0.0.0...
Preprocessing library husk-0.0.0...
Haddock coverage:
100% ( 2 / 2) in 'Husk'
Documentation created: dist/doc/html/husk/index.html
Now it should have source links. If you get a bunch of warnings, you can ignore
them. Haddock is looking for the documentation for the standard library. If you
want to add it, install haskell-platform-doc
.
Tests
You can test Haskell code in two different ways:
- Unit tests using HUnit. Use these to test the behavior of your code. For
example, you could test that
+
returns3
when given1
and2
.
TestCase (assertEqual "1 + 2 = 3" 3 (1 + 2))
- Property tests using QuickCheck. Use these to test the properties of your
code. For example, you could test that
+
always returns an even number when given even arguments.
quickCheck (\ x y -> even ((2 * x) + (2 * y)))
We’re going to use HSpec instead of those libraries. It has a nicer
syntax and a uniform interface for both. Create a test-suite
folder for the
tests. In there, create Spec.hs
, the top-level entry point. HSpec discovers
and runs your tests using the hspec-discover
GHC preprocessor.
-- test-suite/Spec.hs
{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
It assumes you laid your tests out in the format it expects, so create a spec
file for your library. We don’t have much functionality to test, but we can
write a unit test and a property test for the husk
function.
-- test-suite/HuskSpec.hs
module HuskSpec (spec) where
import Husk (husk)
import Test.Hspec
import Test.Hspec.QuickCheck
spec :: Spec
spec = do
describe "husk" $ do
it "returns the unit value" $ do
husk `shouldBe` ()
prop "equals the unit value" $
\ x -> husk == x
With that done, the only piece left is updating the Cabal file. Add a new section at the end for the test suite.
-- husk.cabal
test-suite hspec
build-depends: base, husk, hspec == 1.8.*
default-language: Haskell2010
hs-source-dirs: test-suite
main-is: Spec.hs
type: exitcode-stdio-1.0
The only new build information here is type
. The Cabal documentation
recommends the non-existent detailed-1.0
type. Ignore that and use exitcode-
stdio-1.0
, which uses the exit code to signify success or failure.
After doing all that, you should be able to run the tests.
# cabal install --enable-tests
# cabal test
Test suite hspec: RUNNING...
Test suite hspec: PASS
Test suite logged to: dist/test/husk-0.0.0-hspec.log
Benchmarks
Now that we’ve got tests to ensure our code works, let’s write some benchmarks to make sure it’s fast. We’re going to use Criterion, an exceptional benchmarking library. It handles all the setup for you and lets you focus on writing benchmarks.
So let’s make a new directory, benchmark
, and do just that.
-- benchmark/HuskBench.hs
module HuskBench (benchmarks) where
import Criterion (Benchmark, bench, nf)
import Husk (husk)
benchmarks :: [Benchmark]
benchmarks =
[ bench "husk" (nf (const husk) ())
]
The only tricky part of this is nf
. It evaluates the result of calling the
function with the given value. This is necessary since Haskell is lazy. If you
didn’t do it, only a part of the result might get evaluated. Then your
benchmark wouldn’t be accurate.
Now that we have a benchmark, we need a runner. Unlike HSpec, Criterion doesn’t auto-discover benchmarks. We have to wire it up.
-- benchmark/Bench.hs
module Main (main) where
import Criterion.Main (bgroup, defaultMain)
import qualified HuskBench
main :: IO ()
main = defaultMain
[ bgroup "Husk" HuskBench.benchmarks
]
We need to add a new section to the Cabal file for the benchmarks.
benchmark criterion
build-depends: base, husk, criterion == 0.6.*
default-language: Haskell2010
hs-source-dirs: benchmark
main-is: Bench.hs
type: exitcode-stdio-1.0
With that in place, we can now run the benchmarks.
# cabal install --enable-benchmarks
# cabal bench
Benchmark criterion: RUNNING...
benchmarking Husk/husk
mean: 12.15392 ns, lb 11.89230 ns, ub 12.49891 ns, ci 0.950
std dev: 1.529236 ns, lb 1.229199 ns, ub 1.884049 ns, ci 0.950
found 17 outliers among 100 samples (17.0%)
6 (6.0%) high mild
11 (11.0%) high severe
variance introduced by outliers: 86.253%
variance is severely inflated by outliers
Benchmark criterion: FINISH
Code Quality
That covers the basics for making a library and an executable, along with tests and benchmarks for them. Another important part of software projects is code quality. This includes things like code coverage and linting. Focusing on quality helps you make maintainable and idiomatic software.
Documentation Tests
Before we get to all that, there’s one more thing that needs testing. When we wrote our documentation, we included some example code. We should test that code to make sure it’s correct. Incorrect examples in documentation are frustrating.
Thanks to doctest
, testing documentation is a cinch. We just need to
write a new test suite.
-- test-suite/DocTest.hs
module Main (main) where
import System.FilePath.Glob (glob)
import Test.DocTest (doctest)
main :: IO ()
main = glob "library/**/*.hs" >>= doctest
This uses globbing to avoid listing all the source files. That means you shouldn’t ever have to change it.
Next, create a new section in the Cabal file.
-- husk.cabal
test-suite doctest
build-depends: base, doctest == 0.9.*, Glob == 0.7.*
default-language: Haskell2010
hs-source-dirs: test-suite
main-is: DocTest.hs
type: exitcode-stdio-1.0
You can now run it along with the other test suites.
# cabal install --enable-tests
# cabal test
Test suite doctest: RUNNING...
Test suite doctest: PASS
Test suite logged to: dist/test/husk-0.0.0-doctest.log
Documentation Coverage
After checking our documentation for correctness, we should make sure that we documented everything. Unfortunately there’s no turnkey solution for this. Making one isn’t too hard since Haddock outputs coverage information already. We just need a script for running it and parsing the results. So create a new test suite for that.
-- test-suite/Haddock.hs
module Main (main) where
import Data.List (genericLength)
import Data.Maybe (catMaybes)
import System.Exit (exitFailure, exitSuccess)
import System.Process (readProcess)
import Text.Regex (matchRegex, mkRegex)
average :: (Fractional a, Real b) => [b] -> a
average xs = realToFrac (sum xs) / genericLength xs
expected :: Fractional a => a
expected = 90
main :: IO ()
main = do
output <- readProcess "cabal" ["haddock"] ""
if average (match output) >= expected
then exitSuccess
else putStr output >> exitFailure
match :: String -> [Int]
match = fmap read . concat . catMaybes . fmap (matchRegex pattern) . lines
where
pattern = mkRegex "^ *([0-9]*)% "
This is the most complex code so far. Matching regular expressions in Haskell
isn’t as easy as in scripting languages like Perl. But this code does what we
want and the only part that might change is the expected
value.
To make this a bona fide test suite, we need to tell Cabal.
-- husk.cabal
test-suite haddock
build-depends: base, process == 1.1.*, regex-compat == 0.95.*
default-language: Haskell2010
hs-source-dirs: test-suite
main-is: Haddock.hs
type: exitcode-stdio-1.0
Finally we can run it.
# cabal install --enable-tests
# cabal test
Test suite haddock: RUNNING...
Test suite haddock: PASS
Test suite logged to: dist/test/husk-0.0.0-haddock.log
Test Coverage
We know how much of our code we documented, but we don’t know how much of it we
tested. Let’s fix that by modifying our hspec
test suite to use HPC.
-- husk.cabal
test-suite hspec
ghc-options: -fhpc
hs-source-dirs: test-suite library
other-modules: Husk, HuskSpec
What we’re doing here is telling GHC to enable HPC. We also have to add all the
source and test files to other-modules
so HPC can analyze them. This is kind
of annoying, especially since HSpec discovers our tests. But it’s not too bad
because if you forget a file, HPC will yell at you and your test will fail.
Before it can fail, though, we need to write it. This new test suite looks a lot like the last one.
-- test-suite/HPC.hs
module Main (main) where
import Data.List (genericLength)
import Data.Maybe (catMaybes)
import System.Exit (exitFailure, exitSuccess)
import System.Process (readProcess)
import Text.Regex (matchRegex, mkRegex)
average :: (Fractional a, Real b) => [b] -> a
average xs = realToFrac (sum xs) / genericLength xs
expected :: Fractional a => a
expected = 90
main :: IO ()
main = do
output <- readProcess "hpc" ["report", "dist/hpc/tix/hspec/hspec.tix"] ""
if average (match output) >= expected
then exitSuccess
else putStr output >> exitFailure
match :: String -> [Int]
match = fmap read . concat . catMaybes . fmap (matchRegex pattern) . lines
where
pattern = mkRegex "^ *([0-9]*)% "
Just like the last one, we have to add it to the Cabal file.
-- husk.cabal
test-suite hpc
build-depends: base, process == 1.1.*, regex-compat == 0.95.*
default-language: Haskell2010
hs-source-dirs: test-suite
main-is: HPC.hs
type: exitcode-stdio-1.0
The order of things in the Cabal file doesn’t matter. But it runs the test suites in order of appearance. Since this test uses the output of the HSpec suite, make sure it comes after that one. If it doesn’t, it’ll either run with old data or no data.
# cabal install --enable-tests
# cabal test
Test suite hspec: RUNNING...
Test suite hspec: PASS
Test suite logged to: dist/test/husk-0.0.0-hspec.log
Warning: Your version of HPC (0.6) does not properly handle multiple search
paths. Coverage report generation may fail unexpectedly. These issues are
addressed in version 0.7 or later (GHC 7.8 or later). The following search
paths have been abandoned: ["dist/hpc/mix/husk-0.0.0"]
Writing: hpc_index.html
Writing: hpc_index_fun.html
Writing: hpc_index_alt.html
Writing: hpc_index_exp.html
Test coverage report written to dist/hpc/html/hspec/hpc_index.html
Test suite hpc: RUNNING...
Test suite hpc: PASS
Test suite logged to: dist/test/husk-0.0.0-hpc.log
You can ignore HPC’s warning about search paths. Everything works fine in spite of it.
Static Analysis
You might think we’ve got enough tests, but there’s still one last suite to write. It’s going to enforce code conventions with HLint.
-- test-suite/HLint.hs
module Main (main) where
import Language.Haskell.HLint (hlint)
import System.Exit (exitFailure, exitSuccess)
arguments :: [String]
arguments =
[ "benchmark"
, "executable"
, "library"
, "test-suite"
]
main :: IO ()
main = do
hints <- hlint arguments
if null hints then exitSuccess else exitFailure
Thanks to HLint’s excellent interface, there’s nothing too interesting going on here. Let’s tell Cabal about it.
-- husk.cabal
test-suite hlint
build-depends: base, hlint == 1.8.*
default-language: Haskell2010
hs-source-dirs: test-suite
main-is: HLint.hs
type: exitcode-stdio-1.0
All that’s left to do now is run it!
# cabal install --enable-tests
# cabal test
Test suite hlint: RUNNING...
Test suite hlint: PASS
Test suite logged to: dist/test/husk-0.0.0-hlint.log
Continuous Integration
We’ve got all these tests now. They don’t do us any good if nobody ever runs them. Since it’s all too easy to forget to run the tests when you’re developing, let’s make a computer do it!
[Travis CI]31] makes continuous integration a cinch. Assuming your code is on [GitHub, all you have to do is make one file and add one line to it.
# .travis.yml
language: haskell
Now every time you push to GitHub, Travis will run your tests. You’ll get an email if they aren’t green.
Notes
This turned out to be much bigger than I anticipated. And I had to leave some stuff out! For more details, check out Haskeleton. Hopefully some day it will make this post obsolete.
In the meantime, email me if you have any questions. I’m happy to help!
I used a number of invaluable resources while writing this post, including:
- The Haskell wiki. It contains a staggering amount of useful information and links. Sometimes the information is out of date, but it’s helpful nonetheless.
- Sebastiaan Visser’s post, “Towards a better Haskell package”. It provides reasoned arguments for a number of subjective points about package development.
- Kazu Yamamoto’s unit testing guide. It helped me wrap my head around HUnit, QuickCheck, HSpec, and DocTest.
- hi, a Haskell package generator by Fujimura Daisuke. Haskeleton could end up as a template for this excellent utility.