apply-disable-directives.js 19 KB

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