Declarative and Procedural Macros in Depth

Macro Diagnostics

Macro diagnostics are the compile-time error messages and source-code underlines (spans) emitted when a macro receives invalid syntax, unsupported types, or violated constraints. High-quality macro diagnostics use compile_error!("...") and span-preserving token generation (quote_spanned! / syn::Error::new_spanned) instead of panicking inside procedural macros.

Emitting Custom Compile Errors with compile_error!

The built-in compile_error!("message") macro halts compilation at macro-expansion time and prints "message" as a first-class compiler error—without exposing internal macro implementation noise.

In macro_rules!, a catch-all error arm at the bottom of a macro (or inside an unsupported configuration branch) guides users when they pass invalid syntax:

// ❌ does not compile
macro_rules! define_port {
    (http => $port:literal) => {
        const PORT: u16 = $port;
    };
    (https => $port:literal) => {
        const PORT: u16 = $port;
    };
    // Diagnostic fallback rule for unsupported protocol names:
    ($other:ident => $port:literal) => {
        compile_error!(concat!(
            "unsupported protocol `",
            stringify!($other),
            "`; expected `http` or `https`"
        ));
    };
}

define_port!(ftp => 21);

fn main() {}
error: unsupported protocol `ftp`; expected `http` or `https`
  --> src/main.rs:11:9
   |
11 | /         compile_error!(concat!(
12 | |             "unsupported protocol `",
13 | |             stringify!($other),
14 | |             "`; expected `http` or `https`"
15 | |         ));
   | |__________^
...
19 |   define_port!(ftp => 21);
   |   ----------------------- in this macro invocation

Why Procedural Macros Should Return syn::Error::to_compile_error() Instead of panic!

Inside a procedural macro, calling panic!("invalid field") or .unwrap() produces a generic "proc-macro panicked" diagnostic that points to the top-level #[derive(...)] attribute rather than underlining the specific offending field or literal!

Using syn::Error::new_spanned(ast_node, "message").to_compile_error() emits a compile_error!("message") token stream whose Span is attached to the exact offending AST token:

`panic!("unsupported field")` in Proc Macro:
  error: proc-macro derive panicked
   --> src/main.rs:1:10
    |
  1 | #[derive(MyMacro)]
    |          ^^^^^^^    <-- Points only to the derive header!

`syn::Error::new_spanned(&field.ty, "unsupported field type").to_compile_error()`:
  error: unsupported field type
   --> src/main.rs:5:12
    |
  5 |     count: f64,
    |            ^^^      <-- Underlines the exact offending field type!

Preserving Spans for Type-Checker Errors with quote_spanned!

When a procedural macro generates a trait method call on each struct field (for example, Serialize::serialize(&self.field)), using plain quote! { ... } attaches Span::call_site() to the generated call. If self.field fails to implement Serialize, the compiler underlines #[derive(Serialize)].

Using quote::quote_spanned! { field.span() => ... } attaches the field's source span to the generated trait call so rustc's type-checker error points directly to the specific field declaration that failed the trait bound!

// Inside a procedural derive macro using `quote::quote_spanned!`:
/*
use syn::spanned::Spanned;

let checks = fields.iter().map(|f| {
    let ty = &f.ty;
    // Attaches `f.ty`'s source span to the trait assertion:
    quote::quote_spanned! { ty.span() =>
        const _: fn() = || {
            fn assert_impl<T: ::std::fmt::Display>() {}
            assert_impl::<#ty>();
        };
    }
});
*/

fn main() {
    println!("quote_spanned! attaches exact source coordinates to generated AST tokens.");
}
quote_spanned! attaches exact source coordinates to generated AST tokens.