Skip to main content

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}