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 { 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) => 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) => boolean, update: ( override: Linter.ConfigOverride ) => Linter.ConfigOverride ): 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.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, 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 ): 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 ) { 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(input: unknown): T { if (Array.isArray(input)) { return ts.factory.createArrayLiteralExpression( input.map((item) => generateAst(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(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); }