use crate::models::Location;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Category {
UnusedFile,
UnusedExport,
UnusedSymbol,
UnusedImport,
UnusedMember,
}
impl Category {
pub const ALL: &'static [Category] = &[
Category::UnusedFile,
Category::UnusedExport,
Category::UnusedSymbol,
Category::UnusedImport,
Category::UnusedMember,
];
pub const DEFAULT: &'static [Category] = &[
Category::UnusedFile,
Category::UnusedExport,
Category::UnusedSymbol,
Category::UnusedImport,
];
pub fn as_str(self) -> &'static str {
match self {
Self::UnusedFile => "unused-file",
Self::UnusedExport => "unused-export",
Self::UnusedSymbol => "unused-symbol",
Self::UnusedImport => "unused-import",
Self::UnusedMember => "unused-member",
}
}
pub fn title(self) -> &'static str {
match self {
Self::UnusedFile => "Unused files",
Self::UnusedExport => "Unused exports",
Self::UnusedSymbol => "Unused symbols",
Self::UnusedImport => "Unused imports",
Self::UnusedMember => "Unused members",
}
}
}
impl FromStr for Category {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match normalize(s).as_str() {
"unused-file" | "unused-files" | "files" | "file" => Ok(Self::UnusedFile),
"unused-export" | "unused-exports" | "exports" | "export" => Ok(Self::UnusedExport),
"unused-symbol" | "unused-symbols" | "symbols" | "symbol" => Ok(Self::UnusedSymbol),
"unused-import" | "unused-imports" | "imports" | "import" => Ok(Self::UnusedImport),
"unused-member" | "unused-members" | "members" | "member" => Ok(Self::UnusedMember),
other => Err(format!(
"unknown category '{other}': expected one of unused-file, unused-export, \
unused-symbol, unused-import, unused-member"
)),
}
}
}
fn normalize(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
let mut prev_lower = false;
for ch in s.trim().chars() {
match ch {
'_' | ' ' | '-' => {
if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
prev_lower = false;
}
c if c.is_ascii_uppercase() => {
if prev_lower {
out.push('-');
}
out.push(c.to_ascii_lowercase());
prev_lower = false;
}
c => {
out.push(c);
prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
}
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SymbolKind {
Function,
Class,
Method,
Property,
Interface,
TypeAlias,
Enum,
EnumMember,
Variable,
Import,
ReExport,
}
impl SymbolKind {
pub fn noun(self) -> &'static str {
match self {
Self::Function => "function",
Self::Class => "class",
Self::Method => "method",
Self::Property => "property",
Self::Interface => "interface",
Self::TypeAlias => "type",
Self::Enum => "enum",
Self::EnumMember => "enum member",
Self::Variable => "variable",
Self::Import => "import",
Self::ReExport => "re-export",
}
}
pub fn is_member(self) -> bool {
matches!(self, Self::Method | Self::Property | Self::EnumMember)
}
pub fn is_type_only(self) -> bool {
matches!(self, Self::Interface | Self::TypeAlias)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Finding {
#[serde(with = "category_serde")]
pub category: Category,
pub path: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exported_as: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_kind: Option<SymbolKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub language: String,
pub start: Location,
pub end: Location,
pub lines: u32,
pub confidence: u8,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reasons: Vec<Reason>,
pub message: String,
}
impl Finding {
pub fn level(&self) -> ConfidenceLevel {
ConfidenceLevel::of(self.confidence)
}
pub fn fingerprint(&self) -> String {
let parent = self.parent.as_deref().unwrap_or("");
format!(
"{}:{}:{}:{}",
self.category.as_str(),
self.path,
parent,
if self.name.is_empty() {
"-"
} else {
&self.name
}
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfidenceLevel {
Low,
Medium,
High,
Certain,
}
impl ConfidenceLevel {
pub fn of(score: u8) -> Self {
match score {
90..=u8::MAX => Self::Certain,
75..=89 => Self::High,
50..=74 => Self::Medium,
_ => Self::Low,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Certain => "certain",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Reason {
DynamicAccess,
NameAppearsInString,
Decorated,
PackageSurface,
PublicApi,
Overrides,
Abstract,
UsedOnlyByTests,
InTestFile,
UnparsedModule,
AmbiguousName,
WildcardReExport,
NameReadAsAttribute,
PathAppearsInString,
}
impl Reason {
pub fn penalty(self) -> u8 {
match self {
Self::DynamicAccess => 30,
Self::NameAppearsInString => 35,
Self::Decorated => 40,
Self::PackageSurface => 20,
Self::PublicApi => 60,
Self::Overrides => 45,
Self::Abstract => 50,
Self::UsedOnlyByTests => 25,
Self::InTestFile => 15,
Self::UnparsedModule => 20,
Self::AmbiguousName => 25,
Self::WildcardReExport => 30,
Self::NameReadAsAttribute => 35,
Self::PathAppearsInString => 40,
}
}
pub fn explain(self) -> &'static str {
match self {
Self::DynamicAccess => "file resolves names at runtime",
Self::NameAppearsInString => "name appears in a string literal",
Self::Decorated => "carries an unrecognised decorator",
Self::PackageSurface => "re-exported from a package index",
Self::PublicApi => "part of the package's published API",
Self::Overrides => "overrides an inherited member",
Self::Abstract => "abstract declaration",
Self::UsedOnlyByTests => "only referenced by tests",
Self::InTestFile => "declared in a test file",
Self::UnparsedModule => "a file in the scan did not parse",
Self::AmbiguousName => "the name is declared more than once",
Self::WildcardReExport => "reached through a wildcard re-export",
Self::NameReadAsAttribute => "the name is read as an attribute elsewhere",
Self::PathAppearsInString => "its path appears in a string literal",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Report {
pub findings: Vec<Finding>,
pub statistics: Stats,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Stats {
pub files: u32,
pub unparsed: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unparsed_files: Vec<String>,
pub reachable_files: u32,
pub symbols: u32,
pub entry_points: u32,
#[serde(default)]
pub by_category: Vec<CategoryCount>,
pub dead_lines: u32,
pub total_lines: u32,
pub percentage: f64,
pub detection_date: String,
}
impl Stats {
pub fn total_findings(&self) -> u32 {
self.by_category.iter().map(|c| c.count).sum()
}
pub fn count_of(&self, category: Category) -> u32 {
self.by_category
.iter()
.find(|c| c.category == category)
.map_or(0, |c| c.count)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CategoryCount {
#[serde(with = "category_serde")]
pub category: Category,
pub count: u32,
pub lines: u32,
}
mod category_serde {
use super::Category;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(c: &Category, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(c.as_str())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Category, D::Error> {
let raw = String::deserialize(d)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(category: Category, confidence: u8) -> Finding {
Finding {
category,
path: "src/a.ts".into(),
name: "foo".into(),
exported_as: None,
symbol_kind: Some(SymbolKind::Function),
parent: None,
language: "js".into(),
start: Location {
line: 3,
column: 0,
offset: 20,
},
end: Location {
line: 6,
column: 1,
offset: 60,
},
lines: 4,
confidence,
reasons: Vec::new(),
message: "message".into(),
}
}
#[test]
fn confidence_buckets_have_no_gaps() {
assert_eq!(ConfidenceLevel::of(100), ConfidenceLevel::Certain);
assert_eq!(ConfidenceLevel::of(90), ConfidenceLevel::Certain);
assert_eq!(ConfidenceLevel::of(89), ConfidenceLevel::High);
assert_eq!(ConfidenceLevel::of(75), ConfidenceLevel::High);
assert_eq!(ConfidenceLevel::of(74), ConfidenceLevel::Medium);
assert_eq!(ConfidenceLevel::of(50), ConfidenceLevel::Medium);
assert_eq!(ConfidenceLevel::of(49), ConfidenceLevel::Low);
assert_eq!(ConfidenceLevel::of(0), ConfidenceLevel::Low);
}
#[test]
fn fingerprint_ignores_line_numbers() {
let mut a = finding(Category::UnusedExport, 90);
let mut b = a.clone();
b.start.line = 400;
b.end.line = 410;
assert_eq!(a.fingerprint(), b.fingerprint());
a.name = "bar".into();
assert_ne!(a.fingerprint(), b.fingerprint());
}
#[test]
fn category_round_trips_through_json() {
let f = finding(Category::UnusedMember, 80);
let json = serde_json::to_string(&f).unwrap();
assert!(json.contains("\"unused-member\""), "{json}");
let back: Finding = serde_json::from_str(&json).unwrap();
assert_eq!(back.category, Category::UnusedMember);
}
#[test]
fn stats_sum_findings_across_categories() {
let stats = Stats {
by_category: vec![
CategoryCount {
category: Category::UnusedExport,
count: 3,
lines: 30,
},
CategoryCount {
category: Category::UnusedImport,
count: 5,
lines: 5,
},
],
..Stats::default()
};
assert_eq!(stats.total_findings(), 8);
assert_eq!(stats.count_of(Category::UnusedExport), 3);
assert_eq!(stats.count_of(Category::UnusedFile), 0);
}
}