Skip to main content

cargo_macra/
trace_macros.rs

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