1use skif_core::HashMap;
6use std::path::Path;
7
8use oxc_allocator::Allocator;
9use oxc_ast::ast::{
10 ArrayExpression, ArrayExpressionElement, Comment, Declaration, Expression, ObjectExpression, ObjectPropertyKind,
11 Program, Statement,
12};
13use oxc_ast_visit::Visit;
14use oxc_parser::{ParseOptions as OxcParseOptions, Parser};
15use oxc_span::{GetSpan, SourceType};
16
17use crate::finder::static_string;
18
19const DIRECTIVES: &[&str] = &[
22 "eslint-disable",
23 "eslint-disable-next-line",
24 "eslint-disable-line",
25 "eslint-enable",
26 "eslint-env",
27 "@ts-ignore",
28 "@ts-expect-error",
29 "@ts-nocheck",
30 "@ts-check",
31 "istanbul ignore",
32 "c8 ignore",
33 "prettier-ignore",
34 "biome-ignore",
35 "biome-ignore-all",
36 "biome-ignore-start",
37 "biome-ignore-end",
38 "oxlint-disable",
39 "oxlint-disable-next-line",
40 "oxlint-disable-line",
41 "oxlint-enable",
42];
43
44fn is_directive(text: &str, block: bool) -> bool {
48 let whole_word = |rest: &str| {
49 rest.chars()
50 .next()
51 .is_none_or(|next| next.is_whitespace() || next == ':')
52 };
53 DIRECTIVES
54 .iter()
55 .any(|directive| text.strip_prefix(directive).is_some_and(whole_word))
56 || (block && is_eslint_rule_config(text))
57}
58
59fn is_eslint_rule_config(text: &str) -> bool {
62 let Some(rest) = text
63 .strip_prefix("eslint")
64 .filter(|rest| rest.starts_with(char::is_whitespace))
65 else {
66 return false;
67 };
68 let Some((rule, _)) = rest.trim_start().split_once(':') else {
69 return false;
70 };
71 let rule = rule.trim_end();
72 !rule.is_empty()
73 && rule
74 .chars()
75 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '/' | '@'))
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80#[non_exhaustive]
81pub enum PathSegment {
82 Key(String),
84 Index(usize),
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
91#[non_exhaustive]
92pub struct ElementComment {
93 pub array: Vec<PathSegment>,
95 pub index: usize,
97 pub after_spread: bool,
100 pub value: Option<String>,
102 pub text: String,
105 pub heading: bool,
109}
110
111impl ElementComment {
112 #[must_use]
114 pub fn new(array: Vec<PathSegment>, index: usize, value: Option<String>, text: impl Into<String>) -> Self {
115 Self {
116 array,
117 index,
118 after_spread: false,
119 value,
120 text: text.into(),
121 heading: false,
122 }
123 }
124
125 #[must_use]
127 pub fn after_spread(mut self) -> Self {
128 self.after_spread = true;
129 self
130 }
131
132 #[must_use]
134 pub fn under_heading(mut self) -> Self {
135 self.heading = true;
136 self
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
143#[non_exhaustive]
144pub struct PropertyComment {
145 pub path: Vec<PathSegment>,
147 pub text: String,
149}
150
151impl PropertyComment {
152 #[must_use]
154 pub fn new(path: Vec<PathSegment>, text: impl Into<String>) -> Self {
155 Self {
156 path,
157 text: text.into(),
158 }
159 }
160}
161
162#[derive(Debug, Clone, Default, PartialEq, Eq)]
164#[non_exhaustive]
165pub struct ConfigComments {
166 pub elements: Vec<ElementComment>,
168 pub properties: Vec<PropertyComment>,
170}
171
172#[must_use]
185pub fn extract_array_comments(source: &str, path: &Path) -> Option<Vec<ElementComment>> {
186 let mut comments = extract_config_comments(source, path)?.elements;
187 comments.retain(|comment| !comment.heading);
188 Some(comments)
189}
190
191#[must_use]
198pub fn extract_config_comments(source: &str, path: &Path) -> Option<ConfigComments> {
199 let source_type = SourceType::from_path(path).ok()?;
200 let allocator = Allocator::default();
201 let ret = Parser::new(&allocator, source, source_type)
202 .with_options(OxcParseOptions {
203 preserve_parens: false,
204 ..OxcParseOptions::default()
205 })
206 .parse();
207 if ret.diagnostics.errors().next().is_some() {
208 return None;
209 }
210 let mut roots = Roots {
211 source,
212 comments: &ret.program.comments,
213 constants: constants(&ret.program),
214 following: Vec::new(),
215 out: ConfigComments::default(),
216 };
217 roots.visit_program(&ret.program);
218 Some(roots.out)
219}
220
221fn constants<'s, 'a>(program: &'s Program<'a>) -> HashMap<&'s str, &'s Expression<'a>> {
223 program
224 .body
225 .iter()
226 .filter_map(|statement| match statement {
227 Statement::VariableDeclaration(declaration) => Some(&**declaration),
228 Statement::ExportDeclaration(export) => match &export.declaration {
229 Declaration::VariableDeclaration(declaration) => Some(&**declaration),
230 _ => None,
231 },
232 _ => None,
233 })
234 .filter(|declaration| declaration.kind.is_const())
235 .flat_map(|declaration| declaration.declarations.iter())
236 .filter_map(|declarator| {
237 let name = declarator.id.get_binding_identifier()?.name.as_str();
238 let init = declarator.init.as_ref()?;
239 matches!(
240 init.get_inner_expression(),
241 Expression::ObjectExpression(_) | Expression::ArrayExpression(_)
242 )
243 .then_some((name, init))
244 })
245 .collect()
246}
247
248struct Roots<'s, 'a> {
250 source: &'s str,
251 comments: &'s [Comment],
252 constants: HashMap<&'s str, &'s Expression<'a>>,
254 following: Vec<&'s str>,
256 out: ConfigComments,
257}
258
259impl<'a> Visit<'a> for Roots<'_, '_> {
260 fn visit_object_expression(&mut self, it: &ObjectExpression<'a>) {
261 self.object(it, &mut Vec::new());
262 }
263
264 fn visit_array_expression(&mut self, it: &ArrayExpression<'a>) {
265 self.array(it, &mut Vec::new());
266 }
267}
268
269impl Roots<'_, '_> {
270 fn value(&mut self, expression: &Expression<'_>, path: &mut Vec<PathSegment>) {
271 match expression.get_inner_expression() {
272 Expression::ObjectExpression(object) => self.object(object, path),
273 Expression::ArrayExpression(array) => self.array(array, path),
274 Expression::Identifier(identifier) => {
275 let Some((&name, &bound)) = self.constants.get_key_value(identifier.name.as_str()) else {
276 return;
277 };
278 if !self.following.contains(&name) {
279 self.following.push(name);
280 self.value(bound, path);
281 self.following.pop();
282 }
283 }
284 _ => {}
285 }
286 }
287
288 fn object(&mut self, object: &ObjectExpression<'_>, path: &mut Vec<PathSegment>) {
289 let spans: Vec<(u32, u32)> = object
290 .properties
291 .iter()
292 .map(|property| (property.span().start, property.span().end))
293 .collect();
294 for (index, kind) in object.properties.iter().enumerate() {
295 let ObjectPropertyKind::ObjectProperty(property) = kind else {
296 continue;
297 };
298 let Some(key) = property.key.static_name() else {
299 continue;
300 };
301 path.push(PathSegment::Key(crate::js_string(key.as_js_str())));
302 let (previous_end, next_start) = neighbours(&spans, index, object.span.start + 1, object.span.end);
303 let (start, end) = spans[index];
304 let mut texts = self.leading(previous_end, start);
305 texts.extend(self.after_opening_bracket(&property.value));
306 texts.extend(self.trailing(end, next_start));
307 if !texts.is_empty() {
308 self.out
309 .properties
310 .push(PropertyComment::new(path.clone(), texts.join(" ")));
311 }
312 self.value(&property.value, path);
313 path.pop();
314 }
315 }
316
317 fn after_opening_bracket(&self, value: &Expression<'_>) -> Vec<String> {
319 let Expression::ArrayExpression(array) = value.get_inner_expression() else {
320 return Vec::new();
321 };
322 let open = array.span.start + 1;
323 let first = array
324 .elements
325 .first()
326 .map_or(array.span.end, |element| element.span().start);
327 self.comments
328 .iter()
329 .filter(|comment| comment.span.start >= open && comment.span.end <= first)
330 .filter(|comment| !self.has_newline(open, comment.span.start))
331 .filter_map(|comment| self.comment_text(comment))
332 .collect()
333 }
334
335 fn array(&mut self, array: &ArrayExpression<'_>, path: &mut Vec<PathSegment>) {
336 let spans: Vec<(u32, u32)> = array
337 .elements
338 .iter()
339 .map(|element| (element.span().start, element.span().end))
340 .collect();
341 let mut after_spread = false;
342 let mut heading: Option<String> = None;
343 for (index, element) in array.elements.iter().enumerate() {
344 after_spread |= matches!(element, ArrayExpressionElement::SpreadElement(_));
345 let (previous_end, next_start) = neighbours(&spans, index, array.span.start + 1, array.span.end);
346 let (start, end) = spans[index];
347 let leading = self.leading(previous_end, start);
348 if !leading.is_empty() {
349 heading = Some(leading.join(" "));
350 }
351 let mut own = leading;
352 own.extend(self.trailing(end, next_start));
353 let comment = if own.is_empty() {
354 heading.clone().map(|text| (text, true))
355 } else {
356 Some((own.join(" "), false))
357 };
358 if let Some((text, under_heading)) = comment {
359 let value = element
360 .as_expression()
361 .and_then(|expression| match expression.get_inner_expression() {
362 Expression::RegExpLiteral(regex) => Some(regex.regex.pattern.text.as_str().to_owned()),
363 other => static_string(other),
364 });
365 let mut comment = ElementComment::new(path.clone(), index, value, text);
366 if under_heading {
367 comment = comment.under_heading();
368 }
369 if after_spread {
370 comment = comment.after_spread();
371 }
372 self.out.elements.push(comment);
373 }
374 if let Some(expression) = element.as_expression() {
375 path.push(PathSegment::Index(index));
376 self.value(expression, path);
377 path.pop();
378 }
379 }
380 }
381
382 fn leading(&self, previous_end: u32, start: u32) -> Vec<String> {
386 let mut texts: Vec<String> = Vec::new();
387 let leading: Vec<&Comment> = self
388 .comments
389 .iter()
390 .filter(|comment| comment.span.start >= previous_end && comment.span.end <= start)
391 .filter(|comment| self.has_newline(previous_end, comment.span.start))
392 .collect();
393 let mut boundary = start;
396 let mut kept: Vec<&Comment> = Vec::new();
397 for comment in leading.into_iter().rev() {
398 if self.line_breaks(comment.span.end, boundary) > 1 {
399 break;
400 }
401 kept.push(comment);
402 boundary = comment.span.start;
403 }
404 for comment in kept.iter().rev() {
405 texts.extend(self.comment_text(comment));
406 }
407 texts
408 }
409
410 fn trailing(&self, end: u32, next_start: u32) -> Vec<String> {
413 self.comments
414 .iter()
415 .filter(|comment| comment.span.start >= end && comment.span.end <= next_start)
416 .filter(|comment| !self.has_newline(end, comment.span.start))
417 .filter_map(|comment| self.comment_text(comment))
418 .collect()
419 }
420
421 fn slice(&self, from: u32, to: u32) -> &str {
422 self.source.get(from as usize..to as usize).unwrap_or_default()
423 }
424
425 fn line_breaks(&self, from: u32, to: u32) -> usize {
426 self.slice(from, to).matches('\n').count()
427 }
428
429 fn has_newline(&self, from: u32, to: u32) -> bool {
430 self.line_breaks(from, to) > 0
431 }
432
433 fn comment_text(&self, comment: &Comment) -> Option<String> {
436 let raw = self.slice(comment.span.start, comment.span.end);
437 let body = raw.strip_prefix("//").unwrap_or_else(|| {
438 raw.strip_prefix("/*")
439 .map_or(raw, |inner| inner.strip_suffix("*/").unwrap_or(inner))
440 });
441 let text = body
442 .lines()
443 .map(|line| line.trim().trim_start_matches('*').trim())
444 .filter(|line| !line.is_empty())
445 .collect::<Vec<_>>()
446 .join(" ");
447 (!text.is_empty() && !is_directive(&text, comment.is_block())).then_some(text)
448 }
449}
450
451fn neighbours(spans: &[(u32, u32)], index: usize, open: u32, close: u32) -> (u32, u32) {
454 let previous_end = index
455 .checked_sub(1)
456 .and_then(|previous| spans.get(previous))
457 .map_or(open, |(_, end)| *end);
458 let next_start = spans.get(index + 1).map_or(close, |(start, _)| *start);
459 (previous_end, next_start)
460}
461
462#[cfg(test)]
463#[allow(clippy::unwrap_used)]
464mod tests {
465 use super::*;
466
467 fn comments(source: &str) -> Vec<ElementComment> {
468 extract_array_comments(source, Path::new("knip.ts")).unwrap()
469 }
470
471 fn key(name: &str) -> PathSegment {
472 PathSegment::Key(name.to_owned())
473 }
474
475 #[test]
476 fn a_comment_above_an_element_is_attached_to_it() {
477 let found = comments(
478 "export default {\n workspaces: {\n 'packages/app': {\n ignoreDependencies: [\n // false positive: loaded by the bundler from a config string\n 'some-loader',\n ],\n },\n },\n};\n",
479 );
480 assert_eq!(
481 found,
482 [ElementComment::new(
483 vec![key("workspaces"), key("packages/app"), key("ignoreDependencies")],
484 0,
485 Some("some-loader".to_owned()),
486 "false positive: loaded by the bundler from a config string",
487 )]
488 );
489 }
490
491 #[test]
492 fn a_trailing_comment_belongs_to_the_element_it_follows() {
493 let found = comments(
494 "const config = {\n ignoreBinaries: [\n 'docker', // installed on the CI image\n 'make',\n /^tsc/, /* the compiler */\n ],\n};\nexport default config;\n",
495 );
496 assert_eq!(
497 found,
498 [
499 ElementComment::new(
500 vec![key("ignoreBinaries")],
501 0,
502 Some("docker".to_owned()),
503 "installed on the CI image"
504 ),
505 ElementComment::new(vec![key("ignoreBinaries")], 2, Some("^tsc".to_owned()), "the compiler"),
506 ]
507 );
508 }
509
510 #[test]
511 fn a_comment_separated_by_a_blank_line_and_directives_are_left_out() {
512 let found = comments(
513 "export default defineConfig({\n ignore: [\n // about the whole list\n\n 'a',\n // eslint-disable-next-line\n 'b',\n /**\n * generated\n * by the build\n */\n 'c',\n ],\n});\n",
514 );
515 assert_eq!(
516 found,
517 [ElementComment::new(
518 vec![key("ignore")],
519 2,
520 Some("c".to_owned()),
521 "generated by the build"
522 )]
523 );
524 }
525
526 #[test]
527 fn every_directive_form_is_left_out() {
528 let found = comments(
529 "export default {\n ignore: [\n // eslint-disable-next-line no-console -- noisy\n 'a',\n 'b', // eslint-disable-line\n /* eslint-disable */\n 'c',\n /* eslint-enable */\n 'd',\n /* eslint-env node */\n 'e',\n /* eslint no-console: \"off\" */\n 'f',\n /* eslint @typescript-eslint/no-explicit-any: 0 */\n 'g',\n // @ts-ignore\n 'h',\n // @ts-expect-error: the types lag\n 'i',\n // @ts-nocheck\n 'j',\n // @ts-check\n 'k',\n /* istanbul ignore next */\n 'l',\n /* c8 ignore next */\n 'm',\n // prettier-ignore\n 'n',\n // biome-ignore lint/style: generated\n 'o',\n // oxlint-disable-next-line no-unused-vars\n 'p',\n ],\n};\n",
530 );
531 assert_eq!(found, []);
532 }
533
534 #[test]
535 fn a_reason_that_starts_with_a_package_or_tool_name_is_kept() {
536 let found = comments(
537 "export default {\n ignoreDependencies: [\n // eslint-plugin-foo is loaded by name\n 'eslint-plugin-foo',\n // @ts-rest/core is pulled in by codegen\n '@ts-rest/core',\n // eslint config resolves it\n 'eslint-config-acme',\n /* eslint config resolves this one too */\n 'eslint-config-other',\n // eslint: the line form never configures a rule\n 'eslint-formatter-x',\n // oxlint-plugin-bar is loaded by name\n 'oxlint-plugin-bar',\n // c8 is run by the coverage job\n 'c8',\n ],\n};\n",
538 );
539 let reasons: Vec<(Option<&str>, &str)> = found
540 .iter()
541 .map(|comment| (comment.value.as_deref(), comment.text.as_str()))
542 .collect();
543 assert_eq!(
544 reasons,
545 [
546 (Some("eslint-plugin-foo"), "eslint-plugin-foo is loaded by name"),
547 (Some("@ts-rest/core"), "@ts-rest/core is pulled in by codegen"),
548 (Some("eslint-config-acme"), "eslint config resolves it"),
549 (Some("eslint-config-other"), "eslint config resolves this one too"),
550 (
551 Some("eslint-formatter-x"),
552 "eslint: the line form never configures a rule"
553 ),
554 (Some("oxlint-plugin-bar"), "oxlint-plugin-bar is loaded by name"),
555 (Some("c8"), "c8 is run by the coverage job"),
556 ]
557 );
558 }
559
560 #[test]
561 fn object_elements_are_attached_by_position() {
562 let found = extract_array_comments(
563 "module.exports = {\n \"versionGroups\": [\n // react is pinned until the upgrade\n { \"dependencies\": [\"react\"], \"isIgnored\": true }\n ]\n}",
564 Path::new("config.js"),
565 )
566 .unwrap();
567 assert_eq!(
568 found,
569 [ElementComment::new(
570 vec![key("versionGroups")],
571 0,
572 None,
573 "react is pinned until the upgrade"
574 )]
575 );
576 }
577
578 #[test]
579 fn a_spread_and_the_elements_after_it_are_marked() {
580 let found = comments(
581 "export default {\n versionGroups: [\n // first\n { isIgnored: true },\n ...shared, // the shared groups\n // pinned\n { isIgnored: true },\n ],\n};\n",
582 );
583 assert_eq!(
584 found,
585 [
586 ElementComment::new(vec![key("versionGroups")], 0, None, "first"),
587 ElementComment::new(vec![key("versionGroups")], 1, None, "the shared groups").after_spread(),
588 ElementComment::new(vec![key("versionGroups")], 2, None, "pinned").after_spread(),
589 ]
590 );
591 }
592
593 #[test]
594 fn a_list_behind_a_constant_is_followed_from_the_key_that_names_it() {
595 let found = comments(
596 "const deps = [\n // loaded by the bundler\n 'some-loader',\n];\nexport const shared = { ignoreBinaries: ['make' /* from the image */] };\nconst loop = [loop];\nexport default {\n ignoreDependencies: deps,\n workspaces: { 'packages/app': shared },\n ignore: loop,\n};\n",
597 );
598 let loader = |array: Vec<PathSegment>| {
599 ElementComment::new(array, 0, Some("some-loader".to_owned()), "loaded by the bundler")
600 };
601 let make = |array: Vec<PathSegment>| ElementComment::new(array, 0, Some("make".to_owned()), "from the image");
602 assert_eq!(
603 found,
604 [
605 loader(vec![]),
606 make(vec![key("ignoreBinaries")]),
607 loader(vec![key("ignoreDependencies")]),
608 make(vec![key("workspaces"), key("packages/app"), key("ignoreBinaries")]),
609 ]
610 );
611 }
612
613 #[test]
614 fn a_file_that_does_not_parse_has_no_comments() {
615 assert_eq!(extract_array_comments("export default {", Path::new("knip.ts")), None);
616 assert_eq!(extract_config_comments("export default {", Path::new("knip.ts")), None);
617 }
618
619 fn config_comments(source: &str) -> ConfigComments {
620 extract_config_comments(source, Path::new("knip.ts")).unwrap()
621 }
622
623 #[test]
624 fn a_comment_above_a_group_heads_the_elements_below_it_without_their_own() {
625 let found = config_comments(
626 "export default {\n ignoreDependencies: [\n 'plain',\n // loaded by the bundler\n 'some-loader',\n 'other-loader',\n 'left-pad', // kept for the legacy build\n 'after-trailing',\n // types only\n '@demo/types',\n ...shared,\n '@demo/runtime',\n ],\n};\n",
627 );
628 let array = || vec![key("ignoreDependencies")];
629 let value = |name: &str| Some(name.to_owned());
630 assert_eq!(
631 found.elements,
632 [
633 ElementComment::new(array(), 1, value("some-loader"), "loaded by the bundler"),
634 ElementComment::new(array(), 2, value("other-loader"), "loaded by the bundler").under_heading(),
635 ElementComment::new(array(), 3, value("left-pad"), "kept for the legacy build"),
636 ElementComment::new(array(), 4, value("after-trailing"), "loaded by the bundler").under_heading(),
637 ElementComment::new(array(), 5, value("@demo/types"), "types only"),
638 ElementComment::new(array(), 6, None, "types only")
639 .under_heading()
640 .after_spread(),
641 ElementComment::new(array(), 7, value("@demo/runtime"), "types only")
642 .under_heading()
643 .after_spread(),
644 ]
645 );
646 assert_eq!(found.properties, []);
647 }
648
649 #[test]
650 fn array_comments_leave_out_the_elements_under_a_heading() {
651 let found = comments("export default {\n ignore: [\n // generated\n 'a',\n 'b',\n ],\n};\n");
652 assert_eq!(
653 found,
654 [ElementComment::new(
655 vec![key("ignore")],
656 0,
657 Some("a".to_owned()),
658 "generated"
659 )]
660 );
661 }
662
663 #[test]
664 fn a_comment_on_a_property_belongs_to_its_path() {
665 let found = config_comments(
666 "const deps = ['b'];\nexport default {\n // installed on the CI image\n ignoreBinaries: ['docker', 'make'],\n ignoreUnresolved: [ // virtual modules\n 'virtual:routes',\n ],\n ignoreFiles: ['a'], // generated\n // about the next one\n\n ignoreMembers: ['render'],\n workspaces: {\n 'packages/app': {\n /* kept by the host page */\n ignoreDependencies: deps,\n },\n },\n};\n",
667 );
668 let property = |path: Vec<PathSegment>, text: &str| PropertyComment::new(path, text);
669 assert_eq!(
670 found.properties,
671 [
672 property(vec![key("ignoreBinaries")], "installed on the CI image"),
673 property(vec![key("ignoreUnresolved")], "virtual modules"),
674 property(vec![key("ignoreFiles")], "generated"),
675 property(
676 vec![key("workspaces"), key("packages/app"), key("ignoreDependencies")],
677 "kept by the host page"
678 ),
679 ]
680 );
681 assert_eq!(found.elements, []);
682 }
683}