]> git.proxmox.com Git - pve-eslint.git/blob - eslint/lib/linter/apply-disable-directives.js
import 8.23.1 source
[pve-eslint.git] / eslint / lib / linter / apply-disable-directives.js
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 const escapeRegExp = require("escape-string-regexp");
9
10 /**
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.
16 */
17 function compareLocations(itemA, itemB) {
18 return itemA.line - itemB.line || itemA.column - itemB.column;
19 }
20
21 /**
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.
25 */
26 function groupByParentComment(directives) {
27 const groups = new Map();
28
29 for (const directive of directives) {
30 const { unprocessedDirective: { parentComment } } = directive;
31
32 if (groups.has(parentComment)) {
33 groups.get(parentComment).push(directive);
34 } else {
35 groups.set(parentComment, [directive]);
36 }
37 }
38
39 return [...groups.values()];
40 }
41
42 /**
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.
47 */
48 function createIndividualDirectivesRemoval(directives, commentToken) {
49
50 /*
51 * `commentToken.value` starts right after `//` or `/*`.
52 * All calculated offsets will be relative to this index.
53 */
54 const commentValueStart = commentToken.range[0] + "//".length;
55
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;
58
59 /*
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.
62 *
63 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
64 * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
65 */
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
70
71 /*
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.
75 */
76
77 return directives.map(directive => {
78 const { ruleId } = directive;
79
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;
85
86 const firstIndexOfComma = matchedText.indexOf(",");
87 const lastIndexOfComma = matchedText.lastIndexOf(",");
88
89 let removalStartOffset, removalEndOffset;
90
91 if (firstIndexOfComma !== lastIndexOfComma) {
92
93 /*
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.
96 *
97 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
98 * ^^^^^^^^^^^^^^
99 *
100 * We want to remove only the content between the two commas, and also one of the commas.
101 *
102 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
103 * ^^^^^^^^^^^
104 */
105 removalStartOffset = matchStartOffset + firstIndexOfComma;
106 removalEndOffset = matchStartOffset + lastIndexOfComma;
107
108 } else {
109
110 /*
111 * This is either the first element or the last element.
112 *
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.
117 *
118 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
119 * ^^^^^^^^^^^
120 *
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.
124 *
125 * // eslint-disable-line rule-one , rule-two , rule-three -- comment
126 * ^^^^^^^^^^^^^
127 */
128 removalStartOffset = matchStartOffset;
129 removalEndOffset = matchEndOffset;
130 }
131
132 return {
133 description: `'${ruleId}'`,
134 fix: {
135 range: [
136 commentValueStart + removalStartOffset,
137 commentValueStart + removalEndOffset
138 ],
139 text: ""
140 },
141 unprocessedDirective: directive.unprocessedDirective
142 };
143 });
144 }
145
146 /**
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.
151 */
152 function createCommentRemoval(directives, commentToken) {
153 const { range } = commentToken;
154 const ruleIds = directives.filter(directive => directive.ruleId).map(directive => `'${directive.ruleId}'`);
155
156 return {
157 description: ruleIds.length <= 2
158 ? ruleIds.join(" or ")
159 : `${ruleIds.slice(0, ruleIds.length - 1).join(", ")}, or ${ruleIds[ruleIds.length - 1]}`,
160 fix: {
161 range,
162 text: " "
163 },
164 unprocessedDirective: directives[0].unprocessedDirective
165 };
166 }
167
168 /**
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.
172 */
173 function processUnusedDisableDirectives(allDirectives) {
174 const directiveGroups = groupByParentComment(allDirectives);
175
176 return directiveGroups.flatMap(
177 directives => {
178 const { parentComment } = directives[0].unprocessedDirective;
179 const remainingRuleIds = new Set(parentComment.ruleIds);
180
181 for (const directive of directives) {
182 remainingRuleIds.delete(directive.ruleId);
183 }
184
185 return remainingRuleIds.size
186 ? createIndividualDirectivesRemoval(directives, parentComment.commentToken)
187 : [createCommentRemoval(directives, parentComment.commentToken)];
188 }
189 );
190 }
191
192 /**
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
201 */
202 function applyDirectives(options) {
203 const problems = [];
204 const usedDisableDirectives = new Set();
205
206 for (const problem of options.problems) {
207 let disableDirectivesForProblem = [];
208 let nextDirectiveIndex = 0;
209
210 while (
211 nextDirectiveIndex < options.directives.length &&
212 compareLocations(options.directives[nextDirectiveIndex], problem) <= 0
213 ) {
214 const directive = options.directives[nextDirectiveIndex++];
215
216 if (directive.ruleId === null || directive.ruleId === problem.ruleId) {
217 switch (directive.type) {
218 case "disable":
219 disableDirectivesForProblem.push(directive);
220 break;
221
222 case "enable":
223 disableDirectivesForProblem = [];
224 break;
225
226 // no default
227 }
228 }
229 }
230
231 if (disableDirectivesForProblem.length > 0) {
232 const suppressions = disableDirectivesForProblem.map(directive => ({
233 kind: "directive",
234 justification: directive.unprocessedDirective.justification
235 }));
236
237 if (problem.suppressions) {
238 problem.suppressions = problem.suppressions.concat(suppressions);
239 } else {
240 problem.suppressions = suppressions;
241 usedDisableDirectives.add(disableDirectivesForProblem[disableDirectivesForProblem.length - 1]);
242 }
243 }
244
245 problems.push(problem);
246 }
247
248 const unusedDisableDirectivesToReport = options.directives
249 .filter(directive => directive.type === "disable" && !usedDisableDirectives.has(directive));
250
251 const processed = processUnusedDisableDirectives(unusedDisableDirectivesToReport);
252
253 const unusedDisableDirectives = processed
254 .map(({ description, fix, unprocessedDirective }) => {
255 const { parentComment, type, line, column } = unprocessedDirective;
256
257 return {
258 ruleId: null,
259 message: description
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,
265 nodeType: null,
266 ...options.disableFixes ? {} : { fix }
267 };
268 });
269
270 return { problems, unusedDisableDirectives };
271 }
272
273 /**
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
277 * @param {{
278 * type: ("disable"|"enable"|"disable-line"|"disable-next-line"),
279 * ruleId: (string|null),
280 * line: number,
281 * column: number,
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.
292 */
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);
298
299 const lineDirectives = directives.flatMap(directive => {
300 switch (directive.type) {
301 case "disable":
302 case "enable":
303 return [];
304
305 case "disable-line":
306 return [
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 }
309 ];
310
311 case "disable-next-line":
312 return [
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 }
315 ];
316
317 default:
318 throw new TypeError(`Unrecognized directive type '${directive.type}'`);
319 }
320 }).sort(compareLocations);
321
322 const blockDirectivesResult = applyDirectives({
323 problems,
324 directives: blockDirectives,
325 disableFixes,
326 reportUnusedDisableDirectives
327 });
328 const lineDirectivesResult = applyDirectives({
329 problems: blockDirectivesResult.problems,
330 directives: lineDirectives,
331 disableFixes,
332 reportUnusedDisableDirectives
333 });
334
335 return reportUnusedDisableDirectives !== "off"
336 ? lineDirectivesResult.problems
337 .concat(blockDirectivesResult.unusedDisableDirectives)
338 .concat(lineDirectivesResult.unusedDisableDirectives)
339 .sort(compareLocations)
340 : lineDirectivesResult.problems;
341 };