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, WriteColor};
10
11use crate::node::Node;
12use crate::output::ColorMode;
13use crate::output::color::print_to_stdout;
14use crate::output::color::{color, intense_color};
15
16/// Dumps the `AST` of a code.
17///
18/// Returns a [`Result`] value, when an error occurs.
19///
20/// # Errors
21///
22/// Propagates any [`std::io::Error`] produced by the color-aware
23/// writer that backs `stdout` (broken pipe, write failure, …).
24///
25/// # Examples
26///
27/// ```
28/// use big_code_analysis::{dump_node, Ast, LANG, Source};
29///
30/// let source = b"int a = 42;";
31/// let ast = Ast::parse(Source::new(LANG::Cpp, source))
32///     .expect("cpp feature enabled");
33/// let root = ast.root_node();
34///
35/// // Dump the AST from the first line of code in a file to the last one
36/// dump_node(ast.source(), &root, -1, None, None).unwrap();
37/// ```
38///
39/// # Panics
40///
41/// Panics if `code` is not the exact source `node` was parsed from.
42/// `node`'s byte range is used to slice `code`, so a `node` taken from a
43/// different (or smaller) tree indexes out of bounds. Always pair a node
44/// with the source it came from — e.g. `ast.source()` and a node obtained
45/// from the *same* [`crate::Ast`] (`ast.root_node()` or a descendant).
46pub fn dump_node(
47    code: &[u8],
48    node: &Node,
49    depth: i32,
50    line_start: Option<usize>,
51    line_end: Option<usize>,
52) -> std::io::Result<()> {
53    dump_node_with_color(code, node, depth, line_start, line_end, ColorMode::Always)
54}
55
56/// Like [`dump_node`], but the caller selects the [`ColorMode`].
57///
58/// `bca` resolves a `--color` flag, the `NO_COLOR` convention, and
59/// stdout tty detection into a mode and passes it here so piped output
60/// is escape-free by default. The bare [`dump_node`] keeps the
61/// historical always-colored behavior for backward compatibility.
62///
63/// # Errors
64///
65/// Propagates any [`std::io::Error`] produced by the color-aware
66/// writer that backs `stdout` (broken pipe, write failure, …).
67///
68/// # Panics
69///
70/// Panics if `code` is not the exact source `node` was parsed from.
71/// `node`'s byte range is used to slice `code`, so a `node` taken from a
72/// different (or smaller) tree indexes out of bounds. Always pair a node
73/// with the source it came from — e.g. `ast.source()` and a node obtained
74/// from the *same* [`crate::Ast`] (`ast.root_node()` or a descendant).
75pub fn dump_node_with_color(
76    code: &[u8],
77    node: &Node,
78    depth: i32,
79    line_start: Option<usize>,
80    line_end: Option<usize>,
81    color_mode: ColorMode,
82) -> std::io::Result<()> {
83    // bca: suppress(nargs)
84    // `nargs` sums the six published parameters with the rendering
85    // closure's writer, which no caller supplies: `function_args_max`
86    // is still 6, and the signature is frozen by the stability contract.
87    // This trips the soft tier only — at the hard limit of 7 the count
88    // of 7 is not `> 7` — so a reader checking against the hard gate
89    // will find the marker apparently dead. It is not; deleting it
90    // reddens `make self-scan-headroom`.
91    print_to_stdout(color_mode, |stdout| {
92        let mut state = DumpState {
93            code,
94            line_start: &line_start,
95            line_end: &line_end,
96            stdout,
97        };
98        let ret = dump_tree_helper(&mut state, node, depth);
99
100        color(state.stdout, Color::White)?;
101
102        ret
103    })
104}
105
106/// Recursion-invariant rendering state threaded through the AST walk:
107/// the source bytes, the optional line-range filter, and the colored
108/// writer. Bundling these keeps every walk function under the
109/// argument-count limit (the pre-split helper carried eight arguments)
110/// and lets tests substitute a `termcolor::NoColor` sink over a
111/// `Vec<u8>` for byte-exact output assertions.
112struct DumpState<'a> {
113    code: &'a [u8],
114    line_start: &'a Option<usize>,
115    line_end: &'a Option<usize>,
116    stdout: &'a mut dyn WriteColor,
117}
118
119/// One pending node in the iterative AST walk: the node, the length its
120/// ancestors' box-drawing prefix has in the shared prefix buffer, the
121/// connector glyph it renders with, and the remaining depth budget.
122///
123/// The prefix is stored as a *length* rather than an owned `String`
124/// (#1054). Prefixes only grow by appending as the walk descends, so
125/// `prefix_len` is non-decreasing from the bottom of the stack to the
126/// top: every frame popped before this one truncates to `prefix_len` or
127/// beyond, so the first `prefix_len` bytes of the shared buffer stay
128/// exactly this node's prefix until it is popped. One owned prefix per
129/// frame made a depth-`d` chain cost O(d²) resident bytes plus an O(d)
130/// copy per node.
131struct Frame<'a> {
132    node: Node<'a>,
133    prefix_len: usize,
134    connector: Connector,
135    depth: i32,
136}
137
138/// Which box-drawing connector a node renders with.
139///
140/// Deriving this when the node is *pushed* keeps [`Node::parent`] — an
141/// O(depth) walk in tree-sitter — off the per-node path (#1054): only the
142/// node a walk starts from can lack a parent, and every other node is
143/// reached as a child of a node already on the stack.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145enum Connector {
146    /// No parent: renders flush left, with no glyphs of its own.
147    Flush,
148    /// Last child of its parent, so nothing continues below it.
149    Last,
150    /// Has at least one following sibling, whose line the trailing bar
151    /// must reach.
152    Inner,
153}
154
155impl Connector {
156    /// The `(child, own)` prefix pair: what this node contributes to its
157    /// children's indentation, and the glyph run on its own line.
158    const fn glyphs(self) -> (&'static str, &'static str) {
159        match self {
160            Self::Flush => ("", ""),
161            Self::Last => ("   ", "╰─ "),
162            Self::Inner => ("│  ", "├─ "),
163        }
164    }
165}
166
167/// The connector for the node a walk starts from — the only node whose
168/// parent has to be looked up. A start node with a parent renders as a
169/// last child (`bca find` dumps a matched node this way), matching the
170/// `last = true` the recursive form passed for the root.
171fn start_connector(node: &Node) -> Connector {
172    if node.parent().is_none() {
173        Connector::Flush
174    } else {
175        Connector::Last
176    }
177}
178
179/// Render the subtree rooted at `node` with an explicit work stack rather
180/// than recursion. A pathologically deep AST (thousands of nested
181/// expressions, which the iterative *builder* in tree-sitter accepts
182/// without bound) would otherwise overflow the thread stack at dump time
183/// — an uncatchable abort, forbidden by the no-panic rule. The traversal
184/// order, per-node glyphs, and depth semantics are byte-identical to the
185/// prior recursive form (#700).
186///
187/// The indentation prefix lives in one buffer that is pushed on descent
188/// and truncated back on the next visit, so the walk costs O(depth)
189/// bytes rather than O(depth²) (#1054). The emitted output stays
190/// O(nodes × depth) — every rendered line contains its own indentation,
191/// which is inherent to the tree drawing.
192fn dump_tree_helper<'a>(state: &mut DumpState, node: &Node<'a>, depth: i32) -> std::io::Result<()> {
193    let mut prefix = String::new();
194    // One cursor for the whole dump, not one per node: this visits every
195    // node in the file, and `Node::children` would build and free a
196    // `TreeCursor` at each (#1112, `Node::children_with`).
197    let mut cursor = node.cursor();
198    let mut stack: Vec<Frame<'a>> = vec![Frame {
199        node: *node,
200        prefix_len: 0,
201        connector: start_connector(node),
202        depth,
203    }];
204
205    while let Some(frame) = stack.pop() {
206        if frame.depth == 0 {
207            continue;
208        }
209
210        // Truncating on every visit — not on the way back up — is what
211        // lets a frame carry a bare length: whatever a sibling's subtree
212        // appended is dropped here. Every recorded length came from
213        // `String::len` after appending a whole glyph run, so it is
214        // always a char boundary.
215        prefix.truncate(frame.prefix_len);
216        let (pref_child, pref) = frame.connector.glyphs();
217
218        if line_in_range(frame.node.start_row() + 1, state.line_start, state.line_end) {
219            write_node_line(state.stdout, state.code, &frame.node, &prefix, pref)?;
220        }
221
222        // Leaves are roughly half the nodes and `child_count` is O(1),
223        // so check it before reseating the cursor for the child walk.
224        if frame.node.child_count() == 0 {
225            continue;
226        }
227
228        prefix.push_str(pref_child);
229        push_children(
230            &mut stack,
231            frame.node.children_with(&mut cursor),
232            prefix.len(),
233            frame.depth - 1,
234        );
235    }
236
237    Ok(())
238}
239
240/// Queue `children` so `pop()` visits them in source order, matching the
241/// recursive form's pre-order traversal. The frames go on in source order
242/// and the new tail is then reversed in place, so the walk needs no
243/// separate staging buffer and copies each node once.
244///
245/// Last-child detection uses the child actually walked last, not
246/// `Node::child_count`: [`crate::node::Children`] is cursor-driven and
247/// documents that the two can disagree on a malformed tree, in which case
248/// counting from `child_count` would leave the real last child rendering
249/// as `├─` with a dangling bar below it.
250fn push_children<'a>(
251    stack: &mut Vec<Frame<'a>>,
252    children: impl Iterator<Item = Node<'a>>,
253    prefix_len: usize,
254    depth: i32,
255) {
256    let first_pushed = stack.len();
257    stack.extend(children.map(|node| Frame {
258        node,
259        prefix_len,
260        connector: Connector::Inner,
261        depth,
262    }));
263    stack[first_pushed..].reverse();
264    // After the reversal the source-order-last child sits at the bottom
265    // of the new tail, so it is popped last and closes the subtree.
266    if let Some(last_child) = stack.get_mut(first_pushed) {
267        last_child.connector = Connector::Last;
268    }
269}
270
271/// Whether 1-based `row` falls within the optional `[line_start,
272/// line_end]` filter. Either bound being `None` leaves that side
273/// unconstrained, so `(None, None)` always shows the node.
274fn line_in_range(row: usize, line_start: &Option<usize>, line_end: &Option<usize>) -> bool {
275    line_start.is_none_or(|start| row >= start) && line_end.is_none_or(|end| row <= end)
276}
277
278/// Set `c` then write `args` in that color. Collapsing the recurring
279/// set-color-then-write pair into one fallible call keeps each writer
280/// helper's exit count under the threshold.
281fn paint(stdout: &mut dyn WriteColor, c: Color, args: std::fmt::Arguments) -> std::io::Result<()> {
282    color(stdout, c)?;
283    stdout.write_fmt(args)
284}
285
286/// Emit the full colored description line for one node: header, position
287/// range, optional same-row snippet, then the trailing newline (always,
288/// even for multi-row nodes whose snippet is skipped).
289fn write_node_line(
290    stdout: &mut dyn WriteColor,
291    code: &[u8],
292    node: &Node,
293    prefix: &str,
294    pref: &str,
295) -> std::io::Result<()> {
296    write_node_header(stdout, node, prefix, pref)?;
297    write_node_location(stdout, node)?;
298    write_node_snippet(stdout, code, node)?;
299    writeln!(stdout)
300}
301
302/// Prefix glyphs followed by the `{kind:kind_id}` tag.
303fn write_node_header(
304    stdout: &mut dyn WriteColor,
305    node: &Node,
306    prefix: &str,
307    pref: &str,
308) -> std::io::Result<()> {
309    paint(stdout, Color::Blue, format_args!("{prefix}{pref}"))?;
310    intense_color(stdout, Color::Yellow)?;
311    write!(stdout, "{{{}:{}}} ", node.kind(), node.kind_id())
312}
313
314/// The `from (row, col) to (row, col)` 1-based position range.
315fn write_node_location(stdout: &mut dyn WriteColor, node: &Node) -> std::io::Result<()> {
316    paint(stdout, Color::White, format_args!("from "))?;
317    let (row, column) = node.start_position();
318    paint(
319        stdout,
320        Color::Green,
321        format_args!("({}, {}) ", row + 1, column + 1),
322    )?;
323    paint(stdout, Color::White, format_args!("to "))?;
324    let (row, column) = node.end_position();
325    paint(
326        stdout,
327        Color::Green,
328        format_args!("({}, {}) ", row + 1, column + 1),
329    )
330}
331
332/// Source snippet for single-row nodes only. Multi-row nodes return
333/// without writing (the caller still emits the trailing newline).
334/// Non-UTF-8 spans fall back to raw bytes — regression guard
335/// `dump_node_non_utf8_source_emits_the_raw_snippet`.
336fn write_node_snippet(
337    stdout: &mut dyn WriteColor,
338    code: &[u8],
339    node: &Node,
340) -> std::io::Result<()> {
341    if node.start_row() != node.end_row() {
342        return Ok(());
343    }
344
345    paint(stdout, Color::White, format_args!(": "))?;
346    intense_color(stdout, Color::Red)?;
347    let snippet = &code[node.start_byte()..node.end_byte()];
348    match str::from_utf8(snippet) {
349        Ok(text) => write!(stdout, "{text} "),
350        Err(_) => stdout.write_all(snippet),
351    }
352}
353
354#[cfg(test)]
355#[allow(
356    clippy::float_cmp,
357    clippy::cast_precision_loss,
358    clippy::cast_possible_truncation,
359    clippy::cast_sign_loss,
360    clippy::similar_names,
361    clippy::doc_markdown,
362    clippy::needless_raw_string_hashes,
363    clippy::too_many_lines
364)]
365mod tests {
366    use std::path::PathBuf;
367
368    use termcolor::NoColor;
369
370    use crate::output::test_support::assert_io_error_propagates_at_every_write;
371    use crate::{CppParser, ParserTrait};
372
373    use super::*;
374
375    #[cfg(feature = "cpp")]
376    #[test]
377    fn dump_node_non_utf8_source_emits_the_raw_snippet() {
378        // Regression: `stdout.write_all(code).unwrap()` panicked when the
379        // raw-bytes fallback branch was taken for non-UTF-8 source
380        // content. Reaching the assertion at all covers the panic; the
381        // assertion itself covers the other half — that the fallback
382        // *writes* the bytes. A bare `is_ok()` here passed even with the
383        // fallback arm stubbed out to `Ok(())`, silently dropping the
384        // snippet it exists to render.
385        let code = b"char c = '\xff';";
386        let path = PathBuf::from("test.c");
387        let parser = CppParser::new(code.to_vec(), &path, None);
388        let out = render_raw(code, &parser.root(), -1, None, None);
389        assert!(
390            out.contains(&0xff),
391            "the non-UTF-8 snippet must reach the output: {out:?}"
392        );
393    }
394
395    #[test]
396    fn line_in_range_unbounded_always_shows() {
397        // Both bounds `None` is the "dump everything" default.
398        assert!(line_in_range(5, &None, &None));
399        assert!(line_in_range(1, &None, &None));
400    }
401
402    #[test]
403    fn line_in_range_respects_inclusive_bounds() {
404        // Lower bound only.
405        assert!(line_in_range(5, &Some(3), &None));
406        assert!(!line_in_range(2, &Some(3), &None));
407        // Upper bound only.
408        assert!(line_in_range(5, &None, &Some(6)));
409        assert!(!line_in_range(7, &None, &Some(6)));
410        // Both bounds AND-composed.
411        assert!(line_in_range(5, &Some(3), &Some(6)));
412        assert!(!line_in_range(5, &Some(6), &Some(9))); // below start
413        assert!(!line_in_range(5, &Some(1), &Some(4))); // above end
414        // Bounds are inclusive on both ends.
415        assert!(line_in_range(3, &Some(3), &Some(3)));
416    }
417
418    #[test]
419    fn connector_glyphs_are_stable() {
420        // The rendered tree is these three pairs and nothing else; the
421        // byte-exact walk tests below depend on them verbatim.
422        assert_eq!(Connector::Flush.glyphs(), ("", ""));
423        assert_eq!(Connector::Last.glyphs(), ("   ", "╰─ "));
424        assert_eq!(Connector::Inner.glyphs(), ("│  ", "├─ "));
425    }
426
427    #[cfg(feature = "cpp")]
428    #[test]
429    fn start_connector_distinguishes_parentless_from_parented() {
430        // `start_connector` is the walk's only `Node::parent` call
431        // (#1054), so it carries the whole "is this flush-left?"
432        // decision. A parentless node renders flush left; a start node
433        // that *does* have a parent — how `bca find` dumps a matched
434        // node — renders as a last child.
435        let code = b"int a = 42;\n";
436        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
437        let root = parser.root();
438        assert_eq!(start_connector(&root), Connector::Flush);
439
440        let child = root
441            .children()
442            .next()
443            .expect("translation_unit has a child");
444        assert_eq!(start_connector(&child), Connector::Last);
445    }
446
447    #[cfg(feature = "cpp")]
448    #[test]
449    fn dump_output_matches_expected_tree() {
450        // Byte-exact guard that the split preserves the rendered tree.
451        // `NoColor` discards color directives, so the captured bytes are
452        // the plain text a user sees (the colored CLI output stripped of
453        // ANSI). Expected values were captured from the pre-split code.
454        let code = b"int a = 42;\n";
455        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
456
457        let expected = concat!(
458            "{translation_unit:219} from (1, 1) to (2, 1) \n",
459            "╰─ {declaration:255} from (1, 1) to (1, 12) : int a = 42; \n",
460            "   ├─ {primitive_type:96} from (1, 1) to (1, 4) : int \n",
461            "   ├─ {init_declarator:294} from (1, 5) to (1, 11) : a = 42 \n",
462            "   │  ├─ {identifier:1} from (1, 5) to (1, 6) : a \n",
463            "   │  ├─ {=:74} from (1, 7) to (1, 8) : = \n",
464            "   │  ╰─ {number_literal:158} from (1, 9) to (1, 11) : 42 \n",
465            "   ╰─ {;:42} from (1, 11) to (1, 12) : ; \n",
466        );
467        assert_eq!(render(code, &parser.root(), -1), expected);
468    }
469
470    /// Render `node` to an in-memory sink under the given line filter and
471    /// return the raw bytes. Not necessarily UTF-8: a non-UTF-8 source
472    /// snippet is written through verbatim by `write_node_snippet`.
473    #[cfg(feature = "cpp")]
474    fn render_raw(
475        code: &[u8],
476        node: &Node,
477        depth: i32,
478        line_start: Option<usize>,
479        line_end: Option<usize>,
480    ) -> Vec<u8> {
481        let mut sink = NoColor::new(Vec::new());
482        {
483            let mut state = DumpState {
484                code,
485                line_start: &line_start,
486                line_end: &line_end,
487                stdout: &mut sink,
488            };
489            dump_tree_helper(&mut state, node, depth).expect("dump to in-memory sink");
490        }
491        sink.into_inner()
492    }
493
494    /// The renderer visits every node in the file, so it must hold one
495    /// cursor for the whole dump rather than build one per interior
496    /// node (#1112).
497    ///
498    /// This lives here, not beside the other `child_scan_cursors`
499    /// assertions in `node.rs`, because `dump_tree_helper` is private to
500    /// this module — which is exactly why the guard was missing until
501    /// the counter's accessor was widened to `pub(crate)`. Reverting
502    /// `children_with` to `children` here compiled clean and failed
503    /// nothing.
504    ///
505    /// The counter records in `Node::children`, the allocating form, so
506    /// a hoisted cursor records **zero** and a per-node one records once
507    /// per interior node. The exact zero is the discriminator; a
508    /// fraction-of-nodes bound would hold for either on a small fixture.
509    #[cfg(feature = "cpp")]
510    #[test]
511    fn dump_holds_one_cursor_for_the_whole_tree() {
512        let parser = CppParser::new(
513            b"int f(int a) { if (a) { return a + 1; } return 0; }\n".to_vec(),
514            &PathBuf::from("f.cpp"),
515            None,
516        );
517        let root = parser.root();
518        let nodes = root.preorder().count();
519        assert!(nodes > 20, "fixture is too small to prove much");
520
521        let before = crate::node::child_scan_cursors::observed();
522        let rendered = render_range(parser.code(), &root, -1, None, None);
523        let scans = crate::node::child_scan_cursors::observed() - before;
524
525        assert!(
526            rendered.contains("if_statement"),
527            "the fixture must actually render a tree"
528        );
529        assert_eq!(
530            scans, 0,
531            "the dump built {scans} cursors over {nodes} nodes; it holds one for \
532             the whole render (#1112)"
533        );
534    }
535
536    /// [`render_raw`] as text, for the (usual) UTF-8 case.
537    #[cfg(feature = "cpp")]
538    fn render_range(
539        code: &[u8],
540        node: &Node,
541        depth: i32,
542        line_start: Option<usize>,
543        line_end: Option<usize>,
544    ) -> String {
545        String::from_utf8(render_raw(code, node, depth, line_start, line_end))
546            .expect("dump output is utf-8")
547    }
548
549    /// [`render_range`] with the filter disabled — the `bca dump` default.
550    #[cfg(feature = "cpp")]
551    fn render(code: &[u8], node: &Node, depth: i32) -> String {
552        render_range(code, node, depth, None, None)
553    }
554
555    #[cfg(feature = "cpp")]
556    #[test]
557    fn dump_output_restores_prefix_after_nested_subtree() {
558        // The walk keeps one shared prefix buffer that is appended to on
559        // descent and truncated back on the next visit (#1054), so a
560        // sibling that follows a deeper subtree is where a truncation
561        // bug shows up. Here `+` and the second `number_literal` follow
562        // the three-level `parenthesized_expression` subtree, and the
563        // trailing `;` follows the whole `init_declarator` subtree —
564        // each must resume its own level's indentation exactly.
565        //
566        // Expected text was captured from the pre-#1054 binary (`bca
567        // dump --color never`) on this same source, so it pins byte
568        // identity across the change rather than re-recording whatever
569        // the new walk emits.
570        let code = b"int a = (1) + 2;\n";
571        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
572        let rendered = render(code, &parser.root(), -1);
573
574        let expected = concat!(
575            "{translation_unit:219} from (1, 1) to (2, 1) \n",
576            "╰─ {declaration:255} from (1, 1) to (1, 17) : int a = (1) + 2; \n",
577            "   ├─ {primitive_type:96} from (1, 1) to (1, 4) : int \n",
578            "   ├─ {init_declarator:294} from (1, 5) to (1, 16) : a = (1) + 2 \n",
579            "   │  ├─ {identifier:1} from (1, 5) to (1, 6) : a \n",
580            "   │  ├─ {=:74} from (1, 7) to (1, 8) : = \n",
581            "   │  ╰─ {binary_expression:341} from (1, 9) to (1, 16) : (1) + 2 \n",
582            "   │     ├─ {parenthesized_expression:363} from (1, 9) to (1, 12) : (1) \n",
583            "   │     │  ├─ {(:5} from (1, 9) to (1, 10) : ( \n",
584            "   │     │  ├─ {number_literal:158} from (1, 10) to (1, 11) : 1 \n",
585            "   │     │  ╰─ {):8} from (1, 11) to (1, 12) : ) \n",
586            "   │     ├─ {+:25} from (1, 13) to (1, 14) : + \n",
587            "   │     ╰─ {number_literal:158} from (1, 15) to (1, 16) : 2 \n",
588            "   ╰─ {;:42} from (1, 16) to (1, 17) : ; \n",
589        );
590        assert_eq!(rendered, expected);
591    }
592
593    #[cfg(feature = "cpp")]
594    #[test]
595    fn dump_output_from_a_parented_start_node_indents_as_a_last_child() {
596        // `bca find` dumps the matched node, not the file root, so the
597        // start node usually has a parent and renders `╰─` with its
598        // subtree indented under it. `start_connector` is the only place
599        // that distinction is made now that the walk carries connectors
600        // on the stack (#1054) — this pins it end to end.
601        let code = b"int a = (1) + 2;\n";
602        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
603        let root = parser.root();
604        let paren = root
605            .descendants_by_kind(&["parenthesized_expression"])
606            .into_iter()
607            .next()
608            .expect("source has a parenthesized expression");
609
610        let expected = concat!(
611            "╰─ {parenthesized_expression:363} from (1, 9) to (1, 12) : (1) \n",
612            "   ├─ {(:5} from (1, 9) to (1, 10) : ( \n",
613            "   ├─ {number_literal:158} from (1, 10) to (1, 11) : 1 \n",
614            "   ╰─ {):8} from (1, 11) to (1, 12) : ) \n",
615        );
616        assert_eq!(render(code, &paren, -1), expected);
617
618        // Depth 1 is what `bca find` actually passes: the matched node
619        // alone, still as a last child.
620        assert_eq!(
621            render(code, &paren, 1),
622            "╰─ {parenthesized_expression:363} from (1, 9) to (1, 12) : (1) \n"
623        );
624    }
625
626    #[cfg(feature = "cpp")]
627    #[test]
628    fn dump_output_line_range_filters_rows() {
629        // A tight `[2, 2]` range hides every node whose start row is 1,
630        // exercising `line_in_range` end to end through the walk.
631        let code = b"int a = 1;\nint b = 2;\n";
632        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
633        let rendered = render_range(code, &parser.root(), -1, Some(2), Some(2));
634
635        // Row-1 nodes (`int a = 1;` and the root, which starts on row 1)
636        // are filtered out; only row-2 nodes survive.
637        assert!(
638            !rendered.contains("(1, "),
639            "row-1 nodes should be hidden:\n{rendered}"
640        );
641        assert!(
642            rendered.contains("int b = 2;"),
643            "row-2 declaration should show:\n{rendered}"
644        );
645    }
646
647    #[cfg(feature = "cpp")]
648    #[test]
649    fn deeply_nested_ast_dumps_without_stack_overflow() {
650        // The dump walk is iterative (#700): a pathologically deep AST —
651        // here ~4000 nested parentheses, which tree-sitter builds with an
652        // iterative parser — must dump without overflowing the thread
653        // stack. The pre-fix recursive `dump_tree_helper` aborted the
654        // process here; an abort is uncatchable, so reaching the
655        // assertion at all is the regression guard. Run on a small-stack
656        // thread so a latent re-introduction of recursion fails loudly
657        // rather than relying on the (large) test-runner stack.
658        const DEPTH: usize = 4_000;
659        let mut src = Vec::with_capacity(DEPTH * 2 + 8);
660        src.extend_from_slice(b"int a = ");
661        src.extend(std::iter::repeat_n(b'(', DEPTH));
662        src.push(b'1');
663        src.extend(std::iter::repeat_n(b')', DEPTH));
664        src.extend_from_slice(b";\n");
665
666        let handle = std::thread::Builder::new()
667            .stack_size(512 * 1024)
668            .spawn(move || {
669                let parser = CppParser::new(src.clone(), &PathBuf::from("deep.c"), None);
670                let root = parser.root();
671                let no_start: Option<usize> = None;
672                let no_end: Option<usize> = None;
673                // Discard the bytes rather than buffering them: every
674                // line of a depth-4000 chain carries ~3 x depth bytes of
675                // indentation, so a `Vec` sink held ~140 MB for a test
676                // that only asserts the walk completes.
677                let mut sink = NoColor::new(std::io::sink());
678                let mut state = DumpState {
679                    code: &src,
680                    line_start: &no_start,
681                    line_end: &no_end,
682                    stdout: &mut sink,
683                };
684                dump_tree_helper(&mut state, &root, -1).is_ok()
685            })
686            .expect("spawn dump thread");
687        assert!(
688            handle
689                .join()
690                .expect("dump thread must not overflow the stack"),
691            "deep AST must dump successfully"
692        );
693    }
694
695    #[cfg(feature = "cpp")]
696    #[test]
697    fn dump_output_depth_limits_recursion() {
698        // `bca find` dumps with depth=1
699        // (`big-code-analysis-ast/src/find.rs`) to show only the
700        // matched node, not its subtree. depth=1 renders the node and stops
701        // before its children; depth=0 renders nothing. This is the only
702        // positive-depth path in production, and it is what the `depth - 1`
703        // decrement in `dump_tree_helper`'s iterative walk guards — pin it
704        // explicitly.
705        let code = b"int a = 42;\n";
706        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
707        let root = parser.root();
708
709        // depth = 1: the root renders, but the walk stops before children.
710        let rendered = render(code, &root, 1);
711        assert!(
712            rendered.contains("{translation_unit:"),
713            "depth=1 should render the root:\n{rendered}"
714        );
715        assert!(
716            !rendered.contains("{declaration:"),
717            "depth=1 must not recurse into children:\n{rendered}"
718        );
719        assert_eq!(
720            rendered.lines().count(),
721            1,
722            "depth=1 renders exactly one node:\n{rendered}"
723        );
724
725        // depth = 0: nothing renders at all.
726        assert!(render(code, &root, 0).is_empty(), "depth=0 renders nothing");
727    }
728
729    /// Every write position in the AST walk surfaces an I/O error, and
730    /// the walk stops there.
731    ///
732    /// `dump_node` documents that it propagates any `std::io::Error` the
733    /// writer produces — `bca dump | head` closes the pipe mid-stream —
734    /// but every existing test writes into an infallible `Vec`, leaving
735    /// the failure half of each `?` in `dump_tree_helper`, `paint`,
736    /// `write_node_line`, `write_node_header`, `write_node_location`, and
737    /// `write_node_snippet` unexercised.
738    ///
739    /// The fixture is deliberately the smallest tree that still nests:
740    /// the sweep re-runs the whole dump once per write position, so cost
741    /// is quadratic in the node count.
742    #[cfg(feature = "cpp")]
743    #[test]
744    fn every_write_position_propagates_an_io_error() {
745        let code = b"int a = 42;\n";
746        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
747        let root = parser.root();
748        let (line_start, line_end) = (None, None);
749
750        // 40: the eight nodes of this tree cost several operations each
751        // (connector, header, location, snippet). A floor well under the
752        // real count catches a fixture that collapsed to a leaf without
753        // churning on an exact number.
754        assert_io_error_propagates_at_every_write(40, |sink| {
755            let mut state = DumpState {
756                code,
757                line_start: &line_start,
758                line_end: &line_end,
759                stdout: sink,
760            };
761            dump_tree_helper(&mut state, &root, -1)
762        });
763    }
764}