2 * @fileoverview A module that filters reported problems based on `eslint-disable` and `eslint-enable` comments
8 const escapeRegExp
= require("escape-string-regexp");
11 * Compares the locations of two objects in a source file
12 * @param {{line: number, column: number}} itemA The first object
13 * @param {{line: number, column: number}} itemB The second object
14 * @returns {number} A value less than 1 if itemA appears before itemB in the source file, greater than 1 if
15 * itemA appears after itemB in the source file, or 0 if itemA and itemB have the same location.
17 function compareLocations(itemA
, itemB
) {
18 return itemA
.line
- itemB
.line
|| itemA
.column
- itemB
.column
;
22 * Groups a set of directives into sub-arrays by their parent comment.
23 * @param {Directive[]} directives Unused directives to be removed.
24 * @returns {Directive[][]} Directives grouped by their parent comment.
26 function groupByParentComment(directives
) {
27 const groups
= new Map();
29 for (const directive
of directives
) {
30 const { unprocessedDirective
: { parentComment
} } = directive
;
32 if (groups
.has(parentComment
)) {
33 groups
.get(parentComment
).push(directive
);
35 groups
.set(parentComment
, [directive
]);
39 return [...groups
.values()];
43 * Creates removal details for a set of directives within the same comment.
44 * @param {Directive[]} directives Unused directives to be removed.
45 * @param {Token} commentToken The backing Comment token.
46 * @returns {{ description, fix, unprocessedDirective }[]} Details for later creation of output Problems.
48 function createIndividualDirectivesRemoval(directives
, commentToken
) {
51 * `commentToken.value` starts right after `//` or `/*`.
52 * All calculated offsets will be relative to this index.
54 const commentValueStart
= commentToken
.range
[0] + "//".length
;
56 // Find where the list of rules starts. `\S+` matches with the directive name (e.g. `eslint-disable-line`)
57 const listStartOffset
= /^\s*\S+\s+/u.exec(commentToken
.value
)[0].length
;
60 * Get the list text without any surrounding whitespace. In order to preserve the original
61 * formatting, we don't want to change that whitespace.
63 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
64 * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
66 const listText
= commentToken
.value
67 .slice(listStartOffset
) // remove directive name and all whitespace before the list
68 .split(/\s-{2,}\s/u)[0] // remove `-- comment`, if it exists
69 .trimEnd(); // remove all whitespace after the list
72 * We can assume that `listText` contains multiple elements.
73 * Otherwise, this function wouldn't be called - if there is
74 * only one rule in the list, then the whole comment must be removed.
77 return directives
.map(directive
=> {
78 const { ruleId
} = directive
;
80 const regex
= new RegExp(String
.raw
`(?:^|\s*,\s*)${escapeRegExp(ruleId)}(?:\s*,\s*|$)`, "u");
81 const match
= regex
.exec(listText
);
82 const matchedText
= match
[0];
83 const matchStartOffset
= listStartOffset
+ match
.index
;
84 const matchEndOffset
= matchStartOffset
+ matchedText
.length
;
86 const firstIndexOfComma
= matchedText
.indexOf(",");
87 const lastIndexOfComma
= matchedText
.lastIndexOf(",");
89 let removalStartOffset
, removalEndOffset
;
91 if (firstIndexOfComma
!== lastIndexOfComma
) {
94 * Since there are two commas, this must one of the elements in the middle of the list.
95 * Matched range starts where the previous rule name ends, and ends where the next rule name starts.
97 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
100 * We want to remove only the content between the two commas, and also one of the commas.
102 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
105 removalStartOffset
= matchStartOffset
+ firstIndexOfComma
;
106 removalEndOffset
= matchStartOffset
+ lastIndexOfComma
;
111 * This is either the first element or the last element.
113 * If this is the first element, matched range starts where the first rule name starts
114 * and ends where the second rule name starts. This is exactly the range we want
115 * to remove so that the second rule name will start where the first one was starting
116 * and thus preserve the original formatting.
118 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
121 * Similarly, if this is the last element, we've already matched the range we want to
122 * remove. The previous rule name will end where the last one was ending, relative
123 * to the content on the right side.
125 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
128 removalStartOffset
= matchStartOffset
;
129 removalEndOffset
= matchEndOffset
;
133 description
: `'${ruleId}'`,
136 commentValueStart
+ removalStartOffset
,
137 commentValueStart
+ removalEndOffset
141 unprocessedDirective
: directive
.unprocessedDirective
147 * Creates a description of deleting an entire unused disable comment.
148 * @param {Directive[]} directives Unused directives to be removed.
149 * @param {Token} commentToken The backing Comment token.
150 * @returns {{ description, fix, unprocessedDirective }} Details for later creation of an output Problem.
152 function createCommentRemoval(directives
, commentToken
) {
153 const { range
} = commentToken
;
154 const ruleIds
= directives
.filter(directive
=> directive
.ruleId
).map(directive
=> `'${directive.ruleId}'`);
157 description
: ruleIds
.length
<= 2
158 ? ruleIds
.join(" or ")
159 : `${ruleIds.slice(0, ruleIds.length - 1).join(", ")}, or ${ruleIds[ruleIds.length - 1]}`,
164 unprocessedDirective
: directives
[0].unprocessedDirective
169 * Parses details from directives to create output Problems.
170 * @param {Directive[]} allDirectives Unused directives to be removed.
171 * @returns {{ description, fix, unprocessedDirective }[]} Details for later creation of output Problems.
173 function processUnusedDisableDirectives(allDirectives
) {
174 const directiveGroups
= groupByParentComment(allDirectives
);
176 return directiveGroups
.flatMap(
178 const { parentComment
} = directives
[0].unprocessedDirective
;
179 const remainingRuleIds
= new Set(parentComment
.ruleIds
);
181 for (const directive
of directives
) {
182 remainingRuleIds
.delete(directive
.ruleId
);
185 return remainingRuleIds
.size
186 ? createIndividualDirectivesRemoval(directives
, parentComment
.commentToken
)
187 : [createCommentRemoval(directives
, parentComment
.commentToken
)];
193 * This is the same as the exported function, except that it
194 * doesn't handle disable-line and disable-next-line directives, and it always reports unused
195 * disable directives.
196 * @param {Object} options options for applying directives. This is the same as the options
197 * for the exported function, except that `reportUnusedDisableDirectives` is not supported
198 * (this function always reports unused disable directives).
199 * @returns {{problems: Problem[], unusedDisableDirectives: Problem[]}} An object with a list
200 * of problems (including suppressed ones) and unused eslint-disable directives
202 function applyDirectives(options
) {
204 const usedDisableDirectives
= new Set();
206 for (const problem
of options
.problems
) {
207 let disableDirectivesForProblem
= [];
208 let nextDirectiveIndex
= 0;
211 nextDirectiveIndex
< options
.directives
.length
&&
212 compareLocations(options
.directives
[nextDirectiveIndex
], problem
) <= 0
214 const directive
= options
.directives
[nextDirectiveIndex
++];
216 if (directive
.ruleId
=== null || directive
.ruleId
=== problem
.ruleId
) {
217 switch (directive
.type
) {
219 disableDirectivesForProblem
.push(directive
);
223 disableDirectivesForProblem
= [];
231 if (disableDirectivesForProblem
.length
> 0) {
232 const suppressions
= disableDirectivesForProblem
.map(directive
=> ({
234 justification
: directive
.unprocessedDirective
.justification
237 if (problem
.suppressions
) {
238 problem
.suppressions
= problem
.suppressions
.concat(suppressions
);
240 problem
.suppressions
= suppressions
;
241 usedDisableDirectives
.add(disableDirectivesForProblem
[disableDirectivesForProblem
.length
- 1]);
245 problems
.push(problem
);
248 const unusedDisableDirectivesToReport
= options
.directives
249 .filter(directive
=> directive
.type
=== "disable" && !usedDisableDirectives
.has(directive
));
251 const processed
= processUnusedDisableDirectives(unusedDisableDirectivesToReport
);
253 const unusedDisableDirectives
= processed
254 .map(({ description
, fix
, unprocessedDirective
}) => {
255 const { parentComment
, type
, line
, column
} = unprocessedDirective
;
260 ? `Unused eslint-disable directive (no problems were reported from ${description}).`
261 : "Unused eslint-disable directive (no problems were reported).",
262 line
: type
=== "disable-next-line" ? parentComment
.commentToken
.loc
.start
.line
: line
,
263 column
: type
=== "disable-next-line" ? parentComment
.commentToken
.loc
.start
.column
+ 1 : column
,
264 severity
: options
.reportUnusedDisableDirectives
=== "warn" ? 1 : 2,
266 ...options
.disableFixes
? {} : { fix
}
270 return { problems
, unusedDisableDirectives
};
274 * Given a list of directive comments (i.e. metadata about eslint-disable and eslint-enable comments) and a list
275 * of reported problems, adds the suppression information to the problems.
276 * @param {Object} options Information about directives and problems
278 * type: ("disable"|"enable"|"disable-line"|"disable-next-line"),
279 * ruleId: (string|null),
282 * justification: string
283 * }} options.directives Directive comments found in the file, with one-based columns.
284 * Two directive comments can only have the same location if they also have the same type (e.g. a single eslint-disable
285 * comment for two different rules is represented as two directives).
286 * @param {{ruleId: (string|null), line: number, column: number}[]} options.problems
287 * A list of problems reported by rules, sorted by increasing location in the file, with one-based columns.
288 * @param {"off" | "warn" | "error"} options.reportUnusedDisableDirectives If `"warn"` or `"error"`, adds additional problems for unused directives
289 * @param {boolean} options.disableFixes If true, it doesn't make `fix` properties.
290 * @returns {{ruleId: (string|null), line: number, column: number, suppressions?: {kind: string, justification: string}}[]}
291 * An object with a list of reported problems, the suppressed of which contain the suppression information.
293 module
.exports
= ({ directives
, disableFixes
, problems
, reportUnusedDisableDirectives
= "off" }) => {
294 const blockDirectives
= directives
295 .filter(directive
=> directive
.type
=== "disable" || directive
.type
=== "enable")
296 .map(directive
=> Object
.assign({}, directive
, { unprocessedDirective
: directive
}))
297 .sort(compareLocations
);
299 const lineDirectives
= directives
.flatMap(directive
=> {
300 switch (directive
.type
) {
307 { type
: "disable", line
: directive
.line
, column
: 1, ruleId
: directive
.ruleId
, unprocessedDirective
: directive
},
308 { type
: "enable", line
: directive
.line
+ 1, column
: 0, ruleId
: directive
.ruleId
, unprocessedDirective
: directive
}
311 case "disable-next-line":
313 { type
: "disable", line
: directive
.line
+ 1, column
: 1, ruleId
: directive
.ruleId
, unprocessedDirective
: directive
},
314 { type
: "enable", line
: directive
.line
+ 2, column
: 0, ruleId
: directive
.ruleId
, unprocessedDirective
: directive
}
318 throw new TypeError(`Unrecognized directive type '${directive.type}'`);
320 }).sort(compareLocations
);
322 const blockDirectivesResult
= applyDirectives({
324 directives
: blockDirectives
,
326 reportUnusedDisableDirectives
328 const lineDirectivesResult
= applyDirectives({
329 problems
: blockDirectivesResult
.problems
,
330 directives
: lineDirectives
,
332 reportUnusedDisableDirectives
335 return reportUnusedDisableDirectives
!== "off"
336 ? lineDirectivesResult
.problems
337 .concat(blockDirectivesResult
.unusedDisableDirectives
)
338 .concat(lineDirectivesResult
.unusedDisableDirectives
)
339 .sort(compareLocations
)
340 : lineDirectivesResult
.problems
;