use std::sync::atomic::{AtomicBool, Ordering};
use fallow_config::{ResolvedConfig, RulesConfig, Severity};
use rustc_hash::FxHashMap;
pub use fallow_types::suppress::{IssueKind, Suppression, UnknownSuppressionKind};
pub use fallow_extract::suppress::parse_suppressions_from_source;
use crate::discover::FileId;
use crate::extract::ModuleInfo;
use crate::graph::ModuleGraph;
use crate::results::{StaleSuppression, SuppressionOrigin};
fn severity_for_kind(rules: &RulesConfig, kind: IssueKind) -> Severity {
match kind {
IssueKind::UnusedFile => rules.unused_files,
IssueKind::UnusedExport => rules.unused_exports,
IssueKind::UnusedType => rules.unused_types,
IssueKind::PrivateTypeLeak => rules.private_type_leaks,
IssueKind::UnusedDependency => rules.unused_dependencies,
IssueKind::UnusedDevDependency => rules.unused_dev_dependencies,
IssueKind::UnusedEnumMember => rules.unused_enum_members,
IssueKind::UnusedClassMember => rules.unused_class_members,
IssueKind::UnresolvedImport => rules.unresolved_imports,
IssueKind::UnlistedDependency => rules.unlisted_dependencies,
IssueKind::DuplicateExport => rules.duplicate_exports,
IssueKind::CircularDependency => rules.circular_dependencies,
IssueKind::ReExportCycle => rules.re_export_cycle,
IssueKind::TypeOnlyDependency => rules.type_only_dependencies,
IssueKind::TestOnlyDependency => rules.test_only_dependencies,
IssueKind::BoundaryViolation => rules.boundary_violation,
IssueKind::CoverageGaps => rules.coverage_gaps,
IssueKind::FeatureFlag => rules.feature_flags,
IssueKind::StaleSuppression => rules.stale_suppressions,
IssueKind::PnpmCatalogEntry => rules.unused_catalog_entries,
IssueKind::EmptyCatalogGroup => rules.empty_catalog_groups,
IssueKind::UnresolvedCatalogReference => rules.unresolved_catalog_references,
IssueKind::UnusedDependencyOverride => rules.unused_dependency_overrides,
IssueKind::MisconfiguredDependencyOverride => rules.misconfigured_dependency_overrides,
IssueKind::Complexity | IssueKind::CodeDuplication => Severity::Error,
}
}
const NON_CORE_KINDS: &[IssueKind] = &[
IssueKind::Complexity,
IssueKind::CoverageGaps,
IssueKind::FeatureFlag,
IssueKind::CodeDuplication,
IssueKind::UnusedDependency,
IssueKind::UnusedDevDependency,
IssueKind::UnlistedDependency,
IssueKind::TypeOnlyDependency,
IssueKind::TestOnlyDependency,
IssueKind::PnpmCatalogEntry,
IssueKind::EmptyCatalogGroup,
IssueKind::UnresolvedCatalogReference,
IssueKind::UnusedDependencyOverride,
IssueKind::MisconfiguredDependencyOverride,
IssueKind::StaleSuppression,
];
pub struct SuppressionContext<'a> {
by_file: FxHashMap<FileId, &'a [Suppression]>,
used: FxHashMap<FileId, Vec<AtomicBool>>,
unknown_kinds: FxHashMap<FileId, &'a [UnknownSuppressionKind]>,
}
impl<'a> SuppressionContext<'a> {
pub fn new(modules: &'a [ModuleInfo]) -> Self {
let by_file: FxHashMap<FileId, &[Suppression]> = modules
.iter()
.filter(|m| !m.suppressions.is_empty())
.map(|m| (m.file_id, m.suppressions.as_slice()))
.collect();
let used = by_file
.iter()
.map(|(&fid, supps)| {
(
fid,
std::iter::repeat_with(|| AtomicBool::new(false))
.take(supps.len())
.collect(),
)
})
.collect();
let unknown_kinds: FxHashMap<FileId, &[UnknownSuppressionKind]> = modules
.iter()
.filter(|m| !m.unknown_suppression_kinds.is_empty())
.map(|m| (m.file_id, m.unknown_suppression_kinds.as_slice()))
.collect();
Self {
by_file,
used,
unknown_kinds,
}
}
#[cfg(test)]
pub fn from_map(by_file: FxHashMap<FileId, &'a [Suppression]>) -> Self {
let used = by_file
.iter()
.map(|(&fid, supps)| {
(
fid,
std::iter::repeat_with(|| AtomicBool::new(false))
.take(supps.len())
.collect(),
)
})
.collect();
Self {
by_file,
used,
unknown_kinds: FxHashMap::default(),
}
}
#[cfg(test)]
pub fn empty() -> Self {
Self {
by_file: FxHashMap::default(),
used: FxHashMap::default(),
unknown_kinds: FxHashMap::default(),
}
}
#[must_use]
pub fn is_suppressed(&self, file_id: FileId, line: u32, kind: IssueKind) -> bool {
let Some(supps) = self.by_file.get(&file_id) else {
return false;
};
let Some(used) = self.used.get(&file_id) else {
return false;
};
for (i, s) in supps.iter().enumerate() {
let matched = if s.line == 0 {
s.kind.is_none() || s.kind == Some(kind)
} else {
s.line == line && (s.kind.is_none() || s.kind == Some(kind))
};
if matched {
used[i].store(true, Ordering::Relaxed);
return true;
}
}
false
}
#[must_use]
pub fn is_file_suppressed(&self, file_id: FileId, kind: IssueKind) -> bool {
let Some(supps) = self.by_file.get(&file_id) else {
return false;
};
let Some(used) = self.used.get(&file_id) else {
return false;
};
for (i, s) in supps.iter().enumerate() {
if s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)) {
used[i].store(true, Ordering::Relaxed);
return true;
}
}
false
}
pub fn get(&self, file_id: FileId) -> Option<&[Suppression]> {
self.by_file.get(&file_id).copied()
}
#[must_use]
pub fn used_count(&self) -> usize {
self.used
.values()
.flat_map(|used| used.iter())
.filter(|used| used.load(Ordering::Relaxed))
.count()
}
pub fn find_stale(
&self,
graph: &ModuleGraph,
config: &ResolvedConfig,
) -> Vec<StaleSuppression> {
let mut stale = Vec::new();
for (&file_id, supps) in &self.by_file {
let used = &self.used[&file_id];
let path = &graph.modules[file_id.0 as usize].path;
let file_rules = config.resolve_rules_for_path(path);
for (i, s) in supps.iter().enumerate() {
if used[i].load(Ordering::Relaxed) {
continue;
}
if let Some(kind) = s.kind
&& NON_CORE_KINDS.contains(&kind)
{
continue;
}
if let Some(kind) = s.kind
&& severity_for_kind(&file_rules, kind) == Severity::Off
{
continue;
}
let is_file_level = s.line == 0;
let issue_kind_str = s.kind.map(|k| {
match k {
IssueKind::UnusedFile => "unused-file",
IssueKind::UnusedExport => "unused-export",
IssueKind::UnusedType => "unused-type",
IssueKind::PrivateTypeLeak => "private-type-leak",
IssueKind::UnusedDependency => "unused-dependency",
IssueKind::UnusedDevDependency => "unused-dev-dependency",
IssueKind::UnusedEnumMember => "unused-enum-member",
IssueKind::UnusedClassMember => "unused-class-member",
IssueKind::UnresolvedImport => "unresolved-import",
IssueKind::UnlistedDependency => "unlisted-dependency",
IssueKind::DuplicateExport => "duplicate-export",
IssueKind::CodeDuplication => "code-duplication",
IssueKind::CircularDependency => "circular-dependency",
IssueKind::ReExportCycle => "re-export-cycle",
IssueKind::TypeOnlyDependency => "type-only-dependency",
IssueKind::TestOnlyDependency => "test-only-dependency",
IssueKind::BoundaryViolation => "boundary-violation",
IssueKind::CoverageGaps => "coverage-gaps",
IssueKind::FeatureFlag => "feature-flag",
IssueKind::Complexity => "complexity",
IssueKind::StaleSuppression => "stale-suppression",
IssueKind::PnpmCatalogEntry => "unused-catalog-entry",
IssueKind::EmptyCatalogGroup => "empty-catalog-group",
IssueKind::UnresolvedCatalogReference => "unresolved-catalog-reference",
IssueKind::UnusedDependencyOverride => "unused-dependency-override",
IssueKind::MisconfiguredDependencyOverride => {
"misconfigured-dependency-override"
}
}
.to_string()
});
stale.push(StaleSuppression {
path: path.clone(),
line: s.comment_line,
col: 0,
origin: SuppressionOrigin::Comment {
issue_kind: issue_kind_str,
is_file_level,
kind_known: true,
},
});
}
}
for (&file_id, unknowns) in &self.unknown_kinds {
let path = &graph.modules[file_id.0 as usize].path;
for u in *unknowns {
stale.push(StaleSuppression {
path: path.clone(),
line: u.comment_line,
col: 0,
origin: SuppressionOrigin::Comment {
issue_kind: Some(u.token.clone()),
is_file_level: u.is_file_level,
kind_known: false,
},
});
}
}
stale
}
}
#[must_use]
pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
suppressions.iter().any(|s| {
if s.line == 0 {
return s.kind.is_none() || s.kind == Some(kind);
}
s.line == line && (s.kind.is_none() || s.kind == Some(kind))
})
}
#[must_use]
pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
suppressions
.iter()
.any(|s| s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_for_kind_maps_every_core_kind_to_its_field() {
let rules = RulesConfig {
unused_exports: Severity::Warn,
unused_types: Severity::Off,
unresolved_imports: Severity::Error,
boundary_violation: Severity::Off,
..RulesConfig::default()
};
assert_eq!(
severity_for_kind(&rules, IssueKind::UnusedExport),
Severity::Warn
);
assert_eq!(
severity_for_kind(&rules, IssueKind::UnusedType),
Severity::Off
);
assert_eq!(
severity_for_kind(&rules, IssueKind::UnresolvedImport),
Severity::Error
);
assert_eq!(
severity_for_kind(&rules, IssueKind::BoundaryViolation),
Severity::Off
);
assert_eq!(
severity_for_kind(&rules, IssueKind::PrivateTypeLeak),
Severity::Off
);
}
#[test]
fn issue_kind_from_str_all_variants() {
assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
assert_eq!(
IssueKind::parse("unused-export"),
Some(IssueKind::UnusedExport)
);
assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
assert_eq!(
IssueKind::parse("unused-dependency"),
Some(IssueKind::UnusedDependency)
);
assert_eq!(
IssueKind::parse("unused-dev-dependency"),
Some(IssueKind::UnusedDevDependency)
);
assert_eq!(
IssueKind::parse("unused-enum-member"),
Some(IssueKind::UnusedEnumMember)
);
assert_eq!(
IssueKind::parse("unused-class-member"),
Some(IssueKind::UnusedClassMember)
);
assert_eq!(
IssueKind::parse("unresolved-import"),
Some(IssueKind::UnresolvedImport)
);
assert_eq!(
IssueKind::parse("unlisted-dependency"),
Some(IssueKind::UnlistedDependency)
);
assert_eq!(
IssueKind::parse("duplicate-export"),
Some(IssueKind::DuplicateExport)
);
}
#[test]
fn issue_kind_from_str_unknown() {
assert_eq!(IssueKind::parse("foo"), None);
assert_eq!(IssueKind::parse(""), None);
}
#[test]
fn discriminant_roundtrip() {
for kind in [
IssueKind::UnusedFile,
IssueKind::UnusedExport,
IssueKind::UnusedType,
IssueKind::PrivateTypeLeak,
IssueKind::UnusedDependency,
IssueKind::UnusedDevDependency,
IssueKind::UnusedEnumMember,
IssueKind::UnusedClassMember,
IssueKind::UnresolvedImport,
IssueKind::UnlistedDependency,
IssueKind::DuplicateExport,
IssueKind::CodeDuplication,
IssueKind::CircularDependency,
IssueKind::TestOnlyDependency,
IssueKind::BoundaryViolation,
IssueKind::CoverageGaps,
IssueKind::FeatureFlag,
IssueKind::Complexity,
IssueKind::StaleSuppression,
IssueKind::PnpmCatalogEntry,
IssueKind::EmptyCatalogGroup,
IssueKind::UnresolvedCatalogReference,
IssueKind::UnusedDependencyOverride,
IssueKind::MisconfiguredDependencyOverride,
IssueKind::ReExportCycle,
] {
assert_eq!(
IssueKind::from_discriminant(kind.to_discriminant()),
Some(kind)
);
}
assert_eq!(IssueKind::from_discriminant(0), None);
assert_eq!(IssueKind::from_discriminant(27), None);
}
#[test]
fn parse_file_wide_suppression() {
let source = "// fallow-ignore-file\nexport const foo = 1;\n";
let suppressions = parse_suppressions_from_source(source).suppressions;
assert_eq!(suppressions.len(), 1);
assert_eq!(suppressions[0].line, 0);
assert!(suppressions[0].kind.is_none());
}
#[test]
fn parse_file_wide_suppression_with_kind() {
let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
let suppressions = parse_suppressions_from_source(source).suppressions;
assert_eq!(suppressions.len(), 1);
assert_eq!(suppressions[0].line, 0);
assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
}
#[test]
fn parse_next_line_suppression() {
let source =
"import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
let suppressions = parse_suppressions_from_source(source).suppressions;
assert_eq!(suppressions.len(), 1);
assert_eq!(suppressions[0].line, 3); assert!(suppressions[0].kind.is_none());
}
#[test]
fn parse_next_line_suppression_with_kind() {
let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
let suppressions = parse_suppressions_from_source(source).suppressions;
assert_eq!(suppressions.len(), 1);
assert_eq!(suppressions[0].line, 2);
assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
}
#[test]
fn parse_unknown_kind_surfaces_as_unknown() {
let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
let parsed = parse_suppressions_from_source(source);
assert!(parsed.suppressions.is_empty());
assert_eq!(parsed.unknown_kinds.len(), 1);
assert_eq!(parsed.unknown_kinds[0].token, "typo-kind");
}
#[test]
fn is_suppressed_file_wide() {
let suppressions = vec![Suppression {
line: 0,
comment_line: 1,
kind: None,
}];
assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
}
#[test]
fn is_suppressed_file_wide_specific_kind() {
let suppressions = vec![Suppression {
line: 0,
comment_line: 1,
kind: Some(IssueKind::UnusedExport),
}];
assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
}
#[test]
fn is_suppressed_line_specific() {
let suppressions = vec![Suppression {
line: 5,
comment_line: 4,
kind: None,
}];
assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
}
#[test]
fn is_suppressed_line_and_kind() {
let suppressions = vec![Suppression {
line: 5,
comment_line: 4,
kind: Some(IssueKind::UnusedExport),
}];
assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
}
#[test]
fn is_suppressed_empty() {
assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
}
#[test]
fn is_file_suppressed_works() {
let suppressions = vec![Suppression {
line: 0,
comment_line: 1,
kind: None,
}];
assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
let suppressions = vec![Suppression {
line: 0,
comment_line: 1,
kind: Some(IssueKind::UnusedFile),
}];
assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
let suppressions = vec![Suppression {
line: 5,
comment_line: 4,
kind: None,
}];
assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
}
#[test]
fn parse_oxc_comments() {
use fallow_extract::suppress::parse_suppressions;
use oxc_allocator::Allocator;
use oxc_parser::Parser;
use oxc_span::SourceType;
let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
let suppressions = parse_suppressions(&parser_return.program.comments, source).suppressions;
assert_eq!(suppressions.len(), 2);
assert_eq!(suppressions[0].line, 0);
assert!(suppressions[0].kind.is_none());
assert_eq!(suppressions[1].line, 3); assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedExport));
}
#[test]
fn parse_block_comment_suppression() {
let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
let suppressions = parse_suppressions_from_source(source).suppressions;
assert_eq!(suppressions.len(), 1);
assert_eq!(suppressions[0].line, 0);
assert!(suppressions[0].kind.is_none());
}
#[test]
fn is_suppressed_multiple_suppressions_different_kinds() {
let suppressions = vec![
Suppression {
line: 5,
comment_line: 4,
kind: Some(IssueKind::UnusedExport),
},
Suppression {
line: 5,
comment_line: 4,
kind: Some(IssueKind::UnusedType),
},
];
assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedFile));
}
#[test]
fn is_suppressed_file_wide_blanket_and_specific_coexist() {
let suppressions = vec![
Suppression {
line: 0,
comment_line: 1,
kind: Some(IssueKind::UnusedExport),
},
Suppression {
line: 5,
comment_line: 4,
kind: None, },
];
assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedExport));
assert!(!is_suppressed(&suppressions, 10, IssueKind::UnusedType));
assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
}
#[test]
fn is_file_suppressed_blanket_suppresses_all_kinds() {
let suppressions = vec![Suppression {
line: 0,
comment_line: 1,
kind: None, }];
assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
assert!(is_file_suppressed(&suppressions, IssueKind::UnusedExport));
assert!(is_file_suppressed(&suppressions, IssueKind::UnusedType));
assert!(is_file_suppressed(
&suppressions,
IssueKind::CircularDependency
));
assert!(is_file_suppressed(
&suppressions,
IssueKind::CodeDuplication
));
}
#[test]
fn is_file_suppressed_empty_list() {
assert!(!is_file_suppressed(&[], IssueKind::UnusedFile));
}
#[test]
fn parse_multiple_next_line_suppressions() {
let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n// fallow-ignore-next-line unused-type\nexport type Bar = string;\n";
let suppressions = parse_suppressions_from_source(source).suppressions;
assert_eq!(suppressions.len(), 2);
assert_eq!(suppressions[0].line, 2);
assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
assert_eq!(suppressions[1].line, 4);
assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedType));
}
#[test]
fn parse_code_duplication_suppression() {
let source = "// fallow-ignore-file code-duplication\nexport const foo = 1;\n";
let suppressions = parse_suppressions_from_source(source).suppressions;
assert_eq!(suppressions.len(), 1);
assert_eq!(suppressions[0].line, 0);
assert_eq!(suppressions[0].kind, Some(IssueKind::CodeDuplication));
}
#[test]
fn parse_circular_dependency_suppression() {
let source = "// fallow-ignore-file circular-dependency\nimport { x } from './x';\n";
let suppressions = parse_suppressions_from_source(source).suppressions;
assert_eq!(suppressions.len(), 1);
assert_eq!(suppressions[0].line, 0);
assert_eq!(suppressions[0].kind, Some(IssueKind::CircularDependency));
}
#[test]
fn all_issue_kinds_classified_for_stale_detection() {
let core_kinds = [
IssueKind::UnusedFile,
IssueKind::UnusedExport,
IssueKind::UnusedType,
IssueKind::UnusedEnumMember,
IssueKind::UnusedClassMember,
IssueKind::UnresolvedImport,
IssueKind::DuplicateExport,
IssueKind::CircularDependency,
IssueKind::BoundaryViolation,
];
let all_kinds = [
IssueKind::UnusedFile,
IssueKind::UnusedExport,
IssueKind::UnusedType,
IssueKind::UnusedDependency,
IssueKind::UnusedDevDependency,
IssueKind::UnusedEnumMember,
IssueKind::UnusedClassMember,
IssueKind::UnresolvedImport,
IssueKind::UnlistedDependency,
IssueKind::DuplicateExport,
IssueKind::CodeDuplication,
IssueKind::CircularDependency,
IssueKind::TypeOnlyDependency,
IssueKind::TestOnlyDependency,
IssueKind::BoundaryViolation,
IssueKind::CoverageGaps,
IssueKind::FeatureFlag,
IssueKind::Complexity,
IssueKind::StaleSuppression,
IssueKind::PnpmCatalogEntry,
IssueKind::EmptyCatalogGroup,
IssueKind::UnresolvedCatalogReference,
IssueKind::UnusedDependencyOverride,
IssueKind::MisconfiguredDependencyOverride,
];
for kind in all_kinds {
let in_core = core_kinds.contains(&kind);
let in_non_core = NON_CORE_KINDS.contains(&kind);
assert!(
in_core || in_non_core,
"IssueKind::{kind:?} is not classified in either core_kinds or NON_CORE_KINDS. \
Add it to NON_CORE_KINDS if it is checked outside find_dead_code_full, \
or to core_kinds in this test if a core detector checks it."
);
assert!(
!(in_core && in_non_core),
"IssueKind::{kind:?} is in BOTH core_kinds and NON_CORE_KINDS. Pick one."
);
}
}
}