Now in that blog there was...
I've been trying to learn Clojure for a long time. I recently discovered Babashka (bb
) and now I finally feel like I'm getting somewhere. It's been a lot of fun!
Babashka ships as a self-contained binary loaded with an amazing suite of libraries to get your work done. In addition to libraries, you can use Babashka pods to make a bridge to just about anything else you need. There's a whole box of tools at your disposal! A growing subset of the Clojure ecosystem is also available to load as additional dependencies, such as the wonderful HoneySQL.
One element missing from the bb
toolbox was an answer for making Terminal UIs (TUI) - fortunately there's another tool that composes well with Babashka to fill that gap: gum.
gum
- from charm.shThe Go community has an amazing library for making TUI applications, called bubbletea, along with companion libraries lipgloss for styling, and some reusable components as bubbles. If you weren't writing Go you were out of luck, until fairly recently!
The folks at charm.sh released a tool called gum that, similar to bb
, ships as a single binary loaded with functionality. gum
is intended to be shelled out to from scripts - perfect for bb
!
Recently I supported a training event that required the management of 100+ virtual machines. These machines we assigned to various teams, and each team could request configuration changes. To manage this I used a few scripts and a SQLite database. With bb
that would have been much nicer.
To give gum
and bb
a proper test drive I wrote a program that:
gum writer
to prompt the user, similar to a textarea
sqlite3
to run the querygum table
to display as a tablegum confirm
to show a confirm dialog, an error exit code is used to denote a negative responsegum choose --no-limit
with a list of files from the templates directoryShelling out to gum
is pretty straight forward. The return values and exit codes from gum
make getting user input easy.
(defn header [msg]
(shell (format "gum style --padding 1 --foreground 212 '%s'" msg)))
(defn input [& {:keys [value placeholder] :or {value "" placeholder ""}}]
(-> (shell {:out :string}
(format "gum input --placeholder '%s' --value '%s'" placeholder value))
:out
str/trim))
(defn write [value placeholder]
(-> (shell {:out :string}
(format "gum write --show-line-numbers --placeholder '%s' --value '%s'" placeholder value))
:out
str/trim))
(defn table [csv]
(let [data (csv/read-csv csv)
headers (->> data
first
(map str/upper-case))
num-headers (count headers)
width (int (/ 100.0 num-headers))
cmd (format "gum table --widths %s" (str/join "," (repeat num-headers width)))]
(shell {:in csv :out :string} cmd)))
(defn confirm [msg]
(-> (shell {:continue true}
(format "gum confirm '%s'" msg))
:exit
zero?))
(defn choose
[opts & {:keys [no-limit limit]}]
(let [opts (str/join " " opts)
limit (str "--limit " (or limit 1))
no-limit (if no-limit (str "--no-limit") "")
cmd (format "gum choose %s %s %s" limit no-limit opts)]
(-> (shell {:out :string} cmd)
:out
str/trim
str/split-lines)))
Update! Since publishing this blog post a Clojure library for interacting with Gum has been published: bblgum.This library provides a simple but expressive interface to the underlying Gum controls. Here's how you would display a confirm dialog that returns a
bool
:(b/gum {:cmd :confirm :as :bool :args ["Are you ready?"]})
The source code of the library is short and instructive - definitely worth reading!
To generate the example data for the demo I wrote another Babashka script which uses a few additional dependencies not included by default.
Dependencies are managed by a bb.edn
file, similar to deps.edn
from Clojure.
;; bb.edn
{:paths ["src"]
:deps
{faker/faker {:mvn/version "0.2.2"}
com.github.seancorfield/honeysql {:mvn/version "2.4.969"}}}
Using faker to generate the domain names simplified a lot of the data generation, I only had to generate a random IP address and assign teams.
For IP address generation I wanted addresses in the 10.0.0.0/8
range, so I generated a random int
in that range. Clojure has a nice radix notation for writing number literals.
This example also shows some interop with the Java classes that Babashka also includes.
(require '[clojure.string :as str]
'[faker.internet :as internet]
'[babashka.process :refer [process check]]
'[honey.sql :as sql]
'[honey.sql.helpers :refer [insert-into columns values]])
(import java.net.InetAddress
java.nio.ByteBuffer)
(defn rand-10-addr-int []
(let [min 2r00001010000000000000000000000001
max 2r00001010111111111111111111111111
range (- max min)]
(+ min (rand-int range))))
(defn ip-address [n]
(-> (.getByAddress InetAddress
(-> (ByteBuffer/allocate 4)
(.putInt n)
(.array)))
.toString
(str/replace-first "/" "")))
(defn gen-data [n]
(map vector
(take n (repeatedly #(ip-address (rand-10-addr-int))))
(take n (cycle ["a" "b" "c" "d" "e"]))
(take n (repeatedly internet/domain-name))))
(comment
(let [data (gen-data 85)
sql (sql/format
(-> (insert-into :inventory)
(columns :ip :team :domain)
(values data))
{:inline true})
sql (first sql)]
(-> (process {:in sql :out :string} "sqlite3 some.db")
check
:out))
*e)
You can checkout the full code here
Possible further work on this example program:
gum
pod for easier integrationAdmittedly there are some limitations to the UX exposed by Gum. Another option that keeps you in a REPL with parentheses is Node
Babashka. Advertised as "Ad-hoc CLJS scripting on Node.js" nbb
allows you to write CLJS without any JVM in sight.
The docs showcase using ink to build UIs in the terminal using React with Reagent.
This was my first blog post ever! I hope it was helpful.
For more details on how bb
works check out:
Great thanks and admiration to the awesome @borkdude, the mind behind Babashka, Node.js Babashka, the Small Clojure Interpreter (SCI) and countless other amazing projects
This blog is also powered by quickblog (another borkdude project 🤯)
The gif was recorded with vhs (also from charm.sh)