Call default `Debug` proc macro parser in own derive macro?

We have our own SafeDebug trait and derive proc macro to, by default, help callers avoid leaking PII for as common as logging or tracing with "{:?}" tends to be or, e.g., "%m" for tracing. By default it just prints the type name and ( .. ) or { .. } depending on the type; however, if someone enables a feature (that we'll document as potentially risky), we just want to fall back to the normal Debug support. With attribute proc macros this is easy, but seems I can't with derive macros. Or, rather, I possibly could but core's Debug derive macro support is pub(crate) so I can't call it.

Is there any other way for a derive macro to effectively call another derive macro? Seems I can't take advantage of recursive macro expansion because my derive macro - I believe (happy to be wrong here) implement trait(s) for the type and can't actually insert anything into the existing type AST, like a #[derive(Debug)].

I need to play around with it a little more, but hoping it's just enough to enumerate the members of the type and emit the necessary calls on the std::fmt::Formatter<'_>, relying on the compiler / LSP to err if a member itself doesn't derive or implement Debug.

(Apologies if this was asked; I did search but just keep finding pages of hits against debugging proc macros.)

Not sure what you mean here, ::core::fmt::Debug is public. But as described in your second paragraph, you can't expand your macro to fall back to a different derive macro.


Yes:

The returned token stream will be appended to the containing block or module of the annotated item with the requirement that the token stream consists of a set of valid items.


Using the Formatter that is passed as argument to Debug::fmt would be my approach for creating a custom Debug derive macro.

Which I also previously found in source, but I can't seem to call it. The compiler errs saying I can't call a trait. How would I call/use this macro?

Just like any other derive macro:

#[derive(::core::fmt::Debug)]
struct Foo {
    bar: i32,
    baz: i32,
}

Maybe you can provide us with a small code snippet or pseudocode that illustrates how you intend to use it?

Our model structs and enums derive our own SafeDebug macro:

#[derive(SafeDebug)]
pub struct Model {
  pub name: Option<String>,
}

By default, I'd impl the Debug trait just to emit "Model { .. }". But if the dev enables a feature, we'd rather get the regular behavior as if we emitted the struct with #[derive(Debug)]. That's easy to do with attribute macros (we do it for our recorded test framework) but doesn't seem possible here. Effectively, I'd like to treat our #[derive(SafeDebug)] as if it was a #[derive(Debug)] if that feature was enabled.

We could use the following, but we were trying to keep this 1) easy for doc readers, and 2) easy for our crate devs building on our core libraries to hand-code (most crates are fully generated, but not all):

#[derive(Model)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(not(feature = "debug"), derive(SafeDebug))]
pub struct Foo {
  // ...
}

You could abstract your current cfg_attr solution into a declarative macro to wrap around the items you want to annotate:

macro_rules! cfg_safe_debug {
    ($($item:item)*) => {
        $(
            #[cfg_attr(feature = "debug", derive(Debug))]
            #[cfg_attr(not(feature = "debug"), derive(Debug))] // SafeDebug
            $item
        )*
    }
}

cfg_safe_debug! {
    pub struct Foo {
        bar: Bar,
    }
    
    pub struct Bar;
    
    pub enum Baz {}
}

Playground.[1]

Otherwise, reimplementing the Debug derive macro output in your SafeDerive macro using the Formatter would be the only other option I can think of.


  1. That's how tokio does it. â†Šī¸Ž

Thanks for the good suggestion. It had crossed my mind, but we're trying to be as transparent as possible to any dev hand-coding models, etc. I was even considering if we might somehow "shadow" ::core::fmt::Debug such that ours would take precedent unless the feature is enabled, but it seems there's no guarantee ours would always be in scope, meaning the std Debug would "win" because of the automatic prelude for std. Since starports have fallen out of favor, like most crates these days, it seems, we've decided not to define a prelude.

I was definitely going to use the std::fmt::Formatter<'_> with a derive macro that would enumerate the fields/variants so we end up with something like:

use std::fmt;
impl fmt::Debug for Model {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    f.debug_struct("Model")
      .field("foo", &self.foo)
      .field("bar", &self.bar)
      .finish()
  }
}

Or did you mean something else?

This made me think of something similar, re-exporting the macro from your library crate based on the feature set:

#[cfg(feature = "debug")]
pub use ::core::fmt::Debug as SafeDebug;
#[cfg(not(feature = "debug"))]
pub use safe_debug_derive::SafeDebug;

No, this is what I meant.

1 Like

Hmm. :thinking: That's pretty nifty! I hadn't considered going the other direction with the imports. Debug could still be used (and apart from REST API models, that's fine) easily enough.

And you might want to use macro_rules_attribute - Rust, so that it looks like a proper derive.

1 Like