2023-11-23 21:43:05 +01:00

882 lines
26 KiB
TypeScript

import {
ChangeType,
StringChange,
applyChangesToString,
joinPathFragments,
} from '@nx/devkit';
import { Linter } from 'eslint';
import * as ts from 'typescript';
import { mapFilePath } from './path-utils';
/**
* Remove all overrides from the config file
*/
export function removeOverridesFromLintConfig(content: string): string {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const exportsArray = findAllBlocks(source);
if (!exportsArray) {
return content;
}
const changes: StringChange[] = [];
exportsArray.forEach((node, i) => {
if (isOverride(node)) {
const commaOffset =
i < exportsArray.length - 1 || exportsArray.hasTrailingComma ? 1 : 0;
changes.push({
type: ChangeType.Delete,
start: node.pos,
length: node.end - node.pos + commaOffset,
});
}
});
return applyChangesToString(content, changes);
}
function findAllBlocks(source: ts.SourceFile): ts.NodeArray<ts.Node> {
return ts.forEachChild(source, function analyze(node) {
if (
ts.isExpressionStatement(node) &&
ts.isBinaryExpression(node.expression) &&
node.expression.left.getText() === 'module.exports' &&
ts.isArrayLiteralExpression(node.expression.right)
) {
return node.expression.right.elements;
}
});
}
function isOverride(node: ts.Node): boolean {
return (
(ts.isObjectLiteralExpression(node) &&
node.properties.some((p) => p.name.getText() === 'files')) ||
// detect ...compat.config(...).map(...)
(ts.isSpreadElement(node) &&
ts.isCallExpression(node.expression) &&
ts.isPropertyAccessExpression(node.expression.expression) &&
ts.isArrowFunction(node.expression.arguments[0]) &&
ts.isParenthesizedExpression(node.expression.arguments[0].body))
);
}
export function hasOverride(
content: string,
lookup: (override: Linter.ConfigOverride<Linter.RulesRecord>) => boolean
): boolean {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const exportsArray = findAllBlocks(source);
if (!exportsArray) {
return false;
}
for (const node of exportsArray) {
if (isOverride(node)) {
let objSource;
if (ts.isObjectLiteralExpression(node)) {
objSource = node.getFullText();
} else {
const fullNodeText =
node['expression'].arguments[0].body.expression.getFullText();
// strip any spread elements
objSource = fullNodeText.replace(/\s*\.\.\.[a-zA-Z0-9_]+,?\n?/, '');
}
const data = JSON.parse(
objSource
// ensure property names have double quotes so that JSON.parse works
.replace(/'/g, '"')
.replace(/\s([a-zA-Z0-9_]+)\s*:/g, ' "$1": ')
);
if (lookup(data)) {
return true;
}
}
}
return false;
}
const STRIP_SPREAD_ELEMENTS = /\s*\.\.\.[a-zA-Z0-9_]+,?\n?/g;
function parseTextToJson(text: string): any {
return JSON.parse(
text
// ensure property names have double quotes so that JSON.parse works
.replace(/'/g, '"')
.replace(/\s([a-zA-Z0-9_]+)\s*:/g, ' "$1": ')
);
}
/**
* Finds an override matching the lookup function and applies the update function to it
*/
export function replaceOverride(
content: string,
root: string,
lookup: (override: Linter.ConfigOverride<Linter.RulesRecord>) => boolean,
update: (
override: Linter.ConfigOverride<Linter.RulesRecord>
) => Linter.ConfigOverride<Linter.RulesRecord>
): string {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const exportsArray = findAllBlocks(source);
if (!exportsArray) {
return content;
}
const changes: StringChange[] = [];
exportsArray.forEach((node) => {
if (isOverride(node)) {
let objSource;
let start, end;
if (ts.isObjectLiteralExpression(node)) {
objSource = node.getFullText();
start = node.properties.pos + 1; // keep leading line break
end = node.properties.end;
} else {
const fullNodeText =
node['expression'].arguments[0].body.expression.getFullText();
// strip any spread elements
objSource = fullNodeText.replace(STRIP_SPREAD_ELEMENTS, '');
start =
node['expression'].arguments[0].body.expression.properties.pos +
(fullNodeText.length - objSource.length);
end = node['expression'].arguments[0].body.expression.properties.end;
}
const data = parseTextToJson(objSource);
if (lookup(data)) {
changes.push({
type: ChangeType.Delete,
start,
length: end - start,
});
const updatedData = update(data);
mapFilePaths(updatedData);
changes.push({
type: ChangeType.Insert,
index: start,
text: JSON.stringify(updatedData, null, 2).slice(2, -2), // remove curly braces and start/end line breaks since we are injecting just properties
});
}
}
});
return applyChangesToString(content, changes);
}
/**
* Adding require statement to the top of the file
*/
export function addImportToFlatConfig(
content: string,
variable: string | string[],
imp: string
): string {
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const foundBindingVars: ts.NodeArray<ts.BindingElement> = ts.forEachChild(
source,
function analyze(node) {
// we can only combine object binding patterns
if (!Array.isArray(variable)) {
return;
}
if (
ts.isVariableStatement(node) &&
ts.isVariableDeclaration(node.declarationList.declarations[0]) &&
ts.isObjectBindingPattern(node.declarationList.declarations[0].name) &&
ts.isCallExpression(node.declarationList.declarations[0].initializer) &&
node.declarationList.declarations[0].initializer.expression.getText() ===
'require' &&
ts.isStringLiteral(
node.declarationList.declarations[0].initializer.arguments[0]
) &&
node.declarationList.declarations[0].initializer.arguments[0].text ===
imp
) {
return node.declarationList.declarations[0].name.elements;
}
}
);
if (foundBindingVars && Array.isArray(variable)) {
const newVariables = variable.filter(
(v) => !foundBindingVars.some((fv) => v === fv.name.getText())
);
if (newVariables.length === 0) {
return content;
}
const isMultiLine = foundBindingVars.hasTrailingComma;
const pos = foundBindingVars.end;
const nodes = ts.factory.createNodeArray(
newVariables.map((v) =>
ts.factory.createBindingElement(undefined, undefined, v)
)
);
const insert = printer.printList(
ts.ListFormat.ObjectBindingPatternElements,
nodes,
source
);
return applyChangesToString(content, [
{
type: ChangeType.Insert,
index: pos,
text: isMultiLine ? `,\n${insert}` : `,${insert}`,
},
]);
}
const hasSameIdentifierVar: boolean = ts.forEachChild(
source,
function analyze(node) {
// we are searching for a single variable
if (Array.isArray(variable)) {
return;
}
if (
ts.isVariableStatement(node) &&
ts.isVariableDeclaration(node.declarationList.declarations[0]) &&
ts.isIdentifier(node.declarationList.declarations[0].name) &&
node.declarationList.declarations[0].name.getText() === variable &&
ts.isCallExpression(node.declarationList.declarations[0].initializer) &&
node.declarationList.declarations[0].initializer.expression.getText() ===
'require' &&
ts.isStringLiteral(
node.declarationList.declarations[0].initializer.arguments[0]
) &&
node.declarationList.declarations[0].initializer.arguments[0].text ===
imp
) {
return true;
}
}
);
if (hasSameIdentifierVar) {
return content;
}
// the import was not found, create a new one
const requireStatement = generateRequire(
typeof variable === 'string'
? variable
: ts.factory.createObjectBindingPattern(
variable.map((v) =>
ts.factory.createBindingElement(undefined, undefined, v)
)
),
imp
);
const insert = printer.printNode(
ts.EmitHint.Unspecified,
requireStatement,
source
);
return applyChangesToString(content, [
{
type: ChangeType.Insert,
index: 0,
text: `${insert}\n`,
},
]);
}
/**
* Injects new ts.expression to the end of the module.exports array.
*/
export function addBlockToFlatConfigExport(
content: string,
config: ts.Expression | ts.SpreadElement,
options: { insertAtTheEnd?: boolean; checkBaseConfig?: boolean } = {
insertAtTheEnd: true,
}
): string {
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const exportsArray = ts.forEachChild(source, function analyze(node) {
if (
ts.isExpressionStatement(node) &&
ts.isBinaryExpression(node.expression) &&
node.expression.left.getText() === 'module.exports' &&
ts.isArrayLiteralExpression(node.expression.right)
) {
return node.expression.right.elements;
}
});
const insert = printer.printNode(ts.EmitHint.Expression, config, source);
if (options.insertAtTheEnd) {
const index =
exportsArray.length > 0
? exportsArray.at(exportsArray.length - 1).end
: exportsArray.pos;
return applyChangesToString(content, [
{
type: ChangeType.Insert,
index,
text: `,\n${insert}`,
},
]);
} else {
const index =
exportsArray.length > 0 ? exportsArray.at(0).pos : exportsArray.pos;
return applyChangesToString(content, [
{
type: ChangeType.Insert,
index,
text: `\n${insert},`,
},
]);
}
}
export function removePlugin(
content: string,
pluginName: string,
pluginImport: string
) {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const changes: StringChange[] = [];
ts.forEachChild(source, function analyze(node) {
if (
ts.isVariableStatement(node) &&
ts.isVariableDeclaration(node.declarationList.declarations[0]) &&
ts.isCallExpression(node.declarationList.declarations[0].initializer) &&
node.declarationList.declarations[0].initializer.arguments.length &&
ts.isStringLiteral(
node.declarationList.declarations[0].initializer.arguments[0]
) &&
node.declarationList.declarations[0].initializer.arguments[0].text ===
pluginImport
) {
changes.push({
type: ChangeType.Delete,
start: node.pos,
length: node.end - node.pos,
});
}
});
ts.forEachChild(source, function analyze(node) {
if (
ts.isExpressionStatement(node) &&
ts.isBinaryExpression(node.expression) &&
node.expression.left.getText() === 'module.exports' &&
ts.isArrayLiteralExpression(node.expression.right)
) {
const blockElements = node.expression.right.elements;
blockElements.forEach((element) => {
if (ts.isObjectLiteralExpression(element)) {
const pluginsElem = element.properties.find(
(prop) => prop.name?.getText() === 'plugins'
) as ts.PropertyAssignment;
if (!pluginsElem) {
return;
}
if (ts.isArrayLiteralExpression(pluginsElem.initializer)) {
const pluginsArray = pluginsElem.initializer;
const plugins = parseTextToJson(
pluginsElem.initializer
.getText()
.replace(STRIP_SPREAD_ELEMENTS, '')
);
if (plugins.length > 1) {
changes.push({
type: ChangeType.Delete,
start: pluginsArray.pos,
length: pluginsArray.end - pluginsArray.pos,
});
changes.push({
type: ChangeType.Insert,
index: pluginsArray.pos,
text: JSON.stringify(plugins.filter((p) => p !== pluginName)),
});
} else {
const keys = element.properties.map((prop) =>
prop.name?.getText()
);
if (keys.length > 1) {
const removeComma =
keys.indexOf('plugins') < keys.length - 1 ||
element.properties.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: pluginsElem.pos + (removeComma ? 1 : 0),
length:
pluginsElem.end - pluginsElem.pos + (removeComma ? 1 : 0),
});
} else {
const removeComma =
blockElements.indexOf(element) < blockElements.length - 1 ||
blockElements.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: element.pos + (removeComma ? 1 : 0),
length: element.end - element.pos + (removeComma ? 1 : 0),
});
}
}
} else if (ts.isObjectLiteralExpression(pluginsElem.initializer)) {
const pluginsObj = pluginsElem.initializer;
if (pluginsElem.initializer.properties.length > 1) {
const plugin = pluginsObj.properties.find(
(prop) => prop.name?.['text'] === pluginName
) as ts.PropertyAssignment;
const removeComma =
pluginsObj.properties.indexOf(plugin) <
pluginsObj.properties.length - 1 ||
pluginsObj.properties.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: plugin.pos + (removeComma ? 1 : 0),
length: plugin.end - plugin.pos + (removeComma ? 1 : 0),
});
} else {
const keys = element.properties.map((prop) =>
prop.name?.getText()
);
if (keys.length > 1) {
const removeComma =
keys.indexOf('plugins') < keys.length - 1 ||
element.properties.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: pluginsElem.pos + (removeComma ? 1 : 0),
length:
pluginsElem.end - pluginsElem.pos + (removeComma ? 1 : 0),
});
} else {
const removeComma =
blockElements.indexOf(element) < blockElements.length - 1 ||
blockElements.hasTrailingComma;
changes.push({
type: ChangeType.Delete,
start: element.pos + (removeComma ? 1 : 0),
length: element.end - element.pos + (removeComma ? 1 : 0),
});
}
}
}
}
});
}
});
return applyChangesToString(content, changes);
}
export function removeCompatExtends(
content: string,
compatExtends: string[]
): string {
const source = ts.createSourceFile(
'',
content,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
const changes: StringChange[] = [];
findAllBlocks(source).forEach((node) => {
if (
ts.isSpreadElement(node) &&
ts.isCallExpression(node.expression) &&
ts.isArrowFunction(node.expression.arguments[0]) &&
ts.isParenthesizedExpression(node.expression.arguments[0].body) &&
ts.isPropertyAccessExpression(node.expression.expression) &&
ts.isCallExpression(node.expression.expression.expression)
) {
const callExp = node.expression.expression.expression;
if (
((callExp.expression.getText() === 'compat.config' &&
callExp.arguments[0].getText().includes('extends')) ||
callExp.expression.getText() === 'compat.extends') &&
compatExtends.some((ext) =>
callExp.arguments[0].getText().includes(ext)
)
) {
// remove the whole node
changes.push({
type: ChangeType.Delete,
start: node.pos,
length: node.end - node.pos,
});
// and replace it with new one
const paramName =
node.expression.arguments[0].parameters[0].name.getText();
const body = node.expression.arguments[0].body.expression.getFullText();
changes.push({
type: ChangeType.Insert,
index: node.pos,
text:
'\n' +
body.replace(
new RegExp('[ \t]s*...' + paramName + '[ \t]*,?\\s*', 'g'),
''
),
});
}
}
});
return applyChangesToString(content, changes);
}
/**
* Add plugins block to the top of the export blocks
*/
export function addPluginsToExportsBlock(
content: string,
plugins: { name: string; varName: string; imp: string }[]
): string {
const pluginsBlock = ts.factory.createObjectLiteralExpression(
[
ts.factory.createPropertyAssignment(
'plugins',
ts.factory.createObjectLiteralExpression(
plugins.map(({ name, varName }) => {
return ts.factory.createPropertyAssignment(
ts.factory.createStringLiteral(name),
ts.factory.createIdentifier(varName)
);
})
)
),
],
false
);
return addBlockToFlatConfigExport(content, pluginsBlock, {
insertAtTheEnd: false,
});
}
/**
* Adds compat if missing to flat config
*/
export function addCompatToFlatConfig(content: string) {
let result = content;
result = addImportToFlatConfig(result, 'js', '@eslint/js');
if (result.includes('const compat = new FlatCompat')) {
return result;
}
result = addImportToFlatConfig(result, 'FlatCompat', '@eslint/eslintrc');
const index = result.indexOf('module.exports');
return applyChangesToString(result, [
{
type: ChangeType.Insert,
index: index - 1,
text: `${DEFAULT_FLAT_CONFIG}\n`,
},
]);
}
const DEFAULT_FLAT_CONFIG = `
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
});
`;
/**
* Generate node list representing the imports and the exports blocks
* Optionally add flat compat initialization
*/
export function createNodeList(
importsMap: Map<string, string>,
exportElements: ts.Expression[],
isFlatCompatNeeded: boolean
): ts.NodeArray<
ts.VariableStatement | ts.Identifier | ts.ExpressionStatement | ts.SourceFile
> {
const importsList = [];
if (isFlatCompatNeeded) {
importsMap.set('@eslint/js', 'js');
importsList.push(
generateRequire(
ts.factory.createObjectBindingPattern([
ts.factory.createBindingElement(undefined, undefined, 'FlatCompat'),
]),
'@eslint/eslintrc'
)
);
}
// generateRequire(varName, imp, ts.factory);
Array.from(importsMap.entries()).forEach(([imp, varName]) => {
importsList.push(generateRequire(varName, imp));
});
return ts.factory.createNodeArray([
// add plugin imports
...importsList,
ts.createSourceFile(
'',
isFlatCompatNeeded ? DEFAULT_FLAT_CONFIG : '',
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.JS
),
// creates:
// module.exports = [ ... ];
ts.factory.createExpressionStatement(
ts.factory.createBinaryExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier('module'),
ts.factory.createIdentifier('exports')
),
ts.factory.createToken(ts.SyntaxKind.EqualsToken),
ts.factory.createArrayLiteralExpression(exportElements, true)
)
),
]);
}
export function generateSpreadElement(name: string): ts.SpreadElement {
return ts.factory.createSpreadElement(ts.factory.createIdentifier(name));
}
export function generatePluginExtendsElement(
plugins: string[]
): ts.SpreadElement {
return ts.factory.createSpreadElement(
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier('compat'),
ts.factory.createIdentifier('extends')
),
undefined,
plugins.map((plugin) => ts.factory.createStringLiteral(plugin))
)
);
}
/**
* Stringifies TS nodes to file content string
*/
export function stringifyNodeList(
nodes: ts.NodeArray<
| ts.VariableStatement
| ts.Identifier
| ts.ExpressionStatement
| ts.SourceFile
>
): string {
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const resultFile = ts.createSourceFile(
'',
'',
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS
);
return (
printer
.printList(ts.ListFormat.MultiLine, nodes, resultFile)
// add new line before compat initialization
.replace(
/const compat = new FlatCompat/,
'\nconst compat = new FlatCompat'
)
// add new line before module.exports = ...
.replace(/module\.exports/, '\nmodule.exports')
);
}
/**
* generates AST require statement
*/
export function generateRequire(
variableName: string | ts.ObjectBindingPattern,
imp: string
): ts.VariableStatement {
return ts.factory.createVariableStatement(
undefined,
ts.factory.createVariableDeclarationList(
[
ts.factory.createVariableDeclaration(
variableName,
undefined,
undefined,
ts.factory.createCallExpression(
ts.factory.createIdentifier('require'),
undefined,
[ts.factory.createStringLiteral(imp)]
)
),
],
ts.NodeFlags.Const
)
);
}
/**
* Generates AST object or spread element based on JSON override object
*/
export function generateFlatOverride(
override: Linter.ConfigOverride<Linter.RulesRecord>
): ts.ObjectLiteralExpression | ts.SpreadElement {
mapFilePaths(override);
if (
!override.env &&
!override.extends &&
!override.plugins &&
!override.parser
) {
return generateAst(override);
}
const { files, excludedFiles, rules, ...rest } = override;
const objectLiteralElements: ts.ObjectLiteralElementLike[] = [
ts.factory.createSpreadAssignment(ts.factory.createIdentifier('config')),
];
addTSObjectProperty(objectLiteralElements, 'files', files);
addTSObjectProperty(objectLiteralElements, 'excludedFiles', excludedFiles);
addTSObjectProperty(objectLiteralElements, 'rules', rules);
return ts.factory.createSpreadElement(
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier('compat'),
ts.factory.createIdentifier('config')
),
undefined,
[generateAst(rest)]
),
ts.factory.createIdentifier('map')
),
undefined,
[
ts.factory.createArrowFunction(
undefined,
undefined,
[
ts.factory.createParameterDeclaration(
undefined,
undefined,
'config'
),
],
undefined,
ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
ts.factory.createParenthesizedExpression(
ts.factory.createObjectLiteralExpression(
objectLiteralElements,
true
)
)
),
]
)
);
}
export function mapFilePaths(
override: Linter.ConfigOverride<Linter.RulesRecord>
) {
if (override.files) {
override.files = Array.isArray(override.files)
? override.files
: [override.files];
override.files = override.files.map((file) => mapFilePath(file));
}
if (override.excludedFiles) {
override.excludedFiles = Array.isArray(override.excludedFiles)
? override.excludedFiles
: [override.excludedFiles];
override.excludedFiles = override.excludedFiles.map((file) =>
mapFilePath(file)
);
}
}
function addTSObjectProperty(
elements: ts.ObjectLiteralElementLike[],
key: string,
value: unknown
) {
if (value) {
elements.push(ts.factory.createPropertyAssignment(key, generateAst(value)));
}
}
/**
* Generates an AST from a JSON-type input
*/
export function generateAst<T>(input: unknown): T {
if (Array.isArray(input)) {
return ts.factory.createArrayLiteralExpression(
input.map((item) => generateAst<ts.Expression>(item)),
input.length > 1 // multiline only if more than one item
) as T;
}
if (input === null) {
return ts.factory.createNull() as T;
}
if (typeof input === 'object') {
return ts.factory.createObjectLiteralExpression(
Object.entries(input)
.filter(([_, value]) => value !== undefined)
.map(([key, value]) =>
ts.factory.createPropertyAssignment(
isValidKey(key) ? key : ts.factory.createStringLiteral(key),
generateAst<ts.Expression>(value)
)
),
Object.keys(input).length > 1 // multiline only if more than one property
) as T;
}
if (typeof input === 'string') {
return ts.factory.createStringLiteral(input) as T;
}
if (typeof input === 'number') {
return ts.factory.createNumericLiteral(input) as T;
}
if (typeof input === 'boolean') {
return (input ? ts.factory.createTrue() : ts.factory.createFalse()) as T;
}
// since we are parsing JSON, this should never happen
throw new Error(`Unknown type: ${typeof input} `);
}
function isValidKey(key: string): boolean {
return /^[a-zA-Z0-9_]+$/.test(key);
}