diff options
author | Yoshiya Hinosawa <stibium121@gmail.com> | 2023-12-11 13:30:38 +0900 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-12-11 13:30:38 +0900 |
commit | 67eec263086056713cefce61ed775d126beb5390 (patch) | |
tree | cb4b105b0ab051b3331f48a6d5572d5b00a259b9 /cli/tools/coverage | |
parent | 393abed3873d83019feb5bcebb10a6929133862a (diff) |
refactor(coverage): separate reporter-related structs (#21528)
Diffstat (limited to 'cli/tools/coverage')
-rw-r--r-- | cli/tools/coverage/mod.rs | 573 | ||||
-rw-r--r-- | cli/tools/coverage/reporter.rs | 563 |
2 files changed, 566 insertions, 570 deletions
diff --git a/cli/tools/coverage/mod.rs b/cli/tools/coverage/mod.rs index 79899ddd8..28ecc100e 100644 --- a/cli/tools/coverage/mod.rs +++ b/cli/tools/coverage/mod.rs @@ -1,11 +1,9 @@ // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. use crate::args::CoverageFlags; -use crate::args::CoverageType; use crate::args::FileFlags; use crate::args::Flags; use crate::cdp; -use crate::colors; use crate::factory::CliFactory; use crate::npm::CliNpmResolver; use crate::tools::fmt::format_json; @@ -25,13 +23,10 @@ use deno_core::url::Url; use deno_core::LocalInspectorSession; use deno_core::ModuleCode; use regex::Regex; -use std::collections::HashMap; use std::fs; use std::fs::File; use std::io::BufWriter; -use std::io::Error; use std::io::Write; -use std::io::{self}; use std::path::Path; use std::path::PathBuf; use text_lines::TextLines; @@ -39,6 +34,7 @@ use uuid::Uuid; mod merge; mod range_tree; +mod reporter; mod util; use merge::ProcessCoverage; @@ -176,7 +172,7 @@ struct FunctionCoverageItem { } #[derive(Debug, Clone)] -struct CoverageReport { +pub struct CoverageReport { url: ModuleSpecifier, named_functions: Vec<FunctionCoverageItem>, branches: Vec<BranchCoverageItem>, @@ -185,19 +181,6 @@ struct CoverageReport { output: Option<PathBuf>, } -#[derive(Default)] -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>>; - fn generate_coverage_report( script_coverage: &cdp::ScriptCoverage, script_source: String, @@ -388,550 +371,6 @@ fn generate_coverage_report( coverage_report } -enum CoverageReporterKind { - Pretty, - Lcov, - Html, -} - -fn create_reporter( - kind: CoverageReporterKind, -) -> Box<dyn CoverageReporter + Send> { - match kind { - CoverageReporterKind::Lcov => Box::new(LcovCoverageReporter::new()), - CoverageReporterKind::Pretty => Box::new(PrettyCoverageReporter::new()), - CoverageReporterKind::Html => Box::new(HtmlCoverageReporter::new()), - } -} - -trait CoverageReporter { - fn report( - &mut self, - coverage_report: &CoverageReport, - file_text: &str, - ) -> Result<(), AnyError>; - - fn done(&mut self, _coverage_root: &Path) {} -} - -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 PrettyCoverageReporter {} - -impl PrettyCoverageReporter { - pub fn new() -> PrettyCoverageReporter { - PrettyCoverageReporter {} - } -} - -impl CoverageReporter for PrettyCoverageReporter { - 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 HtmlCoverageReporter { - pub fn new() -> HtmlCoverageReporter { - HtmlCoverageReporter { - file_reports: Vec::new(), - } - } -} - -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(); - 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(); - - println!("HTML coverage report has been generated at {}", root_report); - } -} - -impl HtmlCoverageReporter { - /// Collects the coverage summary of each file or directory. - pub fn collect_summary(&self) -> CoverageSummary { - let urls = self.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 &self.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 - } - - /// 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: &String, - 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'> </span>".to_string() - } else { - format!("<span class='cline-any cline-yes'>x{count}</span>") - } - } else { - "<span class='cline-any cline-neutral'> </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"); - - // 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>" - ) - } -} - fn collect_coverages( files: FileFlags, ) -> Result<Vec<cdp::ScriptCoverage>, AnyError> { @@ -1035,13 +474,7 @@ pub async fn cover_files( vec![] }; - let reporter_kind = match coverage_flags.r#type { - CoverageType::Pretty => CoverageReporterKind::Pretty, - CoverageType::Lcov => CoverageReporterKind::Lcov, - CoverageType::Html => CoverageReporterKind::Html, - }; - - let mut reporter = create_reporter(reporter_kind); + let mut reporter = reporter::create(coverage_flags.r#type); let out_mode = match coverage_flags.output { Some(ref path) => match File::create(path) { diff --git a/cli/tools/coverage/reporter.rs b/cli/tools/coverage/reporter.rs new file mode 100644 index 000000000..da8982b8d --- /dev/null +++ b/cli/tools/coverage/reporter.rs @@ -0,0 +1,563 @@ +// Copyright 2018-2023 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)] +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::Lcov => Box::new(LcovCoverageReporter::new()), + CoverageType::Pretty => Box::new(PrettyCoverageReporter::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) {} +} + +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 PrettyCoverageReporter {} + +impl PrettyCoverageReporter { + pub fn new() -> PrettyCoverageReporter { + PrettyCoverageReporter {} + } +} + +impl CoverageReporter for PrettyCoverageReporter { + 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(); + 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(); + + println!("HTML coverage report has been generated at {}", root_report); + } +} + +impl HtmlCoverageReporter { + pub fn new() -> HtmlCoverageReporter { + HtmlCoverageReporter { + file_reports: Vec::new(), + } + } + + /// Collects the coverage summary of each file or directory. + pub fn collect_summary(&self) -> CoverageSummary { + let urls = self.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 &self.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 + } + + /// 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: &String, + 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'> </span>".to_string() + } else { + format!("<span class='cline-any cline-yes'>x{count}</span>") + } + } else { + "<span class='cline-any cline-neutral'> </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"); + + // 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>" + ) + } +} |