Runtime macroexpand

September 5, 2015

This post explores using ClojureScript's new self-hosting capability to implement something that was previously difficult: Runtime macro expansion.




Let's say you have some code that makes use of macroexpand. And, for the sake of an example, let's focus on expanding the form (cond :else :foo). At a ClojureScript REPL, you can evaluate

(macroexpand '(cond :else :foo))

and get

(if :else :foo (cljs.core/cond))

But… here's the interesting aspect to explore: In Clojure, you can also evaluate the following:

(let [x '(cond :else :foo)]
  (macroexpand x))

This won't work in ClojureScript. Try it in your favorite ClojureScript REPL. You will get an error, rooted in the fact that the symbol x is not ISeqable.

ClojureScript's macroexpand macro is really designed to work at compile time, expecting to be passed the quoted form to expand. An approach to making this work in ClojureScript involves compilation at runtime. Consider this slight change:

(let [x '(cond :else :foo)]
  `(macroexpand (quote ~x)))

This produces the form

(macroexpand (quote (cond :else :foo)))

which, if compiled and evaluated, would produce the desired result.

So, cljs.js to the rescue!

(require '[cljs.js :as cljs])

First, let's put cljs.js to the test, evaluating the macroexpand form above directly:

(binding [cljs/*eval-fn* cljs/js-eval]   
  (cljs/eval (cljs/empty-state)
    '(macroexpand 
      (quote (cond :else :foo)))
    prn))

Note that cljs.js is designed to work in an asynchronous evaluation environment, so the last parameter is a callback which will be passed the evaluation result. Here, we simply pass prn in order to cause the result to be printed. Evaluating the above at the REPL produces the desired result:

(if :else :foo (cljs.core/cond))

Now, this is really no different than what you would get if you were to type (macroexpand (quote (cond :else :foo))) at the REPL.

But, it is possible to wrap all of this into a function that works at runtime:

(defn macroexpand'
 [form cb]
 (binding [cljs/*eval-fn* cljs/js-eval]
   (cljs/eval (cljs/empty-state) 
     `(macroexpand (quote ~form)) 
     cb)))

Now, evaluating this

(let [x '(cond :else :foo)]
  (macroexpand' x prn))

results in the following being printed:

(if :else :foo (cljs.core/cond))

Cool! Macro expansion that can be done at runtime, after your source has been compiled and deployed!

Tags: ClojureScript Bootstrap