Re-emit the item on error¶
Level: 301 · deep dive
One line: An attribute macro that fails and returns only compile_error! deletes the item it was given, so every use the compiler still checks becomes an error of its own; return the error and the item, and the user sees one message.
#[logged("info")] prints a level and the function's name on entry. The crate has it twice: logged_error_only returns the error alone, and logged returns the error followed by the tokens it was given.
//! `#[logged("info")]` prints a level and the function's name when the function
//! is entered. It comes in two versions, which differ only in what they return
//! when something is wrong: with the argument, or with the item.
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{ItemFn, LitStr, parse_quote};
/// Returns the error, and nothing else.
#[proc_macro_attribute]
pub fn logged_error_only(args: TokenStream, item: TokenStream) -> TokenStream {
let level = match level(args.into()) {
Ok(level) => level,
Err(error) => return error.into_compile_error().into(),
};
let function = syn::parse_macro_input!(item as ItemFn);
add_log_line(&level, function).into()
}
/// Returns the error, followed by the item exactly as it arrived.
#[proc_macro_attribute]
pub fn logged(args: TokenStream, item: TokenStream) -> TokenStream {
let item = TokenStream2::from(item);
let result = level(args.into()).and_then(|level| {
let function: ItemFn = syn::parse2(item.clone())?;
Ok(add_log_line(&level, function))
});
match result {
Ok(output) => output.into(),
Err(error) => {
let error = error.into_compile_error();
quote!(#error #item).into()
}
}
}
/// The argument: one string literal, naming one of three levels.
fn level(args: TokenStream2) -> syn::Result<LitStr> {
let level: LitStr = syn::parse2(args)?;
match level.value().as_str() {
"debug" | "info" | "warn" => Ok(level),
_ => Err(syn::Error::new_spanned(level, r#"expected "debug", "info" or "warn""#)),
}
}
fn add_log_line(level: &LitStr, mut function: ItemFn) -> TokenStream2 {
let name = function.sig.ident.to_string();
let body = &function.block;
function.block = parse_quote! {{
::std::println!("[{}] {}", #level, #name);
#body
}};
quote!(#function)
}
logged converts item to a proc_macro2::TokenStream before anything reads it, and parses a clone. Whatever fails, the argument or the item, the original tokens are still there to go back out behind the error.
On a method, with a level it knows:
use logged::logged;
struct Rect {
width: u32,
height: u32,
}
impl Rect {
#[logged("info")]
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect = Rect { width: 2, height: 3 };
println!("{}", rect.area());
println!("{}", rect.area() * 2);
println!("{}", rect.area() + 1);
}
Verified output of cargo run -q -p logged_app — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
One wrong argument, four errors¶
The same program with "warning", a level the macro does not know, under logged_error_only:
use logged::logged_error_only;
struct Rect {
width: u32,
height: u32,
}
impl Rect {
#[logged_error_only("warning")] // the level is "warn"
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect = Rect { width: 2, height: 3 };
println!("{}", rect.area());
println!("{}", rect.area() * 2);
println!("{}", rect.area() + 1);
}
Verified output of cargo build -q -p cascade, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: expected "debug", "info" or "warn"
--> cascade/src/main.rs:9:25
|
9 | #[logged_error_only("warning")] // the level is "warn"
| ^^^^^^^^^
error[E0599]: no method named `area` found for struct `Rect` in the current scope
--> cascade/src/main.rs:17:25
|
3 | struct Rect {
| ----------- method `area` not found for this struct
...
17 | println!("{}", rect.area());
| ^^^^ method not found in `Rect`
error[E0599]: no method named `area` found for struct `Rect` in the current scope
--> cascade/src/main.rs:18:25
|
3 | struct Rect {
| ----------- method `area` not found for this struct
...
18 | println!("{}", rect.area() * 2);
| ^^^^ method not found in `Rect`
error[E0599]: no method named `area` found for struct `Rect` in the current scope
--> cascade/src/main.rs:19:25
|
3 | struct Rect {
| ----------- method `area` not found for this struct
...
19 | println!("{}", rect.area() + 1);
| ^^^^ method not found in `Rect`
For more information about this error, try `rustc --explain E0599`.
error: could not compile `cascade` (bin "cascade") due to 4 previous errors
An attribute's output replaces the item (Three kinds of procedural macro). logged_error_only returned only compile_error!, so after expansion impl Rect holds no area, and each of the three calls is an E0599 about a method the user can see in their own source. The one real mistake is the first of four errors.
Return the item with the error¶
The same program again, under logged:
use logged::logged;
struct Rect {
width: u32,
height: u32,
}
impl Rect {
#[logged("warning")] // the level is "warn"
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect = Rect { width: 2, height: 3 };
println!("{}", rect.area());
println!("{}", rect.area() * 2);
println!("{}", rect.area() + 1);
}
Verified output of cargo build -q -p one_error, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: expected "debug", "info" or "warn"
--> one_error/src/main.rs:9:14
|
9 | #[logged("warning")] // the level is "warn"
| ^^^^^^^^^
error: could not compile `one_error` (bin "one_error") due to 1 previous error
One error, where the build above ended due to 4 previous errors. The difference is the Err arm of logged: quote!(#error #item) puts the untouched area back behind compile_error!, the calls resolve, and nothing else is wrong. The untouched tokens rather than a parsed ItemFn, because the failure can be the parse itself, and then there is no ItemFn to print.
The trap: a free function hides the cascade¶
Try the error-only macro on a free function, and it looks harmless:
use logged::logged_error_only;
#[logged_error_only("warning")] // the level is "warn"
fn area(width: u32, height: u32) -> u32 {
width * height
}
fn main() {
println!("{}", area(2, 3));
println!("{}", area(2, 3) * 2);
println!("{}", area(2, 3) + 1);
println!("{}", perimeter(2, 3)); // defined nowhere
}
Verified output of cargo build -q -p free_function, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: expected "debug", "info" or "warn"
--> free_function/src/main.rs:3:21
|
3 | #[logged_error_only("warning")] // the level is "warn"
| ^^^^^^^^^
error: could not compile `free_function` (bin "free_function") due to 1 previous error
rustc 1.98.0 reported none of the three calls to the deleted area, and not perimeter either, which is defined nowhere. So the compiler did not find area: after a macro error in this module it stopped reporting the names it could not resolve there, real mistakes included. Method calls are not covered by that, which is why the cascade above is on a method. A macro tested only on free functions can return the error alone and seem fine.
When the item is not a function¶
#[logged] on an impl block. The item fails to parse as an ItemFn, and logged sends the block back as it arrived:
use logged::logged;
struct Rect {
width: u32,
height: u32,
}
#[logged("info")] // on the impl block, not on a function
impl Rect {
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect = Rect { width: 2, height: 3 };
println!("{}", rect.area());
println!("{}", rect.area() * 2);
println!("{}", rect.area() + 1);
}
Verified output of cargo build -q -p not_a_function, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: expected `fn`
--> not_a_function/src/main.rs:9:1
|
9 | impl Rect {
| ^^^^
error: could not compile `not_a_function` (bin "not_a_function") due to 1 previous error
One error, pointing at impl. None of the three rect.area() calls is reported, and the cascade above shows that each would be if area were missing, so the impl block went back and compiled. A failed parse is not a reason to stop re-emitting: the tokens were a valid item before the macro saw them, and nothing the macro did changed them.
When the untouched item is the wrong thing to return¶
Some attributes exist to make an item legal. #[tokio::main] turns async fn main into a synchronous main that starts a runtime. A macro like that cannot put the function back unchanged after an argument error, because unchanged it is an error too. This file is those tokens, the error followed by the untouched function:
// What a macro like `#[tokio::main]` would return if it put back the function
// it was given, unchanged, after an error about its arguments.
compile_error!("unknown argument");
async fn main() {
println!("hello");
}
Verified output of cargo build -q -p async_main, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: unknown argument
--> async_main/src/main.rs:3:1
|
3 | compile_error!("unknown argument");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0752]: `main` function is not allowed to be `async`
--> async_main/src/main.rs:4:1
|
4 | async fn main() {
| ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`
For more information about this error, try `rustc --explain E0752`.
error: could not compile `async_main` (bin "async_main") due to 2 previous errors
Two errors, and the second is about exactly the thing the attribute was written to handle. So on a bad argument tokio-macros 2.7.0 re-emits the rewritten function, built with a default configuration, behind the error, and re-emits the item as it arrived only when the item does not parse (entry.rs, lines 582–604 ↗). A comment there gives the goal: output as close as possible to what the macro would normally have produced, which also keeps an IDE's completion working inside the function. That is the rule: return the nearest thing to your normal output that compiles.
In the wild¶
| Crate | On a bad argument | On an item it cannot parse |
|---|---|---|
tokio-macros 2.7.0 ↗, #[tokio::main] |
the error, and the function rewritten with a default configuration (line 602) | the error, and the item as it arrived (line 588) |
tracing-attributes 0.1.31 ↗, #[instrument] |
parse_macro_input!(args as …) (line 580), which returns the error alone |
a second, looser parse that leaves the body as raw tokens (line 582); parse_macro_input! again if that fails (line 591) |
parse_macro_input! is the error-only path in one line. Its body in syn 3.0.6 is a match whose Err arm is return TokenStream::from(err.to_compile_error()) (parse_macro_input.rs, line 108 ↗), which is right for a derive, whose input stays in place whatever it returns, and deletes the item in an attribute. logged_error_only uses it for the item, and logged uses syn::parse2 with its own Err arm instead.
If you are coming from another language¶
- C and C++. A compiler's error recovery is this same problem: a declaration that failed turns every later use into another error. For a missing header, clang and gcc stop at once rather than report every function the header would have declared: clang with "fatal error: 'shapes.h' file not found", gcc with "compilation terminated". A procedural macro has no fatal option. Its output goes back into a compilation that keeps going, so its only control over the cascade is what it leaves behind.
- Python. An exception raised by a decorator at import time stops the module there, so there is no cascade and nothing to re-emit: the user sees one traceback and fixes one thing at a time. rustc does the opposite, reporting as many independent errors as it can in one build, which is why what a failed macro leaves in the item's place decides how many of those errors are real.
- Java. An annotation processor reports a problem with
Messager.printMessage(Diagnostic.Kind.ERROR, message, element), and the element it examined stays in the compilation, because a processor can add files but never remove or rewrite a class. Re-emitting is built into that design. In Rust it is a choice every attribute macro makes in itsErrarm.
See also¶
- Parse, tweak, re-emit — the success path, and the
#[trace]macro that already re-emits on a bad argument - Three kinds of procedural macro — an attribute that returns nothing deletes the item
- Errors: from
panic!tosyn::Error— spanned errors in a derive, where the item cannot disappear - A
#[retry]attribute —darling's argument errors, returned with the function - Testing with
trybuild— putting an error message, and its count, under test syn::Error::into_compile_error↗
Po polsku¶
Gdy makro atrybutowe trafi na błąd i zwróci tylko compile_error!, element, który dostało, znika — bo wynik makra atrybutowego zastępuje element. Każde miejsce, które kompilator dalej sprawdza, staje się osobnym błędem: tu jedna literówka w argumencie ("warning" zamiast "warn") dała cztery błędy, w tym trzy E0599 przy wywołaniach metody area. Rozwiązanie: zwrócić błąd razem z nietkniętym elementem, quote!(#error #item), i użytkownik widzi jeden komunikat.
Pułapka: na zwykłej funkcji (nie metodzie) rustc 1.98.0 nie pokazuje tej kaskady, bo po błędzie makra przestaje zgłaszać nierozwiązane nazwy w tym module — nawet nazwę, której nigdzie nie ma. Nieudane parsowanie elementu nie zwalnia z oddania go: tokeny były poprawnym Rustem, zanim makro je zobaczyło. Wyjątek to makro, bez którego element się nie skompiluje, jak #[tokio::main] na async fn main: wtedy oddaje się element przepisany z domyślnymi ustawieniami, a nie oryginał.
Szukaj po polsku: obsługa błędów w makrach proceduralnych · syn::Error into_compile_error · compile_error! · rust attribute macro error recovery · proc macro return original item on error