Skip to main content

skif_js_syntax/
array_comments.rs

1//! The comments written next to the elements of array literals and the properties of object literals, for a tool
2//! that carries them over into another format (`skif migrate` turns the comment on a knip ignore entry, on the group
3//! of entries it heads, or on the list itself into the `reason` of each entry).
4
5use 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
19/// The directives a comment can start with, each a whole word: `// eslint-disable-next-line no-console` is one,
20/// `// eslint-plugin-foo is loaded by name` is not.
21const 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
44/// Whether the comment `text` (`block` for a `/* */` comment) is a tool directive rather than an explanation: one of
45/// [`DIRECTIVES`] followed by the end of the text, a space or a colon, or the block comment `ESLint` reads as rule
46/// configuration, `/* eslint <rule>: <setting> */`.
47fn 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
59/// Whether `text` is `eslint` followed by a rule name and a colon, as in `eslint no-console: "off"` or
60/// `eslint @typescript-eslint/no-explicit-any: 0`.
61fn 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/// One step of the path from an outermost object or array literal to an array literal.
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80#[non_exhaustive]
81pub enum PathSegment {
82    /// The value of the property with this static key.
83    Key(String),
84    /// The element at this position.
85    Index(usize),
86}
87
88/// The comment written next to one element of an array literal: on the lines directly above it, or after it on the
89/// line where it ends.
90#[derive(Debug, Clone, PartialEq, Eq)]
91#[non_exhaustive]
92pub struct ElementComment {
93    /// The path to the array from the outermost literal that contains it.
94    pub array: Vec<PathSegment>,
95    /// The position of the element in the array literal; a spread counts as one element.
96    pub index: usize,
97    /// Whether the element is a spread or comes after one. An evaluator that expands spreads puts such an element at
98    /// a position that `index` does not give, so pairing by position is only sound when this is `false`.
99    pub after_spread: bool,
100    /// The element when it is a string (its value) or a regular expression literal (its pattern).
101    pub value: Option<String>,
102    /// The comment text without its delimiters, its lines joined by single spaces. Comments both above and after
103    /// the element are joined in source order.
104    pub text: String,
105    /// The element has no comment of its own and `text` is its group heading: the comments above the nearest element
106    /// before it in the same array that has comments above it. A list written as groups, each under one comment
107    /// line, explains every element of a group with that line.
108    pub heading: bool,
109}
110
111impl ElementComment {
112    /// The comment `text` on the element `index` of the array at `array`, whose value is `value`.
113    #[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    /// This comment, recording that its element is a spread or comes after one.
126    #[must_use]
127    pub fn after_spread(mut self) -> Self {
128        self.after_spread = true;
129        self
130    }
131
132    /// This comment, recording that it is the group heading of its element rather than a comment of its own.
133    #[must_use]
134    pub fn under_heading(mut self) -> Self {
135        self.heading = true;
136        self
137    }
138}
139
140/// The comment written next to a property of an object literal: on the lines directly above it, after it on the line
141/// where it ends, or, when its value is an array literal, after the opening bracket on the bracket's line.
142#[derive(Debug, Clone, PartialEq, Eq)]
143#[non_exhaustive]
144pub struct PropertyComment {
145    /// The path from the outermost literal to the property's value; its last segment is the property's key.
146    pub path: Vec<PathSegment>,
147    /// The comment text without its delimiters, its lines joined by single spaces, joined in source order.
148    pub text: String,
149}
150
151impl PropertyComment {
152    /// The comment `text` on the property at `path`.
153    #[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/// The comments of a configuration file's literals, from [`extract_config_comments`].
163#[derive(Debug, Clone, Default, PartialEq, Eq)]
164#[non_exhaustive]
165pub struct ConfigComments {
166    /// The comments on array elements, including the group heading of each element without a comment of its own.
167    pub elements: Vec<ElementComment>,
168    /// The comments on object properties.
169    pub properties: Vec<PropertyComment>,
170}
171
172/// The comments next to the elements of the array literals of `source`, a JavaScript or TypeScript file whose
173/// extension (in `path`) selects the dialect. Paths start at each outermost object or array literal, so the object
174/// a config file exports, directly or through a constant or a call such as `defineConfig({ ... })`, is the root. A
175/// property or element whose value is the name of a top-level `const` bound to an object or array literal is followed
176/// into that literal, so `const deps = [...]; export default { ignoreDependencies: deps }` puts the comments of `deps`
177/// under `ignoreDependencies` (they are also reported under `deps` as a root of its own). A spread is not followed. A
178/// comment that is a tool directive (`// eslint-disable-next-line`, `// @ts-expect-error`) is left out.
179///
180/// The elements a comment only heads (see [`ElementComment::heading`]) are left out; [`extract_config_comments`] has
181/// them, and the comments on properties.
182///
183/// `None` when the file does not parse; nothing is executed.
184#[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/// The comments of `source` that [`extract_array_comments`] reads, with an [`ElementComment`] under a heading for
192/// every element whose only explanation is the comment above an earlier element of its array, and a
193/// [`PropertyComment`] for every property with a static key that has a comment. Properties are walked as elements
194/// are: a property whose value names a top-level `const` literal is followed into it.
195///
196/// `None` when the file does not parse; nothing is executed.
197#[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
221/// The object and array literals bound to the top-level constants of `program`, exported or not, by name.
222fn 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
248/// Walks the outermost literals; the literals inside them are walked by [`Roots::value`], which keeps the path.
249struct Roots<'s, 'a> {
250    source: &'s str,
251    comments: &'s [Comment],
252    /// The literals a property or element can name, from [`constants`].
253    constants: HashMap<&'s str, &'s Expression<'a>>,
254    /// The constants being followed, innermost last, so that a cycle (`const a = [b]; const b = [a];`) ends.
255    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    /// The comments after the opening bracket of `value`, when it is an array literal, on the bracket's line.
318    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    /// The texts of the comments on the lines above the element or property that starts at `start`, after the end of
383    /// the previous one (or the opening bracket or brace, `previous_end`), with no blank line in between. A comment on
384    /// the line where the previous one ends belongs to that one, and one on the line of the opening bracket to none.
385    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        // Keep the run of comments that ends directly above the element: walk back while only one line break
394        // separates a comment from what follows it.
395        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    /// The texts of the comments after the element or property that ends at `end`, on the line where it ends, before
411    /// the start of the next one (or the closing bracket or brace, `next_start`).
412    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    /// The text of `comment` without its delimiters and with its lines joined, or `None` for an empty comment or a
434    /// tool directive.
435    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
451/// The end of the item before `index` (or `open`, after the opening bracket or brace) and the start of the item after
452/// it (or `close`, the end of the literal), from the spans of the items of one literal.
453fn 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}