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}