Nico is blogging

My thoughts on software development, clojure, ruby, and trying to deal with complexity in simple ways

More portable (complex) macro musing

TL;DR: Writing macros that target both Clojure & ClojureScript is possible, but it comes with some gotchas. When those macros have to call other macros defined in one namespace in clj but in a different one in cljs, there are a couple more gotchas :)


This is my follow-up to the great Portable Macro Musing by Mike Fikes. Mike does a great job in showing how to write a single macro targeting Clojure, Clojurescript JVM and ClojureScript JS, when the main goal of the macro is to abstract the differences due to host interop: The example macro emits a call to Integer/parseInt on the JVM but to js/parseInt on JS.

Here I’m going to stress test the ability to write portable macros that need to call other macros while targeting both Clojure and ClojureScript. Not only that, the macro will call a certain macro when targeting Clojure, but needs to call a different macro when targeting ClojureScript.

A snippet of code is worth a thousand words, so let’s see our original macro that targets just Clojure:

1
2
3
4
5
6
7
8
9
(ns more-macro-musings.core
  (:require [clojure.test :refer [is]]))

(defmacro given [v & body]
  `(do
     ~@(for [[a b c] (partition 3 body)]
         (case b
           := `(is (= (~a ~v) ~c))
           :!= `(is (not= (~a ~v) ~c))))))

This is a simplified version of juxt.iota/given from juxt/iota (which is healthy macro sugar by the way!).

The idea of given is to allow us to write our test assertions like:

1
2
3
4
(given {:foo "bar" :woo "baz"}
  count := 2
  :foo := "bar"
  :woo :!= "bar")

instead of:

1
2
3
(is (= (count {:woo "baz", :foo "bar"}) 2))
(is (= (:foo {:woo "baz", :foo "bar"}) "bar"))
(is (not= (:woo {:woo "baz", :foo "bar"}) "bar")))

juxt.iota/given supports much more operators apart from := and :!=, you should check the full list in the README.

So, let’s go back to the main goal of this post.

NOTE: you can follow each of the following examples in the more-macro-musings repo, there’s a commit for each step.

1st try: Use reader conditionals

We might be tempted to introduce a reader conditional to port the macro to cljs:

1
2
3
4
5
6
7
8
9
10
(ns more-macro-musings.core
  (:require #?(:clj  [clojure.test :refer [is]]
               :cljs [cljs.test :refer-macros [is]])))

(defmacro given [v & body]
  `(do
     ~@(for [[a b c] (partition 3 body)]
         (case b
           := `(is (= (~a ~v) ~c))
           :!= `(is (not= (~a ~v) ~c))))))

When we compile the cljs code, we’ll see some warnings:

1
2
3
Compiling test/more_macro_musings/core_test.cljc
WARNING: No such namespace: clojure.test, could not locate clojure/test.cljs, clojure/test.cljc, or Closure namespace "" at line 7 test/more_macro_musings/core_test.cljc
WARNING: Use of undeclared Var clojure.test/do-report at line 7 test/more_macro_musings/core_test.cljc

If we try to run the tests we’ll get something like:

1
2
3
4
ERROR in (given-macro-test) (ReferenceError:NaN:NaN)
Uncaught exception, not in assertion.
expected: nil
  actual: #object[ReferenceError ReferenceError: java is not defined]

And if we take a look to the generated js code we’ll find:

