Prose provides tools in the fr.jeremyschoffen.prose.alpha.eval
namespace to
evaluate Clojure code. There are several things to take into account, just
using eval
on 'code as data' isn't sufficient.
At the moment, the main use case of prose is the creation of programmable documents by embedding Clojure inside them. In other words we want documents as Clojure programs.
The basic idea we cover here works in three phases:
The eval part of this algorithm presents challenges that we explore here.
require
Directly using the eval
function on the data returned by our reader has
issues.
For instance the document:
◊(require '[fr.jeremyschoffen.prose.alpha.out.html.tags :refer [div ul li]])
◊div{
some text
◊ul {
◊li {1}
◊li {2}
}
}
is read as:
[(require
'[fr.jeremyschoffen.prose.alpha.out.html.tags :refer [div ul li]])
"\n\n"
(div
"\n some text\n "
(ul "\n " (li "1") "\n " (li "2") "\n ")
"\n")]
Using eval on this vector is problematic:
(try
(-> "readme/example-doc.html.prose"
lib/slurp-doc
lib/read-doc
eval)
(catch Exception e
(-> e .getCause .getMessage)))
;=>
Unable to resolve symbol: div in this context
This is a classic problem, requiring a namespace and using it in the same evaluation is a no go in Clojure. To solve this, we can evaluate each element in sequence and return the sequence of evaluations. It doesn't solve all possible cases but it seems sufficient for most uses.
Since we use eval, a lot of things can happen.
When it comes to Clojure namespaces in particular:
To mitigate these types of issues our tools allow for the evaluation of
documents in ephemeral namespaces. The idea here is to create an new namespace
with a random name for each document evaluation. We can then switch to it, evaluate
the document, switch back and delete it. Unless code inside the document uses
something like in-ns
or the ns
macro, no definition made inside the document
should pollute the original namespace.
The use of Sci can is also a solution to these kind of problems since it allows the creation of isolated evaluation environments.
We may want to evaluate several documents in parallel. However it seems like the Clojure namespace machinery isn't meant to be used from several threads.
See:
(try
(deref (future (lib/eval-doc '[(+ 1 1)(+ 1 2)])))
(catch Exception e
(-> e ex-cause ex-cause ex-cause ex-message)))
;=>
Can't set!: *ns* from non-binding thread
Since we want to use ephemeral namespaces this is a problem for a pure Clojure evaluation scheme. Sci is an interesting solution to get around this limitation.
At this time evaluating documents is similar to evaluating a script. A document
is read as a sequences Clojure forms. We then want to evaluate these forms in
order. To implement this scheme and provide solutions to the above
considerations prose provides an evaluation model inspired by the ring spec
in the fr.jeremyschoffen.prose.alpha.eval.common
namespace.
As the ring model uses a map to represent a web request, we use a map to represent
an evaluation. Such maps are constructed with the
fr.jeremyschoffen.prose.alpha.eval.common/make-evaluation-ctxt
function.
(defn make-evaluation-ctxt
"Make an evaluation context.
This context is a map of 2 keys:
- `:forms`: a sequence of forms to evaluate
- `:eval-form`: a function that evaluates one form"
[eval-form forms]
{:forms forms
:eval-form (wrap-eval-form-exception eval-form)})
The evaluation proper is realized by the fr.jeremyschoffen.prose.alpha.eval.common/evaluate-ctxt
:
(defn evaluate-ctxt
"Function evaluating a context (produced by [[make-evaluation-ctxt]]).
Returns the context with one of two keys associated:
- `:result`: in the case of a successful evaluation the sequence of evaluations is returned here
- `:error`: in the case of an error, the exception is returned here."
[{:keys [forms eval-form]
:as ctxt}]
(let [[ret res] (try
[:result (eval-forms* eval-form forms)]
(catch #?@(:clj [Exception e] :cljs [js/Error e])
[:error e]))]
(assoc ctxt ret res)))
Note that forms are evaluated in sequence using fr.jeremyschoffen.prose.alpha.eval.common/eval-forms*
:
(defn eval-forms*
"Evaluate a sequences of forms `forms` in sequence with `eval-form`"
[eval-form forms]
(into [] (map eval-form) forms))
This prevent the usual cases of problems that could happen with using require
inside of documents.
The fr.jeremyschoffen.prose.alpha.eval.common/evaluate
function is provided as a helper to tie this model together:
(defn evaluate
"Evaluate a sequence of forms in order. Returns the sequence of evaluations.
To do so an evaluation context is created using [[make-evaluation-ctxt]]. This
context is passed to [[evaluate-ctxt]] that has been wrapped with `middleware`.
Args:
- `ef`: an 'evaluate-form' function that take 1 form and returns the result of evaluating it.
- `middleware`: an 'evaluate-ctxt -> evaluate-ctxt' function
- `forms`: the sequence to forms to evaluate"
[ef middleware forms]
(let [ctxt (make-evaluation-ctxt ef forms)
eval-ctxt (middleware evaluate-ctxt)]
(eval-ctxt ctxt)))
The rest of the fr.jeremyschoffen.prose.alpha.eval.common
namespace provides
tools that answer our other considerations in the form of middleware for the
fr.jeremyschoffen.prose.alpha.eval.common/evaluate-ctxt
context function.
We have already seen that fr.jeremyschoffen.prose.alpha.eval.common/evaluate-ctxt
returns the
evaluation context with either an added :result
or :error
keys. The
middleware fr.jeremyschoffen.prose.alpha.eval.common/wrap-eval-result
may be used to transform
fr.jeremyschoffen.prose.alpha.eval.common/evaluate-ctxt
into a function that either returns the result
of an evaluation or throws an exception.
(defn wrap-eval-result
"Middleware that either returns the result of the evaluation or throws any error raised."
[eval-ctxt]
(fn [ctxt]
(let [{:keys [result error]} (eval-ctxt ctxt)]
(if result
result
(throw error)))))
The ephemeral namespace behavior is provided by the use of two different middleware:
fr.jeremyschoffen.prose.alpha.eval.common/wrap-snapshot-ns
makes sure that the current namespace
after the evaluation is the same as before:(defn wrap-snapshot-ns
"Middleware making sure the current ns stays the same after an evaluation."
[eval-ctxt]
(fn [{:keys [eval-form] :as ctxt}]
(let [current-ns (get-current-ns eval-form)
ret (eval-ctxt ctxt)]
(back-to-base-ns eval-form current-ns)
ret)))
fr.jeremyschoffen.prose.alpha.eval.common/wrap-eval-in-temp-ns
creates an ephemeral namespace, makes
it the current one, evaluates code then deletes it:(defn wrap-eval-in-temp-ns
"Middleware that makes the evaluation take place in a temporary namespace."
([eval-ctxt]
(wrap-eval-in-temp-ns eval-ctxt nil))
([eval-ctxt temp-ns]
(fn [{:keys [eval-form] :as ctxt}]
(let [temp-ns (or temp-ns (gensym "temp_ns__"))
res (do
(switch-to-temp-ns eval-form temp-ns)
(eval-ctxt ctxt))]
(remove-temp-ns eval-form temp-ns)
res))))
This whole execution model takes an eval-form
function as a parameter.
For instance here is fr.jeremyschoffen.prose.alpha.eval.common/eval-forms-in-temp-ns
:
(defn eval-forms-in-temp-ns
"Evaluate a sequence of forms in a temporary namespace.
Args:
- `forms`; a sequence of forms to eval
- `eval-form`: a function a evaluates one form defaulting to `clojure.core/eval`."
([forms]
(eval-forms-in-temp-ns eval forms))
([eval-form forms]
(evaluate eval-form wrap-eval-forms-in-temp-ns forms)))
Using anything other than the default eval
provided by Clojure is possible.
Although not implemented here, we could use this in self-hosted ClojureScript
and that's what we do with sci. Take a look at the
fr.jeremyschoffen.prose.alpha.eval.sci/eval-forms-in-temp-ns
function:
(defn eval-forms-in-temp-ns
"Evaluate a sequence of forms with sci in a temporary namespace."
([forms]
(eval-forms-in-temp-ns (init nil) forms))
([sci-ctxt forms]
(let [ef (sci-ctxt->sci-eval sci-ctxt)]
(eval-common/bind-env {:prose.alpha/env :clojure-sci}
(sci/binding [sci/ns @sci/ns]
(eval-common/eval-forms-in-temp-ns ef forms))))))
We can see that this function uses the machinery provided by the common
namespace (the last line of code). The eval-form
function used named ef
is
created with:
(defn sci-ctxt->sci-eval
"Make an eval function from an sci context.
The result is a function of one argument, a `form` to be evaluated by sci in
the evaluation context `ctxt`."
[ctxt]
(fn [form]
(sci/eval-form ctxt form)))
Sci is interesting in this project for different reasons. For instance, where the Clojure version failed, with Sci we can do:
(deref (future (eval-sci/eval-forms '[(+ 1 1)(+ 1 2)])))
;=>
[2 3]
We can evaluate code in ClojureScript without having to go self hosted.
The fact that it allows us to reify execution environments (and fork them cheaply) opens a lot of possibilities up. Evaluations can occur on any number of threads while being in their own sandboxed environments forked from a common one.
Also I believe that using Sci carrefully, allows us not to take too big a performance hit. Only code declared inside documents is going to be interpreted by Sci. Functions that a document uses and that come from the environment (the one created with sci.core/init) are Clojure functions and I believe run directly as Clojure code.
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close