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 | |
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 | |
instead of:
1 2 3 | |
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 | |
When we compile the cljs code, we’ll see some warnings:
1 2 3 | |
If we try to run the tests we’ll get something like:
1 2 3 4 | |
And if we take a look to the generated js code we’ll find:
1 2 3 4 5 6 7 | |
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 | |
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 | |
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 | |
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 | |
to:
1 2 3 4 | |
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
| |
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 | |
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 | |
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!