fix: this fucking regexp bug that has annoyed me for 4 years

This commit is contained in:
zk
2026-05-17 23:19:38 +00:00
parent c55151745b
commit ff06287e87
7 changed files with 338 additions and 2 deletions
+4
View File
@@ -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",
+2
View File
@@ -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",
+1 -1
View File
@@ -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
+2
View File
@@ -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:",
@@ -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}"`,
);
}
@@ -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,
},
};
@@ -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";