We described several times before how to interface Haskell with other languages. Such interfaces between languages are important because projects rarely use a single language: they are polyglot. So build systems should be polyglot too. It’s better to have one polyglot build system than many single purpose ones. The headline benefit is that a single build system can achieve better caching, better parallelism, and therefore faster builds. It’s also easier to avoid correctness issues.
Bazel, open sourced by Google in 2015, is one such polyglot build system. Using rules_haskell, Bazel supports Haskell. In this post, we’ll show how to get started with Bazel on a small but non-trivial project, featuring a library, a web service and an Hspec test suite.
Bazel has two kinds of files, which both use Python syntax:
- A unique
WORKSPACEfile, which identifies a Bazel workspace. The
WORKSPACEfile is the only place where additional code and data outside of the workspace can be pulled in.
BUILD.bazelfiles containing a specification of the build dependency graph. Bazel is designed for modularity and to scale to very large repositories, so you can break up the dependency graph spec in many
BUILD.bazelfiles scattered across your repository.
First, create a
WORKSPACE file at the root of your project
using the start script. The created file looks like this:
workspace(name = "your_cool_project_name") # Import the rule that can download tarballs using HTTP. load( "@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", ) http_archive( name = "rules_haskell", strip_prefix = "rules_haskell-0.12", urls = ["https://github.com/tweag/rules_haskell/archive/v0.12.tar.gz"], ) load( "@rules_haskell//haskell:repositories.bzl", "rules_haskell_dependencies", ) # Setup rules_haskell. rules_haskell_dependencies() load( "@rules_haskell//haskell:toolchain.bzl", "rules_haskell_toolchains", ) # Download a GHC binary distribution from haskell.org # and register it as an available toolchain. rules_haskell_toolchains(version = "8.6.5")
WORKSPACE file is very explicit. There’s a good reason for this.
To be fast, Bazel is lazy: Bazel caches and reuses the result of previous builds if possible
and loads build files only if required. To do that reliably,
Bazel tracks changes of both source files and build files. Tracking
build files requires tightly controlling what they include,
hence the use of
load statements to make symbols available
http_archive is the first such symbol in the snippet above).
Add what follows to the
WORKSPACE file, adapting the list
of package according to what your project requires:
load( "@rules_haskell//haskell:cabal.bzl", "stack_snapshot", ) stack_snapshot( name = "stackage", packages = [ "aeson", "base", "directory", "filepath", "hspec", "optparse-applicative", # more packages ], snapshot = "lts-14.11", )
packages attribute lists the packages to pull from
snapshot attribute specifies the
desired snapshot version, as in your
name = stackage in the
file extends the Bazel workspace to include third-party code
downloaded from Hackage, in an external repository called
@stackage. This repository includes a number of targets, like
@stackage//:filepath, also defined by
stack_snapshot, based on metadata downloaded from Stackage. Under
the hood, Bazel uses Stack to process this metadata.
Let’s say that your project has the following layout for its Haskell code:
. └── haskell ├── exe ├── src └── test
- library code lives in
- the executable’s code lives in
- test code lives in
Declaring how to build the library code is as simple as adding
the following in
load("@rules_haskell//haskell:defs.bzl", "haskell_library") haskell_library( name = "mylib", srcs = glob(["**/*.hs"]), # match all .hs files deps = [ "@stackage//:aeson", "@stackage//:base", "@stackage//:filepath", ], # Make library code available to executable and tests visibility = [ "//haskell/exe:__pkg__", "//haskell/test:__pkg__", ], )
Then, declare how the executable code is built with the
load("@rules_haskell//haskell:defs.bzl", "haskell_binary") haskell_binary( name = "server", srcs = ["Main.hs"], deps = [ "@stackage//:base", "@stackage//:optparse-applicative", # Depend on the library defined above "//haskell/src:mylib", ], )
And finally for the tests in
load( "@rules_haskell//haskell:defs.bzl", "haskell_test", ) haskell_test( name = "tests", # Lists the source files containing tests, srcs = ["AppSpec.hs", "Spec.hs"], tools = ["@hspec-discover"], compiler_flags = [ "-XCPP", "-DHSPEC_DISCOVER=$(location @hspec-discover)" ], deps = [ "@stackage//:base", "@stackage//:hspec", "//haskell/src:mylib", ], )
This rule states that the executable
hspec-discover is required, via
tools field. See this
for the full
WORKSPACE file to see how this binary is obtained via a simple
application of the
rule. The rule also makes this executable available to the compiler, using the
$(location ...) to find out the runtime path to the executable.
This syntax might seem verbose. But rather than grabbing whatever
hspec-discover happens to be in the
$PATH, what we’re doing here
is telling Bazel exactly which binary we want to use for the above
target. When the project grows large, other parts of the build could
use a different
hspec-discover, potentially. If
ever changes, Bazel knows exactly what needs to be rebuilt: the
:tests target above, since
hspec-discover is a dependency,
Even small projects require important features from a build system:
- mechanisms to specify exactly what compiler toolchain we want to use, to make the build reproducible,
- a way to resolve symbolic names to specific package versions on Hackage, using package snapshots,
- the ability to build preprocessors (like
hspec-discover) and tell build targets about their location,
What we have shown is that Bazel today supports doing all of the
above. Alternatives like cabal-install and Stack
support this too, and
for small to medium sized projects, they work just fine and are
simpler to handle than the Bazel workhorse. But I hope I’ve given you
here a glimpse of what the Bazel way looks like: make all
dependencies explicit including binary dependencies, design for
cacheability, and infinite extensibility using custom build rules
loaded from your own Python-syntax
.bzl files like we do in all the
build and workspace files above.
You may also want to read the
more advanced use cases.
Then you can move on to build the rest of your polyglot project with Bazel, too. Bazel supports a large number of languages. And when the project gets big and the build times substantial, turn to shared caching among all developers.