use std::sync::atomic::{AtomicBool, Ordering};
use rustc_hash::FxHashMap;
pub use fallow_types::suppress::{IssueKind, Suppression};
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};
const NON_CORE_KINDS: &[IssueKind] = &[
IssueKind::Complexity,
IssueKind::CoverageGaps,
IssueKind::FeatureFlag,
IssueKind::CodeDuplication,
IssueKind::UnusedDependency,
IssueKind::UnusedDevDependency,
IssueKind::UnlistedDependency,
IssueKind::TypeOnlyDependency,
IssueKind::TestOnlyDependency,
IssueKind::StaleSuppression,
];
pub struct SuppressionContext<'a> {
by_file: FxHashMap<FileId, &'a [Suppression]>,
used: FxHashMap<FileId, Vec<AtomicBool>>,
}
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();
Self { by_file, used }
}
#[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 }
}
#[cfg(test)]
pub fn empty() -> Self {
Self {
by_file: FxHashMap::default(),
used: 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()
}
pub fn find_stale(&self, graph: &ModuleGraph) -> 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;
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;
}
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::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::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",
}
.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,
},
});
}
}
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 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::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,
] {
assert_eq!(
IssueKind::from_discriminant(kind.to_discriminant()),
Some(kind)
);
}
assert_eq!(IssueKind::from_discriminant(0), None);
assert_eq!(IssueKind::from_discriminant(20), None);
}
#[test]
fn parse_file_wide_suppression() {
let source = "// fallow-ignore-file\nexport const foo = 1;\n";
let suppressions = parse_suppressions_from_source(source);
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);
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);
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);
assert_eq!(suppressions.len(), 1);
assert_eq!(suppressions[0].line, 2);
assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
}
#[test]
fn parse_unknown_kind_ignored() {
let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
let suppressions = parse_suppressions_from_source(source);
assert!(suppressions.is_empty());
}
#[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);
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);
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);
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);
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);
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,
];
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."
);
}
}
}