#![allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::similar_names,
clippy::too_many_lines
)]
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt::Write;
use big_code_analysis::SpaceKind;
use crate::format_util::MetricScalar;
use crate::markdown_report::{
FunctionSummary, is_class_like, mi_rating, sort_by_metric_asc, sort_by_metric_desc, thousands,
title_case,
};
fn escape_html(s: &str) -> Cow<'_, str> {
let needs_escape = s
.bytes()
.any(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\''));
if !needs_escape {
return Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len() + 8);
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
Cow::Owned(out)
}
const INLINE_CSS: &str = "\
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;\
margin:1.5rem;color:#222;background:#fafafa}\
h1{font-size:1.4rem;margin:0 0 0.5rem}\
h2{font-size:1.15rem;margin:1.5rem 0 0.5rem;\
border-bottom:1px solid #ccc;padding-bottom:0.25rem}\
h3{font-size:1rem;margin:1rem 0 0.4rem;color:#444}\
section{margin-top:2rem}\
section.lang-section{padding:0.5rem 1rem;border-radius:4px;\
border-left:3px solid rgba(127,127,127,0.35)}\
section.lang-section>h2{margin-top:0.25rem}\
section.lang-rust{background:rgba(222,128,82,0.08);border-left-color:rgba(222,128,82,0.55)}\
section.lang-python{background:rgba(58,118,196,0.08);border-left-color:rgba(58,118,196,0.55)}\
section.lang-javascript{background:rgba(229,202,71,0.10);border-left-color:rgba(229,202,71,0.65)}\
section.lang-typescript{background:rgba(46,116,194,0.08);border-left-color:rgba(46,116,194,0.55)}\
section.lang-java{background:rgba(196,69,60,0.08);border-left-color:rgba(196,69,60,0.55)}\
section.lang-kotlin{background:rgba(193,71,167,0.08);border-left-color:rgba(193,71,167,0.55)}\
section.lang-go{background:rgba(0,173,181,0.08);border-left-color:rgba(0,173,181,0.55)}\
section.lang-cpp{background:rgba(120,80,180,0.08);border-left-color:rgba(120,80,180,0.55)}\
section.lang-csharp{background:rgba(83,150,80,0.08);border-left-color:rgba(83,150,80,0.55)}\
section.lang-php{background:rgba(98,113,178,0.08);border-left-color:rgba(98,113,178,0.55)}\
section.lang-bash{background:rgba(96,128,96,0.08);border-left-color:rgba(96,128,96,0.55)}\
section.lang-perl{background:rgba(180,120,60,0.08);border-left-color:rgba(180,120,60,0.55)}\
section.lang-lua{background:rgba(0,86,180,0.08);border-left-color:rgba(0,86,180,0.55)}\
section.lang-tcl{background:rgba(160,90,140,0.08);border-left-color:rgba(160,90,140,0.55)}\
section.lang-ruby{background:rgba(204,52,45,0.08);border-left-color:rgba(204,52,45,0.55)}\
section.lang-elixir{background:rgba(110,73,153,0.08);border-left-color:rgba(110,73,153,0.55)}\
section.lang-other{background:rgba(127,127,127,0.06);border-left-color:rgba(127,127,127,0.45)}\
@media (prefers-color-scheme:dark){\
section.lang-rust{background:rgba(222,128,82,0.16)}\
section.lang-python{background:rgba(58,118,196,0.18)}\
section.lang-javascript{background:rgba(229,202,71,0.16)}\
section.lang-typescript{background:rgba(46,116,194,0.18)}\
section.lang-java{background:rgba(196,69,60,0.18)}\
section.lang-kotlin{background:rgba(193,71,167,0.18)}\
section.lang-go{background:rgba(0,173,181,0.18)}\
section.lang-cpp{background:rgba(120,80,180,0.20)}\
section.lang-csharp{background:rgba(83,150,80,0.18)}\
section.lang-php{background:rgba(98,113,178,0.20)}\
section.lang-bash{background:rgba(96,128,96,0.18)}\
section.lang-perl{background:rgba(180,120,60,0.18)}\
section.lang-lua{background:rgba(0,86,180,0.20)}\
section.lang-tcl{background:rgba(160,90,140,0.20)}\
section.lang-ruby{background:rgba(204,52,45,0.18)}\
section.lang-elixir{background:rgba(110,73,153,0.20)}\
section.lang-other{background:rgba(200,200,200,0.10)}\
}\
.summary{font-size:0.9rem;color:#444;margin-bottom:0.5rem}\
.summary strong{color:#222}\
.summary p{margin:0.2rem 0}\
.note{font-size:0.85rem;color:#555;margin:0.4rem 0}\
ul{margin:0.4rem 0 0.4rem 1.2rem;padding:0}\
li{margin:0.15rem 0;font-size:0.9rem}\
table.hotspot{border-collapse:collapse;width:100%;font-size:0.85rem;\
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,0.06);margin-bottom:0.5rem}\
table.hotspot th,table.hotspot td{padding:0.4rem 0.6rem;\
border-bottom:1px solid #e5e5e5;text-align:left;white-space:nowrap}\
table.hotspot th{background:#f0f0f0;cursor:pointer;user-select:none;\
font-weight:600}\
table.hotspot th:hover{background:#e5e5e5}\
table.hotspot th[aria-sort=ascending]::after{content:\" \\2191\"}\
table.hotspot th[aria-sort=descending]::after{content:\" \\2193\"}\
table.hotspot tr:nth-child(even) td{background:#fafafa}\
table.hotspot td.numeric{text-align:right;font-variant-numeric:tabular-nums}\
";
const LANGUAGE_PALETTE: &[(&str, &str)] = &[
("rust", "rust"),
("python", "python"),
("javascript", "javascript"),
("typescript", "typescript"),
("java", "java"),
("kotlin", "kotlin"),
("go", "go"),
("c/c++", "cpp"),
("c#", "csharp"),
("php", "php"),
("bash", "bash"),
("perl", "perl"),
("lua", "lua"),
("tcl", "tcl"),
("ruby", "ruby"),
("elixir", "elixir"),
];
fn language_palette_slug(lang_name: &str) -> &'static str {
LANGUAGE_PALETTE
.iter()
.find_map(|&(name, slug)| (name == lang_name).then_some(slug))
.unwrap_or("other")
}
const INLINE_JS: &str = "\
(function(){\
function num(s){return s===''?Number.POSITIVE_INFINITY:parseFloat(s.replace(/,/g,''));}\
document.querySelectorAll('table.hotspot').forEach(function(table){\
var headers=table.querySelectorAll('thead th');\
headers.forEach(function(th,idx){\
th.addEventListener('click',function(){sort(table,idx,th);});\
});\
});\
function sort(tbl,idx,th){\
var tbody=tbl.tBodies[0];\
if(!tbody)return;\
var rows=Array.prototype.slice.call(tbody.rows);\
var numeric=th.dataset.numeric==='1';\
var dir=th.getAttribute('aria-sort')==='ascending'?'descending':'ascending';\
tbl.querySelectorAll('thead th').forEach(function(h){h.removeAttribute('aria-sort');});\
th.setAttribute('aria-sort',dir);\
var sign=dir==='ascending'?1:-1;\
rows.sort(function(a,b){\
var av=a.cells[idx].textContent;\
var bv=b.cells[idx].textContent;\
if(numeric){\
var an=num(av);\
var bn=num(bv);\
if(an<bn)return -1*sign;\
if(an>bn)return 1*sign;\
return 0;\
}\
return av.localeCompare(bv)*sign;\
});\
rows.forEach(function(r){tbody.appendChild(r);});\
}\
})();\
";
#[derive(Clone, Copy)]
enum Align {
Left,
Right,
}
impl Align {
fn is_numeric(self) -> bool {
matches!(self, Self::Right)
}
}
const MI_TOOLTIP: &str = "Maintainability Index (Visual Studio scale, 0\u{2013}100): composite of Halstead volume, cyclomatic complexity, and SLOC; higher is more maintainable.";
const CC_TOOLTIP: &str = "Cyclomatic Complexity: number of linearly independent control-flow paths through the function.";
const COGNITIVE_TOOLTIP: &str = "Cognitive Complexity: how hard the code is for a human to follow; nesting and breaks in linear flow add weight.";
const HEADER_TOOLTIPS: &[(&str, &str)] = &[
(
"SLOC",
"Source Lines Of Code: non-blank, non-comment source lines.",
),
("MI", MI_TOOLTIP),
("Avg MI", MI_TOOLTIP),
(
"Tokens",
"Total lexical tokens (AST leaves excluding comments) in the unit.",
),
("CC", CC_TOOLTIP),
("Avg CC", CC_TOOLTIP),
("Cognitive", COGNITIVE_TOOLTIP),
("Avg Cognitive", COGNITIVE_TOOLTIP),
(
"Effort",
"Halstead effort: estimated mental effort to (re)create the code.",
),
(
"Volume",
"Halstead volume: program length weighted by vocabulary size.",
),
(
"Est. Bugs",
"Halstead bugs: estimated defect count derived from program volume.",
),
(
"Exits",
"Number of exit points (returns, throws, breaks out of the function).",
),
(
"ABC",
"ABC magnitude: sqrt(A\u{B2} + B\u{B2} + C\u{B2}) over Assignments, Branches, and Conditions.",
),
(
"WMC",
"Weighted Methods per Class: sum of cyclomatic complexity across the class's methods.",
),
("Methods", "Number of methods declared on the class."),
("NPA", "Number of Public Attributes declared on the class."),
("NPM", "Number of Public Methods declared on the class."),
("Args", "Number of declared parameters of the function."),
("Functions", "Number of functions and methods analysed."),
("Files", "Number of source files analysed."),
];
fn header_tooltip(header: &str) -> Option<&'static str> {
HEADER_TOOLTIPS
.iter()
.find_map(|&(name, tip)| (name == header).then_some(tip))
}
#[derive(Clone, Copy)]
enum SortDir {
Asc,
Desc,
}
fn write_table(out: &mut String, headers: &[&str], aligns: &[Align], rows: &[Vec<String>]) {
debug_assert_eq!(headers.len(), aligns.len());
let _ = out.write_str("<table class=\"hotspot\">\n<thead><tr>");
for (h, a) in headers.iter().zip(aligns) {
let numeric_attr = if a.is_numeric() {
" data-numeric=\"1\""
} else {
""
};
let _ = write!(out, "<th{numeric_attr}");
if let Some(tip) = header_tooltip(h) {
let _ = write!(out, " title=\"{}\"", escape_html(tip));
}
let _ = write!(out, ">{}</th>", escape_html(h));
}
let _ = out.write_str("</tr></thead>\n<tbody>\n");
for row in rows {
debug_assert_eq!(row.len(), headers.len());
let _ = out.write_str("<tr>");
for (cell, a) in row.iter().zip(aligns) {
let class = if a.is_numeric() {
" class=\"numeric\""
} else {
""
};
let _ = write!(out, "<td{class}>{}</td>", escape_html(cell));
}
let _ = out.write_str("</tr>\n");
}
let _ = out.write_str("</tbody>\n</table>\n");
}
#[allow(clippy::too_many_arguments)]
fn emit_hotspot(
out: &mut String,
title: &str,
base: &[&FunctionSummary],
keep: impl Fn(&FunctionSummary) -> bool,
metric: impl Fn(&FunctionSummary) -> f64,
dir: SortDir,
top_n: usize,
headers: &[&str],
aligns: &[Align],
row: impl Fn(&FunctionSummary) -> Vec<String>,
) -> bool {
let mut entries: Vec<&FunctionSummary> = base.iter().copied().filter(|s| keep(s)).collect();
if entries.is_empty() {
return false;
}
match dir {
SortDir::Asc => sort_by_metric_asc(&mut entries, &metric),
SortDir::Desc => sort_by_metric_desc(&mut entries, &metric),
}
let count = entries.len().min(top_n);
let _ = writeln!(out, "<h3>{title}</h3>");
let rows: Vec<Vec<String>> = entries[..count].iter().map(|s| row(s)).collect();
write_table(out, headers, aligns, &rows);
true
}
pub(crate) fn generate_html_report(summaries: &[FunctionSummary], top_n: usize) -> String {
let mut out = String::with_capacity(8 * 1024 + summaries.len() * 64);
let by_lang = {
let mut map = BTreeMap::<&str, Vec<&FunctionSummary>>::new();
for s in summaries {
map.entry(s.language.get_name()).or_default().push(s);
}
map
};
let (total_files, total_sloc, total_ploc, total_cloc, total_functions, total_classes) =
summaries.iter().fold(
(0usize, 0usize, 0usize, 0usize, 0usize, 0usize),
|(files, sloc, ploc, cloc, funcs, classes), s| {
(
files + usize::from(s.kind == SpaceKind::Unit),
sloc + if s.kind == SpaceKind::Unit { s.sloc } else { 0 },
ploc + if s.kind == SpaceKind::Unit { s.ploc } else { 0 },
cloc + if s.kind == SpaceKind::Unit { s.cloc } else { 0 },
funcs + usize::from(s.kind == SpaceKind::Function),
classes + usize::from(is_class_like(s.kind)),
)
},
);
let comment_ratio = if total_sloc > 0 {
(total_cloc as f64 / total_sloc as f64) * 100.0
} else {
0.0
};
let languages_list: String = by_lang
.keys()
.map(|k| title_case(k))
.collect::<Vec<_>>()
.join(", ");
let _ = out.write_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
let _ = out.write_str("<meta charset=\"utf-8\">\n");
let _ = writeln!(
out,
"<title>Code Quality Metrics Summary \u{2014} big-code-analysis</title>"
);
let _ = writeln!(out, "<style>{INLINE_CSS}</style>");
let _ = out.write_str("</head>\n<body>\n");
let _ = out.write_str("<h1>Code Quality Metrics Summary</h1>\n");
let _ = out.write_str("<div class=\"summary\">\n");
let _ = writeln!(
out,
"<p><strong>Files analyzed:</strong> {} <strong>Languages:</strong> {}</p>",
escape_html(&thousands(total_files)),
escape_html(&languages_list),
);
let _ = writeln!(
out,
"<p><strong>Total SLOC:</strong> {} <strong>PLOC:</strong> {} <strong>Comments:</strong> {}</p>",
escape_html(&thousands(total_sloc)),
escape_html(&thousands(total_ploc)),
escape_html(&thousands(total_cloc)),
);
let _ = writeln!(
out,
"<p><strong>Functions/methods:</strong> {} <strong>Classes/impls/traits:</strong> {}</p>",
escape_html(&thousands(total_functions)),
escape_html(&thousands(total_classes)),
);
let _ = writeln!(
out,
"<p><strong>Comment ratio:</strong> {comment_ratio:.1}%</p>"
);
let _ = out.write_str("</div>\n");
if !by_lang.is_empty() {
let _ = out.write_str("<h2>Per-language overview</h2>\n");
let mut overview_rows: Vec<Vec<String>> = Vec::with_capacity(by_lang.len());
for (&lang_name, lang_summaries) in &by_lang {
let (lang_unit_count, lang_sloc, mi_sum) = lang_summaries
.iter()
.filter(|s| s.kind == SpaceKind::Unit)
.fold((0usize, 0usize, 0.0f64), |(c, sl, mi), s| {
(c + 1, sl + s.sloc, mi + s.mi_visual_studio)
});
let avg_mi = if lang_unit_count > 0 {
mi_sum / lang_unit_count as f64
} else {
0.0
};
let (func_count, avg_cc, avg_cog) = {
let (count, cc_sum, cog_sum) = lang_summaries
.iter()
.filter(|s| s.kind == SpaceKind::Function)
.fold((0usize, 0.0f64, 0.0f64), |(c, cc, cog), s| {
(c + 1, cc + s.cyclomatic, cog + s.cognitive)
});
if count > 0 {
(count, cc_sum / count as f64, cog_sum / count as f64)
} else {
(0, 0.0, 0.0)
}
};
overview_rows.push(vec![
title_case(lang_name),
thousands(lang_unit_count),
thousands(lang_sloc),
thousands(func_count),
format!("{avg_mi:.1}"),
format!("{avg_cc:.1}"),
format!("{avg_cog:.1}"),
]);
}
write_table(
&mut out,
&[
"Language",
"Files",
"SLOC",
"Functions",
"Avg MI",
"Avg CC",
"Avg Cognitive",
],
&[
Align::Left,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
],
&overview_rows,
);
for (&lang_name, lang_summaries) in &by_lang {
write_language_section(&mut out, lang_name, lang_summaries, top_n);
}
}
let _ = writeln!(out, "<script>{INLINE_JS}</script>");
let _ = out.write_str("</body>\n</html>\n");
out
}
fn write_language_section(
out: &mut String,
lang_name: &str,
entries: &[&FunctionSummary],
top_n: usize,
) {
let display_name = title_case(lang_name);
let slug = language_palette_slug(lang_name);
let _ = writeln!(
out,
"<section class=\"lang-section lang-{slug}\"><h2>{}</h2>",
escape_html(&display_name)
);
let mut units: Vec<&FunctionSummary> = Vec::with_capacity(entries.len());
let mut funcs: Vec<&FunctionSummary> = Vec::with_capacity(entries.len());
for &s in entries {
match s.kind {
SpaceKind::Unit => units.push(s),
SpaceKind::Function => funcs.push(s),
_ => {}
}
}
{
let (files, sloc, ploc, cloc, mi_sum) = units.iter().fold(
(0usize, 0usize, 0usize, 0usize, 0.0f64),
|(f, sl, pl, cl, mi), s| {
(
f + 1,
sl + s.sloc,
pl + s.ploc,
cl + s.cloc,
mi + s.mi_visual_studio,
)
},
);
let cr = if sloc > 0 {
(cloc as f64 / sloc as f64) * 100.0
} else {
0.0
};
let avg_mi = if files > 0 {
mi_sum / files as f64
} else {
0.0
};
let rating = mi_rating(avg_mi);
let _ = out.write_str("<h3>Summary</h3>\n");
let _ = writeln!(
out,
"<p class=\"note\">Files: {} | SLOC: {} | PLOC: {} | Comment ratio: {cr:.1}%</p>",
escape_html(&thousands(files)),
escape_html(&thousands(sloc)),
escape_html(&thousands(ploc)),
);
let _ = writeln!(
out,
"<p class=\"note\">Average MI: {avg_mi:.1} ({rating})</p>"
);
}
emit_hotspot(
out,
&format!("Maintainability Index (lowest files, top-{top_n})"),
&units,
|s| s.mi_visual_studio > 0.0,
|s| s.mi_visual_studio,
SortDir::Asc,
top_n,
&["File", "MI", "SLOC", "Tokens"],
&[Align::Left, Align::Right, Align::Right, Align::Right],
|s| {
vec![
s.file.clone(),
format!("{:.1}", s.mi_visual_studio),
thousands(s.sloc),
thousands(s.tokens),
]
},
);
{
let (cc_sum, cc_count, max_cc, count_gt10, count_gt20) =
funcs.iter().filter(|s| s.cyclomatic > 0.0).fold(
(0.0f64, 0usize, f64::NAN, 0usize, 0usize),
|(sum, cnt, mx, g10, g20), s| {
let c = s.cyclomatic;
(
sum + c,
cnt + 1,
f64::max(mx, c),
g10 + usize::from(c > 10.0),
g20 + usize::from(c > 20.0),
)
},
);
let emitted = emit_hotspot(
out,
"Cyclomatic Complexity Hotspots",
&funcs,
|s| s.cyclomatic > 0.0,
|s| s.cyclomatic,
SortDir::Desc,
top_n,
&[
"Function",
"File",
"Line",
"CC",
"Cognitive",
"SLOC",
"Tokens",
],
&[
Align::Left,
Align::Left,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
],
|s| {
vec![
s.name.clone(),
s.file.clone(),
s.start_line.to_string(),
MetricScalar(s.cyclomatic).to_string(),
MetricScalar(s.cognitive).to_string(),
thousands(s.sloc),
thousands(s.tokens),
]
},
);
if emitted {
let avg_cc = if cc_count > 0 {
cc_sum / cc_count as f64
} else {
0.0
};
let _ = writeln!(
out,
"<p class=\"note\">Average CC: {avg_cc:.1} | Max: {max_cc:.0} | CC > 10: {count_gt10} functions | CC > 20: {count_gt20} functions</p>"
);
}
}
emit_hotspot(
out,
"Cognitive Complexity Hotspots",
&funcs,
|s| s.cognitive > 0.0,
|s| s.cognitive,
SortDir::Desc,
top_n,
&[
"Function",
"File",
"Line",
"Cognitive",
"CC",
"SLOC",
"Tokens",
],
&[
Align::Left,
Align::Left,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
],
|s| {
vec![
s.name.clone(),
s.file.clone(),
s.start_line.to_string(),
MetricScalar(s.cognitive).to_string(),
MetricScalar(s.cyclomatic).to_string(),
thousands(s.sloc),
thousands(s.tokens),
]
},
);
emit_hotspot(
out,
"Halstead Effort Hotspots",
&funcs,
|s| s.halstead_effort > 0.0,
|s| s.halstead_effort,
SortDir::Desc,
top_n,
&[
"Function",
"File",
"Effort",
"Volume",
"Est. Bugs",
"SLOC",
"Tokens",
],
&[
Align::Left,
Align::Left,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
],
|s| {
vec![
s.name.clone(),
s.file.clone(),
MetricScalar(s.halstead_effort).to_string(),
MetricScalar(s.halstead_volume).to_string(),
format!("{:.2}", s.halstead_bugs),
thousands(s.sloc),
thousands(s.tokens),
]
},
);
emit_hotspot(
out,
"Largest Functions by SLOC",
&funcs,
|s| s.sloc > 0,
|s| s.sloc as f64,
SortDir::Desc,
top_n,
&[
"Function",
"File",
"Line",
"SLOC",
"Tokens",
"CC",
"Cognitive",
],
&[
Align::Left,
Align::Left,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
],
|s| {
vec![
s.name.clone(),
s.file.clone(),
s.start_line.to_string(),
thousands(s.sloc),
thousands(s.tokens),
MetricScalar(s.cyclomatic).to_string(),
MetricScalar(s.cognitive).to_string(),
]
},
);
emit_hotspot(
out,
"Functions With Many Parameters (>3)",
&funcs,
|s| s.nargs > 3,
|s| s.nargs as f64,
SortDir::Desc,
top_n,
&["Function", "File", "Args", "SLOC", "Tokens"],
&[
Align::Left,
Align::Left,
Align::Right,
Align::Right,
Align::Right,
],
|s| {
vec![
s.name.clone(),
s.file.clone(),
s.nargs.to_string(),
thousands(s.sloc),
thousands(s.tokens),
]
},
);
{
let (cc_gt10, cog_gt15, sloc_gt100, nargs_gt3, bugs_gt1) = funcs.iter().fold(
(0usize, 0usize, 0usize, 0usize, 0usize),
|(a, b, c, d, e), s| {
(
a + usize::from(s.cyclomatic > 10.0),
b + usize::from(s.cognitive > 15.0),
c + usize::from(s.sloc > 100),
d + usize::from(s.nargs > 3),
e + usize::from(s.halstead_bugs > 1.0),
)
},
);
let _ = out.write_str("<h3>Actionable Summary</h3>\n");
if cc_gt10 == 0 && cog_gt15 == 0 && sloc_gt100 == 0 && nargs_gt3 == 0 && bugs_gt1 == 0 {
let _ = out.write_str("<p class=\"note\">No major quality concerns detected.</p>\n");
} else {
let _ = out.write_str("<ul>\n");
if cc_gt10 > 0 {
let _ = writeln!(
out,
"<li><strong>{cc_gt10}</strong> functions with CC > 10</li>"
);
}
if cog_gt15 > 0 {
let _ = writeln!(
out,
"<li><strong>{cog_gt15}</strong> functions with cognitive complexity > 15</li>"
);
}
if sloc_gt100 > 0 {
let _ = writeln!(
out,
"<li><strong>{sloc_gt100}</strong> functions with SLOC > 100</li>"
);
}
if nargs_gt3 > 0 {
let _ = writeln!(
out,
"<li><strong>{nargs_gt3}</strong> functions with more than 3 parameters</li>"
);
}
if bugs_gt1 > 0 {
let _ = writeln!(
out,
"<li><strong>{bugs_gt1}</strong> functions with estimated Halstead bugs > 1.0</li>"
);
}
let _ = out.write_str("</ul>\n");
}
}
emit_hotspot(
out,
"Class/Trait/Impl Hotspots (WMC)",
entries,
|s| is_class_like(s.kind) && s.wmc > 0.0,
|s| s.wmc,
SortDir::Desc,
top_n,
&[
"Class", "File", "Line", "WMC", "Methods", "NPA", "NPM", "SLOC", "Tokens",
],
&[
Align::Left,
Align::Left,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
],
|s| {
vec![
s.name.clone(),
s.file.clone(),
s.start_line.to_string(),
MetricScalar(s.wmc).to_string(),
s.nom.to_string(),
MetricScalar(s.npa).to_string(),
MetricScalar(s.npm).to_string(),
thousands(s.sloc),
thousands(s.tokens),
]
},
);
emit_hotspot(
out,
"Functions with the most exit points (NEXITS)",
&funcs,
|s| s.nexits > 0,
|s| s.nexits as f64,
SortDir::Desc,
top_n,
&["Function", "File", "Line", "Exits", "CC", "SLOC", "Tokens"],
&[
Align::Left,
Align::Left,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
],
|s| {
vec![
s.name.clone(),
s.file.clone(),
s.start_line.to_string(),
s.nexits.to_string(),
MetricScalar(s.cyclomatic).to_string(),
thousands(s.sloc),
thousands(s.tokens),
]
},
);
emit_hotspot(
out,
"ABC Magnitude Hotspots",
&funcs,
|s| s.abc > 0.0,
|s| s.abc,
SortDir::Desc,
top_n,
&["Function", "File", "Line", "ABC", "SLOC", "Tokens"],
&[
Align::Left,
Align::Left,
Align::Right,
Align::Right,
Align::Right,
Align::Right,
],
|s| {
vec![
s.name.clone(),
s.file.clone(),
s.start_line.to_string(),
format!("{:.1}", s.abc),
thousands(s.sloc),
thousands(s.tokens),
]
},
);
let _ = out.write_str("</section>\n");
}
#[cfg(test)]
#[path = "../tests/common/validators.rs"]
#[allow(dead_code)]
mod validators_for_tests;
#[cfg(test)]
#[allow(
clippy::float_cmp,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::similar_names,
clippy::doc_markdown,
clippy::needless_raw_string_hashes,
clippy::too_many_lines
)]
mod tests {
use super::validators_for_tests::assert_html_well_formed;
use super::*;
use big_code_analysis::LANG;
fn make_summary(name: &str, file: &str, kind: SpaceKind, language: LANG) -> FunctionSummary {
FunctionSummary {
file: file.to_string(),
name: name.to_string(),
kind,
language,
start_line: 1,
end_line: 10,
sloc: 20,
ploc: 25,
lloc: 15,
cloc: 5,
tokens: 30,
cyclomatic: 3.0,
cognitive: 2.0,
halstead_volume: 100.0,
halstead_difficulty: 5.0,
halstead_effort: 500.0,
halstead_bugs: 0.1,
halstead_time: 28.0,
mi_original: 80.0,
mi_sei: 85.0,
mi_visual_studio: 50.0,
nargs: 2,
nexits: 1,
nom: 1,
abc: 5.0,
wmc: 3.0,
npa: 0.0,
npm: 0.0,
}
}
fn rust_fixture() -> Vec<FunctionSummary> {
vec![
make_summary("lib.rs", "src/lib.rs", SpaceKind::Unit, LANG::Rust),
make_summary("do_stuff", "src/lib.rs", SpaceKind::Function, LANG::Rust),
make_summary("compute", "src/lib.rs", SpaceKind::Function, LANG::Rust),
]
}
fn two_lang_fixture() -> Vec<FunctionSummary> {
let mut v = rust_fixture();
v.push(make_summary(
"main.py",
"src/main.py",
SpaceKind::Unit,
LANG::Python,
));
v.push(make_summary(
"greet",
"src/main.py",
SpaceKind::Function,
LANG::Python,
));
v
}
#[test]
fn escape_html_passthrough() {
let s = "plain text with no entities";
assert!(matches!(escape_html(s), Cow::Borrowed(b) if b == s));
}
#[test]
fn escape_html_replaces_all_metacharacters() {
let escaped = escape_html("a&b<c>d\"e'f");
assert_eq!(escaped, "a&b<c>d"e'f");
}
#[test]
fn empty_summaries_emit_no_tables() {
let out = generate_html_report(&[], 20);
assert!(out.contains("<h1>Code Quality Metrics Summary</h1>"));
assert!(!out.contains("<table"));
assert_html_well_formed(&out);
}
#[test]
fn js_handler_binds_all_hotspot_tables() {
let out = generate_html_report(&[], 20);
assert!(
out.contains("document.querySelectorAll('table.hotspot')"),
"JS sort handler must bind to every hotspot table by class, not by id"
);
}
#[test]
fn js_numeric_sort_strips_thousands_separators() {
assert!(
INLINE_JS.contains("replace(/,/g,'')"),
"JS comparator must strip thousands separators before parseFloat"
);
let mut summaries = vec![make_summary(
"lib.rs",
"src/lib.rs",
SpaceKind::Unit,
LANG::Rust,
)];
for i in 0..3 {
let mut s = make_summary(
&format!("fn_{i}"),
"src/lib.rs",
SpaceKind::Function,
LANG::Rust,
);
s.sloc = 10_000 * (i + 1);
s.tokens = 1_500_000 * (i + 1);
summaries.push(s);
}
let out = generate_html_report(&summaries, 5);
assert!(
out.contains(">10,000<") && out.contains(">1,500,000<"),
"expected thousands-formatted cells in output"
);
}
#[test]
fn single_language_well_formed() {
let out = generate_html_report(&rust_fixture(), 20);
assert!(out.contains("<h2>Rust</h2>"));
assert!(out.contains("class=\"hotspot\""));
assert_html_well_formed(&out);
}
#[test]
fn two_language_well_formed_and_alphabetical() {
let out = generate_html_report(&two_lang_fixture(), 20);
assert!(out.contains("<h2>Python</h2>"));
assert!(out.contains("<h2>Rust</h2>"));
let py = out.find("<h2>Python</h2>").expect("python heading");
let rs = out.find("<h2>Rust</h2>").expect("rust heading");
assert!(
py < rs,
"language sections must be alphabetical: python at {py}, rust at {rs}"
);
assert_html_well_formed(&out);
}
#[test]
fn xss_payload_is_escaped() {
let mut summaries = rust_fixture();
summaries[1].name = "<script>alert(1)</script>".to_string();
summaries[1].file = "a&b\"c'd<e>".to_string();
let out = generate_html_report(&summaries, 20);
assert!(
!out.contains("<script>alert(1)"),
"raw <script> payload must not appear in output"
);
assert!(out.contains("<script>"), "< must escape to <");
assert!(out.contains("&"), "& must escape to &");
assert!(out.contains("""), "\" must escape to "");
assert!(out.contains("'"), "' must escape to '");
assert_html_well_formed(&out);
}
#[test]
fn top_n_truncates_hotspot_rows() {
let mut summaries = vec![make_summary(
"lib.rs",
"src/lib.rs",
SpaceKind::Unit,
LANG::Rust,
)];
for i in 0..30 {
let mut s = make_summary(
&format!("fn_{i:02}"),
"src/lib.rs",
SpaceKind::Function,
LANG::Rust,
);
s.cyclomatic = (i + 1) as f64;
s.start_line = 100 + i;
summaries.push(s);
}
let out = generate_html_report(&summaries, 5);
let cc_section = out
.split_once("<h3>Cyclomatic Complexity Hotspots</h3>")
.expect("cyclomatic section present")
.1;
let cc_table = cc_section.split_once("</table>").expect("table closes").0;
let row_count = cc_table.matches("<tr>").count();
assert_eq!(
row_count, 6,
"expected 5 body rows + 1 header, got {row_count}"
);
assert_html_well_formed(&out);
}
#[test]
fn output_is_byte_deterministic() {
let s = two_lang_fixture();
let a = generate_html_report(&s, 20);
let b = generate_html_report(&s, 20);
assert_eq!(a, b, "renderer must be byte-deterministic across runs");
}
#[test]
fn nan_metric_input_does_not_crash_renderer() {
let mut summaries = rust_fixture();
summaries[1].cyclomatic = f64::NAN;
summaries[2].cyclomatic = 5.0;
let out = generate_html_report(&summaries, 20);
assert_html_well_formed(&out);
}
#[test]
fn sort_by_metric_desc_handles_nan() {
let a = make_summary("a", "f.rs", SpaceKind::Function, LANG::Rust);
let b = make_summary("b", "f.rs", SpaceKind::Function, LANG::Rust);
let c = make_summary("c", "f.rs", SpaceKind::Function, LANG::Rust);
let mut entries: Vec<&FunctionSummary> = vec![&a, &b, &c];
sort_by_metric_desc(&mut entries, |s| match s.name.as_str() {
"a" => f64::NAN,
"b" => 1.0,
_ => 5.0,
});
assert_eq!(entries.len(), 3);
}
#[test]
fn metric_headers_carry_tooltips() {
let mut summaries = rust_fixture();
summaries.push(make_summary(
"Widget",
"src/lib.rs",
SpaceKind::Class,
LANG::Rust,
));
summaries[1].nargs = 5;
let out = generate_html_report(&summaries, 20);
for &(header, tip) in HEADER_TOOLTIPS {
let needle = format!(" title=\"{}\">{header}</th>", escape_html(tip));
assert!(
out.contains(&needle),
"header {header:?} should render with title attribute; expected substring {needle:?}"
);
}
for plain in ["File", "Function", "Class", "Line", "Language"] {
assert!(
header_tooltip(plain).is_none(),
"header {plain:?} should not carry a tooltip"
);
let needle = format!(">{plain}</th>");
assert!(
out.contains(&needle),
"expected bare <th>{plain}</th> in output"
);
}
}
#[test]
fn language_palette_slug_known_and_fallback() {
assert_eq!(language_palette_slug("rust"), "rust");
assert_eq!(language_palette_slug("python"), "python");
assert_eq!(language_palette_slug("c/c++"), "cpp");
assert_eq!(language_palette_slug("c#"), "csharp");
assert_eq!(language_palette_slug("typescript"), "typescript");
assert_eq!(language_palette_slug("javascript"), "javascript");
assert_eq!(language_palette_slug("ruby"), "ruby");
assert_eq!(language_palette_slug("elixir"), "elixir");
assert_eq!(language_palette_slug("ccomment"), "other");
assert_eq!(language_palette_slug("preproc"), "other");
assert_eq!(language_palette_slug("tsx"), "other");
assert_eq!(language_palette_slug(""), "other");
}
#[test]
fn language_palette_classes_have_css() {
let dark_block = INLINE_CSS
.split_once("@media (prefers-color-scheme:dark){")
.expect("dark-mode adapter present")
.1;
for slug in LANGUAGE_PALETTE
.iter()
.map(|&(_, slug)| slug)
.chain(std::iter::once("other"))
{
let light = format!("section.lang-{slug}{{background:");
assert!(
INLINE_CSS.contains(&light),
"missing light-mode CSS rule for slug {slug:?}: expected substring {light:?}"
);
assert!(
dark_block.contains(&light),
"missing dark-mode override for slug {slug:?}: expected substring {light:?} inside @media block"
);
}
}
#[test]
fn tsx_section_uses_typescript_palette() {
let entries = vec![
make_summary("App.tsx", "src/App.tsx", SpaceKind::Unit, LANG::Tsx),
make_summary("render", "src/App.tsx", SpaceKind::Function, LANG::Tsx),
];
let out = generate_html_report(&entries, 5);
assert!(
out.contains("<section class=\"lang-section lang-typescript\">"),
"Tsx must reuse the typescript palette class"
);
assert!(!out.contains("lang-tsx"));
assert!(!out.contains("lang-section lang-other"));
}
#[test]
fn per_language_sections_carry_palette_class() {
let out = generate_html_report(&two_lang_fixture(), 5);
assert!(
out.contains("<section class=\"lang-section lang-rust\"><h2>Rust</h2>"),
"Rust section must carry stable lang-rust palette class"
);
assert!(
out.contains("<section class=\"lang-section lang-python\"><h2>Python</h2>"),
"Python section must carry stable lang-python palette class"
);
assert!(out.contains("section.lang-rust{background:"));
assert!(out.contains("section.lang-python{background:"));
assert!(out.contains("@media (prefers-color-scheme:dark)"));
}
#[test]
fn unknown_language_falls_back_to_lang_other() {
let slug = language_palette_slug("zig");
assert_eq!(slug, "other");
assert!(INLINE_CSS.contains("section.lang-other{background:"));
}
#[test]
fn overview_table_and_actionable_summary_not_tinted() {
let out = generate_html_report(&two_lang_fixture(), 5);
let overview = out
.find("<h2>Per-language overview</h2>")
.expect("overview heading present");
let overview_table = overview
+ out[overview..]
.find("<table")
.expect("overview table present");
let overview_end = overview_table
+ out[overview_table..]
.find("</table>")
.expect("overview table closes")
+ "</table>".len();
assert!(
!out[..overview_end].contains("<section class=\"lang-section"),
"overview region must not be wrapped in a per-language tinted section"
);
assert!(!out.contains("lang-section lang-other"));
}
#[test]
fn snapshot_two_lang_report() {
let out = generate_html_report(&two_lang_fixture(), 5);
insta::assert_snapshot!("html_report_two_lang", out);
}
}