1 | /**
|
---|
2 | * @fileoverview A module that filters reported problems based on `eslint-disable` and `eslint-enable` comments
|
---|
3 | * @author Teddy Katz
|
---|
4 | */
|
---|
5 |
|
---|
6 | "use strict";
|
---|
7 |
|
---|
8 | //------------------------------------------------------------------------------
|
---|
9 | // Typedefs
|
---|
10 | //------------------------------------------------------------------------------
|
---|
11 |
|
---|
12 | /** @typedef {import("../shared/types").LintMessage} LintMessage */
|
---|
13 |
|
---|
14 | //------------------------------------------------------------------------------
|
---|
15 | // Module Definition
|
---|
16 | //------------------------------------------------------------------------------
|
---|
17 |
|
---|
18 | const escapeRegExp = require("escape-string-regexp");
|
---|
19 |
|
---|
20 | /**
|
---|
21 | * Compares the locations of two objects in a source file
|
---|
22 | * @param {{line: number, column: number}} itemA The first object
|
---|
23 | * @param {{line: number, column: number}} itemB The second object
|
---|
24 | * @returns {number} A value less than 1 if itemA appears before itemB in the source file, greater than 1 if
|
---|
25 | * itemA appears after itemB in the source file, or 0 if itemA and itemB have the same location.
|
---|
26 | */
|
---|
27 | function compareLocations(itemA, itemB) {
|
---|
28 | return itemA.line - itemB.line || itemA.column - itemB.column;
|
---|
29 | }
|
---|
30 |
|
---|
31 | /**
|
---|
32 | * Groups a set of directives into sub-arrays by their parent comment.
|
---|
33 | * @param {Iterable<Directive>} directives Unused directives to be removed.
|
---|
34 | * @returns {Directive[][]} Directives grouped by their parent comment.
|
---|
35 | */
|
---|
36 | function groupByParentComment(directives) {
|
---|
37 | const groups = new Map();
|
---|
38 |
|
---|
39 | for (const directive of directives) {
|
---|
40 | const { unprocessedDirective: { parentComment } } = directive;
|
---|
41 |
|
---|
42 | if (groups.has(parentComment)) {
|
---|
43 | groups.get(parentComment).push(directive);
|
---|
44 | } else {
|
---|
45 | groups.set(parentComment, [directive]);
|
---|
46 | }
|
---|
47 | }
|
---|
48 |
|
---|
49 | return [...groups.values()];
|
---|
50 | }
|
---|
51 |
|
---|
52 | /**
|
---|
53 | * Creates removal details for a set of directives within the same comment.
|
---|
54 | * @param {Directive[]} directives Unused directives to be removed.
|
---|
55 | * @param {Token} commentToken The backing Comment token.
|
---|
56 | * @returns {{ description, fix, unprocessedDirective }[]} Details for later creation of output Problems.
|
---|
57 | */
|
---|
58 | function createIndividualDirectivesRemoval(directives, commentToken) {
|
---|
59 |
|
---|
60 | /*
|
---|
61 | * `commentToken.value` starts right after `//` or `/*`.
|
---|
62 | * All calculated offsets will be relative to this index.
|
---|
63 | */
|
---|
64 | const commentValueStart = commentToken.range[0] + "//".length;
|
---|
65 |
|
---|
66 | // Find where the list of rules starts. `\S+` matches with the directive name (e.g. `eslint-disable-line`)
|
---|
67 | const listStartOffset = /^\s*\S+\s+/u.exec(commentToken.value)[0].length;
|
---|
68 |
|
---|
69 | /*
|
---|
70 | * Get the list text without any surrounding whitespace. In order to preserve the original
|
---|
71 | * formatting, we don't want to change that whitespace.
|
---|
72 | *
|
---|
73 | * // eslint-disable-line rule-one , rule-two , rule-three -- comment
|
---|
74 | * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
---|
75 | */
|
---|
76 | const listText = commentToken.value
|
---|
77 | .slice(listStartOffset) // remove directive name and all whitespace before the list
|
---|
78 | .split(/\s-{2,}\s/u)[0] // remove `-- comment`, if it exists
|
---|
79 | .trimEnd(); // remove all whitespace after the list
|
---|
80 |
|
---|
81 | /*
|
---|
82 | * We can assume that `listText` contains multiple elements.
|
---|
83 | * Otherwise, this function wouldn't be called - if there is
|
---|
84 | * only one rule in the list, then the whole comment must be removed.
|
---|
85 | */
|
---|
86 |
|
---|
87 | return directives.map(directive => {
|
---|
88 | const { ruleId } = directive;
|
---|
89 |
|
---|
90 | const regex = new RegExp(String.raw`(?:^|\s*,\s*)(?<quote>['"]?)${escapeRegExp(ruleId)}\k<quote>(?:\s*,\s*|$)`, "u");
|
---|
91 | const match = regex.exec(listText);
|
---|
92 | const matchedText = match[0];
|
---|
93 | const matchStartOffset = listStartOffset + match.index;
|
---|
94 | const matchEndOffset = matchStartOffset + matchedText.length;
|
---|
95 |
|
---|
96 | const firstIndexOfComma = matchedText.indexOf(",");
|
---|
97 | const lastIndexOfComma = matchedText.lastIndexOf(",");
|
---|
98 |
|
---|
99 | let removalStartOffset, removalEndOffset;
|
---|
100 |
|
---|
101 | if (firstIndexOfComma !== lastIndexOfComma) {
|
---|
102 |
|
---|
103 | /*
|
---|
104 | * Since there are two commas, this must one of the elements in the middle of the list.
|
---|
105 | * Matched range starts where the previous rule name ends, and ends where the next rule name starts.
|
---|
106 | *
|
---|
107 | * // eslint-disable-line rule-one , rule-two , rule-three -- comment
|
---|
108 | * ^^^^^^^^^^^^^^
|
---|
109 | *
|
---|
110 | * We want to remove only the content between the two commas, and also one of the commas.
|
---|
111 | *
|
---|
112 | * // eslint-disable-line rule-one , rule-two , rule-three -- comment
|
---|
113 | * ^^^^^^^^^^^
|
---|
114 | */
|
---|
115 | removalStartOffset = matchStartOffset + firstIndexOfComma;
|
---|
116 | removalEndOffset = matchStartOffset + lastIndexOfComma;
|
---|
117 |
|
---|
118 | } else {
|
---|
119 |
|
---|
120 | /*
|
---|
121 | * This is either the first element or the last element.
|
---|
122 | *
|
---|
123 | * If this is the first element, matched range starts where the first rule name starts
|
---|
124 | * and ends where the second rule name starts. This is exactly the range we want
|
---|
125 | * to remove so that the second rule name will start where the first one was starting
|
---|
126 | * and thus preserve the original formatting.
|
---|
127 | *
|
---|
128 | * // eslint-disable-line rule-one , rule-two , rule-three -- comment
|
---|
129 | * ^^^^^^^^^^^
|
---|
130 | *
|
---|
131 | * Similarly, if this is the last element, we've already matched the range we want to
|
---|
132 | * remove. The previous rule name will end where the last one was ending, relative
|
---|
133 | * to the content on the right side.
|
---|
134 | *
|
---|
135 | * // eslint-disable-line rule-one , rule-two , rule-three -- comment
|
---|
136 | * ^^^^^^^^^^^^^
|
---|
137 | */
|
---|
138 | removalStartOffset = matchStartOffset;
|
---|
139 | removalEndOffset = matchEndOffset;
|
---|
140 | }
|
---|
141 |
|
---|
142 | return {
|
---|
143 | description: `'${ruleId}'`,
|
---|
144 | fix: {
|
---|
145 | range: [
|
---|
146 | commentValueStart + removalStartOffset,
|
---|
147 | commentValueStart + removalEndOffset
|
---|
148 | ],
|
---|
149 | text: ""
|
---|
150 | },
|
---|
151 | unprocessedDirective: directive.unprocessedDirective
|
---|
152 | };
|
---|
153 | });
|
---|
154 | }
|
---|
155 |
|
---|
156 | /**
|
---|
157 | * Creates a description of deleting an entire unused disable comment.
|
---|
158 | * @param {Directive[]} directives Unused directives to be removed.
|
---|
159 | * @param {Token} commentToken The backing Comment token.
|
---|
160 | * @returns {{ description, fix, unprocessedDirective }} Details for later creation of an output Problem.
|
---|
161 | */
|
---|
162 | function createCommentRemoval(directives, commentToken) {
|
---|
163 | const { range } = commentToken;
|
---|
164 | const ruleIds = directives.filter(directive => directive.ruleId).map(directive => `'${directive.ruleId}'`);
|
---|
165 |
|
---|
166 | return {
|
---|
167 | description: ruleIds.length <= 2
|
---|
168 | ? ruleIds.join(" or ")
|
---|
169 | : `${ruleIds.slice(0, ruleIds.length - 1).join(", ")}, or ${ruleIds[ruleIds.length - 1]}`,
|
---|
170 | fix: {
|
---|
171 | range,
|
---|
172 | text: " "
|
---|
173 | },
|
---|
174 | unprocessedDirective: directives[0].unprocessedDirective
|
---|
175 | };
|
---|
176 | }
|
---|
177 |
|
---|
178 | /**
|
---|
179 | * Parses details from directives to create output Problems.
|
---|
180 | * @param {Iterable<Directive>} allDirectives Unused directives to be removed.
|
---|
181 | * @returns {{ description, fix, unprocessedDirective }[]} Details for later creation of output Problems.
|
---|
182 | */
|
---|
183 | function processUnusedDirectives(allDirectives) {
|
---|
184 | const directiveGroups = groupByParentComment(allDirectives);
|
---|
185 |
|
---|
186 | return directiveGroups.flatMap(
|
---|
187 | directives => {
|
---|
188 | const { parentComment } = directives[0].unprocessedDirective;
|
---|
189 | const remainingRuleIds = new Set(parentComment.ruleIds);
|
---|
190 |
|
---|
191 | for (const directive of directives) {
|
---|
192 | remainingRuleIds.delete(directive.ruleId);
|
---|
193 | }
|
---|
194 |
|
---|
195 | return remainingRuleIds.size
|
---|
196 | ? createIndividualDirectivesRemoval(directives, parentComment.commentToken)
|
---|
197 | : [createCommentRemoval(directives, parentComment.commentToken)];
|
---|
198 | }
|
---|
199 | );
|
---|
200 | }
|
---|
201 |
|
---|
202 | /**
|
---|
203 | * Collect eslint-enable comments that are removing suppressions by eslint-disable comments.
|
---|
204 | * @param {Directive[]} directives The directives to check.
|
---|
205 | * @returns {Set<Directive>} The used eslint-enable comments
|
---|
206 | */
|
---|
207 | function collectUsedEnableDirectives(directives) {
|
---|
208 |
|
---|
209 | /**
|
---|
210 | * A Map of `eslint-enable` keyed by ruleIds that may be marked as used.
|
---|
211 | * If `eslint-enable` does not have a ruleId, the key will be `null`.
|
---|
212 | * @type {Map<string|null, Directive>}
|
---|
213 | */
|
---|
214 | const enabledRules = new Map();
|
---|
215 |
|
---|
216 | /**
|
---|
217 | * A Set of `eslint-enable` marked as used.
|
---|
218 | * It is also the return value of `collectUsedEnableDirectives` function.
|
---|
219 | * @type {Set<Directive>}
|
---|
220 | */
|
---|
221 | const usedEnableDirectives = new Set();
|
---|
222 |
|
---|
223 | /*
|
---|
224 | * Checks the directives backwards to see if the encountered `eslint-enable` is used by the previous `eslint-disable`,
|
---|
225 | * and if so, stores the `eslint-enable` in `usedEnableDirectives`.
|
---|
226 | */
|
---|
227 | for (let index = directives.length - 1; index >= 0; index--) {
|
---|
228 | const directive = directives[index];
|
---|
229 |
|
---|
230 | if (directive.type === "disable") {
|
---|
231 | if (enabledRules.size === 0) {
|
---|
232 | continue;
|
---|
233 | }
|
---|
234 | if (directive.ruleId === null) {
|
---|
235 |
|
---|
236 | // If encounter `eslint-disable` without ruleId,
|
---|
237 | // mark all `eslint-enable` currently held in enabledRules as used.
|
---|
238 | // e.g.
|
---|
239 | // /* eslint-disable */ <- current directive
|
---|
240 | // /* eslint-enable rule-id1 */ <- used
|
---|
241 | // /* eslint-enable rule-id2 */ <- used
|
---|
242 | // /* eslint-enable */ <- used
|
---|
243 | for (const enableDirective of enabledRules.values()) {
|
---|
244 | usedEnableDirectives.add(enableDirective);
|
---|
245 | }
|
---|
246 | enabledRules.clear();
|
---|
247 | } else {
|
---|
248 | const enableDirective = enabledRules.get(directive.ruleId);
|
---|
249 |
|
---|
250 | if (enableDirective) {
|
---|
251 |
|
---|
252 | // If encounter `eslint-disable` with ruleId, and there is an `eslint-enable` with the same ruleId in enabledRules,
|
---|
253 | // mark `eslint-enable` with ruleId as used.
|
---|
254 | // e.g.
|
---|
255 | // /* eslint-disable rule-id */ <- current directive
|
---|
256 | // /* eslint-enable rule-id */ <- used
|
---|
257 | usedEnableDirectives.add(enableDirective);
|
---|
258 | } else {
|
---|
259 | const enabledDirectiveWithoutRuleId = enabledRules.get(null);
|
---|
260 |
|
---|
261 | if (enabledDirectiveWithoutRuleId) {
|
---|
262 |
|
---|
263 | // If encounter `eslint-disable` with ruleId, and there is no `eslint-enable` with the same ruleId in enabledRules,
|
---|
264 | // mark `eslint-enable` without ruleId as used.
|
---|
265 | // e.g.
|
---|
266 | // /* eslint-disable rule-id */ <- current directive
|
---|
267 | // /* eslint-enable */ <- used
|
---|
268 | usedEnableDirectives.add(enabledDirectiveWithoutRuleId);
|
---|
269 | }
|
---|
270 | }
|
---|
271 | }
|
---|
272 | } else if (directive.type === "enable") {
|
---|
273 | if (directive.ruleId === null) {
|
---|
274 |
|
---|
275 | // If encounter `eslint-enable` without ruleId, the `eslint-enable` that follows it are unused.
|
---|
276 | // So clear enabledRules.
|
---|
277 | // e.g.
|
---|
278 | // /* eslint-enable */ <- current directive
|
---|
279 | // /* eslint-enable rule-id *// <- unused
|
---|
280 | // /* eslint-enable */ <- unused
|
---|
281 | enabledRules.clear();
|
---|
282 | enabledRules.set(null, directive);
|
---|
283 | } else {
|
---|
284 | enabledRules.set(directive.ruleId, directive);
|
---|
285 | }
|
---|
286 | }
|
---|
287 | }
|
---|
288 | return usedEnableDirectives;
|
---|
289 | }
|
---|
290 |
|
---|
291 | /**
|
---|
292 | * This is the same as the exported function, except that it
|
---|
293 | * doesn't handle disable-line and disable-next-line directives, and it always reports unused
|
---|
294 | * disable directives.
|
---|
295 | * @param {Object} options options for applying directives. This is the same as the options
|
---|
296 | * for the exported function, except that `reportUnusedDisableDirectives` is not supported
|
---|
297 | * (this function always reports unused disable directives).
|
---|
298 | * @returns {{problems: LintMessage[], unusedDirectives: LintMessage[]}} An object with a list
|
---|
299 | * of problems (including suppressed ones) and unused eslint-disable directives
|
---|
300 | */
|
---|
301 | function applyDirectives(options) {
|
---|
302 | const problems = [];
|
---|
303 | const usedDisableDirectives = new Set();
|
---|
304 |
|
---|
305 | for (const problem of options.problems) {
|
---|
306 | let disableDirectivesForProblem = [];
|
---|
307 | let nextDirectiveIndex = 0;
|
---|
308 |
|
---|
309 | while (
|
---|
310 | nextDirectiveIndex < options.directives.length &&
|
---|
311 | compareLocations(options.directives[nextDirectiveIndex], problem) <= 0
|
---|
312 | ) {
|
---|
313 | const directive = options.directives[nextDirectiveIndex++];
|
---|
314 |
|
---|
315 | if (directive.ruleId === null || directive.ruleId === problem.ruleId) {
|
---|
316 | switch (directive.type) {
|
---|
317 | case "disable":
|
---|
318 | disableDirectivesForProblem.push(directive);
|
---|
319 | break;
|
---|
320 |
|
---|
321 | case "enable":
|
---|
322 | disableDirectivesForProblem = [];
|
---|
323 | break;
|
---|
324 |
|
---|
325 | // no default
|
---|
326 | }
|
---|
327 | }
|
---|
328 | }
|
---|
329 |
|
---|
330 | if (disableDirectivesForProblem.length > 0) {
|
---|
331 | const suppressions = disableDirectivesForProblem.map(directive => ({
|
---|
332 | kind: "directive",
|
---|
333 | justification: directive.unprocessedDirective.justification
|
---|
334 | }));
|
---|
335 |
|
---|
336 | if (problem.suppressions) {
|
---|
337 | problem.suppressions = problem.suppressions.concat(suppressions);
|
---|
338 | } else {
|
---|
339 | problem.suppressions = suppressions;
|
---|
340 | usedDisableDirectives.add(disableDirectivesForProblem[disableDirectivesForProblem.length - 1]);
|
---|
341 | }
|
---|
342 | }
|
---|
343 |
|
---|
344 | problems.push(problem);
|
---|
345 | }
|
---|
346 |
|
---|
347 | const unusedDisableDirectivesToReport = options.directives
|
---|
348 | .filter(directive => directive.type === "disable" && !usedDisableDirectives.has(directive));
|
---|
349 |
|
---|
350 |
|
---|
351 | const unusedEnableDirectivesToReport = new Set(
|
---|
352 | options.directives.filter(directive => directive.unprocessedDirective.type === "enable")
|
---|
353 | );
|
---|
354 |
|
---|
355 | /*
|
---|
356 | * If directives has the eslint-enable directive,
|
---|
357 | * check whether the eslint-enable comment is used.
|
---|
358 | */
|
---|
359 | if (unusedEnableDirectivesToReport.size > 0) {
|
---|
360 | for (const directive of collectUsedEnableDirectives(options.directives)) {
|
---|
361 | unusedEnableDirectivesToReport.delete(directive);
|
---|
362 | }
|
---|
363 | }
|
---|
364 |
|
---|
365 | const processed = processUnusedDirectives(unusedDisableDirectivesToReport)
|
---|
366 | .concat(processUnusedDirectives(unusedEnableDirectivesToReport));
|
---|
367 |
|
---|
368 | const unusedDirectives = processed
|
---|
369 | .map(({ description, fix, unprocessedDirective }) => {
|
---|
370 | const { parentComment, type, line, column } = unprocessedDirective;
|
---|
371 |
|
---|
372 | let message;
|
---|
373 |
|
---|
374 | if (type === "enable") {
|
---|
375 | message = description
|
---|
376 | ? `Unused eslint-enable directive (no matching eslint-disable directives were found for ${description}).`
|
---|
377 | : "Unused eslint-enable directive (no matching eslint-disable directives were found).";
|
---|
378 | } else {
|
---|
379 | message = description
|
---|
380 | ? `Unused eslint-disable directive (no problems were reported from ${description}).`
|
---|
381 | : "Unused eslint-disable directive (no problems were reported).";
|
---|
382 | }
|
---|
383 | return {
|
---|
384 | ruleId: null,
|
---|
385 | message,
|
---|
386 | line: type === "disable-next-line" ? parentComment.commentToken.loc.start.line : line,
|
---|
387 | column: type === "disable-next-line" ? parentComment.commentToken.loc.start.column + 1 : column,
|
---|
388 | severity: options.reportUnusedDisableDirectives === "warn" ? 1 : 2,
|
---|
389 | nodeType: null,
|
---|
390 | ...options.disableFixes ? {} : { fix }
|
---|
391 | };
|
---|
392 | });
|
---|
393 |
|
---|
394 | return { problems, unusedDirectives };
|
---|
395 | }
|
---|
396 |
|
---|
397 | /**
|
---|
398 | * Given a list of directive comments (i.e. metadata about eslint-disable and eslint-enable comments) and a list
|
---|
399 | * of reported problems, adds the suppression information to the problems.
|
---|
400 | * @param {Object} options Information about directives and problems
|
---|
401 | * @param {{
|
---|
402 | * type: ("disable"|"enable"|"disable-line"|"disable-next-line"),
|
---|
403 | * ruleId: (string|null),
|
---|
404 | * line: number,
|
---|
405 | * column: number,
|
---|
406 | * justification: string
|
---|
407 | * }} options.directives Directive comments found in the file, with one-based columns.
|
---|
408 | * Two directive comments can only have the same location if they also have the same type (e.g. a single eslint-disable
|
---|
409 | * comment for two different rules is represented as two directives).
|
---|
410 | * @param {{ruleId: (string|null), line: number, column: number}[]} options.problems
|
---|
411 | * A list of problems reported by rules, sorted by increasing location in the file, with one-based columns.
|
---|
412 | * @param {"off" | "warn" | "error"} options.reportUnusedDisableDirectives If `"warn"` or `"error"`, adds additional problems for unused directives
|
---|
413 | * @param {boolean} options.disableFixes If true, it doesn't make `fix` properties.
|
---|
414 | * @returns {{ruleId: (string|null), line: number, column: number, suppressions?: {kind: string, justification: string}}[]}
|
---|
415 | * An object with a list of reported problems, the suppressed of which contain the suppression information.
|
---|
416 | */
|
---|
417 | module.exports = ({ directives, disableFixes, problems, reportUnusedDisableDirectives = "off" }) => {
|
---|
418 | const blockDirectives = directives
|
---|
419 | .filter(directive => directive.type === "disable" || directive.type === "enable")
|
---|
420 | .map(directive => Object.assign({}, directive, { unprocessedDirective: directive }))
|
---|
421 | .sort(compareLocations);
|
---|
422 |
|
---|
423 | const lineDirectives = directives.flatMap(directive => {
|
---|
424 | switch (directive.type) {
|
---|
425 | case "disable":
|
---|
426 | case "enable":
|
---|
427 | return [];
|
---|
428 |
|
---|
429 | case "disable-line":
|
---|
430 | return [
|
---|
431 | { type: "disable", line: directive.line, column: 1, ruleId: directive.ruleId, unprocessedDirective: directive },
|
---|
432 | { type: "enable", line: directive.line + 1, column: 0, ruleId: directive.ruleId, unprocessedDirective: directive }
|
---|
433 | ];
|
---|
434 |
|
---|
435 | case "disable-next-line":
|
---|
436 | return [
|
---|
437 | { type: "disable", line: directive.line + 1, column: 1, ruleId: directive.ruleId, unprocessedDirective: directive },
|
---|
438 | { type: "enable", line: directive.line + 2, column: 0, ruleId: directive.ruleId, unprocessedDirective: directive }
|
---|
439 | ];
|
---|
440 |
|
---|
441 | default:
|
---|
442 | throw new TypeError(`Unrecognized directive type '${directive.type}'`);
|
---|
443 | }
|
---|
444 | }).sort(compareLocations);
|
---|
445 |
|
---|
446 | const blockDirectivesResult = applyDirectives({
|
---|
447 | problems,
|
---|
448 | directives: blockDirectives,
|
---|
449 | disableFixes,
|
---|
450 | reportUnusedDisableDirectives
|
---|
451 | });
|
---|
452 | const lineDirectivesResult = applyDirectives({
|
---|
453 | problems: blockDirectivesResult.problems,
|
---|
454 | directives: lineDirectives,
|
---|
455 | disableFixes,
|
---|
456 | reportUnusedDisableDirectives
|
---|
457 | });
|
---|
458 |
|
---|
459 | return reportUnusedDisableDirectives !== "off"
|
---|
460 | ? lineDirectivesResult.problems
|
---|
461 | .concat(blockDirectivesResult.unusedDirectives)
|
---|
462 | .concat(lineDirectivesResult.unusedDirectives)
|
---|
463 | .sort(compareLocations)
|
---|
464 | : lineDirectivesResult.problems;
|
---|
465 | };
|
---|