deno/cli/tools/coverage/reporter.rs
David Sherret 47f7bed677
chore: enable clippy::print_stdout and clippy::print_stderr (#23732)
1. Generally we should prefer to use the `log` crate.
2. I very often accidentally commit `eprintln`s.

When we should use `println` or `eprintln`, it's not too bad to be a bit
more verbose and ignore the lint rule.
2024-05-08 22:45:06 -04:00

680 lines
20 KiB
Rust

// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
use super::util;
use super::CoverageReport;
use crate::args::CoverageType;
use crate::colors;
use deno_core::error::AnyError;
use deno_core::url::Url;
use std::collections::HashMap;
use std::fs;
use std::fs::File;
use std::io::Error;
use std::io::Write;
use std::io::{self};
use std::path::Path;
use std::path::PathBuf;
#[derive(Default)]
pub struct CoverageStats<'a> {
pub line_hit: usize,
pub line_miss: usize,
pub branch_hit: usize,
pub branch_miss: usize,
pub parent: Option<String>,
pub file_text: Option<String>,
pub report: Option<&'a CoverageReport>,
}
type CoverageSummary<'a> = HashMap<String, CoverageStats<'a>>;
pub fn create(kind: CoverageType) -> Box<dyn CoverageReporter + Send> {
match kind {
CoverageType::Summary => Box::new(SummaryCoverageReporter::new()),
CoverageType::Lcov => Box::new(LcovCoverageReporter::new()),
CoverageType::Detailed => Box::new(DetailedCoverageReporter::new()),
CoverageType::Html => Box::new(HtmlCoverageReporter::new()),
}
}
pub trait CoverageReporter {
fn report(
&mut self,
coverage_report: &CoverageReport,
file_text: &str,
) -> Result<(), AnyError>;
fn done(&mut self, _coverage_root: &Path) {}
/// Collects the coverage summary of each file or directory.
fn collect_summary<'a>(
&'a self,
file_reports: &'a Vec<(CoverageReport, String)>,
) -> CoverageSummary {
let urls = file_reports.iter().map(|rep| &rep.0.url).collect();
let root = util::find_root(urls).unwrap().to_file_path().unwrap();
// summary by file or directory
// tuple of (line hit, line miss, branch hit, branch miss, parent)
let mut summary = HashMap::new();
summary.insert("".to_string(), CoverageStats::default()); // root entry
for (report, file_text) in file_reports {
let path = report.url.to_file_path().unwrap();
let relative_path = path.strip_prefix(&root).unwrap();
let mut file_text = Some(file_text.to_string());
let mut summary_path = Some(relative_path);
// From leaf to root, adds up the coverage stats
while let Some(path) = summary_path {
let path_str = path.to_str().unwrap().to_string();
let parent = path
.parent()
.and_then(|p| p.to_str())
.map(|p| p.to_string());
let stats = summary.entry(path_str).or_insert(CoverageStats {
parent,
file_text,
report: Some(report),
..CoverageStats::default()
});
stats.line_hit += report
.found_lines
.iter()
.filter(|(_, count)| *count > 0)
.count();
stats.line_miss += report
.found_lines
.iter()
.filter(|(_, count)| *count == 0)
.count();
stats.branch_hit += report.branches.iter().filter(|b| b.is_hit).count();
stats.branch_miss +=
report.branches.iter().filter(|b| !b.is_hit).count();
file_text = None;
summary_path = path.parent();
}
}
summary
}
}
struct SummaryCoverageReporter {
file_reports: Vec<(CoverageReport, String)>,
}
#[allow(clippy::print_stdout)]
impl SummaryCoverageReporter {
pub fn new() -> SummaryCoverageReporter {
SummaryCoverageReporter {
file_reports: Vec::new(),
}
}
fn print_coverage_line(
&self,
node: &str,
node_max: usize,
stats: &CoverageStats,
) {
let CoverageStats {
line_hit,
line_miss,
branch_hit,
branch_miss,
..
} = stats;
let (_, line_percent, line_class) =
util::calc_coverage_display_info(*line_hit, *line_miss);
let (_, branch_percent, branch_class) =
util::calc_coverage_display_info(*branch_hit, *branch_miss);
let file_name = format!(
"{node:node_max$}",
node = node.replace('\\', "/"),
node_max = node_max
);
let file_name = if line_class == "high" {
format!("{}", colors::green(&file_name))
} else if line_class == "medium" {
format!("{}", colors::yellow(&file_name))
} else {
format!("{}", colors::red(&file_name))
};
let branch_percent = if branch_class == "high" {
format!("{}", colors::green(&format!("{:>8.1}", branch_percent)))
} else if branch_class == "medium" {
format!("{}", colors::yellow(&format!("{:>8.1}", branch_percent)))
} else {
format!("{}", colors::red(&format!("{:>8.1}", branch_percent)))
};
let line_percent = if line_class == "high" {
format!("{}", colors::green(&format!("{:>6.1}", line_percent)))
} else if line_class == "medium" {
format!("{}", colors::yellow(&format!("{:>6.1}", line_percent)))
} else {
format!("{}", colors::red(&format!("{:>6.1}", line_percent)))
};
println!(
" {file_name} | {branch_percent} | {line_percent} |",
file_name = file_name,
branch_percent = branch_percent,
line_percent = line_percent,
);
}
}
#[allow(clippy::print_stdout)]
impl CoverageReporter for SummaryCoverageReporter {
fn report(
&mut self,
coverage_report: &CoverageReport,
file_text: &str,
) -> Result<(), AnyError> {
self
.file_reports
.push((coverage_report.clone(), file_text.to_string()));
Ok(())
}
fn done(&mut self, _coverage_root: &Path) {
let summary = self.collect_summary(&self.file_reports);
let root_stats = summary.get("").unwrap();
let mut entries = summary
.iter()
.filter(|(_, stats)| stats.file_text.is_some())
.collect::<Vec<_>>();
entries.sort_by_key(|(node, _)| node.to_owned());
let node_max = entries.iter().map(|(node, _)| node.len()).max().unwrap();
let header =
format!("{node:node_max$} | Branch % | Line % |", node = "File");
let separator = "-".repeat(header.len());
println!("{}", separator);
println!("{}", header);
println!("{}", separator);
entries.iter().for_each(|(node, stats)| {
self.print_coverage_line(node, node_max, stats);
});
println!("{}", separator);
self.print_coverage_line("All files", node_max, root_stats);
println!("{}", separator);
}
}
struct LcovCoverageReporter {}
impl LcovCoverageReporter {
pub fn new() -> LcovCoverageReporter {
LcovCoverageReporter {}
}
}
impl CoverageReporter for LcovCoverageReporter {
fn report(
&mut self,
coverage_report: &CoverageReport,
_file_text: &str,
) -> Result<(), AnyError> {
// pipes output to stdout if no file is specified
let out_mode: Result<Box<dyn Write>, Error> = match coverage_report.output {
// only append to the file as the file should be created already
Some(ref path) => File::options()
.append(true)
.open(path)
.map(|f| Box::new(f) as Box<dyn Write>),
None => Ok(Box::new(io::stdout())),
};
let mut out_writer = out_mode?;
let file_path = coverage_report
.url
.to_file_path()
.ok()
.and_then(|p| p.to_str().map(|p| p.to_string()))
.unwrap_or_else(|| coverage_report.url.to_string());
writeln!(out_writer, "SF:{file_path}")?;
for function in &coverage_report.named_functions {
writeln!(
out_writer,
"FN:{},{}",
function.line_index + 1,
function.name
)?;
}
for function in &coverage_report.named_functions {
writeln!(
out_writer,
"FNDA:{},{}",
function.execution_count, function.name
)?;
}
let functions_found = coverage_report.named_functions.len();
writeln!(out_writer, "FNF:{functions_found}")?;
let functions_hit = coverage_report
.named_functions
.iter()
.filter(|f| f.execution_count > 0)
.count();
writeln!(out_writer, "FNH:{functions_hit}")?;
for branch in &coverage_report.branches {
let taken = if let Some(taken) = &branch.taken {
taken.to_string()
} else {
"-".to_string()
};
writeln!(
out_writer,
"BRDA:{},{},{},{}",
branch.line_index + 1,
branch.block_number,
branch.branch_number,
taken
)?;
}
let branches_found = coverage_report.branches.len();
writeln!(out_writer, "BRF:{branches_found}")?;
let branches_hit =
coverage_report.branches.iter().filter(|b| b.is_hit).count();
writeln!(out_writer, "BRH:{branches_hit}")?;
for (index, count) in &coverage_report.found_lines {
writeln!(out_writer, "DA:{},{}", index + 1, count)?;
}
let lines_hit = coverage_report
.found_lines
.iter()
.filter(|(_, count)| *count != 0)
.count();
writeln!(out_writer, "LH:{lines_hit}")?;
let lines_found = coverage_report.found_lines.len();
writeln!(out_writer, "LF:{lines_found}")?;
writeln!(out_writer, "end_of_record")?;
Ok(())
}
}
struct DetailedCoverageReporter {}
impl DetailedCoverageReporter {
pub fn new() -> DetailedCoverageReporter {
DetailedCoverageReporter {}
}
}
#[allow(clippy::print_stdout)]
impl CoverageReporter for DetailedCoverageReporter {
fn report(
&mut self,
coverage_report: &CoverageReport,
file_text: &str,
) -> Result<(), AnyError> {
let lines = file_text.split('\n').collect::<Vec<_>>();
print!("cover {} ... ", coverage_report.url);
let hit_lines = coverage_report
.found_lines
.iter()
.filter(|(_, count)| *count > 0)
.map(|(index, _)| *index);
let missed_lines = coverage_report
.found_lines
.iter()
.filter(|(_, count)| *count == 0)
.map(|(index, _)| *index);
let lines_found = coverage_report.found_lines.len();
let lines_hit = hit_lines.count();
let line_ratio = lines_hit as f32 / lines_found as f32;
let line_coverage =
format!("{:.3}% ({}/{})", line_ratio * 100.0, lines_hit, lines_found);
if line_ratio >= 0.9 {
println!("{}", colors::green(&line_coverage));
} else if line_ratio >= 0.75 {
println!("{}", colors::yellow(&line_coverage));
} else {
println!("{}", colors::red(&line_coverage));
}
let mut last_line = None;
for line_index in missed_lines {
const WIDTH: usize = 4;
const SEPARATOR: &str = "|";
// Put a horizontal separator between disjoint runs of lines
if let Some(last_line) = last_line {
if last_line + 1 != line_index {
let dash = colors::gray("-".repeat(WIDTH + 1));
println!("{}{}{}", dash, colors::gray(SEPARATOR), dash);
}
}
println!(
"{:width$} {} {}",
line_index + 1,
colors::gray(SEPARATOR),
colors::red(&lines[line_index]),
width = WIDTH
);
last_line = Some(line_index);
}
Ok(())
}
}
struct HtmlCoverageReporter {
file_reports: Vec<(CoverageReport, String)>,
}
impl CoverageReporter for HtmlCoverageReporter {
fn report(
&mut self,
report: &CoverageReport,
text: &str,
) -> Result<(), AnyError> {
self.file_reports.push((report.clone(), text.to_string()));
Ok(())
}
fn done(&mut self, coverage_root: &Path) {
let summary = self.collect_summary(&self.file_reports);
let now = crate::util::time::utc_now().to_rfc2822();
for (node, stats) in &summary {
let report_path =
self.get_report_path(coverage_root, node, stats.file_text.is_none());
let main_content = if let Some(file_text) = &stats.file_text {
self.create_html_code_table(file_text, stats.report.unwrap())
} else {
self.create_html_summary_table(node, &summary)
};
let is_dir = stats.file_text.is_none();
let html = self.create_html(node, is_dir, stats, &now, &main_content);
fs::create_dir_all(report_path.parent().unwrap()).unwrap();
fs::write(report_path, html).unwrap();
}
let root_report = Url::from_file_path(
coverage_root
.join("html")
.join("index.html")
.canonicalize()
.unwrap(),
)
.unwrap();
log::info!("HTML coverage report has been generated at {}", root_report);
}
}
impl HtmlCoverageReporter {
pub fn new() -> HtmlCoverageReporter {
HtmlCoverageReporter {
file_reports: Vec::new(),
}
}
/// Gets the report path for a single file
pub fn get_report_path(
&self,
coverage_root: &Path,
node: &str,
is_dir: bool,
) -> PathBuf {
if is_dir {
// e.g. /path/to/coverage/html/src/index.html
coverage_root.join("html").join(node).join("index.html")
} else {
// e.g. /path/to/coverage/html/src/main.ts.html
Path::new(&format!(
"{}.html",
coverage_root.join("html").join(node).to_str().unwrap()
))
.to_path_buf()
}
}
/// Creates single page of html report.
pub fn create_html(
&self,
node: &str,
is_dir: bool,
stats: &CoverageStats,
timestamp: &str,
main_content: &str,
) -> String {
let title = if node.is_empty() {
"Coverage report for all files".to_string()
} else {
let node = if is_dir {
format!("{}/", node)
} else {
node.to_string()
};
format!("Coverage report for {node}")
};
let title = title.replace(std::path::MAIN_SEPARATOR, "/");
let head = self.create_html_head(&title);
let header = self.create_html_header(&title, stats);
let footer = self.create_html_footer(timestamp);
format!(
"<!doctype html>
<html>
{head}
<body>
<div class='wrapper'>
{header}
<div class='pad1'>
{main_content}
</div>
<div class='push'></div>
</div>
{footer}
</body>
</html>"
)
}
/// Creates <head> tag for html report.
pub fn create_html_head(&self, title: &str) -> String {
let style_css = include_str!("style.css");
format!(
"
<head>
<meta charset='utf-8'>
<title>{title}</title>
<style>{style_css}</style>
<meta name='viewport' content='width=device-width, initial-scale=1' />
</head>"
)
}
/// Creates header part of the contents for html report.
pub fn create_html_header(
&self,
title: &str,
stats: &CoverageStats,
) -> String {
let CoverageStats {
line_hit,
line_miss,
branch_hit,
branch_miss,
..
} = stats;
let (line_total, line_percent, line_class) =
util::calc_coverage_display_info(*line_hit, *line_miss);
let (branch_total, branch_percent, _) =
util::calc_coverage_display_info(*branch_hit, *branch_miss);
format!(
"
<div class='pad1'>
<h1>{title}</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class='strong'>{branch_percent:.2}%</span>
<span class='quiet'>Branches</span>
<span class='fraction'>{branch_hit}/{branch_total}</span>
</div>
<div class='fl pad1y space-right2'>
<span class='strong'>{line_percent:.2}%</span>
<span class='quiet'>Lines</span>
<span class='fraction'>{line_hit}/{line_total}</span>
</div>
</div>
</div>
<div class='status-line {line_class}'></div>"
)
}
/// Creates footer part of the contents for html report.
pub fn create_html_footer(&self, now: &str) -> String {
let version = env!("CARGO_PKG_VERSION");
format!(
"
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href='https://deno.com/' target='_blank'>Deno v{version}</a>
at {now}
</div>"
)
}
/// Creates <table> of summary for html report.
pub fn create_html_summary_table(
&self,
node: &String,
summary: &CoverageSummary,
) -> String {
let mut children = summary
.iter()
.filter(|(_, stats)| stats.parent.as_ref() == Some(node))
.map(|(k, stats)| (stats.file_text.is_some(), k.clone()))
.collect::<Vec<_>>();
// Sort directories first, then files
children.sort();
let table_rows: Vec<String> = children.iter().map(|(is_file, c)| {
let CoverageStats { line_hit, line_miss, branch_hit, branch_miss, .. } =
summary.get(c).unwrap();
let (line_total, line_percent, line_class) =
util::calc_coverage_display_info(*line_hit, *line_miss);
let (branch_total, branch_percent, branch_class) =
util::calc_coverage_display_info(*branch_hit, *branch_miss);
let path = Path::new(c.strip_prefix(&format!("{node}{}", std::path::MAIN_SEPARATOR)).unwrap_or(c)).to_str().unwrap();
let path = path.replace(std::path::MAIN_SEPARATOR, "/");
let path_label = if *is_file { path.to_string() } else { format!("{}/", path) };
let path_link = if *is_file { format!("{}.html", path) } else { format!("{}index.html", path_label) };
format!("
<tr>
<td class='file {line_class}'><a href='{path_link}'>{path_label}</a></td>
<td class='pic {line_class}'>
<div class='chart'>
<div class='cover-fill' style='width: {line_percent:.1}%'></div><div class='cover-empty' style='width: calc(100% - {line_percent:.1}%)'></div>
</div>
</td>
<td class='pct {branch_class}'>{branch_percent:.2}%</td>
<td class='abs {branch_class}'>{branch_hit}/{branch_total}</td>
<td class='pct {line_class}'>{line_percent:.2}%</td>
<td class='abs {line_class}'>{line_hit}/{line_total}</td>
</tr>")}).collect();
let table_rows = table_rows.join("\n");
format!(
"
<table class='coverage-summary'>
<thead>
<tr>
<th class='file'>File</th>
<th class='pic'></th>
<th class='pct'>Branches</th>
<th class='abs'></th>
<th class='pct'>Lines</th>
<th class='abs'></th>
</tr>
</thead>
<tbody>
{table_rows}
</tbody>
</table>"
)
}
/// Creates <table> of single file code coverage.
pub fn create_html_code_table(
&self,
file_text: &str,
report: &CoverageReport,
) -> String {
let line_num = file_text.lines().count();
let line_count = (1..line_num + 1)
.map(|i| format!("<a name='L{i}'></a><a href='#{i}'>{i}</a>"))
.collect::<Vec<_>>()
.join("\n");
let line_coverage = (0..line_num)
.map(|i| {
if let Some((_, count)) =
report.found_lines.iter().find(|(line, _)| i == *line)
{
if *count == 0 {
"<span class='cline-any cline-no'>&nbsp</span>".to_string()
} else {
format!("<span class='cline-any cline-yes'>x{count}</span>")
}
} else {
"<span class='cline-any cline-neutral'>&nbsp</span>".to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let branch_coverage = (0..line_num)
.map(|i| {
let branch_is_missed = report.branches.iter().any(|b| b.line_index == i && !b.is_hit);
if branch_is_missed {
"<span class='missing-if-branch' title='branch condition is missed in this line'>I</span>".to_string()
} else {
"".to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let file_text = file_text
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
// TODO(kt3k): Add syntax highlight to source code
format!(
"<table class='coverage'>
<tr>
<td class='line-count quiet'><pre>{line_count}</pre></td>
<td class='line-coverage quiet'><pre>{line_coverage}</pre></td>
<td class='branch-coverage quiet'><pre>{branch_coverage}</pre></td>
<td class='text'><pre class='prettyprint'>{file_text}</pre></td>
</tr>
</table>"
)
}
}