So the last post went a little deep into macro apocrypha, and this post is going to continue that trend, touching on a couple other macro problems I ran into while writing Judge.
Fair warning, though: everything in this post is pretty Janet-specific, and you might not come out of it with a deeper understanding of macros or testing or programming in general. You can safely skip to the next post if you want to get back to things you can actually apply to your life.
Still with me? Okay great.
on tuples, again
So let’s start with an easy one. Remember how Janet has both bracketed tuples and parenthesized tuples? Quick refresher:
repl> '(1 2 3)
(1 2 3)
repl> [1 2 3]
(1 2 3)
repl> '[1 2 3]
[1 2 3]
This means that when you write expectations involving tuples, they look… dumb:
(expect [1 2 3] (1 2 3))
Yeah! Like that. That doesn’t look right to me either. I want it be, you know:
(expect [1 2 3] [1 2 3])
So I… did that. When I printed out tuples, I actually printed out tuples as bracketed tuples, regardless of their type.
I realized after I did that that that1 would make it difficult to write tests for macro expansions, or other things where you actually do want to see parenthesized tuples.
I think you might be able to hack around that a little bit by saying: okay, if this is a tuple with a source map attached, then preserve its bracket
edness. If it’s a tuple without a source map attached, print it with square brackets.
I think that would work more often? But I haven’t actually tried it. Because… well, because of the next thing.
restricting (expect)
When I was writing Judge, I wanted a restriction: I wanted to say that you can only use the (expect)
macro in the body of a (test)
. You can’t just sprinkle (expect)
s willy-nilly around your code and expect Judge to magically update all of them for you. It’s not that smart.2
Okay, so one way to do that would be to have the (test)
macro expand to something that defined the (expect)
macro:
(defmacro test [name & forms]
~(do
(defmacro expect [...])
...
,;forms))
But I think that would be ergonomically a little weird. I would expect expect
to be a macro defined in the judge
module. I would expect that if I ran (import judge)
, then I would bring the macros judge/test
and judge/expect
into scope. These should appear just like regular macros, even if you can’t use them wherever you want.
So that approach isn’t great.3
Another approach would be to define some “private symbol,” and set that in the lexical scope of the test body, and try to look it up in the expansion of the expect
macro.
(def $unique-test-proof (gensym))
(defmacro test [name & forms]
~(do
(def ,$unique-test-proof "yeah you did it")
,;forms))
(defmacro expect [...]
~(do
(unless (in (fiber/getenv (fiber/current)) ,$unique-test-proof)
(error "oh no you aren't in a test right now"))
...))
But the problem with that is… I didn’t think of it until just now.
I don’t think there is any problem with that; I just didn’t write it because I didn’t know how to examine the current lexical environment at runtime. My mind still thinks in terms of optimizing, ahead-of-time compilers, not dynamic interpreters where this sort of reflection is trivial.
Anyway, that seems like a much better solution than the one I came up with when I was writing Judge. But we’ll still talk about the weirder thing because it will lead us on a more interesting tangent about macros:
What I did was to have the (test)
macro set a dynamic variable, saying “yeah I’m in a test right now everything’s fine.”
At first this sounds crazy, because macro expansion works outside-in, right? Janet will expand the outer macro – the (test)
macro – and that will return some forms. And then it will expand any macros in those returned forms, and so on, until there’s nothing left to expand.
So when you set a dynamic variable in (test)
, there’s no way to unset it after the expansion takes place. You’d set it, return the form, and then… well, now that dynamic variable is set forever; hope that’s what you wanted.
To get around this, I had (test)
manually call (macex)
on its arguments, inside of a (with-dyns)
form. Something kinda like this:
(defmacro test [name & forms]
...
(def expanded-forms
(with-dyns [:i-am-in-a-test true]
(macex forms)))
...)
I felt weird about this, but I didn’t really think about it that hard. But looking back on this, I feel really weird about it, because I would expect the macex
to actually take place in the lexical environment of my macro definition, not in the environment in which my forms were written. Quoted forms don’t carry their lexical environment around with them. Right?
Here’s a minimal example of what I’m talking about:
$ cat test.janet
(defmacro test [& forms]
(pp forms)
(pp (macex forms))
~(do ,;(macex forms)))
(defmacro something []
~(print "hello"))
(test
(something))
$ janet test.janet
((something))
((print "hello"))
hello
So that works fine, right? Because we run macex
in the lexical environment of the macro body, and its parent environment contains the something
binding by the time that we call macex
. Or, in human words: something
is in scope when we call macex
, so it does the thing we expect.
But what if it isn’t? What if we declare something
in a separate file, and try to call it from there?
$ cat test.janet
(defmacro- something []
~(print "test.janet"))
(defmacro test [& forms]
(something)
(pp forms)
(pp (macex forms))
~(do ,;(macex forms)))
$ cat example.janet
(use ./test)
(defmacro- something []
~(print "example.janet"))
(test
(something))
Before I show you the output… what would you expect janet example.janet
to print? It’s an interesting thing to think about. I was kind of surprised by the result.
Ready?
Spoilers below:
$ janet example.janet
test.janet
((something))
((print "example.janet"))
example.janet
Okay. So somehow everything works exactly the way I want it to, which is not the way I expected it to.
I did not understand this; I did not understand why my macex
call somehow expanded these macros that were defined in a different scope.
The Janet documentation doesn’t discuss exactly how macro expansion works, so I had to go to the source to understand this:
static int macroexpand1(
JanetCompiler *c,
Janet x,
Janet *out,
const JanetSpecial **spec) {
/* ...lots of stuff omitted... */
JanetBindingType btype = janet_resolve(c->env, name, ¯oval);
if (btype != JANET_BINDING_MACRO ||
!janet_checktype(macroval, JANET_FUNCTION))
return 0;
So if I’m reading this right, I think what’s happening here is that macex
always looks up symbols in the environment that the compiler is currently compiling. The environment of the macex
call doesn’t matter at all; all that matters is that the compiler is busy compiling the (test (something))
form, and in that environment, something
expands to '(print "example.janet")
.
So macro definitions are sort of, effectively, dynamically scoped – but since macro expansion usually takes place in a simple top-to-bottom order in your file, you don’t actually notice that.
But that’s weird, right? Because then what happens if we call macex
at runtime?
cat runtime.janet
(defmacro- something []
~(print "hi"))
(pp (macex '(something)))
(defn main [&]
(pp (macex '(something))))
$ janet runtime.janet
(print "hi")
(something)
Well, it’s consistent with my reading, at least. The compiler has no “current scope” at runtime, so it can’t do a macro lookup. So you can’t expand macros at runtime. Even if you define the macro at runtime…?
cat runtime.janet
(defn main [&]
(defmacro- something []
~(print "runtime macro party whoooo"))
(pp (macex '(something))))
$ janet runtime.janet
(something)
Nope. Not a thing. Which, like, okay, sure – it doesn’t really make sense to define a macro at runtime. I’m fine with that.
But it does make sense to want to expand macros at runtime. It might sound crazy, but I actually have a very reasonable use case in mind: I want to be able to write tests for macro expansions:
(defmacro something []
'(print "hello"))
(test "something is a trivial macro"
(expect (macex '(something)) (print "hello")))
Wouldn’t that be nice? When I was writing Judge, I would constantly have to run (pp (macex '(test ...)))
. It was very useful to help me wrap my head around quasiquotation. But Judge’s interactive workflow is so nice that I would like to be able to embed those expansions directly in my source file, and see when they change.
But in order to do that, I have to perform the expansion at compile time:
(defmacro something []
'(print "hello"))
(def expansion (macex '(something)))
(test "something is a trivial macro"
(expect expansion (print "hello")))
Which is a little cumbersome, but whatever. It’s not a bad workaround.
Anyway. This behavior is kinda surprising, right?
$ cat test.janet
(defmacro- something []
~(print "lexical scope"))
(defmacro test [& forms]
(pp (macex '(something)))
~(do (print ":(")))
$ cat example.janet
(use ./test)
(defmacro- something []
~(print "dynamic scope"))
(test
(something))
$ janet example.janet
(print "dynamic scope")
:(
gosh who cares
Yeah, this is some interesting Janet trivia, but none of it really affects our ability to write a little game.
This behavior kinda seems like a bug to me, but maybe there is some principled reason why it has to work like this. I don’t know: I have never implemented a compiler. And since I don’t really have a pressing need to write complicated macro tests right now, I’m not going to look into this any further.
So let’s get back to the game.
-
an angel just got its wings ↩︎
-
Why not? I thought it just didn’t make any sense and was a sign that you’d made a mistake, but now I’m not so sure. I don’t think it makes sense to sprinkle arbitrary tests around at runtime – how would you know when to write the
.corrected
file? – but it might be really neat to be able to write self-modifying tests that run at a compile time. That might be a very cool way to write inline documentation, for example. And you could set an environment variable to not run them every time you compile a file, maybe?So I might revisit this decision in the future. But for now, let’s assume that this is something we want to restrict. ↩︎
-
It also wouldn’t actually work, for reasons we’re about to talk about. ↩︎