feat(stageleft): fix q! span attribution via hidden macro export#74
feat(stageleft): fix q! span attribution via hidden macro export#74shadaj wants to merge 1 commit into
Conversation
5c4aa46 to
c249897
Compare
| /// Invokes a quoted closure, capturing its output via catch_unwind. | ||
| fn invoke_quoted<T, Ctx, Props>( | ||
| f: impl FnOnce(&Ctx, &mut QuotedOutput, &mut Option<Props>) -> T, | ||
| ctx: &Ctx, | ||
| ) -> (QuotedOutput, Props) { |
There was a problem hiding this comment.
Is there any extra runtime cost in using catch_unwind?
There was a problem hiding this comment.
Unfortunately, absolutely (especially with the global mutex). But a panic is the only way to get type inference to go through without executing the code being quoted. The earlier implementation (well before this PR, this diff is just a refactor) used MaybeUninit but we found out later that this was completely undefined behavior and we were getting lucky that it worked 🙃
6a349ca to
ce0444d
Compare
| .count() | ||
| }; | ||
| let prefix_segments: Vec<_> = i.segments.iter().take(prefix_len).collect(); | ||
| let key = quote::quote!(#(#prefix_segments)::*).to_string(); |
There was a problem hiding this comment.
What on earth is this doing?
| #[derive(Default)] | ||
| pub struct FreeVariableVisitor { | ||
| pub free_variables: BTreeSet<syn::Ident>, | ||
| pub relative_paths: BTreeMap<String, usize>, |
There was a problem hiding this comment.
The quoted crate, self and super identifiers are being mapped to what here? The order in which they appeared in the quoted code?
There was a problem hiding this comment.
Correct, and that index will then be used for the __sl_p{i} macro inputs that are used to feed the resolved paths at splice-time.
40d0d90 to
307aafc
Compare
|
It would be nice if you could update the PR description to document roughly how it works today as well as how it works after this change, I think that would be a nice addition. |
Added a summary that does a decent job of capturing the PR. (disclaimer: AI generated but I did a couple rounds of polish to make sure it actually reflects my creative intent) |
|
Are there no sort of tricks with |
Nope, basically the issue is that we would want the generated (trybuild) code to effectively have a foreign span. This is a special case where the foreign span originates in Rust code, so we can pull this off on stable with a declarative macro. |
307aafc to
28c9c04
Compare
When code inside a q!() panics at runtime, the backtrace now correctly points to the q!() definition site rather than the splice site. How it works: - q!() emits a hidden #[macro_export] macro_rules! whose body is the original expression with preserved source spans. - Relative paths (crate::, self::, super::) become :path macro parameters, free variables become :expr parameters. - At splice time, to_tokens() emits a macro invocation with resolved paths and captured values as arguments. - Macro name is a SHA-256 hash of canonicalized file path + line + column + expression content for uniqueness across compilation contexts. Also adds splice_untyped_ctx_original / splice_untyped_ctx_props_original methods that bypass the macro when original AST output is preferred. Co-authored-by: Infinity 🤖 <infinity@hydro.run> PR: #74
28c9c04 to
843dba9
Compare
| impl VisitMut for MetavarRewriter<'_> { | ||
| fn visit_path_mut(&mut self, path: &mut syn::Path) { | ||
| if let Some(first) = path.segments.first() | ||
| && let Some(idx_str) = | ||
| first.ident.to_string().strip_prefix("__sl_p") | ||
| && let Ok(idx) = idx_str.parse::<usize>() | ||
| && let Some(resolved) = self.path_args.get(idx) | ||
| { | ||
| let resolved_path: syn::Path = | ||
| syn::parse2(resolved.clone()).unwrap(); | ||
| let remaining: Vec<_> = | ||
| path.segments.iter().skip(1).cloned().collect(); | ||
| *path = resolved_path; | ||
| for seg in remaining { | ||
| path.segments.push(seg); | ||
| } | ||
| } | ||
| syn::visit_mut::visit_path_mut(self, path); | ||
| } |
| // Generate lib_pub.rs if requested | ||
| if gen_pub { | ||
| let flow_lib_pub = gen_staged_mod(lib_path, parse_quote!(crate), None, false); | ||
|
|
||
| fs::write( | ||
| Path::new(&out_dir).join("lib_pub.rs"), | ||
| prettyplease::unparse(&flow_lib_pub), | ||
| ) | ||
| .unwrap(); | ||
| println!("cargo::rerun-if-changed=src"); | ||
| } |
| std::panic::set_hook(Box::new(move |_| { | ||
| let bt = std::backtrace::Backtrace::force_capture(); | ||
| *bt_clone.lock().unwrap() = Some(bt.to_string()); | ||
| })); | ||
| let result = std::panic::catch_unwind(|| { | ||
| panicking_entry!(); | ||
| }); | ||
| let _ = std::panic::take_hook(); |
Summary
When code inside a
q!()panics at runtime, the backtrace now correctly points to theq!()definition site rather than the splice site.Background: How stageleft works today (before this PR)
When you write:
The
q!()macro captures the expression as a string and stores it in a closure. At splice time (when the entry macro is invoked),to_tokens()parses that string, rewrites relative paths (crate::→final_crate::__staged::), and emits the expression as aTokenStream. The generated code looks like:The problem: all tokens in this output have
Span::call_site()spans, so panics, backtraces, and compiler diagnostics point to the splice site — not the originalq!()source location.How it works after this PR
q!()now emits a hidden#[macro_export] macro_rules!alongside the closure. The macro body contains the original expression tokens with their source spans preserved. At splice time, instead of emitting the expression directly, we emit an invocation of this macro. Becausemacro_rules!preserves definition-site spans in its expansion, the compiled code's debug info points back to theq!()source — so backtraces show the correct file and line.Full expansion of
q!()Given:
In non-Rust-Analyzer mode,
q!()expands to:In Rust Analyzer mode, the expansion is simpler (no macro generation, no path rewriting):
At splice time
When the entry macro is invoked (e.g.,
my_entry!()),to_tokens()runs the closure viacatch_unwind, extracts the metadata, and emits:Fallback path
When the macro isn't accessible (e.g.,
q!()was in a#[cfg(test)]module or an example binary), the splice emits the expression directly with path placeholders resolved at runtime:This loses span attribution but is functionally correct.
Key design decisions
Macro naming: The macro name is derived from the source file path (relative to crate root) + line + column, ensuring uniqueness and portability across machines.
Path handling: Relative paths (
crate::,self::,super::) in theq!()expression refer to items relative to where theq!()is written. But when the expression is spliced into a different crate (or a different module within the same crate), these paths would resolve incorrectly. Stageleft solves this by rewriting them to point through the__stagedmodule, which re-exports all items aspub.In this PR, the rewriting is split into two parts:
q!()expansion time: Relative path prefixes are replaced with placeholder idents (__sl_p0,__sl_p1, etc.). For example,crate::module::Foobecomes__sl_p0::module::Foo. This happens in the same visitor pass that detects free variables.__sl_p0→final_crate::__staged) and passed as macro arguments using$($__sl_pN:ident)::*repetition, which allowsmacro_rules!to concatenate the resolved prefix with the remaining path segments.This two-phase approach is necessary because the hidden macro is defined at
q!()time (when the final crate name isn't known), but invoked at splice time (when it is). The$($ident)::*pattern is used instead of:pathbecause:pathfragments cannot be concatenated with::suffixinmacro_rules!.Free variables: Passed as
:exprparameters withname = valuesyntax. The macro body binds them withlet name = $name;.Literal body match: The second
[...]argument contains the expression tokens (post-path-rewrite). This serves as a coherence check — if downstream tooling mutates the AST, the macro pattern won't match.Fallback for inaccessible macros: When the
q!()is in a#[cfg(test)]module or outside the crate (examples, trybuild), the macro may not be accessible. In these cases, the splice emits the expression directly with path resolution done at runtime.Rust Analyzer mode: All macro generation is skipped for performance; paths are left as-is for local resolution.
Single-pass visitor: The
FreeVariableVisitornow handles both free variable detection and path prefix rewriting in one traversal (when not in RA mode).Other changes
stageleft_tool:gen_macroemitsSTAGELEFT_FINAL_CRATE_MANIFEST_DIRfor portable path resolution. Newgen_staged(bool)unifies deps/test-module/lib-pub generation with a single source parse.stageleft_tool: Registers#[cfg(test)]module paths via a ctor, enabling the fallback detection at splice time.CARGO_PROFILE_*_STRIP: debuginfoso backtrace tests can verify line numbers.proc-macro2bumped to 1.0.103 forSpan::file()API.