1
2
3
4
5
6
7
var result__7612__auto___5695 = cljs.core.apply.call(null,cljs.core._EQ_,values__7611__auto___5694);
if(cljs.core.truth_(result__7612__auto___5695)){
clojure.test.do_report.call(null,new cljs.core.PersistentArrayMap(null, 4, [new cljs.core.Keyword(null,"type","type",1174270348) ...));
} else {
...
}catch (e5689){if((e5689 instanceof java.lang.Throwable)){
...

Well, we won’t see the ...: that was me shortening the output, which is much, much longer.

See the call to clojure.test.do_report and the instanceof java.lang.Throwable? AHA!

As the macro was compiled in Clojure, is was captured as clojure.test/is which in turn emits calls to clojure.test/do-report instead of cljs.test/do-report (and catch Throwable instead of catch :default), which is what we really need in cljs land.

2dn try: Use an alias for the namespace

So we do this:

1
2
3
4
5
6
7
8
9
10
(ns more-macro-musings.core
  (:require #?(:clj  [clojure.test :as test :refer [is]]
               :cljs [cljs.test :as test :refer-macros [is]])))

(defmacro given [v & body]
  `(do
     ~@(for [[a b c] (partition 3 body)]
         (case b
           := `(test/is (= (~a ~v) ~c))
           :!= `(test/is (not= (~a ~v) ~c))))))

The result is the same as in the previous try. That’s because test/is is also captured as clojure.test/is.

BTW, If you want to learn more about variable capture and hygienic macros, check out the excellent Clojure and Hygienic Macros by Marek Kubica

3rd try: Quote-unquote

It must work now!

1
2
3
4
5
6
(defmacro given [v & body]
  `(do
     ~@(for [[a b c] (partition 3 body)]
         (case b
           := `(~'is (= (~a ~v) ~c))
           :!= `(~'is (not= (~a ~v) ~c))))))

It doesn’t. We avoided the wrong variable capture by using the quote-unquote, but we changed it for another wrong variable capture: is is captured now as more-macro-musings.core-test/is which doesn’t exist. We can see this by looking into the generated js code:

1
2
3
more_macro_musings.core_test.given_macro_test.cljs$lang$test = (function (){
more_macro_musings.core_test.is.call(null,cljs.core._EQ_.call(null,new cljs.core.Keyword(null,"foo","foo",1268894036) ...
...

There are no compilation errors or warnings, but the error is not gone, it just changed to Cannot read property 'call' of undefined

4th try: Refer the is macro in the test ns

We change our test ns form from:

1
2
3
4
(ns more-macro-musings.core-test
  (:require #?(:clj  [clojure.test :refer        [deftest]]
               :cljs [cljs.test    :refer-macros [deftest]])
            [more-macro-musings.core #?(:clj :refer :cljs :refer-macros) [given]]))

to:

1
2
3
4
(ns more-macro-musings.core-test
  (:require #?(:clj  [clojure.test :refer        [deftest is]]
               :cljs [cljs.test    :refer-macros [deftest is]])
            [more-macro-musings.core #?(:clj :refer :cljs :refer-macros) [given]]))

It works! But

We don’t want to do that. I mean, we wanted to provide the new given macro as a replacement for the is macro, but we force the users of the given macro to refer the is macro in their ns declaration. And if they forget to do it (and they will), they’ll get the obscure error from our 3rd try. Not nice.

There must be another solution.

5th try: Reader conditionals inside of the macro

We try replacing the is form to something like:

1
:= `(#?(:clj clojure.test/is :cljs cljs.test/is) (= (~a ~v) ~c))

It doesn’t work for the same reasons as in our 1st and 2nd try. Check the full code in the reader-conditional-in-macro tag

6th try: if-cljs FTW!

Let’s introduce a new macro: if-cljs and see the finished code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
(ns more-macro-musings.core
  (:require #?(:clj  [clojure.test]
               :cljs [cljs.test :include-macros true])))

(defn- cljs-env?
  "Take the &env from a macro, and tell whether we are expanding into cljs."
  [env]
  (boolean (:ns env)))

(defmacro if-cljs
  "Return then if we are generating cljs code and else for Clojure code.
   https://groups.google.com/d/msg/clojurescript/iBY5HaQda4A/w1lAQi9_AwsJ"
  [then else]
  (if (cljs-env? &env) then else))

(defmacro is [& args]
  `(if-cljs
     (cljs.test/is ~@args)
     (clojure.test/is ~@args)))

(defmacro given [v & body]
  `(do
     ~@(for [[a b c] (partition 3 body)]
         (case b
           := `(is (= (~a ~v) ~c))
           :!= `(is (not= (~a ~v) ~c))))))

We used a trick here: (:ns &env) will be nil when compiling with a Clojure target, but will be truthy when compiling for ClojureScript, so we can emit different code depending on that.

Let’s see a snippet from the generated code:

1
2
3
4
5
6
if(cljs.core.truth_(result__6136__auto___6423)){
cljs.test.do_report.call(null,new cljs.core.PersistentArrayMap(null, ...
...
}catch (e6417){var t__6173__auto___6424 = e6417;
cljs.test.do_report.call(null,new cljs.core.PersistentArrayMap(null, ...
...

Nice! We are emitting calls to cljs.test/do-report and we are not catching java.lang.Throwable. We also don’t need to refer is in our test ns, take a look to the test code in the master branch. Run it with lein doo node node-test if you don’t believe me :)

I learned about if-cljs in a conversation in the clojure google group. It’s defined in a couple projects, like Prismatic/schema, ptaoussanis/encore and some others.


Know of a better way to do this? Leave a comment below here, or in the twitter-lands!

Comments