This is How to Build Haskell with GNU Make (and why it's worth trying)
In a previous article I showed the GHC commands you need to compile a basic Haskell executable without explicitly using the source files from its dependencies. But when you're writing your own Haskell code, 99% of the time you want to be using a Haskell build system like Stack or Cabal for your compilation needs instead of writing your own GHC commands. (And you can learn how to use Stack in my new free course, Setup.hs).
But part of my motivation for solving that problem was that I wanted to try an interesting experiment:
How can I build my Haskell code using GNU Make?
GNU Make is a generic build system that allows you to specify components of your project, map out their dependencies, and dictate how your build artifacts are generated and run.
I wanted to structure my source code the same way I would in a Cabal-style application, but rely on GNU Make to chain together the necessary GHC compilation commands. I did this to help gain a deeper understanding of how a Haskell build system could work under the hood.
In a Haskell project, we map out our project structure in the .cabal
file. When we use GNU Make, our project is mapped out in the makefile
. Here's the Makefile we'll ultimately be constructing:
GHC = ~/.ghcup/ghc/9.2.5/bin/ghc
BIN = ./bin
EXE = ${BIN}/hello
LIB_DIR = ${BIN}/lib
SRCS = $(wildcard src/*.hs)
LIB_OBJS = $(wildcard ${LIB_DIR}/*.o)
library: ${SRCS}
@mkdir -p ${LIB_DIR}
@${GHC} ${SRCS} -hidir ${LIB_DIR} -odir ${LIB_DIR}
generate_run: app/Main.hs library
@mkdir -p ${BIN}
@cp ${LIB_DIR}/*.hi ${BIN}
@${GHC} -i${BIN} -c app/Main.hs -hidir ${BIN} -odir ${BIN}
@${GHC} ${BIN}/Main.o ${LIB_OBJS} -o ${EXE}
run: generate_run
@${EXE}
TEST_DIR = ${BIN}/test
TEST_EXE = ${TEST_DIR}/run_test
generate_test: test/Spec.hs library
@mkdir -p ${TEST_DIR}
@cp ${LIB_DIR}/*.hi ${TEST_DIR}
@${GHC} -i${TEST_DIR} -c test/Spec.hs -hidir ${TEST_DIR} -odir ${TEST_DIR}
@${GHC} ${TEST_DIR}/Main.o ${LIB_OBJS} -o ${TEST_EXE}
test: generate_test
@${TEST_EXE}
clean:
rm -rf ./bin
Over the course of this article, we'll build up this solution piece-by-piece. But first, let's understand exactly what Haskell code we're trying to build.
Our Source Code
We want to lay out our files like this, separating our source code (/src
directory), from our executable code (/app
) and our testing code (/test
):
.
├── app
│ └── Main.hs
├── makefile
├── src
│ ├── MyFunction.hs
│ └── TryStrings.hs
└── test
└── Spec.hs
Here's the source code for our three primary files:
-- src/MyStrings.hs
module MyStrings where
greeting :: String
greeting = "Hello"
-- src/MyFunction.hs
module MyFunction where
modifyString :: String -> String
modifyString x = base <> " " <> base
where
base = tail x <> [head x]
-- app/Main.hs
module Main where
import MyStrings (greeting)
import MyFunction (modifyString)
main :: IO ()
main = putStrLn (modifyString greeting)
And here's what our simple "Spec" test looks like. It doesn't use a testing library, it just prints different messages depending on whether or not we get the expected output from modifyString
.
-- test/Spec.hs
module Main where
import MyFunction (modifyString)
main :: IO ()
main = do
test "abcd" "bcda bcda"
test "Hello" "elloH elloH"
test :: String -> String -> IO ()
test input expected = do
let actual = modifyString input
putStr $ "Testing case: " <> input <> ": "
if expected /= actual
then putStrLn $ "Incorrect result! Expected: " <> expected <> " Actual: " <> actual
else putStrLn "Correct!"
The files are laid out the way we would expect for a basic Haskell application. We have our "library" code in the src
directory. We have a single "executable" in the app
directory. And we have a single "test suite" in the test
directory. Instead of having a Project.cabal
file at the root of our project, we'll have our makefile
. (At the end, we'll actually compare our Makefile with an equivalent .cabal
file).
But what does the Makefile look like? Well it would be overwhelming to construct it all at once. Let's begin slowly by treating our executable as a single file application.
Running a Single File Application
So for now, let's adjust Main.hs
so it's an independent file without any dependencies on our library modules:
-- app/Main.hs
module Main where
main :: IO ()
main = putStrLn "Hello"
The simplest way to run this file is runghc
. So let's create our first makefile
rule that will do this. A rule has a name, a set of prerequisites, and then a set of commands to run. We'll call our rule run
, and have it use runghc
on app/Main.hs
. We'll also include the app/Main.hs
as a prerequisite, since the rule will run differently if that file changes.
run: app/Main.hs
runghc app/Main.hs
And now we can run this run using make run
, and it will work!
$ make run
runghc app/Main.hs
Hello
Notice that it prints the command we're running. We can change this by using the @
symbol in front of the command in our Makefile. We'll do this with almost all our commands:
run: app/Main.hs
@runghc app/Main.hs
And it now runs our application without printing the command.
Using runghc
is convenient, but if we want to use dependencies from different directories, we'll eventually need to use multiple stages of compilation. So we'll want to create two distinct rules. One that generates the executable using ghc
, and another that actually runs the generated executable.
So let's create a generate_run
rule that will produce the build artifacts, and then run
will use them.
generate_run: app/Main.hs
@ghc app/Main.hs
run: generate_run
@./app/Main
Notice that run
can depend on generate_run
as a prerequisite, instead of the source file now. This also generates three build artifacts directly in our app
directory: the interface file Main.hi
, the object file Main.o
, and the executable Main
.
It's bad practice to mix build artifacts with source files, so let's use GHC's arguments (-hidir
, -odir
and -o
) to store these artifacts in their own directory called bin
.
generate_run: app/Main.hs
@mkdir -p ./bin
@ghc app/Main.hs -hidir ./bin -odir ./bin -o ./bin/hello
run: generate_run
@./bin/hello
We can then add a third rule to "clean" our project. This would remove all binary files so that we can do a fresh recompilation if we want.
clean:
rm -rf ./bin
For one final flourish in this section, we can use some variables. We can make one for the GHC compiler, referencing its absolute path instead of a symlink. This would make it easy to switch out the version if we wanted. We'll also add a variable for our bin
directory and the hello
executable, since these are used multiple times.
# Could easily switch versions if desired
# e.g. GHC = ~/.ghcup/ghc/9.4.4/bin/ghc
GHC = ~/.ghcup/ghc/9.2.5/bin/ghc
BIN = ./bin
EXE = ${BIN}/hello
generate_run: app/Main.hs
@mkdir -p ${BIN}
@${GHC} app/Main.hs -hidir ${BIN} -odir ${BIN} -o ${EXE}
run: generate_run
@${EXE}
clean:
rm -rf ./bin
And all this still works as expected!
$ generate_run
[1 of 1] Compiling Main (app/Main.hs, bin/Main.o)
Linking ./bin/hello
$ make run
Hello
$ make clean
rm -rf ./bin
So we have some basic rules for our executable. But remember our goal is to depend on a library. So let's add a new rule to generate the library objects.
Generating a Library
For this step, we would like to compile src/MyStrings.hs
and src/MyFunction.hs
. Each of these will generate an interface file (.hi
) and an object file (.o
). We want to place these artifacts in a specific library directory within our bin
folder.
We'll do this by means of a new rule, library
, which will use our two source files as its prerequisites. It will start by creating the library artifacts directory:
LIB_DIR = ${BIN}/lib
library: src/MyStrings.hs src/MyFunction.hs
@mkdir -p ${LIB_DIR}
...
But now the only thing we have to do is use GHC on both of our source files, using LIB_DIR
as the destination point.
LIB_DIR = ${BIN}/lib
library: src/MyStrings.hs src/MyFunction.hs
@mkdir -p ${LIB_DIR}
@ghc src/MyStrings.hs src/MyFunction.hs -hidir ${LIB_DIR} -odir ${LIB_DIR}
Now when we run the target, we'll see that it produces the desired files:
$ make library
$ ls ./bin/lib
MyFunction.hi MyFunction.o MyStrings.hi MyStrings.o
Right now though, if we added a new source file, we'd have to modify the rule in two places. We can fix this by adding a variable that uses wildcard
to match all our source files in the directory (src/*.hs
).
LIB_DIR = ${BIN}/lib
SRCS = $(wildcard src/*.hs)
library: ${SRCS}
@mkdir -p ${LIB_DIR}
@${GHC} ${SRCS} -hidir ${LIB_DIR} -odir ${LIB_DIR}
While we're learning about wildcard
, let's make another variable to capture all the produced object files. We'll use this in the next section.
LIB_OBJS = $(wildcard ${LIB_DIR}/*.o)
So great! We're producing our library artifacts. How do we use them?
Linking the Library
In this section, we'll link our library code with our executable. We'll begin by assuming our Main
file has gone back to its original form with imports, instead of the simplified form:
-- app/Main.hs
module Main where
import MyStrings (greeting)
import MyFunction (modifyString)
main :: IO ()
main = putStrLn (modifyString greeting)
We when try to generate_run
, compilation fails because it cannot find the modules we're trying to import:
$ make generate_run
...
Could not find module 'MyStrings'
...
Could not find module 'MyFunction'
As we went over in the previous article, the general approach to compiling the Main
module with its dependencies has two steps:
1. Compile with the -c
option (to stop before the linking stage) using -i
to point to a directory containing the interface files.
2. Compile the generated Main.o
object file together with the library .o
files to produce the executable.
So we'll be modifying our generate_main
rule with some extra steps. First of course, it must now depend on the library
rule. Then our first new command will be to copy the .hi
files from the lib
directory into the top-level bin
directory.
generate_run: app/Main.hs library
@mkdir -p ./bin
@cp ${LIB_DIR}/*.hi ${BIN}
...
We could have avoided this step by generating the library artifacts in bin
directly. I wanted to have a separate location for all of them though. And while there may be some way to direct the next command to find the headers in the lib
directory, none of the obvious ways worked for me.
Regardless, our next step will be to modify the ghc
call in this rule to use the -c
and -i
arguments. The rest stays the same:
generate_run: app/Main.hs library
@mkdir -p ./bin
@cp ${LIB_DIR}/*.hi ${BIN}
@${GHC} -i${BIN} -c app/Main.hs -hidir ${BIN} -odir ${BIN}
...
Finally, we invoke our final ghc
call, linking the .o
files together. At the command line, this would look like:
$ ghc ./bin/Main.o ./bin/lib/MyStrings.o ./bin/lib/MyFunction.o -o ./bin/hello
Recalling our LIB_OBJS
variable from up above, we can fill in the rule in our Makefile like so:
LIB_OBJS = $(wildcard ${LIB_DIR}/*.o)
generate_run: app/Main.hs library
@mkdir -p ./bin
@cp ${LIB_DIR}/*.hi ${BIN}
@${GHC} -i${BIN} -c app/Main.hs -hidir ${BIN} -odir ${BIN}
@${GHC} ${BIN}/Main.o ${LIB_OBJS} -o ${EXE}
And now our program will work as expected! We can clean it and jump straight to the make run
rule, since this will run its prerequisites make library
and make generate_run
automatically.
$ make clean
rm -rf ./bin
$ make run
[1 of 2] Compiling MyFunction (src/MyFunction.hs, bin/lib/MyFunction.o)
[2 of 2] Compiling MyStrings (src/MyStrings.hs, bin/lib/MyStrings.o)
elloH elloH
So we've covered the library
and an executable
, but most Haskell projects have at least one test suite. So how would we implement that?
Adding a Test Suite
Well, a test suite is basically just a special executable. So we'll make another pair of rules, generate_test
and test
, that will mimic generate_run
and run
. Very little changes, except that we'll make another special directory within bin
for our test artifacts.
TEST_DIR = ${BIN}/test
TEST_EXE = ${TEST_DIR}/run_test
generate_test: test/Spec.hs library
@mkdir -p ${TEST_DIR}
@cp ${LIB_DIR}/*.hi ${TEST_DIR}
@${GHC} -i${TEST_DIR} -c test/Spec.hs -hidir ${TEST_DIR} -odir ${TEST_DIR}
@${GHC} ${TEST_DIR}/Main.o ${LIB_OBJS} -o ${TEST_EXE}
test: generate_test
@${TEST_EXE}
Of note here is that at the final step, we're still using Main.o
instead of Spec.o
. Since it's an executable module, it also compiles as Main
.
But we can then use this to run our tests!
$ make clean
$ make test
[1 of 2] Compiling MyFunction (src/MyFunction.hs, bin/lib/MyFunction.o)
[2 of 2] Compiling MyStrings (src/MyStrings.hs, bin/lib/MyStrings.o)
Testing case: abcd: Correct!
Testing case: Hello: Correct!
So now we have all the different components we'd expect in a normal Haskell project. So it's interesting to consider how our makefile
definition would compare against an equivalent .cabal
file for this project.
Comparing to a Cabal File
Suppose we want to call our project HaskellMake
and store its configuration in HaskellMake.cabal
. We'd start our Cabal file with four metadata lines:
cabal-version: 1.12
name: HaskellMake
version: 0.1.0.0
build-type: Simple
Now our library would expose its two modules, using the src
directory as its root. The only "dependency" is the Haskell base
packages. Finally, default-language
is a required field.
library
exposed-modules:
MyStrings
, MyFunction
hs-source-dirs:
src
build-depends:
base
default-language: Haskell2010
The executable would similarly describe where the files are located and state a base
dependency as well as a dependency on the library itself.
executable hello
main-is: Main.hs
hs-source-dirs:
app
build-depends:
base
, HaskellMake
default-language: Haskell2010
Finally, our test suite would look very similar to the executable, just with a different directory and filename.
test-suite make-test
type: exitcode-stdio-1.0
main-is: Spec.hs
hs-source-dirs:
test
build-depends:
base
, HaskellMake
default-language: Haskell2010
And, if we add a bit more boilerplate, we could actually then compile our code with Stack! First we need a stack.yaml
specifying the resolver and the package location:
# stack.yaml
resolver: lts-20.12
packages:
- .
Then we need Setup.hs
:
-- Setup.hs
import Distribution.Simple
main = defaultMain
And now we could actually run our code!
$ stack build
$ stack exec hello
elloH elloH
$ stack test
Testing case: abcd: Correct!
Testing case: Hello: Correct!
Now observant viewers will note that we don't use any Hackage dependencies in our code - only base
, which GHC always knows how to find. It would require a lot of work for us to replicate dependency management. We could download a .zip file with curl
easily enough, but tracking the whole dependency tree would be extremely difficult.
And indeed, many engineers have spent a lot of time getting this process to work well with Stack and Cabal! So while it would be a useful exercise to try to do this manually with a simple dependency, I'll leave that for a future article.
When comparing the two file definitions, Undoubtedly, the .cabal
definition is more concise and human readable, but it hides a lot of implementation details. Most of the time, this is a good thing! This is exactly what we expect from tools in general; they should allow us to work more quickly without having to worry about details.
But there are times where we might, on our time, want to occasionally try out a more adventurous path like we've done in this article that avoids relying too much on modern tooling. So why was this article a "useful exercise"™?
What's the Point?
So obviously, there's no chance this Makefile approach is suddenly going to supplant Cabal and Stack for building Haskell projects. Stack and Cabal are "better" for Haskell precisely because they account for the intricacies of Haskell development. In fact, by their design, GHC and Cabal both already incorporate some key ideas and features from GNU Make, especially with avoiding re-work through dependency calculation.
But there's a lot you can learn by trying this kind of exercise.
First of all, we learned about GNU Make. This tool can be very useful if you're constructing a program that combines components from different languages and systems. You could even build your Haskell code with Stack, but combine it with something else in a makefile
.
A case and point for this is my recent work with Haskell and AWS. The commands for creating a docker image, authenticating to AWS and deploying it are lengthy and difficult to remember. A makefile
can, at the very least, serve as a rudimentary aliasing tool. You could run make deploy
and have it automatically rebuild your changes into a Docker image and deploy that to your server.
But beyond this, it's important to take time to deliberately understand how our tools work. Stack and Cabal are great tools. But if they seem like black magic to you, then it can be a good idea to spend some time understanding what is happening at an internal level - like how GHC is being used under the hood to create our build artifacts.
Most of the fun in programming comes in effectively applying tools to create useful programs quickly. But if you ever want to make good tools in the future, you have to understand what's happening at a deeper level! At least a couple times a year, you should strive to go one level deeper in your understanding of your programming stack.
For me this time, it was understanding just a little more about GHC. Next time I might dive into dependency management, or a different topic like the internal workings of Haskell data structures. These kinds of topics might not seem immediately applicable in your day job, but you'll be surprised at the times when deeper knowledge will pay dividends for you.
Getting Better at Haskell
But enough philosophizing. If you're completely new to Haskell, going "one level deeper" might simply mean the practical ability to use these tools at a basic level. If your knowledge is more intermediate, you might want to explore ways to improve your development process. These thoughts can lead to questions like:
1. What's the best way to set up my Haskell toolchain in 2023?
2. How do I get more efficient and effective as a Haskell programmer?
You can answer these questions by signing up for my new free course Setup.hs! This will teach how to install your Haskell toolchain with GHCup and get you started running and testing your code.
Best of all, it will teach you how to use the Haskell Language Server to get code hints in your editor, which can massively increase your rate of progress. You can read more about the course in this blog post.
If you subscribe to our monthly newsletter, you'll also get an extra bonus - a 20% discount on any of our paid courses. This offer is good for two more weeks (until May 1) so don't miss out!