use super::{
DeadCodeContext, DeadCodeReporter, group_by_category, location, reasons_clause, resolve_path,
};
use crate::reporter::{ReporterError, ReporterOptions};
use crate::shared::{Style, extract_lines};
use cpd_core::deadcode::{ConfidenceLevel, Finding};
use std::collections::HashMap;
use std::path::Path;
pub struct ConsoleReporter {
style: Style,
}
impl ConsoleReporter {
pub fn new(options: &ReporterOptions) -> Self {
Self {
style: Style::new(options.no_colors),
}
}
}
impl DeadCodeReporter for ConsoleReporter {
fn name(&self) -> &str {
"console"
}
fn report(
&self,
findings: &[Finding],
ctx: &DeadCodeContext<'_>,
_output_dir: &Path,
) -> Result<(), ReporterError> {
print_groups(findings, &self.style, |_| {});
print_trailer(findings, ctx, &self.style);
Ok(())
}
}
pub struct ConsoleFullReporter {
style: Style,
}
impl ConsoleFullReporter {
pub fn new(options: &ReporterOptions) -> Self {
Self {
style: Style::new(options.no_colors),
}
}
}
const UNPARSED_LISTED: usize = 10;
const SNIPPET_LINES: u32 = 10;
impl DeadCodeReporter for ConsoleFullReporter {
fn name(&self) -> &str {
"console-full"
}
fn report(
&self,
findings: &[Finding],
ctx: &DeadCodeContext<'_>,
_output_dir: &Path,
) -> Result<(), ReporterError> {
let mut cache: HashMap<String, String> = HashMap::new();
let style = &self.style;
let roots = ctx.roots;
print_groups(findings, style, |finding| {
if finding.name.is_empty() {
return;
}
let Some(content) = read_source(&mut cache, &finding.path, roots) else {
return;
};
let last = finding.end.line.min(finding.start.line + SNIPPET_LINES - 1);
for (offset, line) in extract_lines(content, finding.start.line, last)
.lines()
.enumerate()
{
println!(
" {} {}",
style.dim(&format!("{:>5}", finding.start.line as usize + offset)),
line
);
}
if finding.end.line > last {
println!(" {}", style.dim(" ... "));
}
});
print_trailer(findings, ctx, style);
Ok(())
}
}
fn read_source<'a>(
cache: &'a mut HashMap<String, String>,
path: &str,
roots: &[std::path::PathBuf],
) -> Option<&'a str> {
if !cache.contains_key(path) {
let content = resolve_path(path, roots).and_then(|p| std::fs::read_to_string(p).ok())?;
cache.insert(path.to_string(), content);
}
cache.get(path).map(String::as_str)
}
fn print_groups(findings: &[Finding], style: &Style, mut detail: impl FnMut(&Finding)) {
if findings.is_empty() {
println!("{}", style.green_prefix("No dead code found."));
return;
}
for (category, group) in group_by_category(findings) {
println!();
println!(
"{} {}",
style.bold(category.title()),
style.dim(&format!("({})", group.len()))
);
for finding in group {
print_finding(finding, style);
detail(finding);
}
}
}
fn print_finding(finding: &Finding, style: &Style) {
let subject = if finding.name.is_empty() {
style.bold_green(&finding.path)
} else {
format!(
"{} {}",
style.dim(&location(finding)),
style.bold_green(&display_name(finding)),
)
};
let kind = finding
.symbol_kind
.map(|k| format!("{} ", k.noun()))
.unwrap_or_default();
println!(
" - {}{} {} {}",
kind,
subject,
confidence_badge(finding, style),
style.dim(&format!("{} lines", finding.lines)),
);
let reasons = reasons_clause(finding);
if !reasons.is_empty() {
println!(" {}", style.dim(&format!("↳ {reasons}")));
}
}
fn display_name(finding: &Finding) -> String {
match (&finding.parent, &finding.exported_as) {
(Some(parent), _) => format!("{parent}.{}", finding.name),
(None, Some(exported)) => format!("{} (exported as {exported})", finding.name),
(None, None) => finding.name.clone(),
}
}
fn confidence_badge(finding: &Finding, style: &Style) -> String {
let text = format!("{} {}%", finding.level().as_str(), finding.confidence);
match finding.level() {
ConfidenceLevel::Certain | ConfidenceLevel::High => style.red(&text),
ConfidenceLevel::Medium => style.bold(&text),
ConfidenceLevel::Low => style.dim(&text),
}
}
fn print_trailer(findings: &[Finding], ctx: &DeadCodeContext<'_>, style: &Style) {
let stats = ctx.stats;
println!();
if findings.is_empty() {
println!(
"{}",
style.dim(&format!(
"Analyzed {} files, {} declarations, {} entry points.",
stats.files, stats.symbols, stats.entry_points
))
);
} else {
println!(
"Found {} in {} files ({:.1}% of {} lines).",
style.bold(&format!("{} dead code findings", findings.len())),
stats.files,
stats.percentage,
stats.total_lines,
);
}
if stats.unparsed > 0 {
println!(
"{}",
style.dim(&format!(
"{} file(s) could not be parsed; their references are unknown:",
stats.unparsed
))
);
for path in stats.unparsed_files.iter().take(UNPARSED_LISTED) {
println!("{}", style.dim(&format!(" - {path}")));
}
if stats.unparsed_files.len() > UNPARSED_LISTED {
println!(
"{}",
style.dim(&format!(
" ... and {} more (see the JSON report)",
stats.unparsed_files.len() - UNPARSED_LISTED
))
);
}
}
println!("{}", style.dim(&format_duration(ctx.duration)));
}
fn format_duration(duration: std::time::Duration) -> String {
let millis = duration.as_millis();
if millis < 1000 {
format!("Done in {millis}ms")
} else {
format!("Done in {:.2}s", duration.as_secs_f64())
}
}
#[cfg(test)]
mod tests {
use super::super::fixtures;
use super::*;
use cpd_core::deadcode::{Category, Stats};
use std::path::PathBuf;
use std::time::Duration;
fn options(no_colors: bool) -> ReporterOptions {
let mut options = ReporterOptions::new(PathBuf::from("/tmp"));
options.no_colors = no_colors;
options
}
#[test]
fn an_empty_report_does_not_panic() {
let stats = Stats::default();
let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
for name in ["console", "console-full"] {
let reporter = super::super::create_dead_code_reporter(name, &options(true)).unwrap();
assert!(reporter.report(&[], &ctx, &PathBuf::from("/tmp")).is_ok());
}
}
#[test]
fn findings_are_rendered_without_panicking() {
let stats = fixtures::stats();
let ctx = DeadCodeContext::new(&stats, Duration::from_millis(12));
let reporter = ConsoleReporter::new(&options(true));
assert!(
reporter
.report(&fixtures::findings(), &ctx, &PathBuf::from("/tmp"))
.is_ok()
);
}
#[test]
fn console_full_survives_a_finding_whose_file_is_gone() {
let stats = fixtures::stats();
let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
let reporter = ConsoleFullReporter::new(&options(true));
assert!(
reporter
.report(&fixtures::findings(), &ctx, &PathBuf::from("/tmp"))
.is_ok(),
"a deleted or unreadable file must not fail the report"
);
}
#[test]
fn a_member_is_shown_with_the_class_that_owns_it() {
let mut finding = fixtures::finding(Category::UnusedMember, "src/a.ts", "render", 70);
finding.parent = Some("Widget".into());
assert_eq!(display_name(&finding), "Widget.render");
}
#[test]
fn a_renamed_export_shows_both_names() {
let mut finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "inner", 85);
finding.exported_as = Some("outer".into());
assert_eq!(display_name(&finding), "inner (exported as outer)");
}
#[test]
fn durations_switch_units_at_one_second() {
assert_eq!(format_duration(Duration::from_millis(12)), "Done in 12ms");
assert_eq!(format_duration(Duration::from_millis(999)), "Done in 999ms");
assert_eq!(
format_duration(Duration::from_millis(1500)),
"Done in 1.50s"
);
}
#[test]
fn no_colors_produces_no_escape_sequences() {
let style = Style::new(true);
let finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "x", 85);
assert!(!confidence_badge(&finding, &style).contains('\x1b'));
}
}