DEV Community

Matthew McGarvey
Matthew McGarvey

Posted on

Macro Tips: Avoid nesting macros if possible

One issue that has come up recently is that error output for compilations errors only contains the place the error occurred and the place that called the offending code. It might not sound like a problem, but pair that with macros and it will all become clear. It's easiest to show, so here's some code that produces a compilation error:

macro compilation_error(arg)
  1 < {{ arg }}
end

puts compilation_error("asdf")
Enter fullscreen mode Exit fullscreen mode

and the compilation error looks like:

Showing last frame. Use --error-trace for full trace.

There was a problem expanding macro 'compilation_error'

Code in temp.cr:10:6

 10 | puts compilation_error("asdf")
           ^
Called macro defined in temp.cr:2:1

 2 | macro compilation_error(arg)

Which expanded to:

 > 1 | 1 < "asdf"
         ^
Error: no overload matches 'Int32#<' with type String

Overloads are:
 ...cut for brevity
Enter fullscreen mode Exit fullscreen mode

The error output for this compilation error is kind of nice. It states there's an issue and then the very first code reference is to where the macro was called. The next code reference points towards the macro-generated code that caused the error. If the user is not familiar with what code is generated by the macro they are using (and maybe they don't even know it's a macro), the first code reference is often way more helpful than when it dives into the macro code.

Now, let's change how the macro works just a little bit and see what happens to the error output.

macro inner_compilation_error(arg)
  1 < {{ arg }}
end

macro compilation_error(arg)
  inner_compilation_error({{ arg }})
end

puts compilation_error("asdf")
Enter fullscreen mode Exit fullscreen mode

And the new error output

Showing last frame. Use --error-trace for full trace.

There was a problem expanding macro 'inner_compilation_error'

Code in macro 'compilation_error'

 1 | inner_compilation_error("asdf")
     ^
Called macro defined in eval:1:1

Which expanded to:

 > 1 | 1 < "asdf"
         ^
Error: no overload matches 'Int32#<' with type String

Overloads are:
 ...cut for brevity
Enter fullscreen mode Exit fullscreen mode

Can you notice the difference? Where in the error output does it point towards where the macro was called? The answer, it doesn't.

The change to the code was adding another macro method that the original macro method now delegates to. This is very common when making macros since the code generation can become complicated. The downside is that error output only shows the compilation error and the code that called it, and in this case it means that it never makes it out of the macro code. Not so bad when you manage and call the macro, annoying and confusing when you are calling macros provided to you from libraries. At least in this code example you can kind of reference that the error is because you passed in a string, but what if the argument passed into the top-level macro is augmented in some way? What if the string is split into an array? I believe the error output becomes confusing and of little value. (I've written this article without talking about --error-trace because I believe that errors should be helpful without it)

Story from Lucky-land

In Lucky we generate URL helper methods to make it easier to create links in HTML, and we do this using macros called when users set up their actions (what we call controllers in a traditional MVC architecture). A common request for help in our Discord is to help figure out where users went wrong with their action setup. They copy and paste their action code and provide a picture of the stack-trace that looks something like this

Old stack-trace output

You'll notice that the image of the stack-trace looks a little like the second example I laid out above. It talks about an error but the code references it points at are all in macro code. That's why user's end up staring at their action and are forced to ask for help. The awful thing about it is that the error isn't in their action at all. The user spends the most time staring at the action to see the error, but anyone who tries to help also has to spend time carefully reading the code. The annoying thing is that the real problem was with their usage of the generated URL helpers I mentioned, and not with the action implementation at all.

Because I had experienced this issue myself and had helped people out with this often, I really wanted to figure out how to get the usage site (where they originally called into macro code) into the error output. Without going into the code, I looked at the macro and saw something very similar to the code example above where the main macro delegated to another but nowhere else was this second macro called. Moving the second macro code into the first was the solution and now the usage site shows up in the error output!

New stack-trace output

Here's the pull request that made the change if you want to reference it

Inline render_html_page to change compilation error #1373

Purpose

No connected issue

Description

Before

CleanShot 2020-12-28 at 13 20 01@2x

After

CleanShot 2020-12-28 at 13 20 17@2x

Notice in the "Before" that the first code reference was a macro and not the usage site that caused the error. By removing the render_html_page we actually see the usage site in the error which will help people track down issues.

That macro was extracted here but you'll notice that this PR achieves the same level of DRY-ness without the separate macro.

Checklist

  • [ ] - An issue already exists detailing the issue/or feature request that this PR fixes
  • [x] - All specs are formatted with crystal tool format spec src
  • [x] - Inline documentation has been added and/or updated
  • [x] - Lucky builds on docker with ./script/setup
  • [x] - All builds and specs pass on docker with ./script/test

Discussion (0)