use termcolor::{Color, StandardStream, WriteColor};
use serde_json::Value;
use crate::output::ColorMode;
use crate::output::numfmt::F64_SAFE_INT_BOUND;
use crate::spaces::{CodeMetrics, FuncSpace};
use crate::wire;
use crate::tools::{color, intense_color};
const TEXT_FLOAT_DECIMALS: usize = 2;
pub fn dump_root(space: &FuncSpace) -> std::io::Result<()> {
dump_root_with_color(space, ColorMode::Always)
}
pub fn dump_root_with_color(space: &FuncSpace, color_mode: ColorMode) -> std::io::Result<()> {
let stdout = StandardStream::stdout(color_mode.to_color_choice());
let mut stdout = stdout.lock();
dump_space(space, "", true, &mut stdout)?;
color(&mut stdout, Color::White)?;
Ok(())
}
fn dump_space(
space: &FuncSpace,
prefix: &str,
last: bool,
stdout: &mut dyn WriteColor,
) -> std::io::Result<()> {
let mut stack: Vec<(&FuncSpace, String, bool)> = vec![(space, prefix.to_owned(), last)];
while let Some((space, prefix, last)) = stack.pop() {
let (pref_child, pref) = if last { (" ", "`- ") } else { ("| ", "|- ") };
color(stdout, Color::Blue)?;
write!(stdout, "{prefix}{pref}")?;
intense_color(stdout, Color::Yellow)?;
write!(stdout, "{}: ", space.kind)?;
intense_color(stdout, Color::Cyan)?;
write!(stdout, "{}", space.name.as_ref().map_or("", |name| name))?;
intense_color(stdout, Color::Red)?;
writeln!(stdout, " (@{})", space.start_line)?;
let child_prefix = format!("{prefix}{pref_child}");
dump_metrics(
&space.metrics,
&child_prefix,
space.spaces.is_empty(),
stdout,
)?;
let count = space.spaces.len();
for (i, child) in space.spaces.iter().enumerate().rev() {
stack.push((child, child_prefix.clone(), i + 1 == count));
}
}
Ok(())
}
fn dump_metrics(
metrics: &CodeMetrics,
prefix: &str,
last: bool,
stdout: &mut dyn WriteColor,
) -> std::io::Result<()> {
let (pref_child, pref) = if last { (" ", "`- ") } else { ("| ", "|- ") };
color(stdout, Color::Blue)?;
write!(stdout, "{prefix}{pref}")?;
intense_color(stdout, Color::Yellow)?;
writeln!(stdout, "metrics")?;
let wire_metrics = wire::CodeMetrics::from(metrics);
let Value::Object(groups) = serde_json::to_value(&wire_metrics).unwrap_or(Value::Null) else {
return Ok(());
};
let prefix = format!("{prefix}{pref_child}");
let last_index = groups.len().saturating_sub(1);
for (index, (name, value)) in groups.iter().enumerate() {
dump_group(name, value, &prefix, index == last_index, stdout)?;
}
Ok(())
}
fn dump_group(
name: &str,
value: &Value,
prefix: &str,
last: bool,
stdout: &mut dyn WriteColor,
) -> std::io::Result<()> {
let (pref_child, pref) = if last { (" ", "`- ") } else { ("| ", "|- ") };
color(stdout, Color::Blue)?;
write!(stdout, "{prefix}{pref}")?;
intense_color(stdout, Color::Green)?;
writeln!(stdout, "{name}")?;
let prefix = format!("{prefix}{pref_child}");
dump_object(value, &prefix, stdout)
}
fn dump_object(value: &Value, prefix: &str, stdout: &mut dyn WriteColor) -> std::io::Result<()> {
let Value::Object(fields) = value else {
return Ok(());
};
let last_index = fields.len().saturating_sub(1);
for (index, (name, leaf)) in fields.iter().enumerate() {
let last = index == last_index;
if leaf.is_object() {
dump_group(name, leaf, prefix, last, stdout)?;
} else {
dump_value(name, leaf, prefix, last, stdout)?;
}
}
Ok(())
}
fn dump_value(
name: &str,
value: &Value,
prefix: &str,
last: bool,
stdout: &mut dyn WriteColor,
) -> std::io::Result<()> {
let pref = if last { "`- " } else { "|- " };
color(stdout, Color::Blue)?;
write!(stdout, "{prefix}{pref}")?;
intense_color(stdout, Color::Magenta)?;
write!(stdout, "{name}: ")?;
color(stdout, Color::White)?;
writeln!(stdout, "{}", format_leaf(value))
}
fn format_leaf(value: &Value) -> String {
match value {
Value::Null => "NaN".to_owned(),
Value::Number(n) => format_number(n),
other => other.to_string(),
}
}
fn format_number(n: &serde_json::Number) -> String {
if let Some(int) = n.as_u64() {
return int.to_string();
}
if let Some(int) = n.as_i64() {
return int.to_string();
}
let Some(float) = n.as_f64() else {
return n.to_string();
};
if float.fract() == 0.0 && float.abs() < F64_SAFE_INT_BOUND {
#[allow(clippy::cast_possible_truncation)]
return (float as i64).to_string();
}
format!("{float:.TEXT_FLOAT_DECIMALS$}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metric_set::Metric;
use crate::{LANG, MetricsOptions, Source, analyze};
fn render(space: &FuncSpace) -> String {
let mut buf = termcolor::NoColor::new(Vec::new());
dump_space(space, "", true, &mut buf).expect("dump to in-memory buffer");
String::from_utf8(buf.into_inner()).expect("utf-8 dump")
}
#[test]
fn selection_mask_omits_unselected_metric_groups() {
let space = analyze(
Source::new(LANG::Cpp, b"int a = 42;"),
MetricsOptions::default().with_only(&[Metric::Loc]),
)
.expect("snippet has a top-level FuncSpace");
let out = render(&space);
assert!(out.contains("loc\n"), "loc group must be present:\n{out}");
for omitted in ["cognitive", "cyclomatic", "halstead", "nom", "abc"] {
assert!(
!out.contains(&format!("{omitted}\n")),
"unselected `{omitted}` group must be omitted:\n{out}"
);
}
}
#[test]
fn last_emitted_metric_group_uses_closing_connector() {
let space = analyze(
Source::new(LANG::Cpp, b"int a = 42;"),
MetricsOptions::default(),
)
.expect("snippet has a top-level FuncSpace");
let out = render(&space);
let group_lines: Vec<&str> = out
.lines()
.filter(|l| l.starts_with(" |- ") || l.starts_with(" `- "))
.collect();
let last_group = group_lines.last().expect("at least one metric group");
assert!(
last_group.starts_with(" `- "),
"the last emitted metric group must use the closing connector, got: {last_group:?}\n{out}"
);
}
#[test]
fn deeply_nested_spaces_dump_without_stack_overflow() {
use crate::spaces::SpaceKind;
const DEPTH: usize = 8_000;
let handle = std::thread::Builder::new()
.stack_size(512 * 1024)
.spawn(|| {
let leaf = || FuncSpace {
name: Some("f".to_string()),
start_line: 1,
end_line: 1,
kind: SpaceKind::Function,
spaces: Vec::new(),
metrics: CodeMetrics::default(),
suppressed: crate::SuppressionScope::default(),
};
let mut root = leaf();
let mut cursor = &mut root;
for _ in 0..DEPTH {
cursor.spaces.push(leaf());
cursor = cursor.spaces.last_mut().expect("just pushed");
}
let mut sink = termcolor::NoColor::new(Vec::new());
let ok = dump_space(&root, "", true, &mut sink).is_ok();
let mut node = root;
while let Some(child) = node.spaces.pop() {
node = child;
}
ok
})
.expect("spawn dump thread");
assert!(
handle.join().expect("dump thread must not overflow"),
"deep space nesting must dump successfully"
);
}
fn root_block_field(out: &str, block: &str, field: &str) -> String {
let body = &out[out
.find(&format!("{block}\n"))
.expect("metric block present")..];
let at = body.find(&format!("{field}: ")).expect("field present") + field.len() + 2;
body[at..].lines().next().unwrap_or("").trim().to_owned()
}
#[test]
fn dump_nom_and_nargs_use_subtree_aggregates_at_parent_space() {
let space = analyze(
Source::new(
LANG::Rust,
b"mod m { fn a(x: i32, y: i32) -> i32 { x + y } }",
),
MetricsOptions::default(),
)
.expect("snippet has a top-level FuncSpace");
let mut buf = termcolor::NoColor::new(Vec::new());
dump_space(&space, "", true, &mut buf).expect("dump to in-memory buffer");
let out = String::from_utf8(buf.into_inner()).expect("utf-8 dump");
assert_ne!(
root_block_field(&out, "nom", "functions"),
"0",
"root nom must print functions_sum (aggregate), not the immediate 0:\n{out}"
);
assert_ne!(
root_block_field(&out, "nargs", "functions"),
"0",
"root nargs must print fn_args_sum (aggregate), not the immediate 0:\n{out}"
);
}
#[test]
fn dump_halstead_labels_use_underscore_keys() {
let space = analyze(
Source::new(LANG::Cpp, b"int a = 42;"),
MetricsOptions::default(),
)
.expect("snippet has a top-level FuncSpace");
let mut buf = termcolor::NoColor::new(Vec::new());
dump_space(&space, "", true, &mut buf).expect("dump to in-memory buffer");
let out = String::from_utf8(buf.into_inner()).expect("utf-8 dump");
assert!(
out.contains("estimated_program_length: "),
"dump must use the underscore key `estimated_program_length`:\n{out}"
);
assert!(
out.contains("purity_ratio: "),
"dump must use the underscore key `purity_ratio`:\n{out}"
);
assert!(
!out.contains("estimated program length"),
"dump must not emit the space-separated `estimated program length`:\n{out}"
);
assert!(
!out.contains("purity ratio"),
"dump must not emit the space-separated `purity ratio`:\n{out}"
);
}
}