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::tools::{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    #[test]
376    fn dump_node_non_utf8_source_emits_the_raw_snippet() {
377        // Regression: `stdout.write_all(code).unwrap()` panicked when the
378        // raw-bytes fallback branch was taken for non-UTF-8 source
379        // content. Reaching the assertion at all covers the panic; the
380        // assertion itself covers the other half — that the fallback
381        // *writes* the bytes. A bare `is_ok()` here passed even with the
382        // fallback arm stubbed out to `Ok(())`, silently dropping the
383        // snippet it exists to render.
384        let code = b"char c = '\xff';";
385        let path = PathBuf::from("test.c");
386        let parser = CppParser::new(code.to_vec(), &path, None);
387        let out = render_raw(code, &parser.root(), -1, None, None);
388        assert!(
389            out.contains(&0xff),
390            "the non-UTF-8 snippet must reach the output: {out:?}"
391        );
392    }
393
394    #[test]
395    fn line_in_range_unbounded_always_shows() {
396        // Both bounds `None` is the "dump everything" default.
397        assert!(line_in_range(5, &None, &None));
398        assert!(line_in_range(1, &None, &None));
399    }
400
401    #[test]
402    fn line_in_range_respects_inclusive_bounds() {
403        // Lower bound only.
404        assert!(line_in_range(5, &Some(3), &None));
405        assert!(!line_in_range(2, &Some(3), &None));
406        // Upper bound only.
407        assert!(line_in_range(5, &None, &Some(6)));
408        assert!(!line_in_range(7, &None, &Some(6)));
409        // Both bounds AND-composed.
410        assert!(line_in_range(5, &Some(3), &Some(6)));
411        assert!(!line_in_range(5, &Some(6), &Some(9))); // below start
412        assert!(!line_in_range(5, &Some(1), &Some(4))); // above end
413        // Bounds are inclusive on both ends.
414        assert!(line_in_range(3, &Some(3), &Some(3)));
415    }
416
417    #[test]
418    fn connector_glyphs_are_stable() {
419        // The rendered tree is these three pairs and nothing else; the
420        // byte-exact walk tests below depend on them verbatim.
421        assert_eq!(Connector::Flush.glyphs(), ("", ""));
422        assert_eq!(Connector::Last.glyphs(), ("   ", "╰─ "));
423        assert_eq!(Connector::Inner.glyphs(), ("│  ", "├─ "));
424    }
425
426    #[test]
427    fn start_connector_distinguishes_parentless_from_parented() {
428        // `start_connector` is the walk's only `Node::parent` call
429        // (#1054), so it carries the whole "is this flush-left?"
430        // decision. A parentless node renders flush left; a start node
431        // that *does* have a parent — how `bca find` dumps a matched
432        // node — renders as a last child.
433        let code = b"int a = 42;\n";
434        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
435        let root = parser.root();
436        assert_eq!(start_connector(&root), Connector::Flush);
437
438        let child = root
439            .children()
440            .next()
441            .expect("translation_unit has a child");
442        assert_eq!(start_connector(&child), Connector::Last);
443    }
444
445    #[test]
446    fn dump_output_matches_expected_tree() {
447        // Byte-exact guard that the split preserves the rendered tree.
448        // `NoColor` discards color directives, so the captured bytes are
449        // the plain text a user sees (the colored CLI output stripped of
450        // ANSI). Expected values were captured from the pre-split code.
451        let code = b"int a = 42;\n";
452        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
453
454        let expected = concat!(
455            "{translation_unit:219} from (1, 1) to (2, 1) \n",
456            "╰─ {declaration:255} from (1, 1) to (1, 12) : int a = 42; \n",
457            "   ├─ {primitive_type:96} from (1, 1) to (1, 4) : int \n",
458            "   ├─ {init_declarator:294} from (1, 5) to (1, 11) : a = 42 \n",
459            "   │  ├─ {identifier:1} from (1, 5) to (1, 6) : a \n",
460            "   │  ├─ {=:74} from (1, 7) to (1, 8) : = \n",
461            "   │  ╰─ {number_literal:158} from (1, 9) to (1, 11) : 42 \n",
462            "   ╰─ {;:42} from (1, 11) to (1, 12) : ; \n",
463        );
464        assert_eq!(render(code, &parser.root(), -1), expected);
465    }
466
467    /// Render `node` to an in-memory sink under the given line filter and
468    /// return the raw bytes. Not necessarily UTF-8: a non-UTF-8 source
469    /// snippet is written through verbatim by `write_node_snippet`.
470    fn render_raw(
471        code: &[u8],
472        node: &Node,
473        depth: i32,
474        line_start: Option<usize>,
475        line_end: Option<usize>,
476    ) -> Vec<u8> {
477        let mut sink = NoColor::new(Vec::new());
478        {
479            let mut state = DumpState {
480                code,
481                line_start: &line_start,
482                line_end: &line_end,
483                stdout: &mut sink,
484            };
485            dump_tree_helper(&mut state, node, depth).expect("dump to in-memory sink");
486        }
487        sink.into_inner()
488    }
489
490    /// The renderer visits every node in the file, so it must hold one
491    /// cursor for the whole dump rather than build one per interior
492    /// node (#1112).
493    ///
494    /// This lives here, not beside the other `child_scan_cursors`
495    /// assertions in `node.rs`, because `dump_tree_helper` is private to
496    /// this module — which is exactly why the guard was missing until
497    /// the counter's accessor was widened to `pub(crate)`. Reverting
498    /// `children_with` to `children` here compiled clean and failed
499    /// nothing.
500    ///
501    /// The counter records in `Node::children`, the allocating form, so
502    /// a hoisted cursor records **zero** and a per-node one records once
503    /// per interior node. The exact zero is the discriminator; a
504    /// fraction-of-nodes bound would hold for either on a small fixture.
505    #[test]
506    fn dump_holds_one_cursor_for_the_whole_tree() {
507        let parser = CppParser::new(
508            b"int f(int a) { if (a) { return a + 1; } return 0; }\n".to_vec(),
509            &PathBuf::from("f.cpp"),
510            None,
511        );
512        let root = parser.root();
513        let nodes = root.preorder().count();
514        assert!(nodes > 20, "fixture is too small to prove much");
515
516        let before = crate::node::child_scan_cursors::observed();
517        let rendered = render_range(parser.code(), &root, -1, None, None);
518        let scans = crate::node::child_scan_cursors::observed() - before;
519
520        assert!(
521            rendered.contains("if_statement"),
522            "the fixture must actually render a tree"
523        );
524        assert_eq!(
525            scans, 0,
526            "the dump built {scans} cursors over {nodes} nodes; it holds one for \
527             the whole render (#1112)"
528        );
529    }
530
531    /// [`render_raw`] as text, for the (usual) UTF-8 case.
532    fn render_range(
533        code: &[u8],
534        node: &Node,
535        depth: i32,
536        line_start: Option<usize>,
537        line_end: Option<usize>,
538    ) -> String {
539        String::from_utf8(render_raw(code, node, depth, line_start, line_end))
540            .expect("dump output is utf-8")
541    }
542
543    /// [`render_range`] with the filter disabled — the `bca dump` default.
544    fn render(code: &[u8], node: &Node, depth: i32) -> String {
545        render_range(code, node, depth, None, None)
546    }
547
548    #[test]
549    fn dump_output_restores_prefix_after_nested_subtree() {
550        // The walk keeps one shared prefix buffer that is appended to on
551        // descent and truncated back on the next visit (#1054), so a
552        // sibling that follows a deeper subtree is where a truncation
553        // bug shows up. Here `+` and the second `number_literal` follow
554        // the three-level `parenthesized_expression` subtree, and the
555        // trailing `;` follows the whole `init_declarator` subtree —
556        // each must resume its own level's indentation exactly.
557        //
558        // Expected text was captured from the pre-#1054 binary (`bca
559        // dump --color never`) on this same source, so it pins byte
560        // identity across the change rather than re-recording whatever
561        // the new walk emits.
562        let code = b"int a = (1) + 2;\n";
563        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
564        let rendered = render(code, &parser.root(), -1);
565
566        let expected = concat!(
567            "{translation_unit:219} from (1, 1) to (2, 1) \n",
568            "╰─ {declaration:255} from (1, 1) to (1, 17) : int a = (1) + 2; \n",
569            "   ├─ {primitive_type:96} from (1, 1) to (1, 4) : int \n",
570            "   ├─ {init_declarator:294} from (1, 5) to (1, 16) : a = (1) + 2 \n",
571            "   │  ├─ {identifier:1} from (1, 5) to (1, 6) : a \n",
572            "   │  ├─ {=:74} from (1, 7) to (1, 8) : = \n",
573            "   │  ╰─ {binary_expression:341} from (1, 9) to (1, 16) : (1) + 2 \n",
574            "   │     ├─ {parenthesized_expression:363} from (1, 9) to (1, 12) : (1) \n",
575            "   │     │  ├─ {(:5} from (1, 9) to (1, 10) : ( \n",
576            "   │     │  ├─ {number_literal:158} from (1, 10) to (1, 11) : 1 \n",
577            "   │     │  ╰─ {):8} from (1, 11) to (1, 12) : ) \n",
578            "   │     ├─ {+:25} from (1, 13) to (1, 14) : + \n",
579            "   │     ╰─ {number_literal:158} from (1, 15) to (1, 16) : 2 \n",
580            "   ╰─ {;:42} from (1, 16) to (1, 17) : ; \n",
581        );
582        assert_eq!(rendered, expected);
583    }
584
585    #[test]
586    fn dump_output_from_a_parented_start_node_indents_as_a_last_child() {
587        // `bca find` dumps the matched node, not the file root, so the
588        // start node usually has a parent and renders `╰─` with its
589        // subtree indented under it. `start_connector` is the only place
590        // that distinction is made now that the walk carries connectors
591        // on the stack (#1054) — this pins it end to end.
592        let code = b"int a = (1) + 2;\n";
593        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
594        let root = parser.root();
595        let paren = root
596            .descendants_by_kind(&["parenthesized_expression"])
597            .into_iter()
598            .next()
599            .expect("source has a parenthesized expression");
600
601        let expected = concat!(
602            "╰─ {parenthesized_expression:363} from (1, 9) to (1, 12) : (1) \n",
603            "   ├─ {(:5} from (1, 9) to (1, 10) : ( \n",
604            "   ├─ {number_literal:158} from (1, 10) to (1, 11) : 1 \n",
605            "   ╰─ {):8} from (1, 11) to (1, 12) : ) \n",
606        );
607        assert_eq!(render(code, &paren, -1), expected);
608
609        // Depth 1 is what `bca find` actually passes: the matched node
610        // alone, still as a last child.
611        assert_eq!(
612            render(code, &paren, 1),
613            "╰─ {parenthesized_expression:363} from (1, 9) to (1, 12) : (1) \n"
614        );
615    }
616
617    #[test]
618    fn dump_output_line_range_filters_rows() {
619        // A tight `[2, 2]` range hides every node whose start row is 1,
620        // exercising `line_in_range` end to end through the walk.
621        let code = b"int a = 1;\nint b = 2;\n";
622        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
623        let rendered = render_range(code, &parser.root(), -1, Some(2), Some(2));
624
625        // Row-1 nodes (`int a = 1;` and the root, which starts on row 1)
626        // are filtered out; only row-2 nodes survive.
627        assert!(
628            !rendered.contains("(1, "),
629            "row-1 nodes should be hidden:\n{rendered}"
630        );
631        assert!(
632            rendered.contains("int b = 2;"),
633            "row-2 declaration should show:\n{rendered}"
634        );
635    }
636
637    #[test]
638    fn deeply_nested_ast_dumps_without_stack_overflow() {
639        // The dump walk is iterative (#700): a pathologically deep AST —
640        // here ~4000 nested parentheses, which tree-sitter builds with an
641        // iterative parser — must dump without overflowing the thread
642        // stack. The pre-fix recursive `dump_tree_helper` aborted the
643        // process here; an abort is uncatchable, so reaching the
644        // assertion at all is the regression guard. Run on a small-stack
645        // thread so a latent re-introduction of recursion fails loudly
646        // rather than relying on the (large) test-runner stack.
647        const DEPTH: usize = 4_000;
648        let mut src = Vec::with_capacity(DEPTH * 2 + 8);
649        src.extend_from_slice(b"int a = ");
650        src.extend(std::iter::repeat_n(b'(', DEPTH));
651        src.push(b'1');
652        src.extend(std::iter::repeat_n(b')', DEPTH));
653        src.extend_from_slice(b";\n");
654
655        let handle = std::thread::Builder::new()
656            .stack_size(512 * 1024)
657            .spawn(move || {
658                let parser = CppParser::new(src.clone(), &PathBuf::from("deep.c"), None);
659                let root = parser.root();
660                let no_start: Option<usize> = None;
661                let no_end: Option<usize> = None;
662                // Discard the bytes rather than buffering them: every
663                // line of a depth-4000 chain carries ~3 x depth bytes of
664                // indentation, so a `Vec` sink held ~140 MB for a test
665                // that only asserts the walk completes.
666                let mut sink = NoColor::new(std::io::sink());
667                let mut state = DumpState {
668                    code: &src,
669                    line_start: &no_start,
670                    line_end: &no_end,
671                    stdout: &mut sink,
672                };
673                dump_tree_helper(&mut state, &root, -1).is_ok()
674            })
675            .expect("spawn dump thread");
676        assert!(
677            handle
678                .join()
679                .expect("dump thread must not overflow the stack"),
680            "deep AST must dump successfully"
681        );
682    }
683
684    #[test]
685    fn dump_output_depth_limits_recursion() {
686        // `bca find` dumps with depth=1 (src/find.rs) to show only the
687        // matched node, not its subtree. depth=1 renders the node and stops
688        // before its children; depth=0 renders nothing. This is the only
689        // positive-depth path in production, and it is what the `depth - 1`
690        // decrement in `dump_tree_helper`'s iterative walk guards — pin it
691        // explicitly.
692        let code = b"int a = 42;\n";
693        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
694        let root = parser.root();
695
696        // depth = 1: the root renders, but the walk stops before children.
697        let rendered = render(code, &root, 1);
698        assert!(
699            rendered.contains("{translation_unit:"),
700            "depth=1 should render the root:\n{rendered}"
701        );
702        assert!(
703            !rendered.contains("{declaration:"),
704            "depth=1 must not recurse into children:\n{rendered}"
705        );
706        assert_eq!(
707            rendered.lines().count(),
708            1,
709            "depth=1 renders exactly one node:\n{rendered}"
710        );
711
712        // depth = 0: nothing renders at all.
713        assert!(render(code, &root, 0).is_empty(), "depth=0 renders nothing");
714    }
715
716    /// Every write position in the AST walk surfaces an I/O error, and
717    /// the walk stops there.
718    ///
719    /// `dump_node` documents that it propagates any `std::io::Error` the
720    /// writer produces — `bca dump | head` closes the pipe mid-stream —
721    /// but every existing test writes into an infallible `Vec`, leaving
722    /// the failure half of each `?` in `dump_tree_helper`, `paint`,
723    /// `write_node_line`, `write_node_header`, `write_node_location`, and
724    /// `write_node_snippet` unexercised.
725    ///
726    /// The fixture is deliberately the smallest tree that still nests:
727    /// the sweep re-runs the whole dump once per write position, so cost
728    /// is quadratic in the node count.
729    #[test]
730    fn every_write_position_propagates_an_io_error() {
731        let code = b"int a = 42;\n";
732        let parser = CppParser::new(code.to_vec(), &PathBuf::from("t.c"), None);
733        let root = parser.root();
734        let (line_start, line_end) = (None, None);
735
736        // 40: the eight nodes of this tree cost several operations each
737        // (connector, header, location, snippet). A floor well under the
738        // real count catches a fixture that collapsed to a leaf without
739        // churning on an exact number.
740        assert_io_error_propagates_at_every_write(40, |sink| {
741            let mut state = DumpState {
742                code,
743                line_start: &line_start,
744                line_end: &line_end,
745                stdout: sink,
746            };
747            dump_tree_helper(&mut state, &root, -1)
748        });
749    }
750}