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.
require '[babashka.process :refer [shell]])
("whoami") (shell
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
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]])
(:extra-env {"FOO" "bar"}} "printenv" "FOO") (shell {
Bash equivalent:
FOO=bar printenv FOO
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)
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"
require '[babashka.process :refer [process]]
(:as io])
'[clojure.java.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]]
(:as io])
'[clojure.java.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/^/#/'
when (babashka.fs/exists? "/etc/hosts")
(println "File exists")) (
Bash equivalent:
if [[ -f /etc/hosts ]]; then echo File exists; fi
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).
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"
require '[babashka.fs :as fs])
(
"world" "hello\n")
(spit "world" "world2")
(fs/copy "world2" "world\n" :append true)
(spit "world")
(fs/delete "world2" "world")
(fs/move 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
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"
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