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, Gulp, 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:
- It’s dumb (as in simple)
- 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 & 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 egghead.io 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 folks 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 |>
.
: INPUT BLOB |> SHELL COMMAND |> OUTPUT FILES
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.
- Tup’s documentation is scarce
-
Tup isn’t easy to acquire from package managers outside
brew
, Arch’s AUR, and Nixpkgs - Tup does not work with with most cache-y build systems
-
Tup’s
monitor
mode only works on Linux via inotify
Scarce documententation sucks, however Tup is straightforward enough that what is there will usually suffice. Some features are not documented tho which makes it hard to know what Tup can do.
Building Tup via the Git repo but it does require libfuse-dev
. Neat tho 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 tho. 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.
Tip
|
You can be cool human being and upvote the issue for that: Microsoft™ GitHub® #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
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")
break
case "darwin":
case "win32":
case "freebsd":
case "openbsd":
watch([
"./Tupfile",
"./tup.config",
// your source directory here
"./src",
], {
ignored: [ "*.elm" ],
ignoreInitial: true,
apersistent: true,
}).on("all", (event, err) => {
switch(event) {
case "ready":
console.log("Scan complete. Build …")
execute("tup -j2 upd")
console.log("Watcher ready …")
break
case "error":
console.error("Watcher error:", err)
break
case "add":
case "addDir":
case "change":
case "unlink":
case "unlinkDir":
execute("tup -j2 upd")
break
}
})
break
default:
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
, yarn watch
, or whatever your package manager does.
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",
"...": 0,
},
"...": 0
}
and can use npm run build
and npm run 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.