An escape act in a macro

Warning: Yet another post on hygienic macros.


I want to annotate the offsets of all interpolated values in a text string (storing the indices of the slices). I found it convenient to do this with a macro, but still struggle to escape the expressions properly.

MWE

Running the following works:

span(token, start, stop) = ("text" => token, "start" => start, "stop" => stop)
span(token, start) = span(token, start, start + length(token) - 1)

macro spans(text)
    return quote
        local start = 1
        for arg in $(esc(text.args))
            if !isa(arg, String)
                arg = eval(arg)
                println(span(arg, start)) # Might as well store these spans
            end
            start += length(arg)
        end
        $text
    end
end

question = "the ultimate question of life, universe and everything";
answer = "42";
@spans "The answer to $question is $answer.";
Output
("text" => "the ultimate question of life, universe and everything", "start" => 15, "stop" => 68)
("text" => "42", "start" => 73, "stop" => 74)
"The answer to the ultimate question of life, universe and everything is 42."

Issue

After placing this into a separate module, say module Span, I managed to replicate the example above by escape the expressions for the interpolated values correctly and immediately ruined that working version 🤦.

This is the code (identical to the above) that I’m currently trying to get to work:

module Spans

    span(token, start, stop) = ("text" => token, "start" => start, "stop" => stop)
    span(token, start) = span(token, start, start + length(token) - 1)

    macro spans(text)
        return quote
            local start = 1
            for arg in $(esc(text.args))
                if !isa(arg, String)
                    arg = eval(arg)
                    println(span(arg, start)) # Might as well store these spans
                end
                start += length(arg)
            end
            $text
        end
    end

end # module

question = "the ultimate question of life, universe and everything";
answer = "42";
Spans.@spans "The answer to $question is $answer." # ERROR: LoadError: UndefVarError: question not defined

How do I evaluate question and answer within the if block?

If you run @macroexpand Spans.@spans "The answer to $question is $answer.", you’ll see that eval has expanded to Main.Spans.eval which causes it to run the evaluation in the wrong module.

There’s a tip somewhere in the manual that says approximately “an eval inside of a macro is a sign that you’re doing something wrong”.

One “solution” would be to escape the eval, but I would not go down that path…

Instead, I think you need to re-write your macro so that the for-loop runs when the macro runs. (In other words: do not output a quoted for-loop.) Then it will be possible to get the interpolated variables by just escaping the symbols. No need for eval.

3 Likes

This could be a way?

macro spans(strexp)
    @assert strexp isa Expr && strexp.head == :string

    varindices = findall(x -> !(x isa String), strexp.args)
    quote
        cumsumlengths = 1 .+ [0; cumsum(length.([$(esc.(strexp.args)...)]))]
        starts = cumsumlengths[$varindices]
        stops = cumsumlengths[$varindices.+1] .- 1
        (string = $(esc(strexp)), spans = collect(zip(starts, stops)))
    end
end

question = "the ultimate question of life, universe and everything"
answer = "42"
@spans "The answer to $question is $answer."
(string = "The answer to the ultimate question of life, universe and everything is 42.", spans = [(15, 68), (73, 74)])
2 Likes

Yes. That would work.

If one wants to keep the for-loop structure from the original post, that’s also possible. The loop just needs to move outside of the quote. For example:

macro spans(text)
    lines = []
    for arg in text.args
        if !isa(arg, String)
            push!(lines, :( println(span($(esc(arg)), start)) )) # Might as well store these spans
        end
        push!(lines, :( start += length($(esc(arg))) ))
    end
    return quote
        start = 1
        $(lines...)
        $(esc(text))
    end
end
3 Likes