prefer-named-capture-group.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. /**
  2. * @fileoverview Rule to enforce requiring named capture groups in regular expression.
  3. * @author Pig Fang <https://github.com/g-plane>
  4. */
  5. "use strict";
  6. //------------------------------------------------------------------------------
  7. // Requirements
  8. //------------------------------------------------------------------------------
  9. const {
  10. CALL,
  11. CONSTRUCT,
  12. ReferenceTracker,
  13. getStringIfConstant,
  14. } = require("@eslint-community/eslint-utils");
  15. const regexpp = require("@eslint-community/regexpp");
  16. //------------------------------------------------------------------------------
  17. // Typedefs
  18. //------------------------------------------------------------------------------
  19. /** @import { SuggestedEdit } from "@eslint/core"; */
  20. //------------------------------------------------------------------------------
  21. // Helpers
  22. //------------------------------------------------------------------------------
  23. const parser = new regexpp.RegExpParser();
  24. /**
  25. * Creates fixer suggestions for the regex, if statically determinable.
  26. * @param {number} groupStart Starting index of the regex group.
  27. * @param {string} pattern The regular expression pattern to be checked.
  28. * @param {string} rawText Source text of the regexNode.
  29. * @param {ASTNode} regexNode AST node which contains the regular expression.
  30. * @returns {Array<SuggestedEdit>} Fixer suggestions for the regex, if statically determinable.
  31. */
  32. function suggestIfPossible(groupStart, pattern, rawText, regexNode) {
  33. switch (regexNode.type) {
  34. case "Literal":
  35. if (typeof regexNode.value === "string" && rawText.includes("\\")) {
  36. return null;
  37. }
  38. break;
  39. case "TemplateLiteral":
  40. if (
  41. regexNode.expressions.length ||
  42. rawText.slice(1, -1) !== pattern
  43. ) {
  44. return null;
  45. }
  46. break;
  47. default:
  48. return null;
  49. }
  50. const start = regexNode.range[0] + groupStart + 2;
  51. return [
  52. {
  53. fix(fixer) {
  54. const existingTemps = pattern.match(/temp\d+/gu) || [];
  55. const highestTempCount = existingTemps.reduce(
  56. (previous, next) =>
  57. Math.max(previous, Number(next.slice("temp".length))),
  58. 0,
  59. );
  60. return fixer.insertTextBeforeRange(
  61. [start, start],
  62. `?<temp${highestTempCount + 1}>`,
  63. );
  64. },
  65. messageId: "addGroupName",
  66. },
  67. {
  68. fix(fixer) {
  69. return fixer.insertTextBeforeRange([start, start], "?:");
  70. },
  71. messageId: "addNonCapture",
  72. },
  73. ];
  74. }
  75. //------------------------------------------------------------------------------
  76. // Rule Definition
  77. //------------------------------------------------------------------------------
  78. /** @type {import('../types').Rule.RuleModule} */
  79. module.exports = {
  80. meta: {
  81. type: "suggestion",
  82. docs: {
  83. description:
  84. "Enforce using named capture group in regular expression",
  85. recommended: false,
  86. url: "https://eslint.org/docs/latest/rules/prefer-named-capture-group",
  87. },
  88. hasSuggestions: true,
  89. schema: [],
  90. messages: {
  91. addGroupName: "Add name to capture group.",
  92. addNonCapture: "Convert group to non-capturing.",
  93. required:
  94. "Capture group '{{group}}' should be converted to a named or non-capturing group.",
  95. },
  96. },
  97. create(context) {
  98. const sourceCode = context.sourceCode;
  99. /**
  100. * Function to check regular expression.
  101. * @param {string} pattern The regular expression pattern to be checked.
  102. * @param {ASTNode} node AST node which contains the regular expression or a call/new expression.
  103. * @param {ASTNode} regexNode AST node which contains the regular expression.
  104. * @param {string|null} flags The regular expression flags to be checked.
  105. * @returns {void}
  106. */
  107. function checkRegex(pattern, node, regexNode, flags) {
  108. let ast;
  109. try {
  110. ast = parser.parsePattern(pattern, 0, pattern.length, {
  111. unicode: Boolean(flags && flags.includes("u")),
  112. unicodeSets: Boolean(flags && flags.includes("v")),
  113. });
  114. } catch {
  115. // ignore regex syntax errors
  116. return;
  117. }
  118. regexpp.visitRegExpAST(ast, {
  119. onCapturingGroupEnter(group) {
  120. if (!group.name) {
  121. const rawText = sourceCode.getText(regexNode);
  122. const suggest = suggestIfPossible(
  123. group.start,
  124. pattern,
  125. rawText,
  126. regexNode,
  127. );
  128. context.report({
  129. node,
  130. messageId: "required",
  131. data: {
  132. group: group.raw,
  133. },
  134. suggest,
  135. });
  136. }
  137. },
  138. });
  139. }
  140. return {
  141. Literal(node) {
  142. if (node.regex) {
  143. checkRegex(
  144. node.regex.pattern,
  145. node,
  146. node,
  147. node.regex.flags,
  148. );
  149. }
  150. },
  151. Program(node) {
  152. const scope = sourceCode.getScope(node);
  153. const tracker = new ReferenceTracker(scope);
  154. const traceMap = {
  155. RegExp: {
  156. [CALL]: true,
  157. [CONSTRUCT]: true,
  158. },
  159. };
  160. for (const { node: refNode } of tracker.iterateGlobalReferences(
  161. traceMap,
  162. )) {
  163. const regex = getStringIfConstant(refNode.arguments[0]);
  164. const flags = getStringIfConstant(refNode.arguments[1]);
  165. if (regex) {
  166. checkRegex(regex, refNode, refNode.arguments[0], flags);
  167. }
  168. }
  169. },
  170. };
  171. },
  172. };