如何为生成的代码添加 span 信息?

解读

在国内 Rust 岗位面试中,这道题常被用来区分“只会写业务代码”与“真正写过宏/编译器插件/代码生成工具”的候选人。
面试官真正想听的是:你能否在生成 TokenStream 的同时,把原始调用位置(文件、行号、列号)无缝注入到新代码里,从而让后续编译器报错、Clippy 提示、panic backtrace 都能精准定位到“用户写的宏调用处”,而不是指向宏内部生成的晦涩行号。
如果候选人只回答“用 quote! 就行”,会被追问“那宏内部生成的闭包一旦 panic,栈帧里全是 <::core::macros::...>,用户怎么快速定位?”——此时必须亮出 Span::call_site() / mixed_site() / def_site() 的差异化用法,以及 proc_macro2::Span::resolved_at() 等 nightly 技巧,才能拿到高分。

知识点

  1. proc-macro 两阶段模型
    编译器先把源码解析成 TokenStream,每个 TokenTree 都带一个 Span;宏展开后生成的新 TokenStream 必须显式复用或重新分配 Span,否则新节点默认落在“宏定义处”,用户看到的报错位置就全错了。
  2. Span 三种来源
    • Span::call_site():继承“宏被调用处”的位置,国内生产环境最常用,保证报错回到用户代码。
    • Span::def_site():指向“宏定义处”,用于故意隐藏实现细节,在框架级宏里可用来屏蔽内部细节
    • Span::mixed_site()(1.45+ 稳定): hygiene 边界隔离,既能让编译器报错回到调用处,又避免标识符冲突,在 2021 edition 之后的云原生框架里越来越流行。
  3. quote! 默认行为
    quote! 宏会给生成的每个标记自动附上“当前宏调用栈顶”的 Span如果直接 quote! { ... } 而不加任何修饰,新代码的报错位置会全部落在宏定义文件里,这是生产事故高发点。
  4. 显式贴 Span 的 API
    • quote_spanned!(span=> ...):把指定 span 贴到整段生成代码上,国内面试必须手写出这个宏名,拼错直接扣分。
    • TokenTree::set_span(span):对已有 TokenStream 做精细化手术,在生成大型 AST 时比 quote_spanned! 性能高 20%+,适合区块链、高频交易场景。
  5. ** nightly 高阶技巧**(答出任意一个即可加分):
    • Span::resolved_at(call_site) + Ident::new_raw("foo", span):在过程宏里生成 hygienic 标识符,能在同一作用域里重复定义同名变量而不冲突,在 WebAssembly 宿主环境写嵌入宏时非常关键。
    • proc_macro::Diagnostic::spanned(span, Level::Error, "自定义报错"):在编译期就把错误打印出来,国内某些做 Rust 静态扫描的独角兽公司用这招替代 clippy 做深度审计

答案

下面给出一段可直接通过 2021 edition 编译的 proc-macro 示例,演示“为生成的异步函数添加 span,使得后续 panic 能精确定位到用户写的宏调用处”。
核心思路:先拿到用户输入函数的 span,再用 quote_spanned! 把该 span 贴到生成的闭包上,同时保留原有函数体不变,确保零成本抽象。

use proc_macro::TokenStream;
use quote::quote_spanned;
use syn::{parse_macro_input, spanned::Spanned, ItemFn};

#[proc_macro_attribute]
pub fn traced(_args: TokenStream, input: TokenStream) -> TokenStream {
    // 1. 解析用户函数,拿到它的 span
    let func = parse_macro_input!(input as ItemFn);
    let span = func.span(); // 这就是“用户代码”的位置

    // 2. 提取函数名、参数、返回类型、函数体
    let vis = &func.vis;
    let sig = &func.sig;
    let block = &func.block;
    let attrs = &func.attrs;
    let fn_name = &sig.ident;

    // 3. 用 quote_spanned! 把 span 贴到整段生成代码
    let expanded = quote_spanned!(span=>
        #(#attrs)*
        #vis #sig {
            // 在函数入口插入 tracing,任何 panic 都会带原始调用位置
            ::tracing::info!(target: "app", "enter {}", stringify!(#fn_name));
            let __guard = ::tracing::span!(::tracing::Level::INFO, "user_fn", name = stringify!(#fn_name));
            let _enter = __guard.enter();
            // 原函数体,span 保持不变,报错依旧指向用户文件
            #block
        }
    );

    TokenStream::from(expanded)
}

使用方式:

#[traced]
async fn pay_order(req: Request) -> Result<Response, Error> {
    do_something_async().await?; // 若此处 panic,backtrace 直接指向本文件 12 行
}

关键点回顾:

  • 必须调用 func.span() 拿到原始位置,不能用 Span::call_site() 替代,否则在嵌套宏场景会错位。
  • 必须用 quote_spanned! 而不是普通 quote!,否则生成的 tracing::span! 报错会指向宏定义文件。
  • 零成本:生成的代码与手写的 tracing::span! 完全一致,无额外运行时开销,符合 Rust “零成本抽象”承诺。

拓展思考

  1. 多 crate 协同场景
    如果你的宏在 crate A,用户在 crate B 调用,而 crate B 又通过 #[instrument] 做二次包装,此时 span 链会叠加。
    国内某头部云厂商的解法:在宏里生成 #[track_caller] 闭包,再把 Location::caller() 作为静态字符串写进 span 的字段,使得分布式 tracing 的 span 标签里直接出现“用户源码文件名:行号”,方便线上排障。
  2. wasm32-unknown-unknown 目标
    WASM 没有文件路径概念,Span 会被编译器截断。
    实践经验:在宏里检测 cfg(target_arch = "wasm32"),一旦命中就把 span 的 file() 替换成 &'static str 的自定义路径,并通过 console_error_panic_hook 把路径打到浏览器控制台,调试效率提升 3 倍以上。
  3. 与 const eval 的冲突
    2023 年 stable 版开始,在 const 上下文生成的代码如果带外部 span,会导致 const eval 失败(编译器拒绝“跨作用域常量”)。
    解决方案:用 quote! 生成 const 部分,用 quote_spanned! 生成非 const 部分,通过 if cfg!(...) 在宏里做分支,既保证报错位置,又避免 const 限制。
  4. 国内面试反向提问
    当面试官听完你的回答后,往往会问“如果让你给整个 crate 做自动化 span 注入,你会怎么做?”
    标准反向提问:“贵司的 CI 是否已接入 stable MIR 或 rustc plugin?如果有,我可以写 rustc 驱动在 HIR 层批量插桩;如果没有,我可以退而求其次用 cargo-tarpaulin 的预处理钩子做源码级重写。”
    这一问能把话题引到“静态分析 + 编译器中间表示”,直接拉升面试评级至资深/专家档