Skip to main content

cpd_core/
deadcode.rs

1//! What a dead-code run reports.
2//!
3//! These types live beside the clone models rather than in the analyzer that
4//! produces them, so the reporters can render a dead-code run without
5//! depending on the engine that performed it — the same split the clone side
6//! already has between `models` and `cpd-reporter`.
7
8use crate::models::Location;
9use serde::{Deserialize, Serialize};
10use std::str::FromStr;
11
12/// A class of finding. Every category can be switched off independently
13/// because they carry very different false-positive rates: unused imports are
14/// nearly always safe to act on, unused class members in a dynamic codebase
15/// are not.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum Category {
19    /// A file no entry point reaches through the import graph.
20    UnusedFile,
21    /// An exported name that no reachable module imports.
22    UnusedExport,
23    /// A module-private declaration with no references in its own module.
24    UnusedSymbol,
25    /// An import binding with no references.
26    UnusedImport,
27    /// A class member or enum member nothing appears to access.
28    UnusedMember,
29}
30
31impl Category {
32    pub const ALL: &'static [Category] = &[
33        Category::UnusedFile,
34        Category::UnusedExport,
35        Category::UnusedSymbol,
36        Category::UnusedImport,
37        Category::UnusedMember,
38    ];
39
40    /// The default set: every category except members, whose accuracy depends
41    /// most on type information basta does not have.
42    pub const DEFAULT: &'static [Category] = &[
43        Category::UnusedFile,
44        Category::UnusedExport,
45        Category::UnusedSymbol,
46        Category::UnusedImport,
47    ];
48
49    pub fn as_str(self) -> &'static str {
50        match self {
51            Self::UnusedFile => "unused-file",
52            Self::UnusedExport => "unused-export",
53            Self::UnusedSymbol => "unused-symbol",
54            Self::UnusedImport => "unused-import",
55            Self::UnusedMember => "unused-member",
56        }
57    }
58
59    /// Heading used when grouping findings for a human reader.
60    pub fn title(self) -> &'static str {
61        match self {
62            Self::UnusedFile => "Unused files",
63            Self::UnusedExport => "Unused exports",
64            Self::UnusedSymbol => "Unused symbols",
65            Self::UnusedImport => "Unused imports",
66            Self::UnusedMember => "Unused members",
67        }
68    }
69}
70
71impl FromStr for Category {
72    type Err = String;
73
74    fn from_str(s: &str) -> Result<Self, Self::Err> {
75        // Both spellings are accepted so `--categories unusedExports` from a
76        // JSON config and `--categories unused-export` from a shell agree.
77        match normalize(s).as_str() {
78            "unused-file" | "unused-files" | "files" | "file" => Ok(Self::UnusedFile),
79            "unused-export" | "unused-exports" | "exports" | "export" => Ok(Self::UnusedExport),
80            "unused-symbol" | "unused-symbols" | "symbols" | "symbol" => Ok(Self::UnusedSymbol),
81            "unused-import" | "unused-imports" | "imports" | "import" => Ok(Self::UnusedImport),
82            "unused-member" | "unused-members" | "members" | "member" => Ok(Self::UnusedMember),
83            other => Err(format!(
84                "unknown category '{other}': expected one of unused-file, unused-export, \
85                 unused-symbol, unused-import, unused-member"
86            )),
87        }
88    }
89}
90
91/// Fold `unusedExports`, `unused_exports`, `UNUSED EXPORTS` and
92/// `unused-exports` onto one spelling so config files and shells agree.
93fn normalize(s: &str) -> String {
94    let mut out = String::with_capacity(s.len() + 2);
95    let mut prev_lower = false;
96    for ch in s.trim().chars() {
97        match ch {
98            '_' | ' ' | '-' => {
99                if !out.ends_with('-') && !out.is_empty() {
100                    out.push('-');
101                }
102                prev_lower = false;
103            }
104            c if c.is_ascii_uppercase() => {
105                if prev_lower {
106                    out.push('-');
107                }
108                out.push(c.to_ascii_lowercase());
109                prev_lower = false;
110            }
111            c => {
112                out.push(c);
113                prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
114            }
115        }
116    }
117    out
118}
119
120/// What a declaration is. The kind drives both the report wording and the
121/// confidence penalties: an exported type alias that nothing imports is a
122/// safer deletion than a class method that nothing appears to call.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub enum SymbolKind {
126    Function,
127    Class,
128    Method,
129    /// A field or property on a class.
130    Property,
131    Interface,
132    TypeAlias,
133    Enum,
134    EnumMember,
135    Variable,
136    /// A name bound by an `import` / `from x import y` statement.
137    Import,
138    /// A `export * from` / `export { x } from` binding that re-exports
139    /// another module's symbol without declaring anything.
140    ReExport,
141}
142
143impl SymbolKind {
144    /// Lower-case word used in report messages.
145    pub fn noun(self) -> &'static str {
146        match self {
147            Self::Function => "function",
148            Self::Class => "class",
149            Self::Method => "method",
150            Self::Property => "property",
151            Self::Interface => "interface",
152            Self::TypeAlias => "type",
153            Self::Enum => "enum",
154            Self::EnumMember => "enum member",
155            Self::Variable => "variable",
156            Self::Import => "import",
157            Self::ReExport => "re-export",
158        }
159    }
160
161    /// True for declarations that live inside a class body. Members are only
162    /// ever reported when [`Category::UnusedMember`] is on,
163    /// and they resolve against property accesses rather than bindings.
164    pub fn is_member(self) -> bool {
165        matches!(self, Self::Method | Self::Property | Self::EnumMember)
166    }
167
168    /// True for declarations that exist only in the type system. A type that
169    /// is never imported is dead weight, but deleting one can never change
170    /// runtime behavior, which the confidence model rewards.
171    pub fn is_type_only(self) -> bool {
172        matches!(self, Self::Interface | Self::TypeAlias)
173    }
174}
175
176/// A single piece of dead code.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct Finding {
180    /// Which rule produced this.
181    #[serde(with = "category_serde")]
182    pub category: Category,
183    /// Scan-root-relative path of the file the finding is in.
184    pub path: String,
185    /// Declared name. Empty for [`Category::UnusedFile`].
186    pub name: String,
187    /// The name other modules would import it by, when it differs from `name`.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub exported_as: Option<String>,
190    /// What kind of declaration this is. `None` for a whole-file finding.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub symbol_kind: Option<SymbolKind>,
193    /// Enclosing class or enum name, for members.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub parent: Option<String>,
196    /// The analyzer's language id (`js`, `python`, ...). A string rather than
197    /// an enum so that a new language is a new analyzer, not a new variant in
198    /// this crate.
199    pub language: String,
200    pub start: Location,
201    pub end: Location,
202    /// Lines the declaration spans — the size of the deletion.
203    pub lines: u32,
204    /// 0-100. See [`basta::confidence`](https://docs.rs/basta) for how it is derived.
205    pub confidence: u8,
206    /// Why the confidence is not 100, most significant first.
207    #[serde(default, skip_serializing_if = "Vec::is_empty")]
208    pub reasons: Vec<Reason>,
209    /// One-line human-readable statement of the finding.
210    pub message: String,
211}
212
213impl Finding {
214    /// Confidence as a coarse bucket, for reporters that cannot show a number.
215    pub fn level(&self) -> ConfidenceLevel {
216        ConfidenceLevel::of(self.confidence)
217    }
218
219    /// Stable identity of a finding across runs: the same declaration in the
220    /// same file keeps its fingerprint when unrelated lines move, because the
221    /// line number is deliberately not part of it.
222    pub fn fingerprint(&self) -> String {
223        let parent = self.parent.as_deref().unwrap_or("");
224        format!(
225            "{}:{}:{}:{}",
226            self.category.as_str(),
227            self.path,
228            parent,
229            if self.name.is_empty() {
230                "-"
231            } else {
232                &self.name
233            }
234        )
235    }
236}
237
238/// Coarse confidence bucket.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum ConfidenceLevel {
242    Low,
243    Medium,
244    High,
245    Certain,
246}
247
248impl ConfidenceLevel {
249    pub fn of(score: u8) -> Self {
250        match score {
251            90..=u8::MAX => Self::Certain,
252            75..=89 => Self::High,
253            50..=74 => Self::Medium,
254            _ => Self::Low,
255        }
256    }
257
258    pub fn as_str(self) -> &'static str {
259        match self {
260            Self::Low => "low",
261            Self::Medium => "medium",
262            Self::High => "high",
263            Self::Certain => "certain",
264        }
265    }
266}
267
268/// Something about the code that makes a finding less certain. Each reason
269/// subtracts a fixed number of points; the reasons travel with the finding so
270/// a reader can judge the score rather than trust it.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "kebab-case")]
273pub enum Reason {
274    /// The module calls `eval`, `getattr`, `globals()`, `require(expr)` or
275    /// accesses properties by computed key.
276    DynamicAccess,
277    /// The name appears inside a string literal somewhere in the scan, so it
278    /// may be looked up by name at runtime.
279    NameAppearsInString,
280    /// The declaration carries a decorator the analyzer does not recognise.
281    Decorated,
282    /// The declaration is re-exported from a package index / `__init__.py`,
283    /// which is usually a deliberate public surface.
284    PackageSurface,
285    /// The file exports a public API from a published package manifest.
286    PublicApi,
287    /// A member that overrides or implements an inherited declaration.
288    Overrides,
289    /// An abstract declaration whose implementations live in subclasses.
290    Abstract,
291    /// The only references come from test files, which this run does not treat
292    /// as entry points.
293    UsedOnlyByTests,
294    /// The finding is inside a test, fixture or example file.
295    InTestFile,
296    /// A module in the scan failed to parse, so some references are unknown.
297    UnparsedModule,
298    /// The name is also declared elsewhere in the scan, so the reference
299    /// matching may have attributed uses to the wrong declaration.
300    AmbiguousName,
301    /// A wildcard re-export (`export *`, `from m import *`) hides which names
302    /// actually cross the module boundary.
303    WildcardReExport,
304    /// Something in the project reads this name as an attribute. In Python a
305    /// module's contents are reachable as attributes of the module object, so
306    /// `mod.name` may well be this declaration.
307    NameReadAsAttribute,
308    /// A string literal somewhere in the scan ends with this file's path,
309    /// extension left off: `resolve(distDir, 'runtime/handlers/island')`.
310    /// Not an import, so not an edge — but a framework that loads files by
311    /// path writes exactly this, and the file is then very much alive.
312    PathAppearsInString,
313}
314
315impl Reason {
316    /// Points subtracted from the base score.
317    pub fn penalty(self) -> u8 {
318        match self {
319            Self::DynamicAccess => 30,
320            Self::NameAppearsInString => 35,
321            Self::Decorated => 40,
322            Self::PackageSurface => 20,
323            Self::PublicApi => 60,
324            Self::Overrides => 45,
325            Self::Abstract => 50,
326            Self::UsedOnlyByTests => 25,
327            Self::InTestFile => 15,
328            Self::UnparsedModule => 20,
329            Self::AmbiguousName => 25,
330            Self::WildcardReExport => 30,
331            Self::NameReadAsAttribute => 35,
332            // Enough to take an unused file (95) under the default floor of
333            // 60: the string is no proof, and the finding is no longer one a
334            // reader should act on without looking.
335            Self::PathAppearsInString => 40,
336        }
337    }
338
339    /// Short explanation shown next to a finding.
340    pub fn explain(self) -> &'static str {
341        match self {
342            Self::DynamicAccess => "file resolves names at runtime",
343            Self::NameAppearsInString => "name appears in a string literal",
344            Self::Decorated => "carries an unrecognised decorator",
345            Self::PackageSurface => "re-exported from a package index",
346            Self::PublicApi => "part of the package's published API",
347            Self::Overrides => "overrides an inherited member",
348            Self::Abstract => "abstract declaration",
349            Self::UsedOnlyByTests => "only referenced by tests",
350            Self::InTestFile => "declared in a test file",
351            Self::UnparsedModule => "a file in the scan did not parse",
352            Self::AmbiguousName => "the name is declared more than once",
353            Self::WildcardReExport => "reached through a wildcard re-export",
354            Self::NameReadAsAttribute => "the name is read as an attribute elsewhere",
355            Self::PathAppearsInString => "its path appears in a string literal",
356        }
357    }
358}
359
360/// Everything a run produced.
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct Report {
364    pub findings: Vec<Finding>,
365    pub statistics: Stats,
366}
367
368/// Run-level counters, for reporters and for the exit-code gate.
369#[derive(Debug, Clone, Default, Serialize, Deserialize)]
370#[serde(rename_all = "camelCase")]
371pub struct Stats {
372    /// Files walked and analyzed.
373    pub files: u32,
374    /// Files that failed to parse.
375    pub unparsed: u32,
376    /// Their paths, so a reader can tell a broken fixture from a real gap.
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub unparsed_files: Vec<String>,
379    /// Files an entry point reaches.
380    pub reachable_files: u32,
381    /// Declarations found across every analyzed file.
382    pub symbols: u32,
383    /// Entry-point files, however they were detected.
384    pub entry_points: u32,
385    /// Findings surviving the confidence threshold, per category.
386    #[serde(default)]
387    pub by_category: Vec<CategoryCount>,
388    /// Lines of code the surviving findings cover.
389    pub dead_lines: u32,
390    /// Total lines across analyzed files.
391    pub total_lines: u32,
392    /// `dead_lines` as a percentage of `total_lines`.
393    pub percentage: f64,
394    /// ISO-8601 timestamp of the run.
395    pub detection_date: String,
396}
397
398impl Stats {
399    /// Findings reported, across every category.
400    pub fn total_findings(&self) -> u32 {
401        self.by_category.iter().map(|c| c.count).sum()
402    }
403
404    pub fn count_of(&self, category: Category) -> u32 {
405        self.by_category
406            .iter()
407            .find(|c| c.category == category)
408            .map_or(0, |c| c.count)
409    }
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(rename_all = "camelCase")]
414pub struct CategoryCount {
415    #[serde(with = "category_serde")]
416    pub category: Category,
417    pub count: u32,
418    pub lines: u32,
419}
420
421/// `Category` serializes as its kebab-case name in both directions. It is
422/// written by hand because the enum's `Deserialize` would otherwise have to
423/// duplicate the alias list that [`std::str::FromStr`] already owns.
424mod category_serde {
425    use super::Category;
426    use serde::{Deserialize, Deserializer, Serializer};
427
428    pub fn serialize<S: Serializer>(c: &Category, s: S) -> Result<S::Ok, S::Error> {
429        s.serialize_str(c.as_str())
430    }
431
432    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Category, D::Error> {
433        let raw = String::deserialize(d)?;
434        raw.parse().map_err(serde::de::Error::custom)
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn finding(category: Category, confidence: u8) -> Finding {
443        Finding {
444            category,
445            path: "src/a.ts".into(),
446            name: "foo".into(),
447            exported_as: None,
448            symbol_kind: Some(SymbolKind::Function),
449            parent: None,
450            language: "js".into(),
451            start: Location {
452                line: 3,
453                column: 0,
454                offset: 20,
455            },
456            end: Location {
457                line: 6,
458                column: 1,
459                offset: 60,
460            },
461            lines: 4,
462            confidence,
463            reasons: Vec::new(),
464            message: "message".into(),
465        }
466    }
467
468    #[test]
469    fn confidence_buckets_have_no_gaps() {
470        assert_eq!(ConfidenceLevel::of(100), ConfidenceLevel::Certain);
471        assert_eq!(ConfidenceLevel::of(90), ConfidenceLevel::Certain);
472        assert_eq!(ConfidenceLevel::of(89), ConfidenceLevel::High);
473        assert_eq!(ConfidenceLevel::of(75), ConfidenceLevel::High);
474        assert_eq!(ConfidenceLevel::of(74), ConfidenceLevel::Medium);
475        assert_eq!(ConfidenceLevel::of(50), ConfidenceLevel::Medium);
476        assert_eq!(ConfidenceLevel::of(49), ConfidenceLevel::Low);
477        assert_eq!(ConfidenceLevel::of(0), ConfidenceLevel::Low);
478    }
479
480    #[test]
481    fn fingerprint_ignores_line_numbers() {
482        let mut a = finding(Category::UnusedExport, 90);
483        let mut b = a.clone();
484        b.start.line = 400;
485        b.end.line = 410;
486        assert_eq!(a.fingerprint(), b.fingerprint());
487        a.name = "bar".into();
488        assert_ne!(a.fingerprint(), b.fingerprint());
489    }
490
491    #[test]
492    fn category_round_trips_through_json() {
493        let f = finding(Category::UnusedMember, 80);
494        let json = serde_json::to_string(&f).unwrap();
495        assert!(json.contains("\"unused-member\""), "{json}");
496        let back: Finding = serde_json::from_str(&json).unwrap();
497        assert_eq!(back.category, Category::UnusedMember);
498    }
499
500    #[test]
501    fn stats_sum_findings_across_categories() {
502        let stats = Stats {
503            by_category: vec![
504                CategoryCount {
505                    category: Category::UnusedExport,
506                    count: 3,
507                    lines: 30,
508                },
509                CategoryCount {
510                    category: Category::UnusedImport,
511                    count: 5,
512                    lines: 5,
513                },
514            ],
515            ..Stats::default()
516        };
517        assert_eq!(stats.total_findings(), 8);
518        assert_eq!(stats.count_of(Category::UnusedExport), 3);
519        assert_eq!(stats.count_of(Category::UnusedFile), 0);
520    }
521}