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}