Rust log macro

2024-03-20

I've been learning Rust and this language has macros. In my learner project I didn't touch much in this space though I did create one macro to help with debugging.

The learner project is Factini, a Rust to WASM game that runs in html canvas. As such the browser dev tools are my main way of debugging the game.

The macro


So I have a log function to do console logging. It only takes a single string as argument. For a while I used it with a format!(...) arg. Which meant that for a while I had this pattern all over the place:

Code:
log(format!("fps: {}", fps))

At some point I wondered how I would reduce that pattern and I looked into what it would take to create a log!() macro that would do the same. Turned out it was pretty straightforward!

This is the macro I ended up with:

Code:
#[macro_export]
macro_rules! log {
($fmt_str:literal) => {
log(format!($fmt_str))
};

($fmt_str:literal, $($args:expr),*) => {
log(format!($fmt_str, $($args),*))
};
}

As you can see it does the log(format!(...)) pattern for you, transforming the variadic arguments and basically replacing the log! with log(format!. This even allowed the Rust plugin in webstorm (don't hate me) to pick up on the syntax highlighting for {} and {:?} (etc), which was great. Maybe that was driven by Rust rather than the plugin but to the end user (me) what matters is that it works.

So with that macro I can do log!("fps: {}", fps) and it gets translated to log(format!("fps: {}", fps)) at compile time.

The docs state: "Rust macros are expanded into abstract syntax trees, rather than string preprocessing". The reference goes deeper into it and explains that macros are "transcribed". They are expanded according to their definition.

Accessing console.log


On the web the lowest hanging debugging fruit in JS is to use console.log() as I'm sure you all know. To use that in Rust you can import it like this:

Code:
#[wasm_bindgen]
extern {
pub fn log(s: &str); // -> console.log(s)
}

With a JS counterpart that creates this function:

Code:
function log(str) {
console.log(str);
}

Unfortunately, as Rust goes, that won't allow you to use it in a generic polymorphic way like you would in the web. Rust wants things typed explicitly, including the number of args.

Alternatively I left a comment for myself that you could import that like this:

Code:
// #[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(a: &str);
}


But I'm not sure if it's any better, faster, or more "dogmatic".

A third way would be to leverage the web_sys::console module. This requires you to explicitly cast values to a JsValue which feels like a nuisance to me. Not to mention having to update the function name (log_3) every time you change the arg count.

That said, I did end up using that but with the macro above:

Code:
pub fn log(s: String) {
#[cfg(not(target_arch = "wasm32"))]
println!("{}", s);
#[cfg(target_arch = "wasm32")]
let s = s.as_str();
#[cfg(target_arch = "wasm32")]
web_sys::console::log_2(&"(rust)".into(), &s.into());
}

When compiling to WASM it will compile the web_sys::console::log_2() and otherwise it'll do a println!() to the CLI.

(I prefix the "(rust)" string to clarify whether the logging was from JS or Rust land)

Stack traces


Oh while we're on this subject, it's probably helpful to note that you can get Rust stack traces in console.log on the web. A bit, anyways.

There's a crate called console_error_panic_hook (github link). You add it to your cargo.toml like this:
Code:
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "*"

and then in your main (wasm) entry point you have this

Code:
extern crate console_error_panic_hook;

#[wasm_bindgen(start)]
pub fn start() -> Result<(), JsValue> {
panic::set_hook(Box::new(console_error_panic_hook::hook));

You have to call this setup once. After errors thrown due to Rust panics will have the origin of the panic.

Code:
panicked at 'Hit the panic button.', src/_web.rs:2844:7

Stack:

Error
at imports.wbg.__wbg_new_abda76e883ba8a5f (./factini.js:789:21)
at ./factini_bg.wasm:wasm-function[799]:0xaed11
at ./factini_bg.wasm:wasm-function[378]:0x9fbda
at ./factini_bg.wasm:wasm-function[629]:0xabd79

I didn't get a full Rust stack trace to work this way. Not sure if it's something I did wrong or just a limitation. But it's already way better than not having this at all :D

Raw power


To me the macro system looks very powerful (yes yes, I'm sure that's by design, shocking).

At the same time these macros are a bit risky for a few reasons:

- You risk losing yourself in the meta programming rather than solving the actual task :p
- You're making the code more complex, harder to maintain, harder to collaborate, because you would need to know what these macro's do
- The macro may hide logic that would otherwise be clearly exposed
- Transformation bugs are some of the worst category of bugs (together with caching and timezones)
- They are not better than helper / util functions because you have to write the ! every time you invoke them

Macro anatomy


Before I begin I should mention the Rust docs on macros, which also serve as an example. I'm probably just rehashing what they're saying there.

When you use log!(a, b, c) you're said to "invoke the macro".

Each top-level atom inside a macro looks like a "rule" to me. They look similar to a callback or handlers.

The handler "arguments", the ones with a dollar sign, are actually called "designators" and they tell the compiler what kind of token to match on, kicking us right into parser land.

You define a bunch of these rules inside the macro and every time you invoke the macro it will pattern match the right one when compiling the code.

Because it pattern matches it does restrict you in how you can use your macro. For example take the log!() case; I can't invoke it with a variable in the first argument (like log!(FPS_TEMPLATE, fps)) because it has to be a string literal. I can support the identifier case but that would require a different rule.

For my macro above I defined two rules: One with just a string and one with a string and any number of arguments.

I guess I could omit the format!() for the arg-less variation but I'm not sure it matters much and would expect that to only be a compile time thing, anyways. Just means I can do log!("it gets here") too, which would otherwise fail.

Beyond that the code will just do what the macro says.

Additionally, this macro already shows that you can nest these macro's. Not sure what the limit is on that. Guessing circular macro's are ... "tricky". But as long as there's a base rule without circular reference, I guess it could work?

DSL


You can also create DSL's ("domain specific language") this way. The "arguments" of invoking a macro can have any syntax you'd want, not just a comma delimited argument list. Just so long as its syntax is described by the macro. The compilation step will reconcile everything.

From the examples, it means you can have something like this in your Rust code and have it work:

Code:
calc!(
fake_eval (1 + 2) * (3 / 4)
)


I'm not sure if you could create a DSL that works with primitives unknown to Rust. It seems to me like that would hit a parser / tokenizer limitation and how would that work (unless Rust exposes something for that too, but I dunno). So you'd be limited by the "designators" the language exposes.

Conclusion


Macros in Rust are something I didn't explore much but do give you a great level of control over your code base.

The macro system does require a certain way of thinking about language, one that I happen to enjoy. With tokens and matching and what not.

Looking forward to making more use of macros in a future project :)