presumably for side-effects
a blog about programming

How to Do Things With Babashka

published Dec 21, 2022

It’s time to move on from Bash.

Bash may be a fine interactive shell, but it’s inadequate for scripting. By default, Bash scripts swallow errors (and far from fixing this mistake, the set -e option comes with its own set of footguns). Bash has arrays and other useful data structures, but they’re notoriously buggy in Bash 3, which is what’s preinstalled on macOS. And, finally, it lacks the means of abstraction required to safely express complex logic - and by complex logic, I mean anything requiring a loop or function call.

Babashka, a Clojure dialect, is superior in all these respects. It has safe concurrency primitives and comes with support for finding files, starting subprocesses and reading and writing JSON. And the built-in Clojure standard library for transforming data structures is second to none.

What’s the cost? Thanks to the magic of GraalVM, the startup penalty is barely noticeable, despite the fact that Babashka is built on top of Java:

% time bb -e '(println (* 3 4))'
0.020 total  # that's 20 milliseconds
% time bash -c "echo $(( 3 * 4 ))"
0.006 total

This document demonstrates how to approach common Bash scripting tasks in Babashka. With this Rosetta stone, you can translate Bash scripts to Babashka - and get results that are vastly more reliable, maintainable and fun to work with.

Run a shell command

(require '[babashka.process :refer [shell]])
(shell "whoami")

When using babashka.process/shell, the output of the command executed is visible to the user, as the subprocess inherits stdout and stderr from its parent.

Bash equivalent:

whoami

Set environment variable for shell command

Normally, a child process gets the same environment (PATH, HOME, etc) as its parent, but you can choose to pass in additional environment variables:

(require '[babashka.process :refer [shell]])
(shell {:extra-env {"FOO" "bar"}} "printenv" "FOO")

Bash equivalent:

FOO=bar printenv FOO

Capture command output

Sometimes, instead of showing a command output to the user you want to store it in memory as a string:

(require '[babashka.process :refer [sh]])
(def myname (:out (sh "whoami")))

Bash equivalent:

myname=$(whoami)

Spawn background command

(require '[babashka.process :refer [process]])

(let [p (process {:out :inherit, :err :inherit}
                 "sh" "-c" "for i in `seq 3`; do date; sleep 1; done")]
  (println "Waiting for result...")
  ;; dereference to wait for result
  @p
  nil)

Note: this example requires babashka v1.0.168 or higher.

Because of its Java heritage, Babashka has strong threading primitives. Clojure makes working with concurrency safe and easy.

Bash equivalent:

sh -c 'for i in `seq 3`; do date; sleep 1; done' &
pid=$!
echo Waiting for result...
wait "$pid"

Read command output line by line

(require '[babashka.process :refer [process]]
         '[clojure.java.io :as io])

(let [stream (process {:err :inherit} "cat" "/etc/hosts")]
  (with-open [rdr (io/reader (:out stream))]
    (doseq [line (line-seq rdr)]
      (println (str "#" line))))
  nil)

Note: this example requires babashka v1.0.168 or higher.

This reads the command’s stdout in a streaming fashion, making the approach suitable for large files. However, if you know you’re not going to deal with large files, it’s easier to read the file into memory:

(require '[babashka.process :refer [shell]]
         '[clojure.java.io :as io])

(let [p (shell {:out :string} "cat" "/etc/hosts")]
  (doseq [line (clojure.string/split-lines (:out p))]
    (println (str "#" line)))

  nil)

Note that cat is used only as an example here. Use slurp to read a file efficiently.

Bash equivalent:

cat /etc/hosts | sed 's/^/#/'

Check if a file exists

(when (babashka.fs/exists? "/etc/hosts")
   (println "File exists"))

Bash equivalent:

if [[ -f /etc/hosts ]]; then echo File exists; fi

Duplicate an array

As a Bash programmer you may wonder how to duplicate an array in babashka. Well, you don’t.

First, babashka typically uses vectors, not arrays. But more importantly, in babashka you don’t need to make a copy of a thing to preserve the original, because vectors are immutable:

;; straightforward
(def my-args *command-line-args*)

Simple and easy! In Bash however… oh boy:

my_args="${@+"${@}"}"
my_args2="${my_args[@]+"${my_args[@]}"}" 

To the best of my knowledge, this is the only safe incantation to duplicate an array in Bash 3 (which is what ships with macOS).

Find project folder

A typical pattern is to locate the folder of the code project containing a script, regardless of the current working directory.

Assuming your script is located in a top-level folder called scripts/, you can use this:

;; Note that the `*file* form has to be evaluated at the top level of your file,
;; i.e. not in the body a function.
;;
;; Here fs/parent works similarly to the "dirname" Unix command.

(def project-root (-> *file* babashka.fs/parent babashka.fs/parent))

;; Print root folder
(println (str project-root))

;; Print filename in root folder
(println (str (babashka.fs/file project-root "README.txt")))

Bash equivalent:

project_root="$(dirname "${BASH_SOURCE[0]}")/.."

printf "%s\n" "$project_root"
printf "%s\n" "${project_root}/README.txt"

Copy, move, delete, read and write files

(require '[babashka.fs :as fs])

(spit "world" "hello\n")
(fs/copy "world" "world2")
(spit "world2" "world\n" :append true)
(fs/delete "world")
(fs/move "world2" "world")
(print (slurp "world"))

Common operations like spit or slurp and functions in babashka.fs accept a string filename or, alternatively, an instance of java.io.File as returned by babashka.fs/file. So (spit "out" "xxx") and (spit (babashka.fs/file "out") "xxx") are interchangeable.

Bash equivalent:

echo hello > world
cp world world2
echo "world" >> world2
rm world
mv world2 world
cat world

Work with dates and times

The modern java.time API is available in Babashka. For the common “YYYY-MM-DD” pattern you can use a built-in formatter:

(defn iso-date
  []
  (-> (java.time.LocalDateTime/now)
      (.format (java.time.format.DateTimeFormatter/ISO_LOCAL_DATE))))

(def fname (str "backup-" (iso-date) ".zip"))

Bash equivalent:

fname="backup-$(date '+%Y-%m-%d').zip"

If you need more control, you can specify your own DateTimeFormatter pattern:

(defn iso-date-hm
  []
  (-> (java.time.LocalDateTime/now)
      (.format (java.time.format.DateTimeFormatter/ofPattern "yyyy-MM-dd---kk-mm"))))

(def fname (str "backup-" (iso-date-hm) ".zip"))

Bash equivalent:

fname="backup-$(date '+%Y-%m-%d---%H-%M').zip"

Read standard input (stdin)

Like Clojure, Babashka makes standard input available via the dynamic var *in*, which is a java.io.Reader. Often you just want to read the whole input into memory:

(println (clojure.string/upper-case (slurp *in*)))

Alternatively, you can process the input line by line:

;; For line-seq, we need a java.io.BufferedReader

(doseq [line (line-seq (clojure.java.io/reader *in*))]
  (println (clojure.string/upper-case line)))

Bash equivalent (note that this is not unicode-aware):

tr a-z A-Z

This is presumably for side-effects, a blog by Paulus Esterhazy. Don't forget to say hello on twitter or by email