Haskeleton: A Haskell Project Skeleton

by on

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 to Simple. For some reason the default is Custom.
  • 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 is Haskell2010.

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 on base, 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 + returns 3 when given 1 and 2.
    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.