Skip to main content

big_code_analysis/output/
dump.rs

1// Per-language metric and AST modules deliberately consume the macro-
2// generated tree-sitter token enums via `use crate::*` and `use Foo::*`
3// inside match expressions — explicit imports would list dozens of
4// variants per arm and obscure the per-language token sets that are the
5// point of these files. Allowed at the module level rather than per
6// function so the per-language impl blocks stay readable.
7#![allow(clippy::enum_glob_use, clippy::ref_option, clippy::wildcard_imports)]
8
9use termcolor::{Color, StandardStream, WriteColor};
10
11use crate::node::Node;
12use crate::output::ColorMode;
13use crate::tools::{color, intense_color};
14
15/// Dumps the `AST` of a code.
16///
17/// Returns a [`Result`] value, when an error occurs.
18///
19/// # Errors
20///
21/// Propagates any [`std::io::Error`] produced by the color-aware
22/// writer that backs `stdout` (broken pipe, write failure, …).
23///
24/// # Examples
25///
26/// ```
27/// use big_code_analysis::{dump_node, Ast, LANG, Source};
28///
29/// let source = b"int a = 42;";
30/// let ast = Ast::parse(Source::new(LANG::Cpp, source))
31///     .expect("cpp feature enabled");
32/// let root = ast.root_node();
33///
34/// // Dump the AST from the first line of code in a file to the last one
35/// dump_node(ast.source(), &root, -1, None, None).unwrap();
36/// ```
37///
38/// # Panics
39///
40/// Panics if `code` is not the exact source `node` was parsed from.
41/// `node`'s byte range is used to slice `code`, so a `node` taken from a
42/// different (or smaller) tree indexes out of bounds. Always pair a node
43/// with the source it came from — e.g. `ast.source()` and a node obtained
44/// from the *same* [`crate::Ast`] (`ast.root_node()` or a descendant).
45pub fn dump_node(
46    code: &[u8],
47    node: &Node,
48    depth: i32,
49    line_start: Option<usize>,
50    line_end: Option<usize>,
51) -> std::io::Result<()> {
52    dump_node_with_color(code, node, depth, line_start, line_end, ColorMode::Always)
53}
54
55/// Like [`dump_node`], but the caller selects the [`ColorMode`].
56///
57/// `bca` resolves a `--color` flag, the `NO_COLOR` convention, and
58/// stdout tty detection into a mode and passes it here so piped output
59/// is escape-free by default. The bare [`dump_node`] keeps the
60/// historical always-colored behavior for backward compatibility.
61///
62/// # Errors
63///
64/// Propagates any [`std::io::Error`] produced by the color-aware
65/// writer that backs `stdout` (broken pipe, write failure, …).
66///
67/// # Panics
68///
69/// Panics if `code` is not the exact source `node` was parsed from.
70/// `node`'s byte range is used to slice `code`, so a `node` taken from a
71/// different (or smaller) tree indexes out of bounds. Always pair a node
72/// with the source it came from — e.g. `ast.source()` and a node obtained
73/// from the *same* [`crate::Ast`] (`ast.root_node()` or a descendant).
74pub fn dump_node_with_color(
75    code: &[u8],
76    node: &Node,
77    depth: i32,
78    line_start: Option<usize>,
79    line_end: Option<usize>,
80    color_mode: ColorMode,
81) -> std::io::Result<()> {
82    let stdout = StandardStream::stdout(color_mode.to_color_choice());
83    let mut stdout = stdout.lock();
84    let mut state = DumpState {
85        code,
86        line_start: &line_start,
87        line_end: &line_end,
88        stdout: &mut stdout,
89    };
90    let ret = dump_tree_helper(&mut state, node, "", true, depth);
91
92    color(&mut stdout, Color::White)?;
93
94    ret
95}
96
97/// Recursion-invariant rendering state threaded through the AST walk:
98/// the source bytes, the optional line-range filter, and the colored
99/// writer. Bundling these keeps every walk function under the
100/// argument-count limit (the pre-split helper carried eight arguments)
101/// and lets tests substitute a `termcolor::NoColor` sink over a
102/// `Vec<u8>` for byte-exact output assertions.
103struct DumpState<'a> {
104    code: &'a [u8],
105    line_start: &'a Option<usize>,
106    line_end: &'a Option<usize>,
107    stdout: &'a mut dyn WriteColor,
108}
109
110/// One pending node in the iterative AST walk: the node, the box-drawing
111/// prefix accumulated from its ancestors, whether it is the last child of
112/// its parent (which selects its connector glyph), and the remaining
113/// depth budget.
114struct Frame<'a> {
115    node: Node<'a>,
116    prefix: String,
117    last: bool,
118    depth: i32,
119}
120
121/// Render the subtree rooted at `node` with an explicit work stack rather
122/// than recursion. A pathologically deep AST (thousands of nested
123/// expressions, which the iterative *builder* in tree-sitter accepts
124/// without bound) would otherwise overflow the thread stack at dump time
125/// — an uncatchable abort, forbidden by the no-panic rule. The traversal
126/// order, per-node glyphs, and depth semantics are byte-identical to the
127/// prior recursive form (#700).
128fn dump_tree_helper(
129    state: &mut DumpState,
130    node: &Node,
131    prefix: &str,
132    last: bool,
133    depth: i32,
134) -> std::io::Result<()> {
135    let mut stack: Vec<Frame> = vec![Frame {
136        node: *node,
137        prefix: prefix.to_owned(),
138        last,
139        depth,
140    }];
141
142    while let Some(Frame {
143        node,
144        prefix,
145        last,
146        depth,
147    }) = stack.pop()
148    {
149        if depth == 0 {
150            continue;
151        }
152
153        let (pref_child, pref) = branch_glyphs(&node, last);
154
155        if line_in_range(node.start_row() + 1, state.line_start, state.line_end) {
156            write_node_line(state.stdout, state.code, &node, &prefix, pref)?;
157        }
158
159        let count = node.child_count();
160        if count == 0 {
161            continue;
162        }
163
164        // Children share one extended prefix. Push them in reverse so
165        // `pop()` visits them in source order, matching the recursive
166        // form's pre-order traversal. `Children` is not a
167        // `DoubleEndedIterator`, so collect first, then walk the indices
168        // backwards.
169        let child_prefix = format!("{prefix}{pref_child}");
170        let child_depth = depth - 1;
171        let children: Vec<Node> = node.children().collect();
172        for (i, child) in children.into_iter().enumerate().rev() {
173            stack.push(Frame {
174                node: child,
175                prefix: child_prefix.clone(),
176                last: i + 1 == count,
177                depth: child_depth,
178            });
179        }
180    }
181
182    Ok(())
183}
184
185/// Box-drawing prefixes for `node` as `(pref_child, pref)`. The root
186/// (no parent) renders flush-left regardless of `last`; this check must
187/// stay first because `dump_node` passes `last = true` for the root.
188fn branch_glyphs(node: &Node, last: bool) -> (&'static str, &'static str) {
189    if node.parent().is_none() {
190        ("", "")
191    } else if last {
192        ("   ", "╰─ ")
193    } else {
194        ("│  ", "├─ ")
195    }
196}
197
198/// Whether 1-based `row` falls within the optional `[line_start,
199/// line_end]` filter. Either bound being `None` leaves that side
200/// unconstrained, so `(None, None)` always shows the node.
201fn line_in_range(row: usize, line_start: &Option<usize>, line_end: &Option<usize>) -> bool {
202    line_start.is_none_or(|start| row >= start) && line_end.is_none_or(|end| row <= end)
203}
204
205/// Set `c` then write `args` in that color. Collapsing the recurring
206/// set-color-then-write pair into one fallible call keeps each writer
207/// helper's exit count under the threshold.
208fn paint(stdout: &mut dyn WriteColor, c: Color, args: std::fmt::Arguments) -> std::io::Result<()> {
209    color(stdout, c)?;
210    stdout.write_fmt(args)
211}
212
213/// Emit the full colored description line for one node: header, position
214/// range, optional same-row snippet, then the trailing newline (always,
215/// even for multi-row nodes whose snippet is skipped).
216fn write_node_line(
217    stdout: &mut dyn WriteColor,
218    code: &[u8],
219    node: &Node,
220    prefix: &str,
221    pref: &str,
222) -> std::io::Result<()> {
223    write_node_header(stdout, node, prefix, pref)?;
224    write_node_location(stdout, node)?;
225    write_node_snippet(stdout, code, node)?;
226    writeln!(stdout)
227}
228
229/// Prefix glyphs followed by the `{kind:kind_id}` tag.
230fn write_node_header(
231    stdout: &mut dyn WriteColor,
232    node: &Node,
233    prefix: &str,
234    pref: &str,
235) -> std::io::Result<()> {
236    paint(stdout, Color::Blue, format_args!("{prefix}{pref}"))?;
237    intense_color(stdout, Color::Yellow)?;
238    write!(stdout, "{{{}:{}}} ", node.kind(), node.kind_id())
239}
240
241/// The `from (row, col) to (row, col)` 1-based position range.
242fn write_node_location(stdout: &mut dyn WriteColor, node: &Node) -> std::io::Result<()> {
243    paint(stdout, Color::White, format_args!("from "))?;
244    let (row, column) = node.start_position();
245    paint(
246        stdout,
247        Color::Green,
248        format_args!("({}, {}) ", row + 1, column + 1),
249    )?;
250    paint(stdout, Color::White, format_args!("to "))?;
251    let (row, column) = node.end_position();
252    paint(
253        stdout,
254        Color::Green,
255        format_args!("({}, {}) ", row + 1, column + 1),
256    )
257}
258
259/// Source snippet for single-row nodes only. Multi-row nodes return
260/// without writing (the caller still emits the trailing newline).
261/// Non-UTF-8 spans fall back to raw bytes — regression guard
262/// `dump_node_non_utf8_source_does_not_panic`.
263fn write_node_snippet(
264    stdout: &mut dyn WriteColor,
265    code: &[u8],
266    node: &Node,
267) -> std::io::Result<()> {
268    if node.start_row() != node.end_row() {
269        return Ok(());
270    }
271
272    paint(stdout, Color::White, format_args!(": "))?;
273    intense_color(stdout, Color::Red)?;
274    let snippet = &code[node.start_byte()..node.end_byte()];
275    match str::from_utf8(snippet) {
276        Ok(text) => write!(stdout, "{text} "),
277        Err(_) => stdout.write_all(snippet),
278    }
279}
280
281#[cfg(test)]
282#[allow(
283    clippy::float_cmp,
284    clippy::cast_precision_loss,
285    clippy::cast_possible_truncation,
286    clippy::cast_sign_loss,
287    clippy::similar_names,
288    clippy::doc_markdown,
289    clippy::needless_raw_string_hashes,
290    clippy::too_many_lines
291)]
292mod tests {
293    use std::path::PathBuf;
294
295    use termcolor::NoColor;
296
297    use crate::{CppParser, ParserTrait};
298
299    use super::*;
300
301    #[test]
302    fn dump_node_non_utf8_source_does_not_panic() {
303        // Regression: `stdout.write_all(code).unwrap()` panicked when the raw-bytes
304        // fallback branch was taken for non-UTF-8 source content.
305        let code = b"char c = '\xff';";
306        let path = PathBuf::from("test.c");
307        let parser = CppParser::new(code.to_vec(), &path, None);
308        let root = parser.root();
309        assert!(dump_node(code, &root, -1, None, None).is_ok());
310    }
311
312    #[test]
313    fn line_in_range_unbounded_always_shows() {
314        // Both bounds `None` is the "dump everything" default.
315        assert!(line_in_range(5, &None, &None));
316        assert!(line_in_range(1, &None, &None));
317    }
318
319    #[test]
320    fn line_in_range_respects_inclusive_bounds() {
321        // Lower bound only.
322        assert!(line_in_range(5, &Some(3), &None));
323        assert!(!line_in_range(2, &Some(3), &None));
324        // Upper bound only.
325        assert!(line_in_range(5, &None, &Some(6)));
326        assert!(!line_in_range(7, &None, &Some(6)));
327        // Both bounds AND-composed.
328        assert!(line_in_range(5, &Some(3), &Some(6)));
329        assert!(!line_in_range(5, &Some(6), &Some(9))); // below start
330        assert!(!line_in_range(5, &Some(1), &Some(4))); // above end
331        // Bounds are inclusive on both ends.
332        assert!(line_in_range(3, &Some(3), &Some(3)));
333    }
334
335    #[test]
336    fn branch_glyphs_root_is_flush_left_regardless_of_last() {
337        let code = b"int a = 42;\n";
338        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
339        let root = parser.root();
340        // The root has no parent: empty prefixes whatever `last` says.
341        assert_eq!(branch_glyphs(&root, true), ("", ""));
342        assert_eq!(branch_glyphs(&root, false), ("", ""));
343
344        let child = root
345            .children()
346            .next()
347            .expect("translation_unit has a child");
348        assert_eq!(branch_glyphs(&child, true), ("   ", "╰─ "));
349        assert_eq!(branch_glyphs(&child, false), ("│  ", "├─ "));
350    }
351
352    #[test]
353    fn dump_output_matches_expected_tree() {
354        // Byte-exact guard that the split preserves the rendered tree.
355        // `NoColor` discards color directives, so the captured bytes are
356        // the plain text a user sees (the colored CLI output stripped of
357        // ANSI). Expected values were captured from the pre-split code.
358        let code = b"int a = 42;\n";
359        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
360        let root = parser.root();
361
362        let no_start: Option<usize> = None;
363        let no_end: Option<usize> = None;
364        let mut sink = NoColor::new(Vec::new());
365        {
366            let mut state = DumpState {
367                code,
368                line_start: &no_start,
369                line_end: &no_end,
370                stdout: &mut sink,
371            };
372            dump_tree_helper(&mut state, &root, "", true, -1).expect("dump to in-memory sink");
373        }
374        let rendered = String::from_utf8(sink.into_inner()).expect("dump output is utf-8");
375
376        let expected = concat!(
377            "{translation_unit:219} from (1, 1) to (2, 1) \n",
378            "╰─ {declaration:255} from (1, 1) to (1, 12) : int a = 42; \n",
379            "   ├─ {primitive_type:96} from (1, 1) to (1, 4) : int \n",
380            "   ├─ {init_declarator:294} from (1, 5) to (1, 11) : a = 42 \n",
381            "   │  ├─ {identifier:1} from (1, 5) to (1, 6) : a \n",
382            "   │  ├─ {=:74} from (1, 7) to (1, 8) : = \n",
383            "   │  ╰─ {number_literal:158} from (1, 9) to (1, 11) : 42 \n",
384            "   ╰─ {;:42} from (1, 11) to (1, 12) : ; \n",
385        );
386        assert_eq!(rendered, expected);
387    }
388
389    #[test]
390    fn dump_output_line_range_filters_rows() {
391        // A tight `[2, 2]` range hides every node whose start row is 1,
392        // exercising `line_in_range` end to end through the walk.
393        let code = b"int a = 1;\nint b = 2;\n";
394        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
395        let root = parser.root();
396
397        let start: Option<usize> = Some(2);
398        let end: Option<usize> = Some(2);
399        let mut sink = NoColor::new(Vec::new());
400        {
401            let mut state = DumpState {
402                code,
403                line_start: &start,
404                line_end: &end,
405                stdout: &mut sink,
406            };
407            dump_tree_helper(&mut state, &root, "", true, -1).expect("dump to in-memory sink");
408        }
409        let rendered = String::from_utf8(sink.into_inner()).expect("dump output is utf-8");
410
411        // Row-1 nodes (`int a = 1;` and the root, which starts on row 1)
412        // are filtered out; only row-2 nodes survive.
413        assert!(
414            !rendered.contains("(1, "),
415            "row-1 nodes should be hidden:\n{rendered}"
416        );
417        assert!(
418            rendered.contains("int b = 2;"),
419            "row-2 declaration should show:\n{rendered}"
420        );
421    }
422
423    #[test]
424    fn deeply_nested_ast_dumps_without_stack_overflow() {
425        // The dump walk is iterative (#700): a pathologically deep AST —
426        // here ~4000 nested parentheses, which tree-sitter builds with an
427        // iterative parser — must dump without overflowing the thread
428        // stack. The pre-fix recursive `dump_tree_helper` aborted the
429        // process here; an abort is uncatchable, so reaching the
430        // assertion at all is the regression guard. Run on a small-stack
431        // thread so a latent re-introduction of recursion fails loudly
432        // rather than relying on the (large) test-runner stack.
433        const DEPTH: usize = 4_000;
434        let mut src = Vec::with_capacity(DEPTH * 2 + 8);
435        src.extend_from_slice(b"int a = ");
436        src.extend(std::iter::repeat_n(b'(', DEPTH));
437        src.push(b'1');
438        src.extend(std::iter::repeat_n(b')', DEPTH));
439        src.extend_from_slice(b";\n");
440
441        let handle = std::thread::Builder::new()
442            .stack_size(512 * 1024)
443            .spawn(move || {
444                let parser = CppParser::new(src.clone(), &PathBuf::from("deep.c"), None);
445                let root = parser.root();
446                let no_start: Option<usize> = None;
447                let no_end: Option<usize> = None;
448                let mut sink = NoColor::new(Vec::new());
449                let mut state = DumpState {
450                    code: &src,
451                    line_start: &no_start,
452                    line_end: &no_end,
453                    stdout: &mut sink,
454                };
455                dump_tree_helper(&mut state, &root, "", true, -1).is_ok()
456            })
457            .expect("spawn dump thread");
458        assert!(
459            handle
460                .join()
461                .expect("dump thread must not overflow the stack"),
462            "deep AST must dump successfully"
463        );
464    }
465
466    #[test]
467    fn dump_output_depth_limits_recursion() {
468        // `bca find` dumps with depth=1 (src/find.rs) to show only the
469        // matched node, not its subtree. depth=1 renders the node and stops
470        // before its children; depth=0 renders nothing. This is the only
471        // positive-depth path in production, and it is what the `depth - 1`
472        // decrement in `dump_tree_helper`'s iterative walk guards — pin it
473        // explicitly.
474        let code = b"int a = 42;\n";
475        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
476        let root = parser.root();
477        let no_start: Option<usize> = None;
478        let no_end: Option<usize> = None;
479
480        // depth = 1: the root renders, but recursion stops before children.
481        let mut sink = NoColor::new(Vec::new());
482        {
483            let mut state = DumpState {
484                code,
485                line_start: &no_start,
486                line_end: &no_end,
487                stdout: &mut sink,
488            };
489            dump_tree_helper(&mut state, &root, "", true, 1).expect("dump to in-memory sink");
490        }
491        let rendered = String::from_utf8(sink.into_inner()).expect("dump output is utf-8");
492        assert!(
493            rendered.contains("{translation_unit:"),
494            "depth=1 should render the root:\n{rendered}"
495        );
496        assert!(
497            !rendered.contains("{declaration:"),
498            "depth=1 must not recurse into children:\n{rendered}"
499        );
500        assert_eq!(
501            rendered.lines().count(),
502            1,
503            "depth=1 renders exactly one node:\n{rendered}"
504        );
505
506        // depth = 0: nothing renders at all.
507        let mut sink_zero = NoColor::new(Vec::new());
508        {
509            let mut state = DumpState {
510                code,
511                line_start: &no_start,
512                line_end: &no_end,
513                stdout: &mut sink_zero,
514            };
515            dump_tree_helper(&mut state, &root, "", true, 0).expect("dump to in-memory sink");
516        }
517        assert!(sink_zero.into_inner().is_empty(), "depth=0 renders nothing");
518    }
519}