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}
309
310impl Reason {
311    /// Points subtracted from the base score.
312    pub fn penalty(self) -> u8 {
313        match self {
314            Self::DynamicAccess => 30,
315            Self::NameAppearsInString => 35,
316            Self::Decorated => 40,
317            Self::PackageSurface => 20,
318            Self::PublicApi => 60,
319            Self::Overrides => 45,
320            Self::Abstract => 50,
321            Self::UsedOnlyByTests => 25,
322            Self::InTestFile => 15,
323            Self::UnparsedModule => 20,
324            Self::AmbiguousName => 25,
325            Self::WildcardReExport => 30,
326            Self::NameReadAsAttribute => 35,
327        }
328    }
329
330    /// Short explanation shown next to a finding.
331    pub fn explain(self) -> &'static str {
332        match self {
333            Self::DynamicAccess => "file resolves names at runtime",
334            Self::NameAppearsInString => "name appears in a string literal",
335            Self::Decorated => "carries an unrecognised decorator",
336            Self::PackageSurface => "re-exported from a package index",
337            Self::PublicApi => "part of the package's published API",
338            Self::Overrides => "overrides an inherited member",
339            Self::Abstract => "abstract declaration",
340            Self::UsedOnlyByTests => "only referenced by tests",
341            Self::InTestFile => "declared in a test file",
342            Self::UnparsedModule => "a file in the scan did not parse",
343            Self::AmbiguousName => "the name is declared more than once",
344            Self::WildcardReExport => "reached through a wildcard re-export",
345            Self::NameReadAsAttribute => "the name is read as an attribute elsewhere",
346        }
347    }
348}
349
350/// Everything a run produced.
351#[derive(Debug, Clone, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase")]
353pub struct Report {
354    pub findings: Vec<Finding>,
355    pub statistics: Stats,
356}
357
358/// Run-level counters, for reporters and for the exit-code gate.
359#[derive(Debug, Clone, Default, Serialize, Deserialize)]
360#[serde(rename_all = "camelCase")]
361pub struct Stats {
362    /// Files walked and analyzed.
363    pub files: u32,
364    /// Files that failed to parse.
365    pub unparsed: u32,
366    /// Their paths, so a reader can tell a broken fixture from a real gap.
367    #[serde(default, skip_serializing_if = "Vec::is_empty")]
368    pub unparsed_files: Vec<String>,
369    /// Files an entry point reaches.
370    pub reachable_files: u32,
371    /// Declarations found across every analyzed file.
372    pub symbols: u32,
373    /// Entry-point files, however they were detected.
374    pub entry_points: u32,
375    /// Findings surviving the confidence threshold, per category.
376    #[serde(default)]
377    pub by_category: Vec<CategoryCount>,
378    /// Lines of code the surviving findings cover.
379    pub dead_lines: u32,
380    /// Total lines across analyzed files.
381    pub total_lines: u32,
382    /// `dead_lines` as a percentage of `total_lines`.
383    pub percentage: f64,
384    /// ISO-8601 timestamp of the run.
385    pub detection_date: String,
386}
387
388impl Stats {
389    /// Findings reported, across every category.
390    pub fn total_findings(&self) -> u32 {
391        self.by_category.iter().map(|c| c.count).sum()
392    }
393
394    pub fn count_of(&self, category: Category) -> u32 {
395        self.by_category
396            .iter()
397            .find(|c| c.category == category)
398            .map_or(0, |c| c.count)
399    }
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404pub struct CategoryCount {
405    #[serde(with = "category_serde")]
406    pub category: Category,
407    pub count: u32,
408    pub lines: u32,
409}
410
411/// `Category` serializes as its kebab-case name in both directions. It is
412/// written by hand because the enum's `Deserialize` would otherwise have to
413/// duplicate the alias list that [`std::str::FromStr`] already owns.
414mod category_serde {
415    use super::Category;
416    use serde::{Deserialize, Deserializer, Serializer};
417
418    pub fn serialize<S: Serializer>(c: &Category, s: S) -> Result<S::Ok, S::Error> {
419        s.serialize_str(c.as_str())
420    }
421
422    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Category, D::Error> {
423        let raw = String::deserialize(d)?;
424        raw.parse().map_err(serde::de::Error::custom)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    fn finding(category: Category, confidence: u8) -> Finding {
433        Finding {
434            category,
435            path: "src/a.ts".into(),
436            name: "foo".into(),
437            exported_as: None,
438            symbol_kind: Some(SymbolKind::Function),
439            parent: None,
440            language: "js".into(),
441            start: Location {
442                line: 3,
443                column: 0,
444                offset: 20,
445            },
446            end: Location {
447                line: 6,
448                column: 1,
449                offset: 60,
450            },
451            lines: 4,
452            confidence,
453            reasons: Vec::new(),
454            message: "message".into(),
455        }
456    }
457
458    #[test]
459    fn confidence_buckets_have_no_gaps() {
460        assert_eq!(ConfidenceLevel::of(100), ConfidenceLevel::Certain);
461        assert_eq!(ConfidenceLevel::of(90), ConfidenceLevel::Certain);
462        assert_eq!(ConfidenceLevel::of(89), ConfidenceLevel::High);
463        assert_eq!(ConfidenceLevel::of(75), ConfidenceLevel::High);
464        assert_eq!(ConfidenceLevel::of(74), ConfidenceLevel::Medium);
465        assert_eq!(ConfidenceLevel::of(50), ConfidenceLevel::Medium);
466        assert_eq!(ConfidenceLevel::of(49), ConfidenceLevel::Low);
467        assert_eq!(ConfidenceLevel::of(0), ConfidenceLevel::Low);
468    }
469
470    #[test]
471    fn fingerprint_ignores_line_numbers() {
472        let mut a = finding(Category::UnusedExport, 90);
473        let mut b = a.clone();
474        b.start.line = 400;
475        b.end.line = 410;
476        assert_eq!(a.fingerprint(), b.fingerprint());
477        a.name = "bar".into();
478        assert_ne!(a.fingerprint(), b.fingerprint());
479    }
480
481    #[test]
482    fn category_round_trips_through_json() {
483        let f = finding(Category::UnusedMember, 80);
484        let json = serde_json::to_string(&f).unwrap();
485        assert!(json.contains("\"unused-member\""), "{json}");
486        let back: Finding = serde_json::from_str(&json).unwrap();
487        assert_eq!(back.category, Category::UnusedMember);
488    }
489
490    #[test]
491    fn stats_sum_findings_across_categories() {
492        let stats = Stats {
493            by_category: vec![
494                CategoryCount {
495                    category: Category::UnusedExport,
496                    count: 3,
497                    lines: 30,
498                },
499                CategoryCount {
500                    category: Category::UnusedImport,
501                    count: 5,
502                    lines: 5,
503                },
504            ],
505            ..Stats::default()
506        };
507        assert_eq!(stats.total_findings(), 8);
508        assert_eq!(stats.count_of(Category::UnusedExport), 3);
509        assert_eq!(stats.count_of(Category::UnusedFile), 0);
510    }
511}