Skip to main content

big_code_analysis/output/
dump_metrics.rs

1//! Terminal per-metric dump serializer.
2//!
3//! The dump tree is driven by [`wire::CodeMetrics`] — the serialized
4//! metric shape — rather than hand-picking a per-metric subset of stats
5//! (issue #674). Projecting the compute metrics through the wire form and
6//! walking the resulting JSON object guarantees the dump's field set is
7//! *uniform by construction*: every leaf the JSON output carries appears
8//! here under the same key, and a new metric field shows up automatically
9//! with no edit to this file.
10//!
11//! Field *order* in this text view is serde_json's default sorted-key order
12//! (`Value::Object` is a `BTreeMap` because `preserve_order` is deliberately
13//! not enabled — see the root `Cargo.toml`), which is deterministic and
14//! differs from the JSON serializer's struct-field order. The uniform field
15//! *set* is what #674 requires; matching JSON's order would mean enabling
16//! `preserve_order` workspace-wide, which would perturb the frozen
17//! code-climate / SARIF fingerprint contracts (#559).
18//!
19//! The other deliberate divergence from JSON is presentation: float values
20//! render rounded to [`TEXT_FLOAT_DECIMALS`] decimals in this text view,
21//! whereas JSON keeps full precision. Non-finite floats (which serialize
22//! to JSON `null`) render as `NaN`, matching the prior dump and the
23//! human-readable `numfmt` arm.
24
25use termcolor::{Color, StandardStream, WriteColor};
26
27use serde_json::Value;
28
29use crate::output::ColorMode;
30use crate::output::numfmt::F64_SAFE_INT_BOUND;
31use crate::spaces::{CodeMetrics, FuncSpace};
32use crate::wire;
33
34use crate::tools::{color, intense_color};
35
36/// Decimal places used when rendering a non-integer float in the text
37/// dump. JSON output keeps full precision; the terminal view trades the
38/// trailing noise for legibility (issue #674).
39const TEXT_FLOAT_DECIMALS: usize = 2;
40
41/// Dumps the metrics of a code.
42///
43/// Returns a [`Result`] value, when an error occurs.
44///
45/// # Errors
46///
47/// Propagates any [`std::io::Error`] produced by the color-aware
48/// writer that backs `stdout` (broken pipe, write failure, …).
49///
50/// # Examples
51///
52/// ```
53/// use big_code_analysis::{analyze, dump_root, LANG, MetricsOptions, Source};
54///
55/// // Compute metrics via the non-generic `analyze` entry point.
56/// let space = analyze(
57///     Source::new(LANG::Cpp, b"int a = 42;"),
58///     MetricsOptions::default(),
59/// )
60/// .expect("snippet has a top-level FuncSpace");
61///
62/// // Dump all metrics
63/// dump_root(&space).unwrap();
64/// ```
65pub fn dump_root(space: &FuncSpace) -> std::io::Result<()> {
66    dump_root_with_color(space, ColorMode::Always)
67}
68
69/// Like [`dump_root`], but the caller selects the [`ColorMode`].
70///
71/// `bca` resolves a `--color` flag, the `NO_COLOR` convention, and
72/// stdout tty detection into a mode and passes it here so piped output
73/// is escape-free by default. The bare [`dump_root`] keeps the
74/// historical always-colored behavior for backward compatibility.
75///
76/// # Errors
77///
78/// Propagates any [`std::io::Error`] produced by the color-aware
79/// writer that backs `stdout` (broken pipe, write failure, …).
80pub fn dump_root_with_color(space: &FuncSpace, color_mode: ColorMode) -> std::io::Result<()> {
81    let stdout = StandardStream::stdout(color_mode.to_color_choice());
82    let mut stdout = stdout.lock();
83    dump_space(space, "", true, &mut stdout)?;
84    color(&mut stdout, Color::White)?;
85
86    Ok(())
87}
88
89/// Dump the `FuncSpace` metric tree with an explicit work stack rather
90/// than recursion, so a pathologically deep space nesting (closures
91/// within closures) cannot overflow the thread stack at dump time — an
92/// uncatchable abort, forbidden by the no-panic rule (#700). Traversal
93/// order and per-node glyphs are byte-identical to the prior recursive
94/// form.
95fn dump_space(
96    space: &FuncSpace,
97    prefix: &str,
98    last: bool,
99    stdout: &mut dyn WriteColor,
100) -> std::io::Result<()> {
101    let mut stack: Vec<(&FuncSpace, String, bool)> = vec![(space, prefix.to_owned(), last)];
102
103    while let Some((space, prefix, last)) = stack.pop() {
104        let (pref_child, pref) = if last { ("   ", "`- ") } else { ("|  ", "|- ") };
105
106        color(stdout, Color::Blue)?;
107        write!(stdout, "{prefix}{pref}")?;
108
109        intense_color(stdout, Color::Yellow)?;
110        write!(stdout, "{}: ", space.kind)?;
111
112        intense_color(stdout, Color::Cyan)?;
113        write!(stdout, "{}", space.name.as_ref().map_or("", |name| name))?;
114
115        intense_color(stdout, Color::Red)?;
116        writeln!(stdout, " (@{})", space.start_line)?;
117
118        let child_prefix = format!("{prefix}{pref_child}");
119        dump_metrics(
120            &space.metrics,
121            &child_prefix,
122            space.spaces.is_empty(),
123            stdout,
124        )?;
125
126        // Push children in reverse so `pop()` visits them in source
127        // order; the final child carries `last = true` for the closing
128        // `` `- `` glyph, matching the recursive `split_last` form.
129        let count = space.spaces.len();
130        for (i, child) in space.spaces.iter().enumerate().rev() {
131            stack.push((child, child_prefix.clone(), i + 1 == count));
132        }
133    }
134
135    Ok(())
136}
137
138fn dump_metrics(
139    metrics: &CodeMetrics,
140    prefix: &str,
141    last: bool,
142    stdout: &mut dyn WriteColor,
143) -> std::io::Result<()> {
144    let (pref_child, pref) = if last { ("   ", "`- ") } else { ("|  ", "|- ") };
145
146    color(stdout, Color::Blue)?;
147    write!(stdout, "{prefix}{pref}")?;
148
149    intense_color(stdout, Color::Yellow)?;
150    writeln!(stdout, "metrics")?;
151
152    // Project the compute metrics through the wire shape and walk the
153    // serialized object so the dump's field set is the JSON field set
154    // exactly (issue #674). Disabled class-only metrics (`wmc`/`npm`/`npa`
155    // on a non-class language) are already elided by the `From` impl, so
156    // they never appear in the object and need no per-metric guard here.
157    let wire_metrics = wire::CodeMetrics::from(metrics);
158    let Value::Object(groups) = serde_json::to_value(&wire_metrics).unwrap_or(Value::Null) else {
159        return Ok(());
160    };
161
162    let prefix = format!("{prefix}{pref_child}");
163    let last_index = groups.len().saturating_sub(1);
164    for (index, (name, value)) in groups.iter().enumerate() {
165        dump_group(name, value, &prefix, index == last_index, stdout)?;
166    }
167    Ok(())
168}
169
170/// Render one metric group (`cognitive`, `loc`, …) as a green-labelled
171/// subtree, then walk its leaves. A nested object leaf (e.g.
172/// `cyclomatic.modified`) recurses as its own subtree, so the rendered
173/// shape always mirrors the JSON nesting.
174fn dump_group(
175    name: &str,
176    value: &Value,
177    prefix: &str,
178    last: bool,
179    stdout: &mut dyn WriteColor,
180) -> std::io::Result<()> {
181    let (pref_child, pref) = if last { ("   ", "`- ") } else { ("|  ", "|- ") };
182
183    color(stdout, Color::Blue)?;
184    write!(stdout, "{prefix}{pref}")?;
185
186    intense_color(stdout, Color::Green)?;
187    writeln!(stdout, "{name}")?;
188
189    let prefix = format!("{prefix}{pref_child}");
190    dump_object(value, &prefix, stdout)
191}
192
193/// Walk the leaves of a metric object, emitting one `name: value` line
194/// per scalar and recursing into any nested object (rendered as a green
195/// subtree, matching the JSON nesting). A non-object value is ignored
196/// (the wire metric groups are always objects).
197fn dump_object(value: &Value, prefix: &str, stdout: &mut dyn WriteColor) -> std::io::Result<()> {
198    let Value::Object(fields) = value else {
199        return Ok(());
200    };
201    let last_index = fields.len().saturating_sub(1);
202    for (index, (name, leaf)) in fields.iter().enumerate() {
203        let last = index == last_index;
204        if leaf.is_object() {
205            dump_group(name, leaf, prefix, last, stdout)?;
206        } else {
207            dump_value(name, leaf, prefix, last, stdout)?;
208        }
209    }
210    Ok(())
211}
212
213/// Emit a single `name: value` leaf. Floats render rounded to
214/// [`TEXT_FLOAT_DECIMALS`] decimals (text view only — JSON keeps full
215/// precision); integers print verbatim; a JSON `null` (a non-finite
216/// metric) renders as `NaN`, matching the prior dump.
217fn dump_value(
218    name: &str,
219    value: &Value,
220    prefix: &str,
221    last: bool,
222    stdout: &mut dyn WriteColor,
223) -> std::io::Result<()> {
224    let pref = if last { "`- " } else { "|- " };
225
226    color(stdout, Color::Blue)?;
227    write!(stdout, "{prefix}{pref}")?;
228
229    intense_color(stdout, Color::Magenta)?;
230    write!(stdout, "{name}: ")?;
231
232    color(stdout, Color::White)?;
233    writeln!(stdout, "{}", format_leaf(value))
234}
235
236/// Format a scalar wire leaf for the text view. Integral numbers print
237/// without a decimal point; non-integral floats round to
238/// [`TEXT_FLOAT_DECIMALS`] places; `null` (a non-finite metric) becomes
239/// `NaN`.
240fn format_leaf(value: &Value) -> String {
241    match value {
242        Value::Null => "NaN".to_owned(),
243        Value::Number(n) => format_number(n),
244        // The wire metric leaves are only numbers or null; render anything
245        // else verbatim rather than panicking on an unexpected shape.
246        other => other.to_string(),
247    }
248}
249
250/// Render a JSON number: an integer prints without a decimal point; a
251/// float rounds to [`TEXT_FLOAT_DECIMALS`] places, after which a trailing
252/// `.00` is dropped so a whole-valued average reads like a count.
253fn format_number(n: &serde_json::Number) -> String {
254    if let Some(int) = n.as_u64() {
255        return int.to_string();
256    }
257    if let Some(int) = n.as_i64() {
258        return int.to_string();
259    }
260    let Some(float) = n.as_f64() else {
261        return n.to_string();
262    };
263    // A safe-integer-valued float (e.g. an exact `2.0` average) prints as
264    // an integer. This *diverges* from the JSON serializer, which keeps
265    // the `.0` (serde_json renders `2.0` as `"2.0"`, not `"2"`); the dump
266    // drops it deliberately for terminal legibility, the same presentation
267    // tradeoff the module header documents for rounded floats (#674).
268    if float.fract() == 0.0 && float.abs() < F64_SAFE_INT_BOUND {
269        #[allow(clippy::cast_possible_truncation)]
270        return (float as i64).to_string();
271    }
272    format!("{float:.TEXT_FLOAT_DECIMALS$}")
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::metric_set::Metric;
279    use crate::{LANG, MetricsOptions, Source, analyze};
280
281    fn render(space: &FuncSpace) -> String {
282        let mut buf = termcolor::NoColor::new(Vec::new());
283        dump_space(space, "", true, &mut buf).expect("dump to in-memory buffer");
284        String::from_utf8(buf.into_inner()).expect("utf-8 dump")
285    }
286
287    #[test]
288    fn selection_mask_omits_unselected_metric_groups() {
289        // `with_only(&[Loc])` must restrict the dump to the loc group:
290        // the wire-driven projection (#674) elides unselected metrics, so
291        // they never appear in the walked JSON object. A pre-#674 dump
292        // printed all groups with default/zero stats, contradicting the
293        // serialized "present => selected" contract (#700).
294        let space = analyze(
295            Source::new(LANG::Cpp, b"int a = 42;"),
296            MetricsOptions::default().with_only(&[Metric::Loc]),
297        )
298        .expect("snippet has a top-level FuncSpace");
299        let out = render(&space);
300        assert!(out.contains("loc\n"), "loc group must be present:\n{out}");
301        for omitted in ["cognitive", "cyclomatic", "halstead", "nom", "abc"] {
302            assert!(
303                !out.contains(&format!("{omitted}\n")),
304                "unselected `{omitted}` group must be omitted:\n{out}"
305            );
306        }
307    }
308
309    #[test]
310    fn last_emitted_metric_group_uses_closing_connector() {
311        // The genuinely-last emitted metric group must carry the closing
312        // `` `- `` glyph rather than a dangling `|-` (#700, already made
313        // dynamic by the wire projection in #674). For a non-class C
314        // dump, the wmc/npm/npa class-only groups are elided, so the last
315        // group line under the root `metrics` subtree must end the
316        // subtree with `` `- ``. We locate the final group connector
317        // (a `<rail>`- ` or `<rail>|- ` line whose label is a top-level
318        // group, i.e. indented directly under `metrics`) and assert it is
319        // the closing form.
320        let space = analyze(
321            Source::new(LANG::Cpp, b"int a = 42;"),
322            MetricsOptions::default(),
323        )
324        .expect("snippet has a top-level FuncSpace");
325        let out = render(&space);
326        // The root metrics subtree indents its groups under `   ` (root
327        // is the last/only space). The last such group line must use
328        // `` `- ``; if any group dangled, the final group line would read
329        // `|- `.
330        let group_lines: Vec<&str> = out
331            .lines()
332            .filter(|l| l.starts_with("   |- ") || l.starts_with("   `- "))
333            .collect();
334        let last_group = group_lines.last().expect("at least one metric group");
335        assert!(
336            last_group.starts_with("   `- "),
337            "the last emitted metric group must use the closing connector, got: {last_group:?}\n{out}"
338        );
339    }
340
341    #[test]
342    fn deeply_nested_spaces_dump_without_stack_overflow() {
343        // The space walk is iterative (#700): a deep chain of nested
344        // function spaces must dump without overflowing the thread stack.
345        // Run on a small-stack thread so a recursion regression fails
346        // loudly rather than relying on the test-runner stack.
347        use crate::spaces::SpaceKind;
348        const DEPTH: usize = 8_000;
349        let handle = std::thread::Builder::new()
350            .stack_size(512 * 1024)
351            .spawn(|| {
352                let leaf = || FuncSpace {
353                    name: Some("f".to_string()),
354                    start_line: 1,
355                    end_line: 1,
356                    kind: SpaceKind::Function,
357                    spaces: Vec::new(),
358                    metrics: CodeMetrics::default(),
359                    suppressed: crate::SuppressionScope::default(),
360                };
361                let mut root = leaf();
362                let mut cursor = &mut root;
363                for _ in 0..DEPTH {
364                    cursor.spaces.push(leaf());
365                    cursor = cursor.spaces.last_mut().expect("just pushed");
366                }
367                let mut sink = termcolor::NoColor::new(Vec::new());
368                let ok = dump_space(&root, "", true, &mut sink).is_ok();
369                // Flatten the chain before it drops: `FuncSpace`'s derived
370                // `Drop` recurses through `spaces`, so a deep tree would
371                // overflow the small stack on teardown and mask the dump
372                // result. Hoisting each level's children out turns the
373                // drop into an iterative one.
374                let mut node = root;
375                while let Some(child) = node.spaces.pop() {
376                    node = child;
377                }
378                ok
379            })
380            .expect("spawn dump thread");
381        assert!(
382            handle.join().expect("dump thread must not overflow"),
383            "deep space nesting must dump successfully"
384        );
385    }
386
387    /// Value printed after `{field}:` in the FIRST `{block}` metric block of
388    /// the dump — i.e. the root `Unit`'s, which is emitted before any child
389    /// space. `{val}` Display renders whole f64s without a decimal point, so
390    /// callers can compare against `"0"`.
391    fn root_block_field(out: &str, block: &str, field: &str) -> String {
392        let body = &out[out
393            .find(&format!("{block}\n"))
394            .expect("metric block present")..];
395        let at = body.find(&format!("{field}: ")).expect("field present") + field.len() + 2;
396        body[at..].lines().next().unwrap_or("").trim().to_owned()
397    }
398
399    /// Regression for the parent-space aggregate bug: `dump_nom` / `dump_nargs`
400    /// must print the SUBTREE-AGGREGATE counts (`functions_sum` / `fn_args_sum`,
401    /// matching the JSON serializer and `Display`), not the space's IMMEDIATE
402    /// counts — which are 0 at any parent whose functions all live in a nested
403    /// module/impl, and would not sum to the aggregate `total`.
404    #[test]
405    fn dump_nom_and_nargs_use_subtree_aggregates_at_parent_space() {
406        // The one function (with args) is nested in `mod m`, so the root Unit's
407        // immediate function/arg counts are 0 while the subtree aggregates are
408        // not — the exact shape that exposed the bug.
409        let space = analyze(
410            Source::new(
411                LANG::Rust,
412                b"mod m { fn a(x: i32, y: i32) -> i32 { x + y } }",
413            ),
414            MetricsOptions::default(),
415        )
416        .expect("snippet has a top-level FuncSpace");
417
418        let mut buf = termcolor::NoColor::new(Vec::new());
419        dump_space(&space, "", true, &mut buf).expect("dump to in-memory buffer");
420        let out = String::from_utf8(buf.into_inner()).expect("utf-8 dump");
421
422        assert_ne!(
423            root_block_field(&out, "nom", "functions"),
424            "0",
425            "root nom must print functions_sum (aggregate), not the immediate 0:\n{out}"
426        );
427        assert_ne!(
428            root_block_field(&out, "nargs", "functions"),
429            "0",
430            "root nargs must print fn_args_sum (aggregate), not the immediate 0:\n{out}"
431        );
432    }
433
434    /// Regression for #562: the two Halstead dump labels must use the
435    /// underscore key that matches the JSON/CSV key name, so a user can grep
436    /// the same token across `dump` and JSON. The space-separated forms
437    /// (`estimated program length` / `purity ratio`) were the only outliers.
438    #[test]
439    fn dump_halstead_labels_use_underscore_keys() {
440        let space = analyze(
441            Source::new(LANG::Cpp, b"int a = 42;"),
442            MetricsOptions::default(),
443        )
444        .expect("snippet has a top-level FuncSpace");
445
446        let mut buf = termcolor::NoColor::new(Vec::new());
447        dump_space(&space, "", true, &mut buf).expect("dump to in-memory buffer");
448        let out = String::from_utf8(buf.into_inner()).expect("utf-8 dump");
449
450        assert!(
451            out.contains("estimated_program_length: "),
452            "dump must use the underscore key `estimated_program_length`:\n{out}"
453        );
454        assert!(
455            out.contains("purity_ratio: "),
456            "dump must use the underscore key `purity_ratio`:\n{out}"
457        );
458        assert!(
459            !out.contains("estimated program length"),
460            "dump must not emit the space-separated `estimated program length`:\n{out}"
461        );
462        assert!(
463            !out.contains("purity ratio"),
464            "dump must not emit the space-separated `purity ratio`:\n{out}"
465        );
466    }
467}