Skip to main content

cpd_reporter/deadcode/
console.rs

1//! Terminal output for a dead-code run.
2//!
3//! Findings are grouped by category rather than by file, because that is the
4//! order a reader acts in: delete the unused files first and most of the rest
5//! of the report disappears with them.
6
7use super::{
8    DeadCodeContext, DeadCodeReporter, group_by_category, location, reasons_clause, resolve_path,
9};
10use crate::reporter::{ReporterError, ReporterOptions};
11use crate::shared::{Style, extract_lines};
12use cpd_core::deadcode::{ConfidenceLevel, Finding};
13use std::collections::HashMap;
14use std::path::Path;
15
16pub struct ConsoleReporter {
17    style: Style,
18}
19
20impl ConsoleReporter {
21    pub fn new(options: &ReporterOptions) -> Self {
22        Self {
23            style: Style::new(options.no_colors),
24        }
25    }
26}
27
28impl DeadCodeReporter for ConsoleReporter {
29    fn name(&self) -> &str {
30        "console"
31    }
32
33    fn report(
34        &self,
35        findings: &[Finding],
36        ctx: &DeadCodeContext<'_>,
37        _output_dir: &Path,
38    ) -> Result<(), ReporterError> {
39        print_groups(findings, &self.style, |_| {});
40        print_trailer(findings, ctx, &self.style);
41        Ok(())
42    }
43}
44
45/// The console report with the source of each finding printed beneath it.
46pub struct ConsoleFullReporter {
47    style: Style,
48}
49
50impl ConsoleFullReporter {
51    pub fn new(options: &ReporterOptions) -> Self {
52        Self {
53            style: Style::new(options.no_colors),
54        }
55    }
56}
57
58/// How many unparsed files the trailer names before summarising the rest.
59const UNPARSED_LISTED: usize = 10;
60
61/// How many lines of a declaration the console shows before truncating. Long
62/// enough to recognise what is being deleted, short enough that a report with
63/// fifty findings still scrolls.
64const SNIPPET_LINES: u32 = 10;
65
66impl DeadCodeReporter for ConsoleFullReporter {
67    fn name(&self) -> &str {
68        "console-full"
69    }
70
71    fn report(
72        &self,
73        findings: &[Finding],
74        ctx: &DeadCodeContext<'_>,
75        _output_dir: &Path,
76    ) -> Result<(), ReporterError> {
77        let mut cache: HashMap<String, String> = HashMap::new();
78        let style = &self.style;
79        let roots = ctx.roots;
80        print_groups(findings, style, |finding| {
81            // A whole-file finding has no snippet worth printing: the file is
82            // the snippet.
83            if finding.name.is_empty() {
84                return;
85            }
86            let Some(content) = read_source(&mut cache, &finding.path, roots) else {
87                return;
88            };
89            let last = finding.end.line.min(finding.start.line + SNIPPET_LINES - 1);
90            for (offset, line) in extract_lines(content, finding.start.line, last)
91                .lines()
92                .enumerate()
93            {
94                println!(
95                    "     {} {}",
96                    style.dim(&format!("{:>5}", finding.start.line as usize + offset)),
97                    line
98                );
99            }
100            if finding.end.line > last {
101                println!("     {}", style.dim("  ...  "));
102            }
103        });
104        print_trailer(findings, ctx, style);
105        Ok(())
106    }
107}
108
109/// Read a finding's file once per run. A file that has moved since the scan
110/// yields nothing rather than failing the report.
111fn read_source<'a>(
112    cache: &'a mut HashMap<String, String>,
113    path: &str,
114    roots: &[std::path::PathBuf],
115) -> Option<&'a str> {
116    if !cache.contains_key(path) {
117        let content = resolve_path(path, roots).and_then(|p| std::fs::read_to_string(p).ok())?;
118        cache.insert(path.to_string(), content);
119    }
120    cache.get(path).map(String::as_str)
121}
122
123/// Print every category block, calling `detail` after each finding line.
124fn print_groups(findings: &[Finding], style: &Style, mut detail: impl FnMut(&Finding)) {
125    if findings.is_empty() {
126        println!("{}", style.green_prefix("No dead code found."));
127        return;
128    }
129    for (category, group) in group_by_category(findings) {
130        println!();
131        println!(
132            "{} {}",
133            style.bold(category.title()),
134            style.dim(&format!("({})", group.len()))
135        );
136        for finding in group {
137            print_finding(finding, style);
138            detail(finding);
139        }
140    }
141}
142
143fn print_finding(finding: &Finding, style: &Style) {
144    let subject = if finding.name.is_empty() {
145        style.bold_green(&finding.path)
146    } else {
147        format!(
148            "{} {}",
149            style.dim(&location(finding)),
150            style.bold_green(&display_name(finding)),
151        )
152    };
153    let kind = finding
154        .symbol_kind
155        .map(|k| format!("{} ", k.noun()))
156        .unwrap_or_default();
157    println!(
158        " - {}{}  {}  {}",
159        kind,
160        subject,
161        confidence_badge(finding, style),
162        style.dim(&format!("{} lines", finding.lines)),
163    );
164    let reasons = reasons_clause(finding);
165    if !reasons.is_empty() {
166        println!("   {}", style.dim(&format!("↳ {reasons}")));
167    }
168}
169
170/// `name`, or `name → exportedAs` when the two differ.
171fn display_name(finding: &Finding) -> String {
172    match (&finding.parent, &finding.exported_as) {
173        (Some(parent), _) => format!("{parent}.{}", finding.name),
174        (None, Some(exported)) => format!("{} (exported as {exported})", finding.name),
175        (None, None) => finding.name.clone(),
176    }
177}
178
179fn confidence_badge(finding: &Finding, style: &Style) -> String {
180    let text = format!("{} {}%", finding.level().as_str(), finding.confidence);
181    match finding.level() {
182        ConfidenceLevel::Certain | ConfidenceLevel::High => style.red(&text),
183        ConfidenceLevel::Medium => style.bold(&text),
184        ConfidenceLevel::Low => style.dim(&text),
185    }
186}
187
188fn print_trailer(findings: &[Finding], ctx: &DeadCodeContext<'_>, style: &Style) {
189    let stats = ctx.stats;
190    println!();
191    if findings.is_empty() {
192        println!(
193            "{}",
194            style.dim(&format!(
195                "Analyzed {} files, {} declarations, {} entry points.",
196                stats.files, stats.symbols, stats.entry_points
197            ))
198        );
199    } else {
200        println!(
201            "Found {} in {} files ({:.1}% of {} lines).",
202            style.bold(&format!("{} dead code findings", findings.len())),
203            stats.files,
204            stats.percentage,
205            stats.total_lines,
206        );
207    }
208    if stats.unparsed > 0 {
209        println!(
210            "{}",
211            style.dim(&format!(
212                "{} file(s) could not be parsed; their references are unknown:",
213                stats.unparsed
214            ))
215        );
216        // Enough to recognise a pattern (a fixtures directory, a generated
217        // tree), not so many that they crowd out the findings.
218        for path in stats.unparsed_files.iter().take(UNPARSED_LISTED) {
219            println!("{}", style.dim(&format!("   - {path}")));
220        }
221        if stats.unparsed_files.len() > UNPARSED_LISTED {
222            println!(
223                "{}",
224                style.dim(&format!(
225                    "   ... and {} more (see the JSON report)",
226                    stats.unparsed_files.len() - UNPARSED_LISTED
227                ))
228            );
229        }
230    }
231    println!("{}", style.dim(&format_duration(ctx.duration)));
232}
233
234fn format_duration(duration: std::time::Duration) -> String {
235    let millis = duration.as_millis();
236    if millis < 1000 {
237        format!("Done in {millis}ms")
238    } else {
239        format!("Done in {:.2}s", duration.as_secs_f64())
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::super::fixtures;
246    use super::*;
247    use cpd_core::deadcode::{Category, Stats};
248    use std::path::PathBuf;
249    use std::time::Duration;
250
251    fn options(no_colors: bool) -> ReporterOptions {
252        let mut options = ReporterOptions::new(PathBuf::from("/tmp"));
253        options.no_colors = no_colors;
254        options
255    }
256
257    #[test]
258    fn an_empty_report_does_not_panic() {
259        let stats = Stats::default();
260        let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
261        for name in ["console", "console-full"] {
262            let reporter = super::super::create_dead_code_reporter(name, &options(true)).unwrap();
263            assert!(reporter.report(&[], &ctx, &PathBuf::from("/tmp")).is_ok());
264        }
265    }
266
267    #[test]
268    fn findings_are_rendered_without_panicking() {
269        let stats = fixtures::stats();
270        let ctx = DeadCodeContext::new(&stats, Duration::from_millis(12));
271        let reporter = ConsoleReporter::new(&options(true));
272        assert!(
273            reporter
274                .report(&fixtures::findings(), &ctx, &PathBuf::from("/tmp"))
275                .is_ok()
276        );
277    }
278
279    #[test]
280    fn console_full_survives_a_finding_whose_file_is_gone() {
281        let stats = fixtures::stats();
282        let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
283        let reporter = ConsoleFullReporter::new(&options(true));
284        assert!(
285            reporter
286                .report(&fixtures::findings(), &ctx, &PathBuf::from("/tmp"))
287                .is_ok(),
288            "a deleted or unreadable file must not fail the report"
289        );
290    }
291
292    #[test]
293    fn a_member_is_shown_with_the_class_that_owns_it() {
294        let mut finding = fixtures::finding(Category::UnusedMember, "src/a.ts", "render", 70);
295        finding.parent = Some("Widget".into());
296        assert_eq!(display_name(&finding), "Widget.render");
297    }
298
299    #[test]
300    fn a_renamed_export_shows_both_names() {
301        let mut finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "inner", 85);
302        finding.exported_as = Some("outer".into());
303        assert_eq!(display_name(&finding), "inner (exported as outer)");
304    }
305
306    #[test]
307    fn durations_switch_units_at_one_second() {
308        assert_eq!(format_duration(Duration::from_millis(12)), "Done in 12ms");
309        assert_eq!(format_duration(Duration::from_millis(999)), "Done in 999ms");
310        assert_eq!(
311            format_duration(Duration::from_millis(1500)),
312            "Done in 1.50s"
313        );
314    }
315
316    #[test]
317    fn no_colors_produces_no_escape_sequences() {
318        let style = Style::new(true);
319        let finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "x", 85);
320        assert!(!confidence_badge(&finding, &style).contains('\x1b'));
321    }
322}