Skip to main content

fallow_core/
suppress.rs

1use oxc_ast::ast::Comment;
2
3/// Issue kind for suppression matching.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum IssueKind {
6    UnusedFile,
7    UnusedExport,
8    UnusedType,
9    UnusedDependency,
10    UnusedDevDependency,
11    UnusedEnumMember,
12    UnusedClassMember,
13    UnresolvedImport,
14    UnlistedDependency,
15    DuplicateExport,
16    CodeDuplication,
17}
18
19impl IssueKind {
20    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
21    pub fn parse(s: &str) -> Option<Self> {
22        match s {
23            "unused-file" => Some(Self::UnusedFile),
24            "unused-export" => Some(Self::UnusedExport),
25            "unused-type" => Some(Self::UnusedType),
26            "unused-dependency" => Some(Self::UnusedDependency),
27            "unused-dev-dependency" => Some(Self::UnusedDevDependency),
28            "unused-enum-member" => Some(Self::UnusedEnumMember),
29            "unused-class-member" => Some(Self::UnusedClassMember),
30            "unresolved-import" => Some(Self::UnresolvedImport),
31            "unlisted-dependency" => Some(Self::UnlistedDependency),
32            "duplicate-export" => Some(Self::DuplicateExport),
33            "code-duplication" => Some(Self::CodeDuplication),
34            _ => None,
35        }
36    }
37
38    /// Convert to a u8 discriminant for compact cache storage.
39    pub fn to_discriminant(self) -> u8 {
40        match self {
41            Self::UnusedFile => 1,
42            Self::UnusedExport => 2,
43            Self::UnusedType => 3,
44            Self::UnusedDependency => 4,
45            Self::UnusedDevDependency => 5,
46            Self::UnusedEnumMember => 6,
47            Self::UnusedClassMember => 7,
48            Self::UnresolvedImport => 8,
49            Self::UnlistedDependency => 9,
50            Self::DuplicateExport => 10,
51            Self::CodeDuplication => 11,
52        }
53    }
54
55    /// Reconstruct from a cache discriminant.
56    pub fn from_discriminant(d: u8) -> Option<Self> {
57        match d {
58            1 => Some(Self::UnusedFile),
59            2 => Some(Self::UnusedExport),
60            3 => Some(Self::UnusedType),
61            4 => Some(Self::UnusedDependency),
62            5 => Some(Self::UnusedDevDependency),
63            6 => Some(Self::UnusedEnumMember),
64            7 => Some(Self::UnusedClassMember),
65            8 => Some(Self::UnresolvedImport),
66            9 => Some(Self::UnlistedDependency),
67            10 => Some(Self::DuplicateExport),
68            11 => Some(Self::CodeDuplication),
69            _ => None,
70        }
71    }
72}
73
74/// A suppression directive parsed from a source comment.
75#[derive(Debug, Clone)]
76pub struct Suppression {
77    /// 1-based line this suppression applies to. 0 = file-wide suppression.
78    pub line: u32,
79    /// None = suppress all issue kinds on this line.
80    pub kind: Option<IssueKind>,
81}
82
83/// Convert a byte offset to a 1-based line number.
84fn byte_offset_to_line(source: &str, byte_offset: u32) -> u32 {
85    let byte_offset = byte_offset as usize;
86    let prefix = &source[..byte_offset.min(source.len())];
87    prefix.bytes().filter(|&b| b == b'\n').count() as u32 + 1
88}
89
90/// Parse all fallow suppression comments from a file's comment list.
91///
92/// Supports:
93/// - `// fallow-ignore-file` — suppress all issues in the file
94/// - `// fallow-ignore-file unused-export` — suppress specific issue type for the file
95/// - `// fallow-ignore-next-line` — suppress all issues on the next line
96/// - `// fallow-ignore-next-line unused-export` — suppress specific issue type on the next line
97pub fn parse_suppressions(comments: &[Comment], source: &str) -> Vec<Suppression> {
98    let mut suppressions = Vec::new();
99
100    for comment in comments {
101        let content_span = comment.content_span();
102        let text = &source
103            [content_span.start as usize..content_span.end.min(source.len() as u32) as usize];
104        let trimmed = text.trim();
105
106        if let Some(rest) = trimmed.strip_prefix("fallow-ignore-file") {
107            let rest = rest.trim();
108            if rest.is_empty() {
109                suppressions.push(Suppression {
110                    line: 0,
111                    kind: None,
112                });
113            } else if let Some(kind) = IssueKind::parse(rest) {
114                suppressions.push(Suppression {
115                    line: 0,
116                    kind: Some(kind),
117                });
118            }
119            // Unknown kind token: silently ignore (no suppression created)
120        } else if let Some(rest) = trimmed.strip_prefix("fallow-ignore-next-line") {
121            let rest = rest.trim();
122            let comment_line = byte_offset_to_line(source, comment.span.start);
123            let suppressed_line = comment_line + 1;
124
125            if rest.is_empty() {
126                suppressions.push(Suppression {
127                    line: suppressed_line,
128                    kind: None,
129                });
130            } else if let Some(kind) = IssueKind::parse(rest) {
131                suppressions.push(Suppression {
132                    line: suppressed_line,
133                    kind: Some(kind),
134                });
135            }
136            // Unknown kind token: silently ignore
137        }
138    }
139
140    suppressions
141}
142
143/// Parse suppressions from raw source text using simple string scanning.
144/// Used for SFC files where comment byte offsets don't correspond to the original file.
145pub fn parse_suppressions_from_source(source: &str) -> Vec<Suppression> {
146    let mut suppressions = Vec::new();
147
148    for (line_idx, line) in source.lines().enumerate() {
149        let trimmed = line.trim();
150
151        // Match both // and /* */ style comments
152        let comment_text = if let Some(rest) = trimmed.strip_prefix("//") {
153            Some(rest.trim())
154        } else if let Some(rest) = trimmed.strip_prefix("/*") {
155            rest.strip_suffix("*/").map(|r| r.trim())
156        } else {
157            None
158        };
159
160        let Some(text) = comment_text else {
161            continue;
162        };
163
164        if let Some(rest) = text.strip_prefix("fallow-ignore-file") {
165            let rest = rest.trim();
166            if rest.is_empty() {
167                suppressions.push(Suppression {
168                    line: 0,
169                    kind: None,
170                });
171            } else if let Some(kind) = IssueKind::parse(rest) {
172                suppressions.push(Suppression {
173                    line: 0,
174                    kind: Some(kind),
175                });
176            }
177        } else if let Some(rest) = text.strip_prefix("fallow-ignore-next-line") {
178            let rest = rest.trim();
179            let suppressed_line = (line_idx as u32) + 2; // 1-based, next line
180
181            if rest.is_empty() {
182                suppressions.push(Suppression {
183                    line: suppressed_line,
184                    kind: None,
185                });
186            } else if let Some(kind) = IssueKind::parse(rest) {
187                suppressions.push(Suppression {
188                    line: suppressed_line,
189                    kind: Some(kind),
190                });
191            }
192        }
193    }
194
195    suppressions
196}
197
198/// Check if a specific issue at a given line should be suppressed.
199pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
200    suppressions.iter().any(|s| {
201        // File-wide suppression
202        if s.line == 0 {
203            return s.kind.is_none() || s.kind == Some(kind);
204        }
205        // Line-specific suppression
206        s.line == line && (s.kind.is_none() || s.kind == Some(kind))
207    })
208}
209
210/// Check if the entire file is suppressed (for issue types that don't have line numbers).
211pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
212    suppressions
213        .iter()
214        .any(|s| s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)))
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn issue_kind_from_str_all_variants() {
223        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
224        assert_eq!(
225            IssueKind::parse("unused-export"),
226            Some(IssueKind::UnusedExport)
227        );
228        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
229        assert_eq!(
230            IssueKind::parse("unused-dependency"),
231            Some(IssueKind::UnusedDependency)
232        );
233        assert_eq!(
234            IssueKind::parse("unused-dev-dependency"),
235            Some(IssueKind::UnusedDevDependency)
236        );
237        assert_eq!(
238            IssueKind::parse("unused-enum-member"),
239            Some(IssueKind::UnusedEnumMember)
240        );
241        assert_eq!(
242            IssueKind::parse("unused-class-member"),
243            Some(IssueKind::UnusedClassMember)
244        );
245        assert_eq!(
246            IssueKind::parse("unresolved-import"),
247            Some(IssueKind::UnresolvedImport)
248        );
249        assert_eq!(
250            IssueKind::parse("unlisted-dependency"),
251            Some(IssueKind::UnlistedDependency)
252        );
253        assert_eq!(
254            IssueKind::parse("duplicate-export"),
255            Some(IssueKind::DuplicateExport)
256        );
257    }
258
259    #[test]
260    fn issue_kind_from_str_unknown() {
261        assert_eq!(IssueKind::parse("foo"), None);
262        assert_eq!(IssueKind::parse(""), None);
263    }
264
265    #[test]
266    fn discriminant_roundtrip() {
267        for kind in [
268            IssueKind::UnusedFile,
269            IssueKind::UnusedExport,
270            IssueKind::UnusedType,
271            IssueKind::UnusedDependency,
272            IssueKind::UnusedDevDependency,
273            IssueKind::UnusedEnumMember,
274            IssueKind::UnusedClassMember,
275            IssueKind::UnresolvedImport,
276            IssueKind::UnlistedDependency,
277            IssueKind::DuplicateExport,
278            IssueKind::CodeDuplication,
279        ] {
280            assert_eq!(
281                IssueKind::from_discriminant(kind.to_discriminant()),
282                Some(kind)
283            );
284        }
285        assert_eq!(IssueKind::from_discriminant(0), None);
286        assert_eq!(IssueKind::from_discriminant(12), None);
287    }
288
289    #[test]
290    fn parse_file_wide_suppression() {
291        let source = "// fallow-ignore-file\nexport const foo = 1;\n";
292        let suppressions = parse_suppressions_from_source(source);
293        assert_eq!(suppressions.len(), 1);
294        assert_eq!(suppressions[0].line, 0);
295        assert!(suppressions[0].kind.is_none());
296    }
297
298    #[test]
299    fn parse_file_wide_suppression_with_kind() {
300        let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
301        let suppressions = parse_suppressions_from_source(source);
302        assert_eq!(suppressions.len(), 1);
303        assert_eq!(suppressions[0].line, 0);
304        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
305    }
306
307    #[test]
308    fn parse_next_line_suppression() {
309        let source =
310            "import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
311        let suppressions = parse_suppressions_from_source(source);
312        assert_eq!(suppressions.len(), 1);
313        assert_eq!(suppressions[0].line, 3); // suppresses line 3 (the export)
314        assert!(suppressions[0].kind.is_none());
315    }
316
317    #[test]
318    fn parse_next_line_suppression_with_kind() {
319        let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
320        let suppressions = parse_suppressions_from_source(source);
321        assert_eq!(suppressions.len(), 1);
322        assert_eq!(suppressions[0].line, 2);
323        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
324    }
325
326    #[test]
327    fn parse_unknown_kind_ignored() {
328        let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
329        let suppressions = parse_suppressions_from_source(source);
330        assert!(suppressions.is_empty());
331    }
332
333    #[test]
334    fn is_suppressed_file_wide() {
335        let suppressions = vec![Suppression {
336            line: 0,
337            kind: None,
338        }];
339        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
340        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
341    }
342
343    #[test]
344    fn is_suppressed_file_wide_specific_kind() {
345        let suppressions = vec![Suppression {
346            line: 0,
347            kind: Some(IssueKind::UnusedExport),
348        }];
349        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
350        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
351    }
352
353    #[test]
354    fn is_suppressed_line_specific() {
355        let suppressions = vec![Suppression {
356            line: 5,
357            kind: None,
358        }];
359        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
360        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
361    }
362
363    #[test]
364    fn is_suppressed_line_and_kind() {
365        let suppressions = vec![Suppression {
366            line: 5,
367            kind: Some(IssueKind::UnusedExport),
368        }];
369        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
370        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
371        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
372    }
373
374    #[test]
375    fn is_suppressed_empty() {
376        assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
377    }
378
379    #[test]
380    fn is_file_suppressed_works() {
381        let suppressions = vec![Suppression {
382            line: 0,
383            kind: None,
384        }];
385        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
386
387        let suppressions = vec![Suppression {
388            line: 0,
389            kind: Some(IssueKind::UnusedFile),
390        }];
391        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
392        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
393
394        // Line-specific suppression should not count as file-wide
395        let suppressions = vec![Suppression {
396            line: 5,
397            kind: None,
398        }];
399        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
400    }
401
402    #[test]
403    fn parse_oxc_comments() {
404        use oxc_allocator::Allocator;
405        use oxc_parser::Parser;
406        use oxc_span::SourceType;
407
408        let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
409        let allocator = Allocator::default();
410        let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
411
412        let suppressions = parse_suppressions(&parser_return.program.comments, source);
413        assert_eq!(suppressions.len(), 2);
414
415        // File-wide suppression
416        assert_eq!(suppressions[0].line, 0);
417        assert!(suppressions[0].kind.is_none());
418
419        // Next-line suppression with kind
420        assert_eq!(suppressions[1].line, 3); // suppresses line 3 (export const foo)
421        assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedExport));
422    }
423
424    #[test]
425    fn parse_block_comment_suppression() {
426        let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
427        let suppressions = parse_suppressions_from_source(source);
428        assert_eq!(suppressions.len(), 1);
429        assert_eq!(suppressions[0].line, 0);
430        assert!(suppressions[0].kind.is_none());
431    }
432}