Custom REPL in Clojure
A read-eval-print loop (REPL) is an interactive environment that reacts to user-input. REPL-driven development is a very interactive programming style with a short feedback cycle enabling developers to evolve their projects and try new features quickly.
Tools like MatLab, RStudio, Gnu Octave are REPL-based because one of their major use-cases is (scientific) experimentation and exploration for which short feedback cycles are essential. For example, imagine a data-scientist has the task of correlating data samples in a meaningful way. At the beginning, she may not know herself which machine learning algorithm is most suitable for the problem at hand; she may want to try different ones to see which one fits best. In a non-interactive environment, this would be laborious as the scientist would waste a lot of time by changing and re-running the code for her experiment whereas with REPL-based development, she could just try to apply her ideas with inside the interactive environment with instant feedback.
Why REPL-integration is useful#
For applications that want their users to experiment and explore, it may be useful to integrate them into a REPL right away. A REPL is based on an interpreter for a certain language. Hence, developing a REPL from scratch would also mean that a domain-specific-language (DSL) and the corresponding interpreter would have to be defined and implemented first which imposes additional maintenance burden on the developers.
Luckily, many programming languages such as Java, Kotlin, Groovy, Scala, Clojure, Python, Javascript, etc. provide their own REPL-based environments. When deciding which REPL to use for integration, there are two important points to consider:
-
Language/eco-system: The choice of a particular REPL dictates the programming language and eco-system you are able to use. For example, in case you want to integrate your application into a Kotlin-based REPL, the users of that REPL have to know Kotlin to effectively use it. At the same time you would be able to benefit from the JVM eco-system (for example Maven).
-
REPL performance/features: Not all of the programming languages were originally designed with REPL-driven development in mind; consequently, some programming languages provide more feature-rich REPLs (syntax-highlighting, auto-completion), whereas the REPLs for other languages provide merely a command-line interface for compiling one-line expressions. If a language was originally designed for REPL-based development (e.g., Clojure), this usually implies that the REPL for this language is a better tool in itself (more responsive, more features).
Levels of Integration#
There are two levels of REPL integration:
- Direct integration means that your tool is tightly integrated into the REPL which provides a direct interface to the functionality of your tool.
- Indirect integration means that your tool is producing some output (e.g., serialized data) which can then be loaded and the resulting data-structure can be explored with inside the REPL.
The direct approach is preferable for cases where you want to provide a wrapper for the functionality of your tool. This approach works best if your tool is based on the same technology as the REPL. For example, in case your tool is developed in a JVM language (e.g., Kotlin) you can easily integrate it into Ammonite (i.e., a Scala REPL) because both Scala and Kotlin are JVM languages.
The indirect approach is preferable for cases in which you rather want to provide some exploration environment on your tool’s output than providing an interface to your tool’s functionality. Although indirect integration can impose some performance overhead due to the serialization step, you gain flexibility as it does not really matter on which technology your tool is based on, as long as it produces the output in the right (expected) format. Most of the available languages already provide libraries to load different kinds of serialization formats so that it is very likely that the deserialization inside the REPL works out-of-the-box.
Why using a Clojure-based REPL?#
In this section, I would like to explain why I decided to use a Clojure-based REPL; for my personal use-case, I was looking for a REPL with the following capabilities:
- Auto-completion: Have a self-contained environment that enables users to live with inside the REPL and to make this stay an enjoyable experience.
- Syntax Highlighting: Provide all the help to users they can get to make their work more efficient and enable them to live with inside to REPL and to make this stay an enjoyable experience.
- Responsiveness: Having a responsive REPL is an essential part of inviting users to quickly experiment with their ideas. Some REPLs literally take half a second to respond (compute the output) to a single expression. In order to provide a good user experience, I wanted to have a responsive environment. Moreover, also the syntax highlighting and auto-completion features have to provide responsive feedback.
- As lightweight as possible: The REPL should contain all the features/functionality required to do its job, but not more than that.
- Scripting (Possibility to launch scripts): Users should be able to write scripts that can be launched with inside the REPL.
- Extensibility: REPLs that are embedded in a large eco-system (e.g., JVM) are generally preferable because that also implies that it is easier to find/add functionality. Moreover, users should be able to add their own custom functions.
- Stability: I was looking only at “standard” REPLs, i.e., REPLs that were officially maintained by the language developers or that are considered de-facto standard REPLs.
- Cross-Platform: The REPL should run on common platforms (MacOS, Win, Linux).
I compared all REPLs listed below. Please note that this list is not exhaustive. Please also note that the comparison is not meant to be a scientific evaluation. For comparing them, I basically just played around with them after spinning them up to get an intuition about the look and feel; so the comparison is rather subjective. However, I tried to provide some explanations why I opted for or against certain REPLs.
- Ammonite is a feature-rich REPL for the JVM language Scala. Basically, it fulfills all the requirements mentioned above. However, in terms of responsiveness, clj (i.e, the Clojure REPL) performed slightly better which was the reason why I did not go for this tool.
- Groovy Shell is the official REPL for the JVM language Groovy. Due to the absence of proper (multi-level) auto-completion and syntax-highlighting, I did not decide to use this REPL.
- kotlinc-jvm is the official REPL for the JVM language Kotlin. Due to missing auto-completion, syntax highlighting, I decided against this tool. In terms of performance, the REPL performed relatively slow. At its current stage, kotlinc-jvm can be rather considered to be an interactive interface to the kotlin compiler instead of a REPL. kscript tries to mitigate the performance issue by introducing caching.
- lumo is a REPL for the language ClojureScript. In terms of response time, it was the best REPL, in this test. However, in comparison the combination of clj and rebel-readline still provided more assistance features (including proper syntax highlighting) so that I decided against lumo.
- irb is an interactive Shell for the language Ruby; Similarly Python provides its own interactive shell. However, both of them lack features such as auto-completion and Syntax Highlighting. The bpython project tries to mitigate this limitation for Python.
- clj is the official REPL for the JVM language Clojure. Clojure is often referred to as a LISP for the JVM. Together with the readline package rebel-readline, it provides a lot of features that encourage users to stay with inside the REPL (interactive help, syntax highlighting, formatting). In addition to that, since clj is the official REPL of Clojure, which is designed around the concept of REPL-driven development, it is well-maintained and stable.
Setup your own custom REPL in Clojure#
Setting up your own custom REPL is relatively straight-forward. You can find an example project on the repository clojure-based-repl.
The snippet below shows the content of the leiningen project file project.clj.
(defproject repl "0.1.0-SNAPSHOT"
:description "An example how to integrate with the Clojure REPL."
:url "https://gitlab.com/julianthome/clojure-based-repl"
:license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
:url "https://www.eclipse.org/legal/epl-2.0/"}
:dependencies [[org.clojure/clojure "1.10.0"]
[com.bhauman/rebel-readline "0.1.4"]]
:main ^:skip-aot repl.core
:target-path "target/%s"
:profiles {:uberjar {:aot :all}}
)
In the :dependency
field you can basically two dependencies: clojure
and
rebel-readline
. The former gives us access to clj
, while the latter adds
additional functionality (syntax highlighting, code completion, integrated help)
to clj
. A side note: since Clojure is a JVM language, you have access to the
whole JVM eco-system; you can add any Maven package to the dependency list. If
the application you would like to integrate into the REPL is developed in a JVM
language, you yould add it to the dependency list.
The snippet below shows the
core.clj
file. After specifying the namespace repl.core
, you can see that we have
defined a custom function myfunc
that prints out a string. At the botton, you
can see the main function that sets up rebel-readline
and invokes
clojure.main/repl
, i.e., the command that actually spins up the REPL. This
video provides some insight about
rebel-readline
itself.
(ns repl.core
(:require [clojure.main])
(:require [rebel-readline.clojure.main])
(:gen-class))
(defn myfunc [] (println "hello"))
(defn -main
"Main"
[& args]
(rebel-readline.core/with-line-reader
(rebel-readline.clojure.line-reader/create
(rebel-readline.clojure.service.local/create))
(clojure.main/repl
:prompt (fn [])
:need-prompt (fn [] false)
:read (rebel-readline.clojure.main/create-repl-read)))
)
The command lein trampoline run
spins up the REPL. You can invoke your custom
function by executing the following commands.
repl-core=> (in-ns 'repl.core)
repl.core=> (myfunc)
hello
nil
repl.core=> :repl/quit
Bye!
In case you would like to ship your REPL-based application, you could run lein uberjar
. This command creates a self-contained, executable jar
which can be
executed by invoking the following commands.
git:(master) java -jar target/uberjar/repl-0.1.0-SNAPSHOT-standalone.jar
clojure.core=> (in-ns 'repl.core)
#object[clojure.lang.Namespace 0x7ea08277 "repl.core"]
repl.core=> (myfunc)
hello
nil
repl.core=> :repl/quit
Bye!
Thanks to the use of rebel-readline
, we get auto-completion and
syntax-highlighting (when hitting the Tab key) as illustrated in the screenshot
below.
You could also put the call of your custom function into a script. The snippet
below displays the content of a script script.clj
which we would like to
invoke.
(in-ns 'repl.core)
(myfunc)
By invoking (clojure.main/main "script.clj")
from the REPL, you can see the
script output (i.e., the result of the call to myfunc
).
clojure.core=> (clojure.main/main "script.clj")
hello
nil
clojure.core=> :repl/quit
Bye!