Skip to main content

cargo_macra/
parse_trace.rs

1use std::io::{BufRead, BufReader, Read};
2
3/// The kind of macro invocation.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum MacroExpansionKind {
6    /// Function-like macro: `name!(...)` / `name![...]` / `name!{...}`
7    Bang,
8    /// Attribute macro: `#[name]` or `#[name(...)]`
9    Attribute,
10    /// Derive macro: `#[derive(Name)]`
11    Derive,
12}
13
14/// A single macro expansion pair: the "expanding" text and the "to" text.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct MacroExpansion {
17    pub expanding: String,
18    pub arguments: String,
19    pub to: String,
20    /// Macro name (e.g. `"println"`, `"derive"`, `"test"`).
21    pub name: String,
22    /// Kind of macro invocation.
23    pub kind: MacroExpansionKind,
24    /// Raw input token stream that the macro receives.
25    pub input: String,
26}
27
28/// A group of macro expansions from a single `note: trace_macro` block.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct TraceGroup {
31    pub expansions: Vec<MacroExpansion>,
32}
33
34/// Iterator over trace groups parsed from macro tracing output.
35pub struct TraceParser<R: Read> {
36    reader: BufReader<R>,
37    current_line: String,
38    peeked_line: Option<String>,
39}
40
41impl<R: Read> TraceParser<R> {
42    pub fn new(reader: R) -> Self {
43        Self {
44            reader: BufReader::new(reader),
45            current_line: String::new(),
46            peeked_line: None,
47        }
48    }
49
50    fn strip_ansi_escape_sequences(s: &str) -> String {
51        let mut out = String::with_capacity(s.len());
52        let mut chars = s.chars().peekable();
53        while let Some(ch) = chars.next() {
54            if ch == '\u{1b}' {
55                // Skip CSI sequences: ESC [ ... final-byte
56                if chars.peek() == Some(&'[') {
57                    let _ = chars.next();
58                    for c in chars.by_ref() {
59                        if ('@'..='~').contains(&c) {
60                            break;
61                        }
62                    }
63                }
64                continue;
65            }
66            out.push(ch);
67        }
68        out
69    }
70
71    fn read_line(&mut self) -> Option<String> {
72        if let Some(line) = self.peeked_line.take() {
73            return Some(line);
74        }
75        self.current_line.clear();
76        match self.reader.read_line(&mut self.current_line) {
77            Ok(0) => None,
78            Ok(_) => {
79                let line = self.current_line.trim_end_matches('\n');
80                Some(Self::strip_ansi_escape_sequences(line))
81            }
82            Err(_) => None,
83        }
84    }
85
86    fn peek_line(&mut self) -> Option<&str> {
87        if self.peeked_line.is_none() {
88            self.peeked_line = self.read_line();
89        }
90        self.peeked_line.as_deref()
91    }
92
93    /// Extract content between backticks, handling multi-line content.
94    /// Returns the content and consumes lines as needed.
95    ///
96    /// The rustc trace-macros output wraps expanding/to content in backticks:
97    ///   `= note: to \`CONTENT\``
98    /// Content may itself contain backticks (e.g. doc comments with markdown
99    /// links like `` [`something`] ``).  For single-line content the closing
100    /// backtick is the *last* backtick on the line.  For multi-line content
101    /// the closing backtick is the last character on the final line.
102    fn extract_backtick_content(&mut self, first_line: &str) -> Option<String> {
103        // Find the opening backtick
104        let start_idx = first_line.find('`')?;
105        let after_backtick = &first_line[start_idx + 1..];
106
107        // Check if content ends on the same line (use rfind to skip inner backticks)
108        if let Some(end_idx) = after_backtick.rfind('`') {
109            return Some(after_backtick[..end_idx].to_string());
110        }
111
112        // Multi-line content: collect until closing backtick at end of line
113        let mut content = after_backtick.to_string();
114
115        loop {
116            let line = self.read_line()?;
117            if line.ends_with('`') {
118                content.push('\n');
119                content.push_str(&line[..line.len() - 1]);
120                break;
121            } else {
122                content.push('\n');
123                content.push_str(&line);
124            }
125        }
126
127        Some(content)
128    }
129
130    /// Extract macro name from an `expanding` string.
131    ///
132    /// The expanding string has the form `name! { args }` (or with `()` / `[]`).
133    /// Returns the name part before `!`.
134    fn extract_macro_name(expanding: &str) -> String {
135        if let Some(bang_pos) = expanding.find('!') {
136            expanding[..bang_pos].trim().to_string()
137        } else {
138            expanding.to_string()
139        }
140    }
141
142    /// Extract macro arguments from an `expanding` string.
143    ///
144    /// The expanding string has the form `name! { args }` (or with `()` / `[]`).
145    /// Returns the content between the outermost delimiters, trimmed.
146    fn extract_arguments(expanding: &str) -> String {
147        // Find the `!` that separates macro name from arguments
148        let bang_pos = match expanding.find('!') {
149            Some(p) => p,
150            None => return String::new(),
151        };
152        let after_bang = expanding[bang_pos + 1..].trim_start();
153        let first_char = match after_bang.chars().next() {
154            Some(c) => c,
155            None => return String::new(),
156        };
157        let (open, close) = match first_char {
158            '{' => ('{', '}'),
159            '(' => ('(', ')'),
160            '[' => ('[', ']'),
161            _ => return String::new(),
162        };
163        let mut depth = 0i32;
164        let mut start = None;
165        let mut end = None;
166        for (i, ch) in after_bang.char_indices() {
167            if ch == open {
168                depth += 1;
169                if start.is_none() {
170                    start = Some(i + ch.len_utf8());
171                }
172            } else if ch == close {
173                depth -= 1;
174                if depth == 0 {
175                    end = Some(i);
176                    break;
177                }
178            }
179        }
180        match (start, end) {
181            (Some(s), Some(e)) => after_bang[s..e].trim().to_string(),
182            _ => String::new(),
183        }
184    }
185
186    fn parse_trace_group(&mut self) -> Option<TraceGroup> {
187        let mut expansions = Vec::new();
188
189        while let Some(l) = self.peek_line() {
190            let line = l.to_string();
191
192            if line.starts_with("note: trace_macro") {
193                // Next trace group starts
194                if !expansions.is_empty() {
195                    break;
196                }
197                // Consume the "note: trace_macro" line
198                self.read_line();
199                continue;
200            }
201
202            if line.contains("= note: expanding `") {
203                self.read_line(); // consume the line
204                let expanding = self.extract_backtick_content(&line)?;
205
206                // Now look for the corresponding "to" line
207                loop {
208                    let to_line = match self.peek_line() {
209                        Some(l) => l.to_string(),
210                        None => return None,
211                    };
212
213                    if to_line.contains("= note: to `") {
214                        self.read_line(); // consume the line
215                        let to = self.extract_backtick_content(&to_line)?;
216                        let input = Self::extract_arguments(&expanding);
217                        let name = Self::extract_macro_name(&expanding);
218                        expansions.push(MacroExpansion {
219                            expanding,
220                            arguments: String::new(),
221                            to,
222                            name,
223                            kind: MacroExpansionKind::Bang,
224                            input,
225                        });
226                        break;
227                    } else if to_line.starts_with("note: trace_macro")
228                        || to_line.contains("= note: expanding `")
229                    {
230                        // Unexpected: got another expanding before to
231                        break;
232                    } else {
233                        // Skip other lines (location info, source code, etc.)
234                        self.read_line();
235                    }
236                }
237            } else if line.trim().is_empty()
238                || line.starts_with("   -->")
239                || line.starts_with("    |")
240                || line.starts_with("   |")
241                || line.starts_with("  -->")
242                || line.starts_with("...")
243                || line.contains("= note: this note originates")
244            {
245                // Skip location/formatting lines
246                self.read_line();
247            } else if !expansions.is_empty() {
248                // Non-trace content after we have some expansions means end of group
249                break;
250            } else {
251                // Skip unrelated lines before finding any expansions
252                self.read_line();
253            }
254        }
255
256        if expansions.is_empty() {
257            None
258        } else {
259            Some(TraceGroup { expansions })
260        }
261    }
262}
263
264impl<R: Read> Iterator for TraceParser<R> {
265    type Item = TraceGroup;
266
267    fn next(&mut self) -> Option<Self::Item> {
268        // Skip lines until we find "note: trace_macro"
269        loop {
270            match self.peek_line() {
271                Some(line) if line.starts_with("note: trace_macro") => {
272                    return self.parse_trace_group();
273                }
274                Some(_) => {
275                    self.read_line();
276                }
277                None => return None,
278            }
279        }
280    }
281}
282
283/// Parse macro tracing output from a reader.
284///
285/// Takes any `std::io::Read` and returns an iterator over `TraceGroup`s.
286/// Each `TraceGroup` contains one or more `MacroExpansion` pairs from
287/// a single `note: trace_macro` block.
288pub fn parse_trace<R: Read>(reader: R) -> TraceParser<R> {
289    TraceParser::new(reader)
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_single_expansion() {
298        let input = r#"note: trace_macro
299  --> src/lib.rs:15:17
300   |
30115 |             if !matches!(segment.arguments, PathArguments::None) {
302   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
303   |
304   = note: expanding `matches! { segment.arguments, PathArguments::None }`
305   = note: to `match segment.arguments { PathArguments::None => true, _ => false }`
306"#;
307
308        let groups: Vec<_> = parse_trace(input.as_bytes()).collect();
309        assert_eq!(groups.len(), 1);
310        assert_eq!(groups[0].expansions.len(), 1);
311        assert_eq!(
312            groups[0].expansions[0].expanding,
313            "matches! { segment.arguments, PathArguments::None }"
314        );
315        assert_eq!(
316            groups[0].expansions[0].to,
317            "match segment.arguments { PathArguments::None => true, _ => false }"
318        );
319    }
320
321    #[test]
322    fn test_multiple_expansions_in_group() {
323        let input = r#"note: trace_macro
324  --> macro/lib.rs:16:17
325   |
32616 |                 abort!(segment, "Path arguments are not allowed");
327   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
328   |
329   = note: expanding `abort! { segment, "message" }`
330   = note: to `diagnostic!(segment, Error, "message").abort()`
331   = note: expanding `diagnostic! { segment, Error, "message" }`
332   = note: to `Diagnostic::new(segment, Error, "message")`
333"#;
334
335        let groups: Vec<_> = parse_trace(input.as_bytes()).collect();
336        assert_eq!(groups.len(), 1);
337        assert_eq!(groups[0].expansions.len(), 2);
338        assert_eq!(
339            groups[0].expansions[0].expanding,
340            "abort! { segment, \"message\" }"
341        );
342        assert_eq!(
343            groups[0].expansions[0].to,
344            "diagnostic!(segment, Error, \"message\").abort()"
345        );
346        assert_eq!(
347            groups[0].expansions[1].expanding,
348            "diagnostic! { segment, Error, \"message\" }"
349        );
350        assert_eq!(
351            groups[0].expansions[1].to,
352            "Diagnostic::new(segment, Error, \"message\")"
353        );
354    }
355
356    #[test]
357    fn test_multiline_to_content() {
358        let input = r#"note: trace_macro
359  --> src/lib.rs:1:1
360   |
361   = note: expanding `vec! { 1, 2, 3 }`
362   = note: to `{
363       let mut v = Vec::new();
364       v.push(1);
365       v.push(2);
366       v.push(3);
367       v
368   }`
369"#;
370
371        let groups: Vec<_> = parse_trace(input.as_bytes()).collect();
372        assert_eq!(groups.len(), 1);
373        assert_eq!(groups[0].expansions.len(), 1);
374        assert_eq!(groups[0].expansions[0].expanding, "vec! { 1, 2, 3 }");
375        assert_eq!(
376            groups[0].expansions[0].to,
377            r#"{
378       let mut v = Vec::new();
379       v.push(1);
380       v.push(2);
381       v.push(3);
382       v
383   }"#
384        );
385    }
386
387    #[test]
388    fn test_multiple_trace_groups() {
389        let input = r#"note: trace_macro
390  --> src/lib.rs:1:1
391   |
392   = note: expanding `println! { "hello" }`
393   = note: to `print!("hello\n")`
394
395note: trace_macro
396  --> src/lib.rs:2:1
397   |
398   = note: expanding `dbg! { x }`
399   = note: to `{ eprintln!("{}", x); x }`
400"#;
401
402        let groups: Vec<_> = parse_trace(input.as_bytes()).collect();
403        assert_eq!(groups.len(), 2);
404        assert_eq!(groups[0].expansions[0].expanding, "println! { \"hello\" }");
405        assert_eq!(groups[1].expansions[0].expanding, "dbg! { x }");
406    }
407
408    #[test]
409    fn test_empty_input() {
410        let input = "";
411        let groups: Vec<_> = parse_trace(input.as_bytes()).collect();
412        assert!(groups.is_empty());
413    }
414
415    #[test]
416    fn test_no_trace_macro() {
417        let input = r#"   Compiling myproject v0.1.0
418    Finished dev profile
419"#;
420        let groups: Vec<_> = parse_trace(input.as_bytes()).collect();
421        assert!(groups.is_empty());
422    }
423
424    #[test]
425    fn test_real_rustc_output() {
426        // Real output from `RUSTFLAGS="-Z trace-macros" cargo +nightly check`
427        let input = r#"note: trace_macro
428  --> macro/lib.rs:15:17
429   |
43015 |             if !matches!(segment.arguments, PathArguments::None) {
431   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
432   |
433   = note: expanding `matches! { segment.arguments, PathArguments::None }`
434   = note: to `#[allow(non_exhaustive_omitted_patterns)] match segment.arguments
435           { PathArguments::None => true, _ => false }`
436
437note: trace_macro
438  --> macro/lib.rs:16:17
439   |
44016 |                 abort!(segment, "Path arguments are not allowed");
441   |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
442   |
443   = note: expanding `abort! { segment, "Path arguments are not allowed" }`
444   = note: to `$crate :: diagnostic!
445           (segment, $crate :: Level :: Error, "Path arguments are not allowed").abort()`
446   = note: expanding `diagnostic! { segment, $crate :: Level :: Error, "Path arguments are not allowed" }`
447   = note: to `{
448               #[allow(unused_imports)] use $crate :: __export ::
449               {
450                   ToTokensAsSpanRange, Span2AsSpanRange, SpanAsSpanRange,
451                   SpanRangeAsSpanRange
452               }; use $crate :: DiagnosticExt; let span_range =
453               (&
454               segment).FIRST_ARG_MUST_EITHER_BE_Span_OR_IMPLEMENT_ToTokens_OR_BE_SpanRange();
455               $crate :: Diagnostic ::
456               spanned_range(span_range, $crate :: Level :: Error,
457               "Path arguments are not allowed".to_string())
458           }`
459
460note: trace_macro
461  --> macro/lib.rs:43:41
462   |
46343 |     if input.peek(Ident) && input.peek2(Token![=]) {
464   |                                         ^^^^^^^^^
465   |
466   = note: expanding `Token! { = }`
467   = note: to `$crate :: token :: Eq`
468    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.37s
469"#;
470
471        let groups: Vec<_> = parse_trace(input.as_bytes()).collect();
472        assert_eq!(groups.len(), 3);
473
474        // First group: single expansion
475        assert_eq!(groups[0].expansions.len(), 1);
476        assert_eq!(
477            groups[0].expansions[0].expanding,
478            "matches! { segment.arguments, PathArguments::None }"
479        );
480        assert_eq!(
481            groups[0].expansions[0].to,
482            r#"#[allow(non_exhaustive_omitted_patterns)] match segment.arguments
483           { PathArguments::None => true, _ => false }"#
484        );
485
486        // Second group: multiple expansions (abort! -> diagnostic!)
487        assert_eq!(groups[1].expansions.len(), 2);
488        assert_eq!(
489            groups[1].expansions[0].expanding,
490            "abort! { segment, \"Path arguments are not allowed\" }"
491        );
492        assert_eq!(
493            groups[1].expansions[0].to,
494            r#"$crate :: diagnostic!
495           (segment, $crate :: Level :: Error, "Path arguments are not allowed").abort()"#
496        );
497        assert_eq!(
498            groups[1].expansions[1].expanding,
499            "diagnostic! { segment, $crate :: Level :: Error, \"Path arguments are not allowed\" }"
500        );
501        assert_eq!(
502            groups[1].expansions[1].to,
503            r#"{
504               #[allow(unused_imports)] use $crate :: __export ::
505               {
506                   ToTokensAsSpanRange, Span2AsSpanRange, SpanAsSpanRange,
507                   SpanRangeAsSpanRange
508               }; use $crate :: DiagnosticExt; let span_range =
509               (&
510               segment).FIRST_ARG_MUST_EITHER_BE_Span_OR_IMPLEMENT_ToTokens_OR_BE_SpanRange();
511               $crate :: Diagnostic ::
512               spanned_range(span_range, $crate :: Level :: Error,
513               "Path arguments are not allowed".to_string())
514           }"#
515        );
516
517        // Third group: Token! macro
518        assert_eq!(groups[2].expansions.len(), 1);
519        assert_eq!(groups[2].expansions[0].expanding, "Token! { = }");
520        assert_eq!(groups[2].expansions[0].to, "$crate :: token :: Eq");
521    }
522}