Embedding SCI
SCI — the Small Clojure Interpreter behind babashka — runs on Jolt as an ordinary dependency, and a Jolt program can use it the way a JVM program does: to evaluate user-supplied code, plugins or extensions in a sandboxed context. This page covers the one place where an embedder has to know something Jolt-specific: sharing a host protocol with the code SCI evaluates. Everything else — sci/init, sci/eval-string*, :classes, :namespaces, copy-var* of functions and values — works as documented by SCI.
{:deps {org.babashka/sci {:mvn/version "0.13.53"}}}
Sharing functions and values
sci/copy-var* and :namespaces work as on the JVM:
(require '[sci.core :as sci])
(def my-ns (sci/create-ns 'my.api))
(def ctx (sci/init {:namespaces {'my.api {'greet (sci/copy-var* #'greet my-ns)}}}))
(sci/eval-string* ctx "(my.api/greet \"world\")")
Sharing a protocol
SCI has one model of a protocol: a map {:methods #{…} :ns <sci namespace>} whose methods are multimethods dispatching on sci.impl.types/type-impl. That is what defrecord, deftype, extend-type and extend-protocol evaluated inside SCI register into — one defmethod per method, keyed by the SCI type. A host protocol var copied in as-is (sci/copy-var* on #'P) is not that shape on any host: Jolt's protocol value has no :ns and its methods are plain dispatch functions, and the JVM's has no :ns either. SCI cannot name the methods it generates, and a record implementing the protocol fails in analysis (Unable to resolve symbol: m). On babashka it appears to work only because babashka's own defprotocol is SCI's.
The supported way to share a host protocol is the one babashka itself uses for clojure.core.protocols (babashka.impl.protocols): one multimethod per method whose :default answers through the host protocol, a SCI-side protocol map naming them, and — for the other direction — the host protocol extended to SCI's record and type classes, routing back through the multimethods. It is host-agnostic: the same code runs on Jolt and on the JVM.
(ns my.plugins
(:require [sci.core :as sci]
[sci.impl.types :as types]))
;; the host protocol
(defprotocol Shape
(area [this])
(scaled [this k]))
;; SCI-side methods: dispatch on SCI's notion of a value's type. A value SCI
;; did not build — a host record, a string — falls to the host protocol.
(defmulti sci-area types/type-impl)
(defmulti sci-scaled types/type-impl)
(defmethod sci-area :default [x] (area x))
(defmethod sci-scaled :default [x k] (scaled x k))
;; the other direction: a SCI record or type reaching the HOST protocol answers
;; through the method its defrecord/deftype registered. Only a method the type
;; registered counts — the :default is the host protocol itself, and answering
;; through it here would loop.
(defn- sci-method [mm this]
(let [f (get-method mm (types/type-impl this))]
(when-not (identical? f (get-method mm :default)) f)))
(defn- via-sci [mm]
(fn [this & args]
(if-let [f (sci-method mm this)]
(apply f this args)
(throw (IllegalArgumentException.
(str "No implementation of method: " mm " for SCI type: "
(types/type-impl this)))))))
(doseq [c [sci.impl.records.SciRecord sci.impl.deftype.SciType]]
(extend c Shape {:area (via-sci sci-area) :scaled (via-sci sci-scaled)}))
;; the SCI namespace: the protocol map plus its methods
(def shapes-ns (sci/create-ns 'shapes))
(def ctx
(sci/init {:namespaces
{'shapes {'Shape (sci/new-var 'shapes/Shape
{:methods #{sci-area sci-scaled}
:ns shapes-ns
:name 'shapes/Shape
:protocol Shape}
{:ns shapes-ns})
'area (sci/copy-var* #'sci-area shapes-ns)
'scaled (sci/copy-var* #'sci-scaled shapes-ns)}}}))
With that in place, code evaluated in the context implements the protocol the usual ways, and values it builds answer the host protocol when they come back:
(sci/eval-string* ctx
"(defrecord Sq [s] shapes/Shape (area [_] (* s s)) (scaled [_ k] (->Sq (* s k))))
(deftype Rect [w h] shapes/Shape (area [_] (* w h)) (scaled [_ k] (->Rect (* w k) (* h k))))
(extend-type String shapes/Shape (area [s] (count s)) (scaled [s k] (apply str (repeat k s))))
(shapes/area (->Sq 3))") ; => 9
(area (sci/eval-string* ctx "(->Rect 2 5)")) ; => 10, from the host side
(satisfies? Shape (sci/eval-string* ctx "(->Sq 1)")) ; => true
Inside SCI, satisfies?, extends? and instance? on shapes/Shape read the :methods set, so they answer for SCI records, types and extend-type targets alike. A SCI record whose type does not implement the protocol is refused by the host protocol with IllegalArgumentException, the same exception the reference raises for a missing implementation.
extend-type on a host class inside SCI (String above) registers into the SCI multimethod only; the host protocol called from host code on a string does not see it. That is how babashka behaves too, and it keeps a sandboxed context from extending the host's protocols behind its back.
This recipe is pinned by Jolt's scifunctional gate (test/chez/sci-functional-test.clj), which runs it end to end on every build.