big_code_analysis/output/dump_ops.rs
1use termcolor::{Color, WriteColor};
2
3use crate::ops::Ops;
4use crate::output::color::print_to_stdout;
5use crate::output::{ColorMode, branch_glyphs};
6
7use crate::tools::{color, intense_color};
8
9/// Dumps all operands and operators of a code.
10///
11/// Returns a [`Result`] value, when an error occurs.
12///
13/// # Errors
14///
15/// Propagates any [`std::io::Error`] produced by the color-aware
16/// writer that backs `stdout` (broken pipe, write failure, …).
17///
18/// # Examples
19///
20/// ```
21/// use big_code_analysis::{dump_ops, Ast, LANG, Source};
22///
23/// let source_code = "int a = 42;";
24///
25/// // Retrieve all operands and operators via the `Ast::ops` seam.
26/// let ops = Ast::parse(
27/// Source::new(LANG::Cpp, source_code.as_bytes())
28/// .with_name(Some("foo.c".to_owned())),
29/// )
30/// .expect("cpp feature enabled")
31/// .ops()
32/// .unwrap();
33///
34/// // Dump all operands and operators
35/// dump_ops(&ops).unwrap();
36/// ```
37pub fn dump_ops(ops: &Ops) -> std::io::Result<()> {
38 dump_ops_with_color(ops, ColorMode::Always)
39}
40
41/// Like [`dump_ops`], but the caller selects the [`ColorMode`].
42///
43/// `bca` resolves a `--color` flag, the `NO_COLOR` convention, and
44/// stdout tty detection into a mode and passes it here so piped output
45/// is escape-free by default. The bare [`dump_ops`] keeps the
46/// historical always-colored behavior for backward compatibility.
47///
48/// # Errors
49///
50/// Propagates any [`std::io::Error`] produced by the color-aware
51/// writer that backs `stdout` (broken pipe, write failure, …).
52pub fn dump_ops_with_color(ops: &Ops, color_mode: ColorMode) -> std::io::Result<()> {
53 print_to_stdout(color_mode, |stdout| {
54 dump_space(ops, stdout)?;
55 color(stdout, Color::White)
56 })
57}
58
59/// One pending space in the walk: the space, the length its indentation
60/// prefix has in the shared buffer, and whether it is its parent's last
61/// child.
62///
63/// The prefix is a *length* rather than an owned copy (#1054): prefixes
64/// only grow as the walk descends, so the first `prefix_len` bytes stay
65/// this space's prefix until it is popped. Owning one prefix per stack
66/// entry cost O(depth²) resident bytes on a deep closure nest.
67type OpsFrame<'a> = (&'a Ops, usize, bool);
68
69/// Dump the `Ops` space tree with an explicit work stack rather than
70/// recursion, so a pathologically deep space nesting (closures within
71/// closures) cannot overflow the thread stack at dump time — an
72/// uncatchable abort, forbidden by the no-panic rule (#700). Traversal
73/// order and per-node glyphs are byte-identical to the prior recursive
74/// form.
75fn dump_space(space: &Ops, stdout: &mut dyn WriteColor) -> std::io::Result<()> {
76 let mut prefix = String::new();
77 let mut stack: Vec<OpsFrame> = vec![(space, 0, true)];
78
79 while let Some((space, prefix_len, last)) = stack.pop() {
80 // Truncating on every visit — rather than on the way back up —
81 // is what lets a frame carry a bare length: whatever a sibling's
82 // subtree appended is dropped here. Recorded lengths always sit
83 // on a char boundary because only whole glyph runs are appended.
84 prefix.truncate(prefix_len);
85 let (pref_child, pref) = branch_glyphs(last);
86
87 color(stdout, Color::Blue)?;
88 write!(stdout, "{prefix}{pref}")?;
89
90 intense_color(stdout, Color::Yellow)?;
91 write!(stdout, "{}: ", space.kind)?;
92
93 intense_color(stdout, Color::Cyan)?;
94 write!(stdout, "{}", space.name.as_ref().map_or("", |name| name))?;
95
96 intense_color(stdout, Color::Red)?;
97 writeln!(stdout, " (@{})", space.start_line)?;
98
99 prefix.push_str(pref_child);
100 let child_prefix_len = prefix.len();
101 dump_space_ops(space, &mut prefix, space.spaces.is_empty(), stdout)?;
102
103 // Push children in reverse so `pop()` visits them in source
104 // order; the final child carries `last = true` for the closing
105 // `` `- `` glyph, matching the recursive `split_last` form.
106 let count = space.spaces.len();
107 for (i, child) in space.spaces.iter().enumerate().rev() {
108 stack.push((child, child_prefix_len, i + 1 == count));
109 }
110 }
111
112 Ok(())
113}
114
115/// Render a space's `operators` / `operands` blocks. `prefix` is the
116/// shared indentation buffer; each block extends it in place and the
117/// truncation between the two restores the block-level indentation.
118fn dump_space_ops(
119 ops: &Ops,
120 prefix: &mut String,
121 last: bool,
122 stdout: &mut dyn WriteColor,
123) -> std::io::Result<()> {
124 let base = prefix.len();
125 // `operands` always follows `operators` within a space's op block, so
126 // `operators` is never the last child and must render the mid-child
127 // connector (`|-`), regardless of whether the block itself is the
128 // last child of the space. Passing the block's `last` to both made
129 // `operators` draw the closing `` `- `` glyph and mis-indent its
130 // operand subtree (#700). Only `operands` inherits the block's
131 // `last`.
132 dump_ops_values("operators", &ops.operators, prefix, false, stdout)?;
133 prefix.truncate(base);
134 dump_ops_values("operands", &ops.operands, prefix, last, stdout)
135}
136
137/// Render one named op list. Extends `prefix` in place for the list
138/// entries and leaves it extended; the caller truncates back (the space
139/// walk re-truncates on its next visit anyway).
140fn dump_ops_values(
141 name: &str,
142 ops: &[String],
143 prefix: &mut String,
144 last: bool,
145 stdout: &mut dyn WriteColor,
146) -> std::io::Result<()> {
147 let (pref_child, pref) = branch_glyphs(last);
148
149 color(stdout, Color::Blue)?;
150 write!(stdout, "{prefix}{pref}")?;
151
152 intense_color(stdout, Color::Green)?;
153 writeln!(stdout, "{name}")?;
154
155 let Some((last_op, rest)) = ops.split_last() else {
156 return Ok(());
157 };
158
159 prefix.push_str(pref_child);
160 for op in rest {
161 color(stdout, Color::Blue)?;
162 write!(stdout, "{prefix}|- ")?;
163
164 color(stdout, Color::White)?;
165 writeln!(stdout, "{op}")?;
166 }
167
168 color(stdout, Color::Blue)?;
169 write!(stdout, "{prefix}`- ")?;
170
171 color(stdout, Color::White)?;
172 writeln!(stdout, "{last_op}")
173}
174
175#[cfg(test)]
176#[allow(
177 clippy::float_cmp,
178 clippy::cast_precision_loss,
179 clippy::cast_possible_truncation,
180 clippy::cast_sign_loss,
181 clippy::similar_names,
182 clippy::doc_markdown,
183 clippy::needless_raw_string_hashes,
184 clippy::too_many_lines
185)]
186mod tests {
187 use super::*;
188 use crate::output::test_support::assert_io_error_propagates_at_every_write;
189 use crate::spaces::SpaceKind;
190 use termcolor::NoColor;
191
192 fn leaf_ops(operators: Vec<String>, operands: Vec<String>) -> Ops {
193 Ops {
194 name: Some("unit".to_string()),
195 name_was_lossy: false,
196 start_line: 1,
197 end_line: 1,
198 kind: SpaceKind::Unit,
199 spaces: vec![],
200 operators,
201 operands,
202 }
203 }
204
205 fn render(ops: &Ops) -> String {
206 let mut sink = NoColor::new(Vec::new());
207 dump_space(ops, &mut sink).expect("dump to in-memory sink");
208 String::from_utf8(sink.into_inner()).expect("utf-8 dump")
209 }
210
211 #[test]
212 fn sibling_space_after_a_nested_one_resumes_its_own_rail() {
213 // The walk keeps one shared indentation buffer that is extended
214 // on descent and truncated on the next visit (#1054). `after` is
215 // a top-level sibling that follows `outer`'s deeper subtree, so a
216 // truncation bug leaves it (and its op blocks) indented under
217 // `inner`'s rail instead of back at the unit's. Built by hand so
218 // the tree shape and the op lists are exactly what the expected
219 // rails below spell out, independent of any grammar's parse.
220 //
221 // The expected rails match what the pre-#1054 binary emits for
222 // the equivalent parsed tree (`function outer(){function
223 // inner(){}} function after(){}`).
224 // `Ops` has an iterative `Drop` (#1056), so struct-update syntax
225 // (`..leaf_ops(…)`) cannot move fields out of it; build each node
226 // whole.
227 let func = |name: &str, line: usize, operators: Vec<String>, spaces: Vec<Ops>| Ops {
228 name: Some(name.to_string()),
229 name_was_lossy: false,
230 start_line: line,
231 end_line: line,
232 kind: SpaceKind::Function,
233 spaces,
234 operators,
235 operands: vec![],
236 };
237 let space = Ops {
238 name: Some("u".to_string()),
239 name_was_lossy: false,
240 start_line: 1,
241 end_line: 4,
242 kind: SpaceKind::Unit,
243 spaces: vec![
244 func("outer", 1, vec![], vec![func("inner", 2, vec![], vec![])]),
245 func("after", 4, vec!["+".to_string()], vec![]),
246 ],
247 operators: vec![],
248 operands: vec![],
249 };
250
251 let expected = concat!(
252 "`- unit: u (@1)\n",
253 " |- operators\n",
254 " |- operands\n",
255 " |- function: outer (@1)\n",
256 " | |- operators\n",
257 " | |- operands\n",
258 " | `- function: inner (@2)\n",
259 " | |- operators\n",
260 " | `- operands\n",
261 " `- function: after (@4)\n",
262 " |- operators\n",
263 " | `- +\n",
264 " `- operands\n",
265 );
266 assert_eq!(render(&space), expected);
267 }
268
269 #[test]
270 fn dump_ops_empty_operators_and_operands_renders_bare_headers() {
271 // Regression: `ops.len() - 1` underflowed (usize) when ops was
272 // empty, then `ops.last().unwrap()` panicked. A space with no
273 // Halstead operators or operands is a realistic input. Asserting
274 // the rendered text rather than `dump_ops(..).is_ok()` keeps the
275 // no-panic guard while also pinning what an empty block looks
276 // like — and keeps the test off the process's real stdout.
277 assert_eq!(
278 render(&leaf_ops(vec![], vec![])),
279 concat!(
280 "`- unit: unit (@1)\n",
281 " |- operators\n",
282 " `- operands\n",
283 )
284 );
285 }
286
287 #[test]
288 fn operators_render_mid_child_connector_not_last() {
289 // `operands` always follows `operators`, so in a leaf space the
290 // `operators` line must use the mid-child glyph `|-` and indent
291 // its children under `| `; the closing `` `- `` belongs to
292 // `operands`. The pre-fix code passed the block's `last` to both,
293 // so `operators` drew `` `- `` and mis-indented its operator
294 // subtree under ` ` (#700).
295 let ops = leaf_ops(vec!["+".to_string()], vec!["a".to_string()]);
296 let out = render(&ops);
297 assert!(
298 out.contains("|- operators"),
299 "operators must use the mid-child connector:\n{out}"
300 );
301 assert!(
302 out.contains("`- operands"),
303 "operands must use the last-child connector:\n{out}"
304 );
305 // The operator leaf indents under `| ` (operators is not last),
306 // not under the ` ` the buggy last-child glyph would produce.
307 assert!(
308 out.contains("| `- +"),
309 "operator leaf must indent under the mid-child rail:\n{out}"
310 );
311 }
312
313 #[test]
314 fn deeply_nested_spaces_dump_without_stack_overflow() {
315 // The space walk is iterative (#700): a deep chain of nested
316 // spaces must dump without overflowing the thread stack. Built by
317 // hand so the test is grammar-independent; run on a small-stack
318 // thread so a recursion regression fails loudly.
319 const DEPTH: usize = 8_000;
320 let handle = std::thread::Builder::new()
321 .stack_size(512 * 1024)
322 .spawn(|| {
323 let mut root = leaf_ops(vec!["+".to_string()], vec!["a".to_string()]);
324 let mut cursor = &mut root;
325 for _ in 0..DEPTH {
326 cursor
327 .spaces
328 .push(leaf_ops(vec!["+".to_string()], vec!["a".to_string()]));
329 cursor = cursor.spaces.last_mut().expect("just pushed");
330 }
331 // Discard the bytes rather than buffering them: every
332 // line of a depth-8000 chain carries ~3 x depth bytes of
333 // indentation, so a `Vec` sink held ~0.5 GB for a test
334 // that only asserts the walk completes.
335 let mut sink = NoColor::new(std::io::sink());
336 let ok = dump_space(&root, &mut sink).is_ok();
337 // `root` drops here without flattening: `Ops`'s `Drop` is
338 // iterative as of #1056, so teardown costs no stack depth
339 // and cannot mask the dump result.
340 ok
341 })
342 .expect("spawn dump thread");
343 assert!(
344 handle.join().expect("dump thread must not overflow"),
345 "deep space nesting must dump successfully"
346 );
347 }
348
349 /// Every write position in the walk surfaces an I/O error, and the
350 /// walk stops there.
351 ///
352 /// `dump_ops` documents that it propagates any `std::io::Error` the
353 /// writer produces — `bca ops | head` closes the pipe mid-stream, so
354 /// this is a real path, not a hypothetical. Sweeping the failure
355 /// across every operation the dump performs is what makes the test
356 /// discriminating: a single `let _ = write!(..)` anywhere in
357 /// `dump_space` / `dump_space_ops` / `dump_ops_values` leaves exactly
358 /// one budget in the sweep returning `Ok`, and a swallow that let the
359 /// walk continue shows up as extra attempts after the failure.
360 #[test]
361 fn every_write_position_propagates_an_io_error() {
362 // Two levels, and both op blocks populated at both levels, so the
363 // sweep reaches the nested-space descent, both block headers, and
364 // the per-entry lines with both connector glyphs.
365 let space = Ops {
366 name: Some("u".to_string()),
367 name_was_lossy: false,
368 start_line: 1,
369 end_line: 2,
370 kind: SpaceKind::Unit,
371 spaces: vec![Ops {
372 name: Some("inner".to_string()),
373 name_was_lossy: false,
374 start_line: 2,
375 end_line: 2,
376 kind: SpaceKind::Function,
377 spaces: vec![],
378 operators: vec!["*".to_string()],
379 operands: vec!["b".to_string()],
380 }],
381 operators: vec!["+".to_string(), "-".to_string()],
382 operands: vec!["a".to_string()],
383 };
384
385 // 30: the fixture's two levels, two op blocks each, and three op
386 // entries come to 37 operations; a floor well under that catches
387 // a fixture that collapsed without churning on exact counts.
388 assert_io_error_propagates_at_every_write(30, |sink| dump_space(&space, sink));
389 }
390}