1use oxc_ast::ast::Comment;
2
3#[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 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 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 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#[derive(Debug, Clone)]
76pub struct Suppression {
77 pub line: u32,
79 pub kind: Option<IssueKind>,
81}
82
83fn 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
90pub 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 } 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 }
138 }
139
140 suppressions
141}
142
143pub 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 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; 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
198pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
200 suppressions.iter().any(|s| {
201 if s.line == 0 {
203 return s.kind.is_none() || s.kind == Some(kind);
204 }
205 s.line == line && (s.kind.is_none() || s.kind == Some(kind))
207 })
208}
209
210pub 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); 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 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 assert_eq!(suppressions[0].line, 0);
417 assert!(suppressions[0].kind.is_none());
418
419 assert_eq!(suppressions[1].line, 3); 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}