1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
use std::io;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::process::{Command, Stdio};
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::thread;
use crate::parse_trace::{MacroExpansion, MacroExpansionKind, parse_trace};
/// Cargo arguments for macro tracing (no clap dependency).
#[derive(Debug, Clone, Default)]
pub struct Args {
pub package: Option<String>,
pub bin: Option<String>,
pub lib: bool,
pub test: Option<String>,
pub example: Option<String>,
pub manifest_path: Option<String>,
pub cargo_args: Vec<String>,
/// Path to the macra-hook shared library (e.g. `libmacra_hook.so`).
/// When non-empty, the library is injected via `LD_PRELOAD` / `DYLD_INSERT_LIBRARIES`.
pub hook_lib: PathBuf,
}
/// Spawns `cargo check` with `-Z trace-macros` (and optionally the macra-hook)
/// and yields [`MacroExpansion`]s as they become available.
pub struct TraceMacros {
cargo_path: PathBuf,
args: Args,
}
/// Blocking iterator over [`MacroExpansion`] items produced by a running cargo
/// process.
pub struct MacroExpansionIter {
rx: mpsc::Receiver<io::Result<MacroExpansion>>,
}
/// Result of spawning trace-macros collection.
pub struct TraceRun {
pub iter: MacroExpansionIter,
/// Receives cargo check result once the child exits.
pub check_result: mpsc::Receiver<io::Result<CheckResult>>,
}
/// Result details for the traced `cargo check` execution.
pub struct CheckResult {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
impl MacroExpansionIter {
/// Non-blocking attempt to receive the next expansion.
///
/// Returns `Ok(Some(exp))` if an item was ready, `Ok(None)` if the channel
/// is still open but nothing is available yet, or `Err(())` if the channel
/// has been closed (the background thread finished).
#[allow(clippy::result_unit_err)]
pub fn try_next(&mut self) -> Result<Option<io::Result<MacroExpansion>>, ()> {
match self.rx.try_recv() {
Ok(item) => Ok(Some(item)),
Err(mpsc::TryRecvError::Empty) => Ok(None),
Err(mpsc::TryRecvError::Disconnected) => Err(()),
}
}
}
impl Iterator for MacroExpansionIter {
type Item = io::Result<MacroExpansion>;
fn next(&mut self) -> Option<Self::Item> {
self.rx.recv().ok()
}
}
const HOOK_LINE_PREFIX: &str = "__MACRA_HOOK__:";
#[cfg(target_os = "macos")]
static LINKER_WRAPPER_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(serde::Deserialize)]
struct HookRecord {
name: String,
kind: String,
#[serde(default)]
arguments: String,
input: String,
output: String,
}
fn parse_hook_json(json: &str) -> Option<MacroExpansion> {
let record: HookRecord = serde_json::from_str(json).ok()?;
let kind = match record.kind.as_str() {
"CustomDerive" => MacroExpansionKind::Derive,
"Attr" => MacroExpansionKind::Attribute,
_ => MacroExpansionKind::Bang,
};
let expanding = match kind {
MacroExpansionKind::Derive => record.name.clone(),
MacroExpansionKind::Attribute => {
if record.input.contains('(') || record.input.contains('{') {
record.input.clone()
} else {
format!("{} {{ {} }}", record.name, record.input)
}
}
MacroExpansionKind::Bang => record.input.clone(),
};
Some(MacroExpansion {
expanding,
arguments: record.arguments,
to: record.output,
name: record.name,
kind,
input: record.input,
})
}
impl TraceMacros {
pub fn new(cargo_path: &Path, args: &Args) -> Self {
Self {
cargo_path: cargo_path.to_path_buf(),
args: args.clone(),
}
}
pub fn args(&self) -> &Args {
&self.args
}
/// Spawn `cargo check` and return a blocking iterator of macro expansions.
///
/// Hook-based expansions (proc-macros captured via `LD_PRELOAD`) are emitted
/// immediately as the child process writes them. Trace-macros expansions
/// (from rustc's `-Z trace-macros`) are emitted after the child exits.
pub fn run(&self) -> io::Result<TraceRun> {
let mut cmd = Command::new(&self.cargo_path);
cmd.arg("check");
cmd.env("RUSTC_BOOTSTRAP", "1");
if let Some(ref pkg) = self.args.package {
cmd.arg("-p").arg(pkg);
}
if let Some(ref bin) = self.args.bin {
cmd.arg("--bin").arg(bin);
}
if self.args.lib {
cmd.arg("--lib");
}
if let Some(ref test) = self.args.test {
cmd.arg("--test").arg(test);
}
if let Some(ref example) = self.args.example {
cmd.arg("--example").arg(example);
}
if let Some(ref manifest_path) = self.args.manifest_path {
cmd.arg("--manifest-path").arg(manifest_path);
}
for arg in &self.args.cargo_args {
cmd.arg(arg);
}
// Append -Z trace-macros to existing RUSTFLAGS
let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
if !rustflags.is_empty() {
rustflags.push(' ');
}
rustflags.push_str("-Z trace-macros");
// Set up macra-hook via LD_PRELOAD if available
if !self.args.hook_lib.as_os_str().is_empty() {
let lib = self
.args
.hook_lib
.canonicalize()
.unwrap_or_else(|_| self.args.hook_lib.clone());
if cfg!(target_os = "macos") {
cmd.env("DYLD_INSERT_LIBRARIES", &lib);
// DYLD_INSERT_LIBRARIES propagates into the linker process (cc),
// which can fail due to arch constraints on newer macOS runners.
// Route linker invocations through a tiny wrapper that unsets DYLD.
#[cfg(target_os = "macos")]
if let Ok(wrapper) = create_macos_linker_wrapper() {
cmd.env("CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER", &wrapper);
cmd.env("CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER", &wrapper);
// Also cover toolchains/build scripts that consult CC directly.
cmd.env("CC", &wrapper);
}
} else if cfg!(target_os = "windows") {
// On Windows, use RUSTC_WRAPPER to inject the hook DLL into
// rustc via CreateRemoteThread + LoadLibraryW.
#[cfg(target_os = "windows")]
if let Some(wrapper_exe) =
crate::find_wrapper_exe(std::env::current_exe().ok().as_deref())
{
cmd.env("RUSTC_WRAPPER", &wrapper_exe);
cmd.env("MACRA_HOOK_DLL_PATH", &lib);
}
} else {
cmd.env("LD_PRELOAD", &lib);
}
// Include a hook-specific cfg flag in RUSTFLAGS so that cargo's
// build fingerprint changes when switching between hook-enabled
// and non-hook builds. Without this, a previous build without the
// hook (but with the same -Z trace-macros flag) would leave cached
// artifacts that cargo considers "Fresh", causing it to replay
// the cached stderr — which lacks hook output.
rustflags.push_str(" --cfg macra_hook_active");
// All platforms use stderr for hook output. The hook library
// writes JSON lines to stderr with a `__MACRA_HOOK__:` prefix.
// Cargo caches and replays stderr diagnostics for "Fresh" crates,
// so hook output from previously-compiled dependencies is
// automatically available even when cargo skips recompilation.
// This is critical because parallel tests share a target directory
// and only the first test to compile a dependency crate triggers
// actual rustc invocations.
//
// On Windows (RUSTC_WRAPPER), rustc inherits the wrapper's stderr
// handle via bInheritHandles=TRUE in CreateProcessW, so hook
// output reaches cargo's stderr pipe just like on Linux/macOS.
}
cmd.env("RUSTFLAGS", rustflags);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("failed to capture stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| io::Error::other("failed to capture stderr"))?;
let (tx, rx) = mpsc::channel();
let (status_tx, status_rx) = mpsc::channel();
// Drain stdout in a background thread to prevent the child from blocking.
// Keep a copy because some cargo/rustc setups emit diagnostics on stdout.
let stdout_thread = thread::spawn(move || {
use std::io::Read;
let mut stdout = stdout;
let mut collected = String::new();
let mut buf = [0u8; 4096];
loop {
match stdout.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
collected.push_str(&String::from_utf8_lossy(&buf[..n]));
}
}
}
collected
});
// Read stderr: handle hook lines (legacy fallback), collect the rest
// for trace-macros parsing after the child exits.
thread::spawn(move || {
use std::io::BufRead;
let reader = io::BufReader::new(stderr);
let mut stderr_buf = String::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
let _ = tx.send(Err(e));
break;
}
};
// Legacy path: hook output on stderr (when MACRA_HOOK_OUTPUT_DIR
// is not used or the hook falls back to stderr).
if let Some(json) = line.strip_prefix(HOOK_LINE_PREFIX) {
if let Some(expansion) = parse_hook_json(json) {
let _ = tx.send(Ok(expansion));
}
} else {
stderr_buf.push_str(&line);
stderr_buf.push('\n');
}
}
// Wait for stdout draining and child process to finish
let stdout_buf = stdout_thread.join().unwrap_or_default();
let wait_result: io::Result<ExitStatus> = child.wait();
// Parse plain-text trace-macros output from stderr and stdout.
for group in parse_trace(stderr_buf.as_bytes()) {
for expansion in group.expansions {
let _ = tx.send(Ok(expansion));
}
}
for group in parse_trace(stdout_buf.as_bytes()) {
for expansion in group.expansions {
let _ = tx.send(Ok(expansion));
}
}
match wait_result {
Ok(status) => {
let _ = status_tx.send(Ok(CheckResult {
success: status.success(),
stdout: stdout_buf,
stderr: stderr_buf,
}));
}
Err(e) => {
let _ = status_tx.send(Err(e));
}
}
// tx drops here, closing the channel
});
Ok(TraceRun {
iter: MacroExpansionIter { rx },
check_result: status_rx,
})
}
}
#[cfg(target_os = "macos")]
fn create_macos_linker_wrapper() -> io::Result<PathBuf> {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let unique = LINKER_WRAPPER_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir();
let bin_path = dir.join(format!(
"cargo-macra-linker-wrapper-{}-{}",
std::process::id(),
unique
));
// Compile a native arm64 binary wrapper instead of a shell script.
// On newer macOS, system binaries like /bin/sh are arm64e. dyld refuses
// to inject an arm64 dylib into an arm64e process, so the shell script
// approach dies with SIGABRT before the `unset` line ever runs.
// A compiled arm64 binary can load the arm64 hook dylib harmlessly, then
// unset DYLD_INSERT_LIBRARIES before exec-ing the real (arm64e) linker.
let c_src = concat!(
"#include <stdlib.h>\n",
"#include <unistd.h>\n",
"#include <string.h>\n",
"int main(int argc, char *argv[]) {\n",
" (void)argc;\n",
" unsetenv(\"DYLD_INSERT_LIBRARIES\");\n",
" if (argv[1]) {\n",
" const char *b = strrchr(argv[1], '/');\n",
" if (!b) b = argv[1]; else b++;\n",
" if (strcmp(b,\"cc\")==0||strcmp(b,\"clang\")==0||strcmp(b,\"gcc\")==0) {\n",
" execvp(argv[1], argv+1);\n",
" _exit(127);\n",
" }\n",
" }\n",
" argv[0] = \"/usr/bin/cc\";\n",
" execvp(\"/usr/bin/cc\", argv);\n",
" _exit(127);\n",
"}\n",
);
let src_path = dir.join(format!(
"cargo-macra-linker-wrapper-{}-{}.c",
std::process::id(),
unique
));
fs::write(&src_path, c_src)?;
let compile = std::process::Command::new("cc")
.arg("-o")
.arg(&bin_path)
.arg(&src_path)
.status();
let _ = fs::remove_file(&src_path);
if let Ok(st) = compile {
if st.success() {
return Ok(bin_path);
}
}
// Fallback: shell script (works on systems where /bin/sh is arm64).
let script_path = dir.join(format!(
"cargo-macra-linker-wrapper-{}-{}.sh",
std::process::id(),
unique
));
let script = r#"#!/bin/sh
unset DYLD_INSERT_LIBRARIES
if [ "$#" -gt 0 ]; then
case "$1" in
*/cc|cc|*/clang|clang|*/gcc|gcc)
linker="$1"
shift
exec "$linker" "$@"
;;
esac
fi
exec /usr/bin/cc "$@"
"#;
fs::write(&script_path, script)?;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
Ok(script_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_hook_json_bang() {
let json = r#"{"name":"println","kind":"Bang","arguments":"","input":"println!(\"hello\")","output":"{ ::std::io::_print(format_args!(\"hello\\n\")); }"}"#;
let exp = parse_hook_json(json).unwrap();
assert_eq!(exp.name, "println");
assert_eq!(exp.kind, MacroExpansionKind::Bang);
assert_eq!(exp.expanding, "println!(\"hello\")");
}
#[test]
fn test_parse_hook_json_derive() {
let json = r#"{"name":"Debug","kind":"CustomDerive","arguments":"","input":"struct Foo {}","output":"impl Debug for Foo {}"}"#;
let exp = parse_hook_json(json).unwrap();
assert_eq!(exp.name, "Debug");
assert_eq!(exp.kind, MacroExpansionKind::Derive);
assert_eq!(exp.expanding, "Debug");
}
#[test]
fn test_parse_hook_json_attribute() {
// Input contains '{' so expanding == input (not wrapped)
let json = r#"{"name":"test","kind":"Attr","arguments":"","input":"fn foo() {}","output":"fn foo() { /* test */ }"}"#;
let exp = parse_hook_json(json).unwrap();
assert_eq!(exp.name, "test");
assert_eq!(exp.kind, MacroExpansionKind::Attribute);
assert_eq!(exp.expanding, "fn foo() {}");
}
#[test]
fn test_parse_hook_json_attribute_simple_input() {
// Input without '(' or '{' gets wrapped as "name { input }"
let json = r#"{"name":"cfg","kind":"Attr","arguments":"","input":"feature = \"foo\"","output":""}"#;
let exp = parse_hook_json(json).unwrap();
assert_eq!(exp.name, "cfg");
assert_eq!(exp.kind, MacroExpansionKind::Attribute);
assert_eq!(exp.expanding, "cfg { feature = \"foo\" }");
}
#[test]
fn test_parse_hook_json_invalid() {
assert!(parse_hook_json("not json").is_none());
}
}