1use std::sync::atomic::{AtomicBool, Ordering};
2
3use rustc_hash::FxHashMap;
4
5pub use fallow_types::suppress::{IssueKind, Suppression};
7
8pub use fallow_extract::suppress::parse_suppressions_from_source;
10
11use crate::discover::FileId;
12use crate::extract::ModuleInfo;
13use crate::graph::ModuleGraph;
14use crate::results::{StaleSuppression, SuppressionOrigin};
15
16const NON_CORE_KINDS: &[IssueKind] = &[
22 IssueKind::Complexity,
24 IssueKind::CoverageGaps,
25 IssueKind::FeatureFlag,
26 IssueKind::CodeDuplication,
27 IssueKind::UnusedDependency,
29 IssueKind::UnusedDevDependency,
30 IssueKind::UnlistedDependency,
31 IssueKind::TypeOnlyDependency,
32 IssueKind::TestOnlyDependency,
33 IssueKind::StaleSuppression,
37];
38
39pub struct SuppressionContext<'a> {
48 by_file: FxHashMap<FileId, &'a [Suppression]>,
49 used: FxHashMap<FileId, Vec<AtomicBool>>,
50}
51
52impl<'a> SuppressionContext<'a> {
53 pub fn new(modules: &'a [ModuleInfo]) -> Self {
55 let by_file: FxHashMap<FileId, &[Suppression]> = modules
56 .iter()
57 .filter(|m| !m.suppressions.is_empty())
58 .map(|m| (m.file_id, m.suppressions.as_slice()))
59 .collect();
60
61 let used = by_file
62 .iter()
63 .map(|(&fid, supps)| {
64 (
65 fid,
66 std::iter::repeat_with(|| AtomicBool::new(false))
67 .take(supps.len())
68 .collect(),
69 )
70 })
71 .collect();
72
73 Self { by_file, used }
74 }
75
76 #[cfg(test)]
78 pub fn from_map(by_file: FxHashMap<FileId, &'a [Suppression]>) -> Self {
79 let used = by_file
80 .iter()
81 .map(|(&fid, supps)| {
82 (
83 fid,
84 std::iter::repeat_with(|| AtomicBool::new(false))
85 .take(supps.len())
86 .collect(),
87 )
88 })
89 .collect();
90 Self { by_file, used }
91 }
92
93 #[cfg(test)]
95 pub fn empty() -> Self {
96 Self {
97 by_file: FxHashMap::default(),
98 used: FxHashMap::default(),
99 }
100 }
101
102 #[must_use]
105 pub fn is_suppressed(&self, file_id: FileId, line: u32, kind: IssueKind) -> bool {
106 let Some(supps) = self.by_file.get(&file_id) else {
107 return false;
108 };
109 let Some(used) = self.used.get(&file_id) else {
110 return false;
111 };
112 for (i, s) in supps.iter().enumerate() {
113 let matched = if s.line == 0 {
114 s.kind.is_none() || s.kind == Some(kind)
115 } else {
116 s.line == line && (s.kind.is_none() || s.kind == Some(kind))
117 };
118 if matched {
119 used[i].store(true, Ordering::Relaxed);
120 return true;
121 }
122 }
123 false
124 }
125
126 #[must_use]
129 pub fn is_file_suppressed(&self, file_id: FileId, kind: IssueKind) -> bool {
130 let Some(supps) = self.by_file.get(&file_id) else {
131 return false;
132 };
133 let Some(used) = self.used.get(&file_id) else {
134 return false;
135 };
136 for (i, s) in supps.iter().enumerate() {
137 if s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)) {
138 used[i].store(true, Ordering::Relaxed);
139 return true;
140 }
141 }
142 false
143 }
144
145 pub fn get(&self, file_id: FileId) -> Option<&[Suppression]> {
147 self.by_file.get(&file_id).copied()
148 }
149
150 pub fn find_stale(&self, graph: &ModuleGraph) -> Vec<StaleSuppression> {
156 let mut stale = Vec::new();
157
158 for (&file_id, supps) in &self.by_file {
159 let used = &self.used[&file_id];
160 let path = &graph.modules[file_id.0 as usize].path;
161
162 for (i, s) in supps.iter().enumerate() {
163 if used[i].load(Ordering::Relaxed) {
164 continue;
165 }
166
167 if let Some(kind) = s.kind
171 && NON_CORE_KINDS.contains(&kind)
172 {
173 continue;
174 }
175
176 let is_file_level = s.line == 0;
177 let issue_kind_str = s.kind.map(|k| {
178 match k {
180 IssueKind::UnusedFile => "unused-file",
181 IssueKind::UnusedExport => "unused-export",
182 IssueKind::UnusedType => "unused-type",
183 IssueKind::UnusedDependency => "unused-dependency",
184 IssueKind::UnusedDevDependency => "unused-dev-dependency",
185 IssueKind::UnusedEnumMember => "unused-enum-member",
186 IssueKind::UnusedClassMember => "unused-class-member",
187 IssueKind::UnresolvedImport => "unresolved-import",
188 IssueKind::UnlistedDependency => "unlisted-dependency",
189 IssueKind::DuplicateExport => "duplicate-export",
190 IssueKind::CodeDuplication => "code-duplication",
191 IssueKind::CircularDependency => "circular-dependency",
192 IssueKind::TypeOnlyDependency => "type-only-dependency",
193 IssueKind::TestOnlyDependency => "test-only-dependency",
194 IssueKind::BoundaryViolation => "boundary-violation",
195 IssueKind::CoverageGaps => "coverage-gaps",
196 IssueKind::FeatureFlag => "feature-flag",
197 IssueKind::Complexity => "complexity",
198 IssueKind::StaleSuppression => "stale-suppression",
199 }
200 .to_string()
201 });
202
203 stale.push(StaleSuppression {
204 path: path.clone(),
205 line: s.comment_line,
206 col: 0,
207 origin: SuppressionOrigin::Comment {
208 issue_kind: issue_kind_str,
209 is_file_level,
210 },
211 });
212 }
213 }
214
215 stale
216 }
217}
218
219#[must_use]
224pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
225 suppressions.iter().any(|s| {
226 if s.line == 0 {
228 return s.kind.is_none() || s.kind == Some(kind);
229 }
230 s.line == line && (s.kind.is_none() || s.kind == Some(kind))
232 })
233}
234
235#[must_use]
239pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
240 suppressions
241 .iter()
242 .any(|s| s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)))
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn issue_kind_from_str_all_variants() {
251 assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
252 assert_eq!(
253 IssueKind::parse("unused-export"),
254 Some(IssueKind::UnusedExport)
255 );
256 assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
257 assert_eq!(
258 IssueKind::parse("unused-dependency"),
259 Some(IssueKind::UnusedDependency)
260 );
261 assert_eq!(
262 IssueKind::parse("unused-dev-dependency"),
263 Some(IssueKind::UnusedDevDependency)
264 );
265 assert_eq!(
266 IssueKind::parse("unused-enum-member"),
267 Some(IssueKind::UnusedEnumMember)
268 );
269 assert_eq!(
270 IssueKind::parse("unused-class-member"),
271 Some(IssueKind::UnusedClassMember)
272 );
273 assert_eq!(
274 IssueKind::parse("unresolved-import"),
275 Some(IssueKind::UnresolvedImport)
276 );
277 assert_eq!(
278 IssueKind::parse("unlisted-dependency"),
279 Some(IssueKind::UnlistedDependency)
280 );
281 assert_eq!(
282 IssueKind::parse("duplicate-export"),
283 Some(IssueKind::DuplicateExport)
284 );
285 }
286
287 #[test]
288 fn issue_kind_from_str_unknown() {
289 assert_eq!(IssueKind::parse("foo"), None);
290 assert_eq!(IssueKind::parse(""), None);
291 }
292
293 #[test]
294 fn discriminant_roundtrip() {
295 for kind in [
296 IssueKind::UnusedFile,
297 IssueKind::UnusedExport,
298 IssueKind::UnusedType,
299 IssueKind::UnusedDependency,
300 IssueKind::UnusedDevDependency,
301 IssueKind::UnusedEnumMember,
302 IssueKind::UnusedClassMember,
303 IssueKind::UnresolvedImport,
304 IssueKind::UnlistedDependency,
305 IssueKind::DuplicateExport,
306 IssueKind::CodeDuplication,
307 IssueKind::CircularDependency,
308 IssueKind::TestOnlyDependency,
309 IssueKind::BoundaryViolation,
310 IssueKind::CoverageGaps,
311 IssueKind::FeatureFlag,
312 IssueKind::Complexity,
313 IssueKind::StaleSuppression,
314 ] {
315 assert_eq!(
316 IssueKind::from_discriminant(kind.to_discriminant()),
317 Some(kind)
318 );
319 }
320 assert_eq!(IssueKind::from_discriminant(0), None);
321 assert_eq!(IssueKind::from_discriminant(20), None);
322 }
323
324 #[test]
325 fn parse_file_wide_suppression() {
326 let source = "// fallow-ignore-file\nexport const foo = 1;\n";
327 let suppressions = parse_suppressions_from_source(source);
328 assert_eq!(suppressions.len(), 1);
329 assert_eq!(suppressions[0].line, 0);
330 assert!(suppressions[0].kind.is_none());
331 }
332
333 #[test]
334 fn parse_file_wide_suppression_with_kind() {
335 let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
336 let suppressions = parse_suppressions_from_source(source);
337 assert_eq!(suppressions.len(), 1);
338 assert_eq!(suppressions[0].line, 0);
339 assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
340 }
341
342 #[test]
343 fn parse_next_line_suppression() {
344 let source =
345 "import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
346 let suppressions = parse_suppressions_from_source(source);
347 assert_eq!(suppressions.len(), 1);
348 assert_eq!(suppressions[0].line, 3); assert!(suppressions[0].kind.is_none());
350 }
351
352 #[test]
353 fn parse_next_line_suppression_with_kind() {
354 let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
355 let suppressions = parse_suppressions_from_source(source);
356 assert_eq!(suppressions.len(), 1);
357 assert_eq!(suppressions[0].line, 2);
358 assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
359 }
360
361 #[test]
362 fn parse_unknown_kind_ignored() {
363 let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
364 let suppressions = parse_suppressions_from_source(source);
365 assert!(suppressions.is_empty());
366 }
367
368 #[test]
369 fn is_suppressed_file_wide() {
370 let suppressions = vec![Suppression {
371 line: 0,
372 comment_line: 1,
373 kind: None,
374 }];
375 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
376 assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
377 }
378
379 #[test]
380 fn is_suppressed_file_wide_specific_kind() {
381 let suppressions = vec![Suppression {
382 line: 0,
383 comment_line: 1,
384 kind: Some(IssueKind::UnusedExport),
385 }];
386 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
387 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
388 }
389
390 #[test]
391 fn is_suppressed_line_specific() {
392 let suppressions = vec![Suppression {
393 line: 5,
394 comment_line: 4,
395 kind: None,
396 }];
397 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
398 assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
399 }
400
401 #[test]
402 fn is_suppressed_line_and_kind() {
403 let suppressions = vec![Suppression {
404 line: 5,
405 comment_line: 4,
406 kind: Some(IssueKind::UnusedExport),
407 }];
408 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
409 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
410 assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
411 }
412
413 #[test]
414 fn is_suppressed_empty() {
415 assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
416 }
417
418 #[test]
419 fn is_file_suppressed_works() {
420 let suppressions = vec![Suppression {
421 line: 0,
422 comment_line: 1,
423 kind: None,
424 }];
425 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
426
427 let suppressions = vec![Suppression {
428 line: 0,
429 comment_line: 1,
430 kind: Some(IssueKind::UnusedFile),
431 }];
432 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
433 assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
434
435 let suppressions = vec![Suppression {
437 line: 5,
438 comment_line: 4,
439 kind: None,
440 }];
441 assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
442 }
443
444 #[test]
445 fn parse_oxc_comments() {
446 use fallow_extract::suppress::parse_suppressions;
447 use oxc_allocator::Allocator;
448 use oxc_parser::Parser;
449 use oxc_span::SourceType;
450
451 let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
452 let allocator = Allocator::default();
453 let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
454
455 let suppressions = parse_suppressions(&parser_return.program.comments, source);
456 assert_eq!(suppressions.len(), 2);
457
458 assert_eq!(suppressions[0].line, 0);
460 assert!(suppressions[0].kind.is_none());
461
462 assert_eq!(suppressions[1].line, 3); assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedExport));
465 }
466
467 #[test]
468 fn parse_block_comment_suppression() {
469 let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
470 let suppressions = parse_suppressions_from_source(source);
471 assert_eq!(suppressions.len(), 1);
472 assert_eq!(suppressions[0].line, 0);
473 assert!(suppressions[0].kind.is_none());
474 }
475
476 #[test]
477 fn is_suppressed_multiple_suppressions_different_kinds() {
478 let suppressions = vec![
479 Suppression {
480 line: 5,
481 comment_line: 4,
482 kind: Some(IssueKind::UnusedExport),
483 },
484 Suppression {
485 line: 5,
486 comment_line: 4,
487 kind: Some(IssueKind::UnusedType),
488 },
489 ];
490 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
491 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
492 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedFile));
493 }
494
495 #[test]
496 fn is_suppressed_file_wide_blanket_and_specific_coexist() {
497 let suppressions = vec![
498 Suppression {
499 line: 0,
500 comment_line: 1,
501 kind: Some(IssueKind::UnusedExport),
502 },
503 Suppression {
504 line: 5,
505 comment_line: 4,
506 kind: None, },
508 ];
509 assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedExport));
511 assert!(!is_suppressed(&suppressions, 10, IssueKind::UnusedType));
512
513 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
515 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
516 }
517
518 #[test]
519 fn is_file_suppressed_blanket_suppresses_all_kinds() {
520 let suppressions = vec![Suppression {
521 line: 0,
522 comment_line: 1,
523 kind: None, }];
525 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
526 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedExport));
527 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedType));
528 assert!(is_file_suppressed(
529 &suppressions,
530 IssueKind::CircularDependency
531 ));
532 assert!(is_file_suppressed(
533 &suppressions,
534 IssueKind::CodeDuplication
535 ));
536 }
537
538 #[test]
539 fn is_file_suppressed_empty_list() {
540 assert!(!is_file_suppressed(&[], IssueKind::UnusedFile));
541 }
542
543 #[test]
544 fn parse_multiple_next_line_suppressions() {
545 let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n// fallow-ignore-next-line unused-type\nexport type Bar = string;\n";
546 let suppressions = parse_suppressions_from_source(source);
547 assert_eq!(suppressions.len(), 2);
548 assert_eq!(suppressions[0].line, 2);
549 assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
550 assert_eq!(suppressions[1].line, 4);
551 assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedType));
552 }
553
554 #[test]
555 fn parse_code_duplication_suppression() {
556 let source = "// fallow-ignore-file code-duplication\nexport const foo = 1;\n";
557 let suppressions = parse_suppressions_from_source(source);
558 assert_eq!(suppressions.len(), 1);
559 assert_eq!(suppressions[0].line, 0);
560 assert_eq!(suppressions[0].kind, Some(IssueKind::CodeDuplication));
561 }
562
563 #[test]
564 fn parse_circular_dependency_suppression() {
565 let source = "// fallow-ignore-file circular-dependency\nimport { x } from './x';\n";
566 let suppressions = parse_suppressions_from_source(source);
567 assert_eq!(suppressions.len(), 1);
568 assert_eq!(suppressions[0].line, 0);
569 assert_eq!(suppressions[0].kind, Some(IssueKind::CircularDependency));
570 }
571
572 #[test]
578 fn all_issue_kinds_classified_for_stale_detection() {
579 let core_kinds = [
581 IssueKind::UnusedFile,
582 IssueKind::UnusedExport,
583 IssueKind::UnusedType,
584 IssueKind::UnusedEnumMember,
585 IssueKind::UnusedClassMember,
586 IssueKind::UnresolvedImport,
587 IssueKind::DuplicateExport,
588 IssueKind::CircularDependency,
589 IssueKind::BoundaryViolation,
590 ];
591
592 let all_kinds = [
594 IssueKind::UnusedFile,
595 IssueKind::UnusedExport,
596 IssueKind::UnusedType,
597 IssueKind::UnusedDependency,
598 IssueKind::UnusedDevDependency,
599 IssueKind::UnusedEnumMember,
600 IssueKind::UnusedClassMember,
601 IssueKind::UnresolvedImport,
602 IssueKind::UnlistedDependency,
603 IssueKind::DuplicateExport,
604 IssueKind::CodeDuplication,
605 IssueKind::CircularDependency,
606 IssueKind::TypeOnlyDependency,
607 IssueKind::TestOnlyDependency,
608 IssueKind::BoundaryViolation,
609 IssueKind::CoverageGaps,
610 IssueKind::FeatureFlag,
611 IssueKind::Complexity,
612 IssueKind::StaleSuppression,
613 ];
614
615 for kind in all_kinds {
616 let in_core = core_kinds.contains(&kind);
617 let in_non_core = NON_CORE_KINDS.contains(&kind);
618 assert!(
619 in_core || in_non_core,
620 "IssueKind::{kind:?} is not classified in either core_kinds or NON_CORE_KINDS. \
621 Add it to NON_CORE_KINDS if it is checked outside find_dead_code_full, \
622 or to core_kinds in this test if a core detector checks it."
623 );
624 assert!(
625 !(in_core && in_non_core),
626 "IssueKind::{kind:?} is in BOTH core_kinds and NON_CORE_KINDS. Pick one."
627 );
628 }
629 }
630}