Build tools… task runners… gotta love hating them… gotta have them too.

In Unix land it’s popular to see build systems like Make, Ninja, Shake, CMake, SCons, fabricate, Gup, Fbuild, Aquali. And in JavaScript front-end alone you get your picks of Grunt, Gulp, Webpack, Broccoli, Brunch, Mimosa, Jake, Cake—and probably more. There’s scores of blogs about pros and cons of each and the other.

Personally, I have my own favorite: Tup.

This blog will mostly be told from a Unix and front-end perspective.

I Haven’t Heard of tup… Why Should I care about it?

Well, two reasons:

  1. It’s dumb
  2. It has guarantees

Dumb is Good

Have you ever touched any of these other build tools? Often times there’s configuration all over the place. Particularly in the JS ones you normally blindly install a bunch of plugins from npm that all and trying to figure out how those plugins work, because they may or may not implement all of the features of the underlying tool—many of which will just sit on top of command line tools anyhow. I don’t even know anyone that truly understands how Webpack works (in fact, there’s a whole course just on the build tool).

How do most of us start a project? Usually by typing in a command on the command line (or in JavaScript land, a lot of people will check out these so-called “starter packs” because the tooling is so complicated). Eventually this becomes a bit more complicated and we’ll choose to write a Bash script that isn’t optimized for building, but is dumb enough to follow. Some of the tools are more straightforward like Make. But I personally find the premise of “it looks and feels like the command line is” to be a really strong reason to choose one tool over the next. The dumbness and simplicity of the command line is good.

High-Level Syntax Explaination

There’s barely even documentation because of how dumb tup really is. In fun pseudo-code, it looks like this …and it even has our FP-loved pipe infix |>.


Functional Programming Has Guarantees — Your Build Tool Can Too!

So in the short syntax idea example, we can see our input files will be piped into our shell command in out to the specified output files. The way the output area works is that when we run that command we most get the specified output files—nothing more nothing less. If we fail to satisfy this test and, say command has a side-effect we didn’t see, then it fails. Tup is in constant assertion mode. It will use this input/output specification to build a dependency graph as well to optimize rebuilds.

Referential transparency is a great thing where we can use substitution to replace our functions with values with the same results. Tup literally does the same thing. For reference: %f is the input file name & path, %o the output file name & path, and %B is the input file name stripped of the extension.

: ./src/main.sass |> sassc %f %o |> ./dist/%B.css

While tup is running, we’ll even see sassc ./src/main.sass ./dist/main.css—which if it’s failing and the test the command, we can copy the shell’d line and run to prove it works. To top it off, Tup also has a generate flag that will turn its config into a shell script of its commands if some lazy coworker refuses to install Tup.

The Unix pipes we love work too so we can do command composition. Here we’ll build a macro for Sass compilation with flags that pipes the any .sass file through sassc and into PostCSS’s Autoprefixer CLI:

# Flags
SASSC_FLAGS = --style compressed
SASSC_FLAGS += --precision 10

POSTCSS_FLAGS = --use autoprefixer
POSTCSS_FLAGS += --autoprefixer.remove false
POSTCSS_FLAGS += --autoprefixer.browsers "last 2 versions, > 2%, Firefox ESR, not IE <= 10"

# Macros
!sass = |> sassc $(SASSC_FLAGS) %f | `yarn bin`/postcss $(POSTCSS_FLAGS) --output %o |>

# Sass - building main.css
:foreach ./src/main.sass | ./src/*.sass |> !sass |> ./dist/%B.css

It’s all just substitution.

Other Cool Things

Since tup is building a dependency graph, when you remove a source file, it is wiped from the build. What this means: you get clean for free because that is how Tup works.

Tup can also be used in tandem with Lua to make even more complicated parsers.

What Are the Downsides

Tup is not perfect. I have not been able to use it as often as I like.

Scarce documententation sucks, however Tup is straightforward enough that what is there will usually suffice. Some features are not documented though which makes it hard to know what Tup can do.

Building Tup via the Git repo but it does require libfuse-dev. Neat though is that it bootstraps to build itself so a git pull followed by tup is a fast upgrade.

The cache-y build thing is pretty bad though. This includes the most dear to me: elm-make whose elm-stuff folder makes the build faster, but I’ve also had problems in the pre-libsass options of the Ruby gem sass and the caching options for rollup. At one point I had one that worked OK-ish with Elm, but it was ineffecient to parse every file’s imports and see what was going to go where. This issue will kill entirely some options from being used within Tup. Be a cool human being and upvote this Github issue for that: #295.

The monitor mode can be gotten around—it may not be optimal, but Tup does not build if doesn’t have to so we can be a little careless on the triggering a rebuild.

Getting Cross-Platform Watching/Monitoring

I know with Haskell, you have standard packages for file watching, but for the greater audience we can write something with Node. Our dependency needs to be added to the project.

npm install --save chokidar
# or
yarn add chokidar

And we have a script watch.js:

const { execSync } = require("child_process")
const { watch } = require("chokidar")

// partially applied `execSync` with stdio stuff
const execute = (cmd) =>
  execSync(cmd, { stdio: [0, 1, 2] })

try {
  switch (process.platform) {
    // tup's monitor mode is only supported on Linux
    case "linux":
      execute("tup monitor -j2 -a")

    case "darwin":
    case "win32":
    case "freebsd":
    case "openbsd":
        [ "./Tupfile"
        , "./tup.config"
        // your source directory here
        , "./src"
        { ignored: [ "*.elm" ]
        , ignoreInitial: true
        , persistent: true
      ).on("all", (event, x) => {
          console.log(event, x)
          switch(event) {
            case "ready":
              console.log("Scan complete. Build ...")
              execute("tup -j2 upd")
              console.log("Watcher ready ...")

            case "error":
              console.error("Watcher error:", x)

            case "add":
            case "addDir":
            case "change":
            case "unlink":
            case "unlinkDir":
              execute("tup -j2 upd")

      console.warn("Unsupported platform:", process.platform)
} catch (error) {
  console.error("Unable to watch files:", error)

Now you can run node watch.js and have watching. You can add this to your package.json too for npm run watch or yarn run watch.

Final Thoughts

What I have been doing is using Tup where I can, and using other tools where I can’t. So in my package.json is

  "scripts": {
    "start": "concurrently -r \"elm-live --path-to-elm-make=./node_modules/.bin/elm-make --dir=./dist --pushstate --output=./dist/elm.js ./src/Main.elm\" && \"node ./watch.js\"",
    "build": "tup && elm-make --output=./dist/elm.js ./src/Main.elm",

and can use yarn run build and yarn start to build and watch respectively.

The purpose isn’t to show how to use Tup, but rather where Tup is a good option in the current sea of build tools. Particularly if you’re just doing front-end work, it’s a pretty simple solution that doesn’t involve a bajillion moving parts/plugins as seen in the current landscape. It’s got the feel of the Bash script with a couple things to make it more effecient and directed toward the purpose of building.