From ff06287e871b65ea5285e571c1f0e718f2c5ba43 Mon Sep 17 00:00:00 2001 From: zk Date: Sun, 17 May 2026 23:19:38 +0000 Subject: [PATCH] fix: this fucking regexp bug that has annoyed me for 4 years --- bun.lock | 4 + package.json | 2 + typescript/eslint-config/index.js | 2 +- typescript/eslint-config/package.json | 2 + .../rules/require-unicode-regexp-fix.js | 325 ++++++++++++++++++ .../rules/tachi-imports-plugin.js | 3 + .../rerunners/sdvx/add-sdvx-clear-tierlist.js | 2 +- 7 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 typescript/eslint-config/rules/require-unicode-regexp-fix.js diff --git a/bun.lock b/bun.lock index 362ae18ff..db878516e 100644 --- a/bun.lock +++ b/bun.lock @@ -226,6 +226,8 @@ "name": "eslint-config-tachi", "version": "1.0.0", "dependencies": { + "@eslint-community/eslint-utils": "catalog:", + "@eslint-community/regexpp": "catalog:", "@eslint/js": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/parser": "catalog:", @@ -455,6 +457,8 @@ "@codemirror/language": "^6.12.3", "@discordjs/builders": "0.5.0", "@discordjs/rest": "0.1.0-canary.0", + "@eslint-community/eslint-utils": "^4.9.1", + "@eslint-community/regexpp": "^4.12.2", "@eslint/js": "^9.20.0", "@fortawesome/fontawesome-free": "5.13.0", "@fullcalendar/core": "^6.0.0", diff --git a/package.json b/package.json index 56356987b..6b786753d 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,8 @@ "@aws-sdk/client-s3": "3.49.0", "@discordjs/builders": "0.5.0", "@discordjs/rest": "0.1.0-canary.0", + "@eslint-community/eslint-utils": "^4.9.1", + "@eslint-community/regexpp": "^4.12.2", "@eslint/js": "^9.20.0", "@fortawesome/fontawesome-free": "5.13.0", "@fullcalendar/core": "^6.0.0", diff --git a/typescript/eslint-config/index.js b/typescript/eslint-config/index.js index 371d806c4..f882939b6 100644 --- a/typescript/eslint-config/index.js +++ b/typescript/eslint-config/index.js @@ -121,7 +121,7 @@ const base = tseslint.config( "quote-props": ["error", "as-needed"], radix: "error", "require-await": "warn", - "require-unicode-regexp": "error", + "tachi/require-unicode-regexp": "error", "space-before-blocks": "error", yoda: "error", // import/extensions resolves the on-disk file and compares that extension (.ts vs a diff --git a/typescript/eslint-config/package.json b/typescript/eslint-config/package.json index 36e57310c..a93a6ac38 100644 --- a/typescript/eslint-config/package.json +++ b/typescript/eslint-config/package.json @@ -14,6 +14,8 @@ } }, "dependencies": { + "@eslint-community/eslint-utils": "catalog:", + "@eslint-community/regexpp": "catalog:", "@eslint/js": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/parser": "catalog:", diff --git a/typescript/eslint-config/rules/require-unicode-regexp-fix.js b/typescript/eslint-config/rules/require-unicode-regexp-fix.js new file mode 100644 index 000000000..50e4ddb5b --- /dev/null +++ b/typescript/eslint-config/rules/require-unicode-regexp-fix.js @@ -0,0 +1,325 @@ +/** + * Same behavior as ESLint core `require-unicode-regexp`, but applies the `u` / `v` flag + * via autofix (`--fix`). Core exposes the equivalent only as suggestions. + */ + +/** @import { Rule } from "eslint" */ + +import { + CALL, + CONSTRUCT, + ReferenceTracker, + getStringIfConstant, + isCommaToken, +} from "@eslint-community/eslint-utils"; +import { RegExpValidator } from "@eslint-community/regexpp"; + +const REGEXPP_LATEST_ECMA_VERSION = 2025; + +/** @typedef {"u"|"v"|undefined} RequireFlagOption */ + +/** + * @param {unknown} ecmaVersion + */ +function normalizeEcmaVersion(ecmaVersion) { + return typeof ecmaVersion === "number" && Number.isFinite(ecmaVersion) + ? ecmaVersion + : REGEXPP_LATEST_ECMA_VERSION; +} + +/** + * @param {RequireFlagOption} requireFlag + * @param {string} flags + */ +function checkFlags(requireFlag, flags) { + if (requireFlag === "v") { + return !flags.includes("v"); + } + if (requireFlag === "u") { + return !flags.includes("u"); + } + return !flags.includes("u") && !flags.includes("v"); +} + +/** + * Same contract as eslint/lib/rules/utils/regular-expressions.js + * + * @param {unknown} ecmaVersion Raw `languageOptions.ecmaVersion` + * @param {string} pattern + * @param {"u"|"v"} flag + */ +function isValidWithUnicodeFlag(ecmaVersion, pattern, flag = "u") { + const normalized = normalizeEcmaVersion(ecmaVersion); + if (flag === "u" && normalized <= 5) { + return false; + } + if (flag === "v" && normalized <= 2023) { + return false; + } + + const validator = new RegExpValidator({ + ecmaVersion: Math.min(normalized, REGEXPP_LATEST_ECMA_VERSION), + }); + + try { + validator.validatePattern( + pattern, + void 0, + void 0, + flag === "u" + ? { + unicode: true, + } + : { + unicodeSets: true, + }, + ); + } catch { + return false; + } + + return true; +} + +/** @type {Rule.RuleModule} */ +export const requireUnicodeRegexpFix = { + meta: { + type: "suggestion", + + defaultOptions: [{}], + + docs: { + description: + "Enforce the use of `u` or `v` flag on regular expressions (same checks as ESLint core; autofix inserts the chosen flag)", + }, + + fixable: "code", + + messages: { + requireUFlag: "Use the 'u' flag.", + requireVFlag: "Use the 'v' flag.", + }, + + schema: [ + { + type: "object", + properties: { + requireFlag: { + enum: ["u", "v"], + }, + }, + additionalProperties: false, + }, + ], + }, + + create(context) { + const sourceCode = /** @type {import("eslint").SourceCode} */ ( + context.sourceCode ?? context.getSourceCode?.() + ); + if (!sourceCode?.getScope) { + throw new Error("eslint-config-tachi: sourceCode unavailable on ESLint rule context"); + } + + /** @type {[{ requireFlag?: "u"|"v" }]} */ + const [{ requireFlag }] = context.options; + + return { + "Literal[regex]"(node) { + const flagsText = node.regex.flags ?? ""; + + if (!checkFlags(requireFlag, flagsText)) { + return; + } + + context.report({ + messageId: + requireFlag === "v" /** @type {"requireVFlag"|"requireUFlag"} */ + ? "requireVFlag" + : "requireUFlag", + node, + fix: isValidWithUnicodeFlag( + context.languageOptions.ecmaVersion, + node.regex.pattern, + requireFlag ?? "u", + ) + ? (fixer) => fixRegexLiteral(fixer, sourceCode, node, requireFlag) + : null, + }); + }, + + Program(node) { + const scope = /** @type {import("eslint").Scope.Scope} */ (sourceCode.getScope(node)); + + const tracker = new ReferenceTracker(scope); + const trackMap = { RegExp: { [CALL]: true, [CONSTRUCT]: true } }; + + for (const { node: refNode } of tracker.iterateGlobalReferences(trackMap)) { + const [patternNode, flagsNode] = refNode.arguments; + + if ( + patternNode && + patternNode.type === "SpreadElement" + ) { + continue; + } + + const pattern = + getStringIfConstant(patternNode, scope); + const flags = + flagsNode ? getStringIfConstant(flagsNode, scope) : undefined; + + let missingFlag = !flagsNode; + + if (typeof flags === "string") { + missingFlag = checkFlags(requireFlag, flags); + } + + if (!missingFlag) { + continue; + } + + const canFixPattern = + typeof pattern === "string" && + isValidWithUnicodeFlag( + context.languageOptions.ecmaVersion, + pattern, + requireFlag ?? "u", + ); + + context.report({ + messageId: + requireFlag === "v" /** @type {"requireVFlag"|"requireUFlag"} */ + ? "requireVFlag" + : "requireUFlag", + node: refNode, + fix: + canFixPattern + ? (fixer) => + fixGlobalRegExpCall( + fixer, + sourceCode, + refNode, + flagsNode, + flags ?? "", + requireFlag, + ) + : null, + }); + } + }, + }; + }, +}; + +/** + * @param {Rule.RuleFixer} fixer + * @param {import("eslint").SourceCode} sourceCode + * @param {Rule.Node} node + * @param {RequireFlagOption} requireFlag + */ +function fixRegexLiteral(fixer, sourceCode, node, requireFlag) { + const replaceFlag = requireFlag ?? "u"; + const regexText = sourceCode.getText(node); + const slashPos = regexText.lastIndexOf("/"); + + if (requireFlag) { + const conflicting = requireFlag === "u" /** @type {"u"|"v"} */ ? "v" : "u"; + if ( + regexText.includes( + conflicting, + slashPos, + ) + ) { + return fixer.replaceText( + node, + regexText.slice(0, slashPos) + + regexText.slice(slashPos).replace(conflicting, requireFlag), + ); + } + } + + return fixer.insertTextAfter(node, replaceFlag); +} + +/** + * Mirrors ESLint `require-unicode-regexp` suggestion logic for constructor calls (as autofix). + * + * @param {Rule.RuleFixer} fixer + * @param {import("eslint").SourceCode} sourceCode + * @param {Rule.Node & { callee: unknown; arguments: import("eslint").Rule.Node[] }} refNode + * @param {import("estree").Expression | SpreadElement | undefined} flagsNode + * @param {string} flags Resolved constant flags when determinable (`""` if omitted) + * @param {RequireFlagOption} requireFlag + */ +function fixGlobalRegExpCall( + fixer, + sourceCode, + refNode, + flagsNode, + flags, + requireFlag, +) { + const replaceFlag = + requireFlag ?? + /** @type {"u"|"v"} */ + ("u"); + + if (flagsNode) { + if ( + (flagsNode.type === "Literal" && typeof flagsNode.value === "string") || + flagsNode.type === "TemplateLiteral" + ) { + const flagsNodeText = sourceCode.getText(flagsNode); + + const conflicting = requireFlag === "u" ? "v" : "u"; + if (requireFlag && flags.includes(conflicting)) { + if ( + flagsNode.type === "Literal" && + typeof flagsNode.raw === "string" && + flagsNode.raw.includes("\\") + ) { + return null; + } + if ( + flagsNode.type === "TemplateLiteral" && + (flagsNode.expressions.length || + flagsNode.quasis.some( + (q) => + q.value.raw.includes("\\"), + )) + ) { + return null; + } + + return fixer.replaceText( + flagsNode, + flagsNodeText.replace(conflicting, replaceFlag), + ); + } + + return fixer.replaceText(flagsNode, [ + flagsNodeText.slice(0, flagsNodeText.length - 1), + flagsNodeText.slice(flagsNodeText.length - 1), + ].join(replaceFlag)); + } + + return null; + } + + const penultimateToken = + sourceCode.getLastToken( + refNode, + { skip: 1 }, + ); + if (!penultimateToken) { + return null; + } + + return fixer.insertTextAfter( + penultimateToken, + isCommaToken(penultimateToken) + ? ` "${replaceFlag}",` + : `, "${replaceFlag}"`, + ); +} diff --git a/typescript/eslint-config/rules/tachi-imports-plugin.js b/typescript/eslint-config/rules/tachi-imports-plugin.js index 0a50b6bc8..c29dfd954 100644 --- a/typescript/eslint-config/rules/tachi-imports-plugin.js +++ b/typescript/eslint-config/rules/tachi-imports-plugin.js @@ -3,6 +3,8 @@ import fs from "node:fs"; import path from "node:path"; +import { requireUnicodeRegexpFix } from "./require-unicode-regexp-fix.js"; + const MESSAGE_NO_REDUNDANT_JS = "noRedundantJsExtension"; const MESSAGE_PREFER_HASH = "preferHashImport"; @@ -299,5 +301,6 @@ export default { rules: { "no-redundant-js-extension": noRedundantJsExtension, "prefer-hash-import": preferHashImport, + "require-unicode-regexp": requireUnicodeRegexpFix, }, }; diff --git a/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-clear-tierlist.js b/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-clear-tierlist.js index 98f2d5574..39095d61e 100644 --- a/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-clear-tierlist.js +++ b/typescript/seeds-scripts/rerunners/sdvx/add-sdvx-clear-tierlist.js @@ -1,4 +1,4 @@ -/* eslint no-labels: "off", require-unicode-regexp: "off", no-irregular-whitespace: "off" */ +/* eslint no-labels: "off", "tachi/require-unicode-regexp": "off", no-irregular-whitespace: "off" */ import { Command } from "commander"; import { parse } from "csv-parse/sync"; import fs from "fs";