eslint-plugin-command
Version:
Comment-as-command for one-off codemod with ESLint
1,244 lines (1,219 loc) • 43.2 kB
JavaScript
import { parseComment } from '@es-joy/jsdoccomment';
const hoistRegExp = {
name: "hoist-regexp",
match: /^\s*[/:@]\s*(?:hoist-|h)reg(?:exp?)?(?:\s+(\S+)\s*)?$/,
action(ctx) {
const regexNode = ctx.findNodeBelow((node) => node.type === "Literal" && "regex" in node);
if (!regexNode)
return ctx.reportError("No regular expression literal found");
const topNodes = ctx.source.ast.body;
const scope = ctx.source.getScope(regexNode);
let parent = regexNode.parent;
while (parent && !topNodes.includes(parent))
parent = parent.parent;
if (!parent)
return ctx.reportError("Failed to find top-level node");
let name = ctx.matches[1];
if (name) {
if (scope.references.find((ref) => ref.identifier.name === name))
return ctx.reportError(`Variable '${name}' is already in scope`);
} else {
let baseName = regexNode.regex.pattern.replace(/\W/g, "_").replace(/_{2,}/g, "_").replace(/^_+|_+$/, "").toLowerCase();
if (baseName.length > 0)
baseName = baseName[0].toUpperCase() + baseName.slice(1);
let i = 0;
name = `re${baseName}`;
while (scope.references.find((ref) => ref.identifier.name === name)) {
i++;
name = `${baseName}${i}`;
}
}
ctx.report({
node: regexNode,
message: `Hoist regular expression to ${name}`,
*fix(fixer) {
yield fixer.insertTextBefore(parent, `const ${name} = ${ctx.source.getText(regexNode)}
`);
yield fixer.replaceText(regexNode, name);
}
});
}
};
function getNodesByIndexes(nodes, indexes) {
return indexes.length ? indexes.map((n) => nodes[n]).filter(Boolean) : nodes;
}
function parseToNumberArray(value, integer = false) {
return value?.split(" ").map(Number).filter(
(n) => !Number.isNaN(n) && integer ? Number.isInteger(n) && n > 0 : true
) ?? [];
}
function unwrapType(node) {
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSInstantiationExpression" || node.type === "TSTypeAssertion") {
return node.expression;
}
return node;
}
const inlineArrow = {
name: "inline-arrow",
match: /^\s*[/:@]\s*(inline-arrow|ia)$/,
action(ctx) {
const arrowFn = ctx.findNodeBelow("ArrowFunctionExpression");
if (!arrowFn)
return ctx.reportError("Unable to find arrow function to convert");
const body = arrowFn.body;
if (body.type !== "BlockStatement")
return ctx.reportError("Arrow function body must have a block statement");
const statements = body.body;
if ((statements.length !== 1 || statements[0].type !== "ReturnStatement") && statements.length !== 0) {
return ctx.reportError("Arrow function body must have a single statement");
}
const statement = statements[0];
const argument = statement?.argument ? unwrapType(statement.argument) : null;
const isObject = argument?.type === "ObjectExpression";
ctx.report({
node: arrowFn,
loc: body.loc,
message: "Inline arrow function",
fix(fixer) {
let raw = statement && statement.argument ? ctx.getTextOf(statement.argument) : "undefined";
if (isObject)
raw = `(${raw})`;
return fixer.replaceTextRange(body.range, raw);
}
});
}
};
const reLine$2 = /^[/@:]\s*keep-aligned(?<repeat>\*?)(?<symbols>(\s+\S+)+)$/;
const keepAligned = {
name: "keep-aligned",
commentType: "line",
match: (comment) => comment.value.trim().match(reLine$2),
action(ctx) {
const node = ctx.findNodeBelow(() => true);
if (!node)
return;
const alignmentSymbols = ctx.matches.groups?.symbols?.trim().split(/\s+/);
if (!alignmentSymbols)
return ctx.reportError("No alignment symbols provided");
const repeat = ctx.matches.groups?.repeat;
const nLeadingSpaces = node.range[0] - ctx.comment.range[1] - 1;
const text = ctx.context.sourceCode.getText(node, nLeadingSpaces);
const lines = text.split("\n");
const nSymbols = alignmentSymbols.length;
if (nSymbols === 0)
return ctx.reportError("No alignment symbols provided");
const n = repeat ? Number.MAX_SAFE_INTEGER : nSymbols;
let lastPos = 0;
for (let i = 0; i < n && i < 20; i++) {
const symbol = alignmentSymbols[i % nSymbols];
const maxIndex = lines.reduce((maxIndex2, line) => Math.max(line.indexOf(symbol, lastPos), maxIndex2), -1);
if (maxIndex < 0) {
if (!repeat)
return ctx.reportError(`Alignment symbol "${symbol}" not found`);
else
break;
}
for (let j = 0; j < lines.length; j++) {
const line = lines[j];
const index = line.indexOf(symbol, lastPos);
if (index < 0)
continue;
if (index !== maxIndex) {
const padding = maxIndex - index;
lines[j] = line.slice(0, index) + " ".repeat(padding) + line.slice(index);
}
}
lastPos = maxIndex + symbol.length;
}
const modifiedText = lines.join("\n");
if (text === modifiedText)
return;
ctx.report({
node,
message: "Keep aligned",
removeComment: false,
fix: (fixer) => fixer.replaceText(node, modifiedText.slice(nLeadingSpaces))
});
}
};
const reLine$1 = /^[/@:]\s*(?:keep-sorted|sorted)\s*(\{.*\})?$/;
const reBlock$1 = /(?:\b|\s)@keep-sorted\s*(\{.*\})?(?:\b|\s|$)/;
const keepSorted = {
name: "keep-sorted",
commentType: "both",
match: (comment) => comment.value.trim().match(comment.type === "Line" ? reLine$1 : reBlock$1),
action(ctx) {
const optionsRaw = ctx.matches[1] || "{}";
let options = null;
try {
options = JSON.parse(optionsRaw);
} catch {
return ctx.reportError(`Failed to parse options: ${optionsRaw}`);
}
let node = ctx.findNodeBelow(
"ObjectExpression",
"ObjectPattern",
"ArrayExpression",
"TSInterfaceBody",
"TSTypeLiteral",
"TSSatisfiesExpression"
) || ctx.findNodeBelow(
"ExportNamedDeclaration",
"TSInterfaceDeclaration",
"VariableDeclaration"
);
if (node?.type === "TSInterfaceDeclaration") {
node = node.body;
}
if (node?.type === "VariableDeclaration") {
const dec = node.declarations[0];
if (!dec) {
node = void 0;
} else if (dec.id.type === "ObjectPattern") {
node = dec.id;
} else {
node = Array.isArray(dec.init) ? dec.init[0] : dec.init;
if (node && node.type !== "ObjectExpression" && node.type !== "ArrayExpression" && node.type !== "TSSatisfiesExpression")
node = void 0;
}
}
if (node?.type === "TSSatisfiesExpression") {
if (node.expression.type !== "ArrayExpression" && node.expression.type !== "ObjectExpression") {
node = void 0;
} else {
node = node.expression;
}
}
if (!node)
return ctx.reportError("Unable to find object/array/interface to sort");
const objectKeys = [
options?.key,
...options?.keys || []
].filter((x) => x != null);
if (objectKeys.length > 0 && node.type !== "ArrayExpression" && node.type !== "ObjectExpression")
return ctx.reportError(`Only arrays and objects can be sorted by keys, but got ${node.type}`);
if (node.type === "ObjectExpression") {
return sort(
ctx,
node,
node.properties.filter(Boolean),
(prop) => {
if (objectKeys.length) {
if (prop.type === "Property" && prop.value.type === "ObjectExpression") {
const objectProp = prop.value;
return objectKeys.map((key) => {
for (const innerProp of objectProp.properties) {
if (innerProp.type === "Property" && getString(innerProp.key) === key) {
return getString(innerProp.value);
}
}
return null;
});
}
} else if (prop.type === "Property") {
return getString(prop.key);
}
return null;
}
);
} else if (node.type === "ObjectPattern") {
sort(
ctx,
node,
node.properties,
(prop) => {
if (prop.type === "Property")
return getString(prop.key);
return null;
}
);
} else if (node.type === "ArrayExpression") {
return sort(
ctx,
node,
node.elements.filter(Boolean),
(element) => {
if (objectKeys.length) {
if (element.type === "ObjectExpression") {
return objectKeys.map((key) => {
for (const prop of element.properties) {
if (prop.type === "Property" && getString(prop.key) === key)
return getString(prop.value);
}
return null;
});
} else {
return null;
}
}
return getString(element);
}
);
} else if (node.type === "TSInterfaceBody") {
return sort(
ctx,
node,
node.body,
(prop) => {
if (prop.type === "TSPropertySignature")
return getString(prop.key);
return null;
},
false
);
} else if (node.type === "TSTypeLiteral") {
return sort(
ctx,
node,
node.members,
(prop) => {
if (prop.type === "TSPropertySignature")
return getString(prop.key);
return null;
},
false
);
} else if (node.type === "ExportNamedDeclaration") {
return sort(
ctx,
node,
node.specifiers,
(prop) => {
if (prop.type === "ExportSpecifier")
return getString(prop.exported);
return null;
}
);
} else {
return false;
}
}
};
function sort(ctx, node, list, getName, insertComma = true) {
const firstToken = ctx.context.sourceCode.getFirstToken(node);
const lastToken = ctx.context.sourceCode.getLastToken(node);
if (!firstToken || !lastToken)
return ctx.reportError("Unable to find object/array/interface to sort");
if (list.length < 2)
return false;
const reordered = list.slice();
const ranges = /* @__PURE__ */ new Map();
const names = /* @__PURE__ */ new Map();
const rangeStart = Math.max(
firstToken.range[1],
ctx.context.sourceCode.getIndexFromLoc({
line: list[0].loc.start.line,
column: 0
})
);
let rangeEnd = rangeStart;
for (let i = 0; i < list.length; i++) {
const item = list[i];
let name = getName(item);
if (typeof name === "string")
name = [name];
names.set(item, name);
let lastRange = item.range[1];
const nextToken = ctx.context.sourceCode.getTokenAfter(item);
if (nextToken?.type === "Punctuator" && nextToken.value === ",")
lastRange = nextToken.range[1];
const nextChar = ctx.context.sourceCode.getText()[lastRange];
let text = ctx.getTextOf([rangeEnd, lastRange]);
if (nextToken === lastToken && insertComma)
text += ",";
if (nextChar === "\n") {
lastRange++;
text += "\n";
}
ranges.set(item, [rangeEnd, lastRange, text]);
rangeEnd = lastRange;
}
const segments = [];
let segmentStart = -1;
for (let i = 0; i < list.length; i++) {
if (names.get(list[i]) == null) {
if (segmentStart > -1)
segments.push([segmentStart, i]);
segmentStart = -1;
} else {
if (segmentStart === -1)
segmentStart = i;
}
}
if (segmentStart > -1 && segmentStart !== list.length - 1)
segments.push([segmentStart, list.length]);
for (const [start, end] of segments) {
reordered.splice(
start,
end - start,
...reordered.slice(start, end).sort((a, b) => {
const nameA = names.get(a);
const nameB = names.get(b);
const length = Math.max(nameA.length, nameB.length);
for (let i = 0; i < length; i++) {
const a2 = nameA[i];
const b2 = nameB[i];
if (a2 == null || b2 == null || a2 === b2)
continue;
return a2.localeCompare(b2);
}
return 0;
})
);
}
const changed = reordered.some((prop, i) => prop !== list[i]);
if (!changed)
return false;
const newContent = reordered.map((i) => ranges.get(i)[2]).join("");
ctx.report({
node,
message: "Keep sorted",
removeComment: false,
fix(fixer) {
return fixer.replaceTextRange([rangeStart, rangeEnd], newContent);
}
});
}
function getString(node) {
if (node.type === "Identifier")
return node.name;
if (node.type === "Literal")
return String(node.raw);
return null;
}
const reLine = /^[/@:]\s*(?:keep-)?uni(?:que)?$/;
const reBlock = /(?:\b|\s)@keep-uni(?:que)?(?:\b|\s|$)/;
const keepUnique = {
name: "keep-unique",
commentType: "both",
match: (comment) => comment.value.trim().match(comment.type === "Line" ? reLine : reBlock),
action(ctx) {
const node = ctx.findNodeBelow("ArrayExpression");
if (!node)
return ctx.reportError("Unable to find array to keep unique");
const set = /* @__PURE__ */ new Set();
const removalIndex = /* @__PURE__ */ new Set();
node.elements.forEach((item, idx) => {
if (!item)
return;
if (item.type !== "Literal")
return;
if (set.has(String(item.raw)))
removalIndex.add(idx);
else
set.add(String(item.raw));
});
if (removalIndex.size === 0)
return false;
ctx.report({
node,
message: "Keep unique",
removeComment: false,
fix(fixer) {
const removalRanges = Array.from(removalIndex).map((idx) => {
const item = node.elements[idx];
const nextItem = node.elements[idx + 1];
if (nextItem)
return [item.range[0], nextItem.range[0]];
const nextToken = ctx.source.getTokenAfter(item);
if (nextToken && nextToken.value === ",")
return [item.range[0], nextToken.range[1]];
return item.range;
}).sort((a, b) => b[0] - a[0]);
let text = ctx.getTextOf(node);
for (const [start, end] of removalRanges)
text = text.slice(0, start - node.range[0]) + text.slice(end - node.range[0]);
return fixer.replaceText(node, text);
}
});
}
};
const noShorthand = {
name: "no-shorthand",
match: /^\s*[/:@]\s*(no-shorthand|nsh)$/,
action(ctx) {
const nodes = ctx.findNodeBelow({
filter: (node) => node.type === "Property" && node.shorthand,
findAll: true
});
if (!nodes || nodes.length === 0)
return ctx.reportError("Unable to find shorthand object property to convert");
ctx.report({
nodes,
message: "Expand shorthand",
*fix(fixer) {
for (const node of nodes)
yield fixer.insertTextAfter(node.key, `: ${ctx.getTextOf(node.key)}`);
}
});
}
};
const noType = {
name: "no-type",
match: /^\s*[/:@]\s*(no-type|nt)$/,
action(ctx) {
const nodes = ctx.findNodeBelow({
filter: (node) => node.type.startsWith("TS"),
findAll: true,
shallow: true
});
if (!nodes || nodes.length === 0)
return ctx.reportError("Unable to find type to remove");
ctx.report({
nodes,
message: "Remove type",
*fix(fixer) {
for (const node of nodes.reverse()) {
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSInstantiationExpression") {
yield fixer.removeRange([node.expression.range[1], node.range[1]]);
} else if (node.type === "TSTypeAssertion") {
yield fixer.removeRange([node.range[0], node.expression.range[0]]);
} else {
yield fixer.remove(node);
}
}
}
});
}
};
const types = [
"await"
// TODO: implement
// 'statements',
// 'functions',
];
const noXAbove = {
name: "no-x-above",
match: new RegExp(`^\\s*[/:@]\\s*no-(${types.join("|")})-(above|below)$`),
action(ctx) {
const type = ctx.matches[1];
const direction = ctx.matches[2];
const node = ctx.findNodeBelow(() => true);
const parent = node?.parent;
if (!parent)
return ctx.reportError("No parent node found");
if (parent.type !== "Program" && parent.type !== "BlockStatement")
return ctx.reportError("Parent node is not a block");
const children = parent.body;
const targetNodes = direction === "above" ? children.filter((c) => c.range[1] <= ctx.comment.range[0]) : children.filter((c) => c.range[0] >= ctx.comment.range[1]);
if (!targetNodes.length)
return;
switch (type) {
case "await":
for (const target of targetNodes) {
ctx.traverse(target, (path, { SKIP }) => {
if (path.node.type === "FunctionDeclaration" || path.node.type === "FunctionExpression" || path.node.type === "ArrowFunctionExpression") {
return SKIP;
}
if (path.node.type === "AwaitExpression") {
ctx.report({
node: path.node,
message: "Disallowed await expression"
});
}
});
}
return;
default:
return ctx.reportError(`Unknown type: ${type}`);
}
}
};
const reCodeBlock = /```(.*)\n([\s\S]*)\n```/;
const regex101 = {
name: "regex101",
/**
* @regex101 https://regex101.com/?regex=%28%5Cb%7C%5Cs%7C%5E%29%28%40regex101%29%28%5Cs%5CS%2B%29%3F%28%5Cb%7C%5Cs%7C%24%29&flavor=javascript
*/
match: /(\b|\s|^)(@regex101)(\s\S+)?(\b|\s|$)/,
commentType: "both",
action(ctx) {
const literal = ctx.findNodeBelow((n) => {
return n.type === "Literal" && "regex" in n;
});
if (!literal)
return ctx.reportError("Unable to find a regexp literal to generate");
const [
_fullStr = "",
spaceBefore = "",
commandStr = "",
existingUrl = "",
_spaceAfter = ""
] = ctx.matches;
let example;
if (ctx.comment.value.includes("```") && ctx.comment.value.includes("@example")) {
try {
const parsed = parseComment(ctx.comment, "");
const tag = parsed.tags.find((t) => t.tag === "example");
const description = tag?.description;
const code = description?.match(reCodeBlock)?.[2].trim();
if (code)
example = code;
} catch {
}
}
const query = new URLSearchParams();
query.set("regex", literal.regex.pattern);
if (literal.regex.flags)
query.set("flags", literal.regex.flags);
query.set("flavor", "javascript");
if (example)
query.set("testString", example);
const url = `https://regex101.com/?${query}`;
if (existingUrl.trim() === url.trim())
return;
const indexStart = ctx.comment.range[0] + ctx.matches.index + spaceBefore.length + 2;
const indexEnd = indexStart + commandStr.length + existingUrl.length;
ctx.report({
loc: {
start: ctx.source.getLocFromIndex(indexStart),
end: ctx.source.getLocFromIndex(indexEnd)
},
removeComment: false,
message: `Update the regex101 link`,
fix(fixer) {
return fixer.replaceTextRange([indexStart, indexEnd], `@regex101 ${url}`);
}
});
}
};
const reverseIfElse = {
name: "reverse-if-else",
match: /^\s*[/:@]\s*(reverse-if-else|rife|rif)$/,
action(ctx) {
const node = ctx.findNodeBelow("IfStatement");
if (!node)
return ctx.reportError("Cannot find if statement");
const elseNode = node.alternate;
const isElseif = elseNode?.type === "IfStatement";
if (isElseif)
return ctx.reportError("Unable reverse when `else if` statement exist");
const ifNode = node.consequent;
ctx.report({
loc: node.loc,
message: "Reverse if-else",
fix(fixer) {
const lineIndent = ctx.getIndentOfLine(node.loc.start.line);
const conditionText = ctx.getTextOf(node.test);
const ifText = ctx.getTextOf(ifNode);
const elseText = elseNode ? ctx.getTextOf(elseNode) : "{\n}";
const str = [
`if (!(${conditionText})) ${elseText}`,
`else ${ifText}`
].map((line, idx) => idx ? lineIndent + line : line).join("\n");
return fixer.replaceText(node, str);
}
});
}
};
const toArrow = {
name: "to-arrow",
match: /^\s*[/:@]\s*(to-arrow|2a|ta)$/,
action(ctx) {
const fn = ctx.findNodeBelow("FunctionDeclaration", "FunctionExpression");
if (!fn)
return ctx.reportError("Unable to find function declaration to convert");
const id = fn.id;
const body = fn.body;
let rangeStart = fn.range[0];
const rangeEnd = fn.range[1];
const parent = fn.parent;
if (parent.type === "Property" && parent.kind !== "init")
return ctx.reportError(`Cannot convert ${parent.kind}ter property to arrow function`);
ctx.report({
node: fn,
loc: {
start: fn.loc.start,
end: body.loc.start
},
message: "Convert to arrow function",
fix(fixer) {
let textName = ctx.getTextOf(id);
const textArgs = fn.params.length ? ctx.getTextOf([fn.params[0].range[0], fn.params[fn.params.length - 1].range[1]]) : "";
const textBody = body.type === "BlockStatement" ? ctx.getTextOf(body) : `{
return ${ctx.getTextOf(body)}
}`;
const textGeneric = ctx.getTextOf(fn.typeParameters);
const textTypeReturn = ctx.getTextOf(fn.returnType);
const textAsync = fn.async ? "async" : "";
let final = [textAsync, `${textGeneric}(${textArgs})${textTypeReturn} =>`, textBody].filter(Boolean).join(" ");
if (fn.type === "FunctionDeclaration" && textName) {
final = `const ${textName} = ${final}`;
} else if (parent.type === "Property") {
rangeStart = parent.range[0];
textName = ctx.getTextOf(parent.key);
final = `${parent.computed ? `[${textName}]` : textName}: ${final}`;
} else if (parent.type === "MethodDefinition") {
rangeStart = parent.range[0];
textName = ctx.getTextOf(parent.key);
final = `${[
parent.accessibility,
parent.static && "static",
parent.override && "override",
parent.computed ? `[${textName}]` : textName,
parent.optional && "?"
].filter(Boolean).join(" ")} = ${final}`;
}
return fixer.replaceTextRange([rangeStart, rangeEnd], final);
}
});
}
};
const toDestructuring = {
name: "to-destructuring",
match: /^\s*[/:@]\s*(?:to-|2)(?:destructuring|dest)$/i,
action(ctx) {
const node = ctx.findNodeBelow(
"VariableDeclaration",
"AssignmentExpression"
);
if (!node)
return ctx.reportError("Unable to find object/array to convert");
const isDeclaration = node.type === "VariableDeclaration";
const rightExpression = isDeclaration ? node.declarations[0].init : node.right;
const member = rightExpression?.type === "ChainExpression" ? rightExpression.expression : rightExpression;
if (member?.type !== "MemberExpression")
return ctx.reportError("Unable to convert to destructuring");
const id = isDeclaration ? ctx.getTextOf(node.declarations[0].id) : ctx.getTextOf(node.left);
const property = ctx.getTextOf(member.property);
const isArray = !Number.isNaN(Number(property));
const left = isArray ? `${",".repeat(Number(property))}${id}` : `${id === property ? id : `${property}: ${id}`}`;
let right = `${ctx.getTextOf(member.object)}`;
if (member.optional)
right += ` ?? ${isArray ? "[]" : "{}"}`;
let str = isArray ? `[${left}] = ${right}` : `{ ${left} } = ${right}`;
str = isDeclaration ? `${node.kind} ${str}` : `;(${str})`;
ctx.report({
node,
message: "Convert to destructuring",
fix: (fixer) => fixer.replaceTextRange(node.range, str)
});
}
};
const toDynamicImport = {
name: "to-dynamic-import",
match: /^\s*[/:@]\s*(?:to-|2)?(?:dynamic|d)(?:-?import)?$/i,
action(ctx) {
const node = ctx.findNodeBelow("ImportDeclaration");
if (!node)
return ctx.reportError("Unable to find import statement to convert");
let namespace;
if (node.importKind === "type")
return ctx.reportError("Unable to convert type import to dynamic import");
const typeSpecifiers = [];
const destructure = node.specifiers.map((specifier) => {
if (specifier.type === "ImportSpecifier") {
if (specifier.importKind === "type") {
typeSpecifiers.push(specifier);
return null;
}
if (specifier.imported.type === "Identifier" && specifier.local.name === specifier.imported.name)
return ctx.getTextOf(specifier.imported);
else
return `${ctx.getTextOf(specifier.imported)}: ${ctx.getTextOf(specifier.local)}`;
} else if (specifier.type === "ImportDefaultSpecifier") {
return `default: ${ctx.getTextOf(specifier.local)}`;
} else if (specifier.type === "ImportNamespaceSpecifier") {
namespace = ctx.getTextOf(specifier.local);
return null;
}
return null;
}).filter(Boolean).join(", ");
let str = namespace ? `const ${namespace} = await import(${ctx.getTextOf(node.source)})` : `const { ${destructure} } = await import(${ctx.getTextOf(node.source)})`;
if (typeSpecifiers.length)
str = `import { ${typeSpecifiers.map((s) => ctx.getTextOf(s)).join(", ")} } from ${ctx.getTextOf(node.source)}
${str}`;
ctx.report({
node,
message: "Convert to dynamic import",
fix: (fixer) => fixer.replaceText(node, str)
});
}
};
const FOR_TRAVERSE_IGNORE = [
"FunctionDeclaration",
"FunctionExpression",
"ArrowFunctionExpression",
"WhileStatement",
"DoWhileStatement",
"ForInStatement",
"ForOfStatement",
"ForStatement",
"ArrowFunctionExpression"
];
const toForEach = {
name: "to-for-each",
match: /^\s*[/:@]\s*(?:to-|2)?for-?each$/i,
action(ctx) {
const node = ctx.findNodeBelow("ForInStatement", "ForOfStatement");
if (!node)
return ctx.reportError("Unable to find for statement to convert");
const continueNodes = [];
const result = ctx.traverse(node.body, (path, { STOP, SKIP }) => {
if (FOR_TRAVERSE_IGNORE.includes(path.node.type))
return SKIP;
if (path.node.type === "ContinueStatement") {
continueNodes.push(path.node);
} else if (path.node.type === "BreakStatement") {
ctx.reportError(
"Unable to convert for statement with break statement",
{
node: path.node,
message: "Break statement has no equivalent in forEach"
}
);
return STOP;
} else if (path.node.type === "ReturnStatement") {
ctx.reportError(
"Unable to convert for statement with return statement",
{
node: path.node,
message: "Return statement has no equivalent in forEach"
}
);
return STOP;
}
});
if (!result)
return;
let textBody = ctx.getTextOf(node.body);
continueNodes.sort((a, b) => b.loc.start.line - a.loc.start.line).forEach((c) => {
textBody = textBody.slice(0, c.range[0] - node.body.range[0]) + "return" + textBody.slice(c.range[1] - node.body.range[0]);
});
if (!textBody.trim().startsWith("{"))
textBody = `{
${textBody}
}`;
const localId = node.left.type === "VariableDeclaration" ? node.left.declarations[0].id : node.left;
const textLocal = ctx.getTextOf(localId);
let textIterator = ctx.getTextOf(node.right);
if (!["Identifier", "MemberExpression", "CallExpression"].includes(node.right.type))
textIterator = `(${textIterator})`;
let str = node.type === "ForOfStatement" ? `${textIterator}.forEach((${textLocal}) => ${textBody})` : `Object.keys(${textIterator}).forEach((${textLocal}) => ${textBody})`;
if (str[0] === "(")
str = `;${str}`;
ctx.report({
node,
message: "Convert to forEach",
fix(fixer) {
return fixer.replaceText(node, str);
}
});
}
};
const toForOf = {
name: "to-for-of",
match: /^\s*[/:@]\s*(?:to-|2)?for-?of$/i,
action(ctx) {
const target = ctx.findNodeBelow((node) => {
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && node.callee.property.type === "Identifier" && node.callee.property.name === "forEach")
return true;
return false;
});
if (!target)
return ctx.reportError("Unable to find .forEach() to convert");
const member = target.callee;
const iterator = member.object;
const fn = target.arguments[0];
if (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression")
return ctx.reportError("Unable to find .forEach() to convert");
if (fn.params.length !== 1) {
return ctx.reportError(
"Unable to convert forEach",
{
node: fn.params[0],
message: "Index argument in forEach is not yet supported for conversion"
}
);
}
const returnNodes = [];
ctx.traverse(fn.body, (path, { SKIP }) => {
if (FOR_TRAVERSE_IGNORE.includes(path.node.type))
return SKIP;
if (path.node.type === "ReturnStatement")
returnNodes.push(path.node);
});
let textBody = ctx.getTextOf(fn.body);
returnNodes.sort((a, b) => b.loc.start.line - a.loc.start.line).forEach((c) => {
textBody = textBody.slice(0, c.range[0] - fn.body.range[0]) + "continue" + textBody.slice(c.range[1] - fn.body.range[0]);
});
const local = fn.params[0];
const str = `for (const ${ctx.getTextOf(local)} of ${ctx.getTextOf(iterator)}) ${textBody}`;
ctx.report({
node: target,
message: "Convert to for-of loop",
fix(fixer) {
return fixer.replaceText(target, str);
}
});
}
};
const toFunction = {
name: "to-function",
match: /^\s*[/:@]\s*(to-(?:fn|function)|2f|tf)$/,
action(ctx) {
const arrowFn = ctx.findNodeBelow("ArrowFunctionExpression");
if (!arrowFn)
return ctx.reportError("Unable to find arrow function to convert");
let start = arrowFn;
let id;
const body = arrowFn.body;
if (arrowFn.parent.type === "VariableDeclarator" && arrowFn.parent.id.type === "Identifier") {
id = arrowFn.parent.id;
if (arrowFn.parent.parent.type === "VariableDeclaration" && arrowFn.parent.parent.kind === "const" && arrowFn.parent.parent.declarations.length === 1)
start = arrowFn.parent.parent;
} else if (arrowFn.parent.type === "Property" && arrowFn.parent.key.type === "Identifier") {
id = arrowFn.parent.key;
start = arrowFn.parent.key;
}
ctx.report({
node: arrowFn,
loc: {
start: start.loc.start,
end: body.loc.start
},
message: "Convert to function",
fix(fixer) {
const textName = ctx.getTextOf(id);
const textArgs = arrowFn.params.length ? ctx.getTextOf([arrowFn.params[0].range[0], arrowFn.params[arrowFn.params.length - 1].range[1]]) : "";
const textBody = body.type === "BlockStatement" ? ctx.getTextOf(body) : `{
return ${ctx.getTextOf(body)}
}`;
const textGeneric = ctx.getTextOf(arrowFn.typeParameters);
const textTypeReturn = ctx.getTextOf(arrowFn.returnType);
const textAsync = arrowFn.async ? "async" : "";
const fnBody = [`${textGeneric}(${textArgs})${textTypeReturn}`, textBody].filter(Boolean).join(" ");
let final = [textAsync, `function`, textName, fnBody].filter(Boolean).join(" ");
if (arrowFn.parent.type === "Property")
final = [textAsync, textName, fnBody].filter(Boolean).join(" ");
return fixer.replaceTextRange([start.range[0], arrowFn.range[1]], final);
}
});
}
};
const toOneLine = {
name: "to-one-line",
match: /^[/@:]\s*(?:to-one-line|21l|tol)$/,
action(ctx) {
const node = ctx.findNodeBelow(
"VariableDeclaration",
"AssignmentExpression",
"CallExpression",
"FunctionDeclaration",
"FunctionExpression",
"ReturnStatement"
);
if (!node)
return ctx.reportError("Unable to find node to convert");
let target = null;
if (node.type === "VariableDeclaration") {
const decl = node.declarations[0];
if (decl && decl.init && isAllowedType(decl.init.type))
target = decl.init;
} else if (node.type === "AssignmentExpression") {
if (node.right && isAllowedType(node.right.type))
target = node.right;
} else if (node.type === "CallExpression") {
target = node.arguments.find((arg) => isAllowedType(arg.type)) || null;
} else if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression") {
target = node.params.find((param) => isAllowedType(param.type)) || null;
} else if (node.type === "ReturnStatement") {
if (node.argument && isAllowedType(node.argument.type))
target = node.argument;
}
if (!target)
return ctx.reportError("Unable to find object/array literal or pattern to convert");
const original = ctx.getTextOf(target);
let oneLine = original.replace(/\n/g, " ").replace(/\s{2,}/g, " ").trim();
oneLine = oneLine.replace(/,\s*([}\]])/g, "$1");
if (target.type === "ArrayExpression" || target.type === "ArrayPattern") {
oneLine = oneLine.replace(/\[\s+/g, "[").replace(/\s+\]/g, "]");
} else {
oneLine = oneLine.replace(/([^ \t])([}\]])/g, "$1 $2");
oneLine = oneLine.replace(/\](\})/g, "] $1");
}
oneLine = oneLine.replace(/\[\s+/g, "[").replace(/\s+\]/g, "]");
ctx.report({
node: target,
message: "Convert object/array to one line",
fix: (fixer) => fixer.replaceTextRange(target.range, oneLine)
});
function isAllowedType(type) {
return type === "ObjectExpression" || type === "ArrayExpression" || type === "ObjectPattern" || type === "ArrayPattern";
}
}
};
const toPromiseAll = {
name: "to-promise-all",
match: /^[/@:]\s*(?:to-|2)(?:promise-all|pa)$/,
action(ctx) {
const parent = ctx.getParentBlock();
const nodeStart = ctx.findNodeBelow(isTarget);
let nodeEnd = nodeStart;
if (!nodeStart)
return ctx.reportError("Unable to find variable declaration");
if (!parent.body.includes(nodeStart))
return ctx.reportError("Variable declaration is not in the same block");
function isTarget(node) {
if (node.type === "VariableDeclaration")
return node.declarations.some((declarator) => declarator.init?.type === "AwaitExpression");
else if (node.type === "ExpressionStatement")
return node.expression.type === "AwaitExpression";
return false;
}
function getDeclarators(node) {
if (node.type === "VariableDeclaration")
return node.declarations;
if (node.expression.type === "AwaitExpression")
return [node.expression];
return [];
}
let declarationType = "const";
const declarators = [];
for (let i = parent.body.indexOf(nodeStart); i < parent.body.length; i++) {
const node = parent.body[i];
if (isTarget(node)) {
declarators.push(...getDeclarators(node));
nodeEnd = node;
if (node.type === "VariableDeclaration" && node.kind !== "const")
declarationType = "let";
} else {
break;
}
}
function unwrapAwait(node) {
if (node?.type === "AwaitExpression")
return node.argument;
return node;
}
ctx.report({
loc: {
start: nodeStart.loc.start,
end: nodeEnd.loc.end
},
message: "Convert to `await Promise.all`",
fix(fixer) {
const lineIndent = ctx.getIndentOfLine(nodeStart.loc.start.line);
const isTs = ctx.context.filename.match(/\.[mc]?tsx?$/);
function getId(declarator) {
if (declarator.type === "AwaitExpression")
return "/* discarded */";
return ctx.getTextOf(declarator.id);
}
function getInit(declarator) {
if (declarator.type === "AwaitExpression")
return ctx.getTextOf(declarator.argument);
return ctx.getTextOf(unwrapAwait(declarator.init));
}
const str = [
`${declarationType} [`,
...declarators.map((declarator) => `${getId(declarator)},`),
"] = await Promise.all([",
...declarators.map((declarator) => `${getInit(declarator)},`),
isTs ? "] as const)" : "])"
].map((line, idx) => idx ? lineIndent + line : line).join("\n");
return fixer.replaceTextRange([
nodeStart.range[0],
nodeEnd.range[1]
], str);
}
});
}
};
const toStringLiteral = {
name: "to-string-literal",
match: /^\s*[/:@]\s*(?:to-|2)?(?:string-literal|sl)\s*(\S.*)?$/,
action(ctx) {
const numbers = ctx.matches[1];
const indexes = parseToNumberArray(numbers, true).map((n) => n - 1);
const nodes = ctx.findNodeBelow({
types: ["TemplateLiteral"],
shallow: true,
findAll: true
});
if (!nodes?.length)
return ctx.reportError("No template literals found");
ctx.report({
nodes,
message: "Convert to string literal",
*fix(fixer) {
for (const node of getNodesByIndexes(nodes, indexes)) {
const ids = extractIdentifiers(node);
let raw = JSON.stringify(ctx.source.getText(node).slice(1, -1)).slice(1, -1);
if (ids.length)
raw = toStringWithIds(raw, node, ids);
else
raw = `"${raw}"`;
yield fixer.replaceTextRange(node.range, raw);
}
}
});
}
};
function extractIdentifiers(node) {
const ids = [];
for (const child of node.expressions) {
if (child.type === "Identifier")
ids.push({ name: child.name, range: child.range });
}
return ids;
}
function toStringWithIds(raw, node, ids) {
let hasStart = false;
let hasEnd = false;
ids.forEach(({ name, range }, index) => {
let startStr = `" + `;
let endStr = ` + "`;
if (index === 0) {
hasStart = range[0] - /* `${ */
3 === node.range[0];
if (hasStart)
startStr = "";
}
if (index === ids.length - 1) {
hasEnd = range[1] + /* }` */
2 === node.range[1];
if (hasEnd)
endStr = "";
}
raw = raw.replace(`\${${name}}`, `${startStr}${name}${endStr}`);
});
return `${hasStart ? "" : `"`}${raw}${hasEnd ? "" : `"`}`;
}
const toTemplateLiteral = {
name: "to-template-literal",
match: /^\s*[/:@]\s*(?:to-|2)?(?:template-literal|tl)\s*(\S.*)?$/,
action(ctx) {
const numbers = ctx.matches[1];
const indexes = parseToNumberArray(numbers, true).map((n) => n - 1);
let nodes;
nodes = ctx.findNodeBelow({
types: ["Literal", "BinaryExpression"],
shallow: true,
findAll: true
})?.filter(
(node) => node.type === "Literal" ? typeof node.value === "string" : node.type === "BinaryExpression" ? node.operator === "+" : false
);
if (!nodes || !nodes.length)
return ctx.reportError("No string literals or binary expressions found");
nodes = getNodesByIndexes(nodes, indexes);
ctx.report({
nodes,
message: "Convert to template literal",
*fix(fixer) {
for (const node of nodes.reverse()) {
if (node.type === "BinaryExpression")
yield fixer.replaceText(node, `\`${traverseBinaryExpression(node)}\``);
else
yield fixer.replaceText(node, `\`${escape(node.value)}\``);
}
}
});
}
};
function getExpressionValue(node) {
if (node.type === "Identifier")
return `\${${node.name}}`;
if (node.type === "Literal" && typeof node.value === "string")
return escape(node.value);
return "";
}
function traverseBinaryExpression(node) {
let deepestExpr = node;
let str = "";
while (deepestExpr.left.type === "BinaryExpression")
deepestExpr = deepestExpr.left;
let currentExpr = deepestExpr;
while (currentExpr) {
str += getExpressionValue(currentExpr.left) + getExpressionValue(currentExpr.right);
if (currentExpr === node)
break;
currentExpr = currentExpr.parent;
}
return str;
}
function escape(raw) {
return raw.replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
const toTernary = {
name: "to-ternary",
match: /^\s*[/:@]\s*(?:to-|2)(?:ternary|3)$/,
action(ctx) {
const node = ctx.findNodeBelow("IfStatement");
if (!node)
return ctx.reportError("Unable to find an `if` statement to convert");
let result = "";
let isAssignment = true;
const normalizeStatement = (n) => {
if (!n)
return ctx.reportError("Unable to convert `if` statement without an `else` clause");
if (n.type === "BlockStatement") {
if (n.body.length !== 1)
return ctx.reportError("Unable to convert statement contains more than one expression");
else return n.body[0];
} else {
return n;
}
};
const getAssignmentId = (n) => {
if (n.type === "IfStatement")
n = n.consequent;
if (n.type !== "ExpressionStatement" || n.expression.type !== "AssignmentExpression" || n.expression.left.type !== "Identifier")
return;
return ctx.getTextOf(n.expression.left);
};
let ifNode = node;
while (ifNode) {
const consequent = normalizeStatement(ifNode.consequent);
const alternate = normalizeStatement(ifNode.alternate);
if (!consequent || !alternate)
return;
if (isAssignment) {
const ifId = getAssignmentId(consequent);
const elseId = getAssignmentId(alternate);
if (!ifId || ifId !== elseId)
isAssignment = false;
}
result += `${ctx.getTextOf(ifNode.test)} ? ${ctx.getTextOf(consequent)} : `;
if (alternate.type !== "IfStatement") {
result += ctx.getTextOf(alternate);
break;
} else {
ifNode = alternate;
}
}
if (isAssignment) {
const id = getAssignmentId(normalizeStatement(node.consequent));
result = `${id} = ${result.replaceAll(`${id} = `, "")}`;
}
ctx.report({
node,
message: "Convert to ternary",
fix: (fix) => fix.replaceTextRange(node.range, result)
});
}
};
const builtinCommands = [
hoistRegExp,
inlineArrow,
keepAligned,
keepSorted,
keepUnique,
noShorthand,
noType,
noXAbove,
regex101,
reverseIfElse,
toArrow,
toDestructuring,
toDynamicImport,
toForEach,
toForOf,
toFunction,
toOneLine,
toPromiseAll,
toStringLiteral,
toTemplateLiteral,
toTernary
];
export { keepSorted as a, keepUnique as b, noType as c, noXAbove as d, reverseIfElse as e, toDestructuring as f, toDynamicImport as g, hoistRegExp as h, inlineArrow as i, toForEach as j, keepAligned as k, toForOf as l, toFunction as m, noShorthand as n, toOneLine as o, toPromiseAll as p, toStringLiteral as q, regex101 as r, toTemplateLiteral as s, toArrow as t, toTernary as u, builtinCommands as v };