From 983d4958f2a1f4bde57fc188445999218cb0dc18 Mon Sep 17 00:00:00 2001 From: Guz013 <43732358+Guz013@users.noreply.github.com> Date: Tue, 8 Aug 2023 16:08:23 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20write=20and=20manipulate=20?= =?UTF-8?q?eslint.config.js=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/package.json | 1 + packages/cli/src/cli.js | 6 +- packages/cli/src/configsWriter.js | 176 +++++++++++++++++++++++++++++- pnpm-lock.yaml | 3 + 4 files changed, 181 insertions(+), 5 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index ba0196b..eee25ee 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,7 @@ "yaml": "^2.3.1" }, "devDependencies": { + "@types/estree": "^1.0.1", "@types/node": "^20.4.2", "@types/prompts": "^2.4.4" } diff --git a/packages/cli/src/cli.js b/packages/cli/src/cli.js index bcd4167..999cf35 100644 --- a/packages/cli/src/cli.js +++ b/packages/cli/src/cli.js @@ -84,7 +84,11 @@ export default class Cli { console.log(packages[0].config); - packages.map(pkg => {pkg.configFile = writer.generateFileObj(pkg); return pkg;}); + packages.map(async pkg => { + pkg.configFile = writer.generateObj(pkg); + console.log(await writer.write(pkg.configFile)); + return pkg; + }); } diff --git a/packages/cli/src/configsWriter.js b/packages/cli/src/configsWriter.js index 625cc05..701473d 100644 --- a/packages/cli/src/configsWriter.js +++ b/packages/cli/src/configsWriter.js @@ -1,6 +1,8 @@ import path from 'node:path'; -import {} from 'recast'; import notNull from './lib/notNull.js'; +import * as recast from 'recast'; +import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; /** * @param {import('./types').ConfigFile['imports']} map1 - The map to has it values merged from map2 @@ -68,7 +70,7 @@ export default class ConfigsWriter { * @param {import('./types').Package} pkg The package to generate the config string from * @returns {import('./types').ConfigFile} The config file object */ - generateFileObj(pkg) { + generateObj(pkg) { /** @type {import('./types').ConfigFile} */ const configObj = { path: path.join(pkg.path, 'eslint.config.js'), @@ -118,9 +120,175 @@ export default class ConfigsWriter { } - console.log(configObj); - return configObj; } + /** + * ! NOTE: + * These functions declared bellow are notably hard to read and have lots of exceptions and + * disabled eslint and typescript checks. Unfortunately this is something that I wasn't able to + * prevent because a lot of the AST typescript types are somewhat wrong or simply hard to work + * with them. + * + * But for somewhat help developing and prevent unwanted errors in the future, the types and eslint + * errors are explicitly disabled and types are explicitly overridden. This is why there are so + * many JSDoc type annotations and comments in general. + * + * Any help to make this code more readable and robust is appreciated + */ + + /** + * @param {import('./types').ConfigFile['configs']} configs The configs objects defined in the config file + * @returns {(import('estree').MemberExpression | import('estree').Identifier | import('estree').CallExpression)[]} + * The ast expressions nodes to be printed + */ + createConfigExpressions(configs) { + return configs + .map(c => { + /** @type {import('estree').MemberExpression | import('estree').Identifier | import('estree').CallExpression} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const e = recast.parse(c).program.body[0].expression; return e; + }) + .filter(e => ['MemberExpression', 'Identifier', 'CallExpression'].includes(e.type)); + } + + /** + * @param {import('./types').ConfigFile['presets']} presets The presets objects defined in the config file + * @returns {import('estree').SpreadElement[]} + * The ast expressions nodes to be printed + */ + createPresetExpressions(presets) { + return presets + .map(p => { + /** @type {import('estree').MemberExpression | import('estree').Identifier | import('estree').CallExpression} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const e = recast.parse(p).program.body[0].expression; return e; + }) + .filter(e => ['MemberExpression', 'Identifier', 'CallExpression'].includes(e.type)) + .map(e => { + /** @type {import('estree').SpreadElement} */ + const spreadElement = { + type: 'SpreadElement', + argument: e, + }; + return spreadElement; + }); + } + + /** + * @param {import('./types').ConfigFile['rules']} rules The rules objects defined in the config file + * @returns {import('estree').ObjectExpression} + * The ast object expression nodes to be printed + */ + createRulesExpression(rules) { + /** @type {import('estree').ObjectExpression} */ + const rulesObjectExpression = rules + .map(r => { + /** @type {import('estree').MemberExpression | import('estree').Identifier | import('estree').CallExpression} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const e = recast.parse(r).program.body[0].expression; return e; + }) + .filter(e => ['MemberExpression', 'Identifier', 'CallExpression'].includes(e.type)) + .map(e => { + /** @type {import('estree').SpreadElement} */ + const spreadElement = { + type: 'SpreadElement', + argument: e, + }; + return spreadElement; + }).reduce((acc, spreadElement) => { + acc.properties.push(spreadElement); + return acc; + }, { + /** @type {import('estree').ObjectExpression['type']} */ + type: 'ObjectExpression', + /** @type {import('estree').ObjectExpression['properties']} */ + properties: [], + }); + /** @type {import('estree').ObjectExpression} */ + const rulesExpression = { + type: 'ObjectExpression', + properties: [{ + // @ts-expect-error because ObjectProperty doesn't exist in estree types + type: 'ObjectProperty', + key: { type: 'Identifier', name: 'rules' }, + value: rulesObjectExpression, + }], + }; + return rulesExpression; + } + + /** + * @param {import('./types').ConfigFile} config The config file object to be transformed into a eslint.config.js file + * @returns {Promise} + */ + async write(config) { + + const existingConfig = existsSync(config.path) ? await fs.readFile(config.path, 'utf-8') : ''; + + /** @type {import('estree').ExportDefaultDeclaration}} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const defaultExportTemplate = recast.parse([ + '/** @type {import(\'eslint\').Linter.FlatConfig[]} */', + 'export default [', + '', + '];', + ].join('\n'), { + parser: (await import('recast/parsers/babel.js')), + }).program.body.find((/** @type {{ type: string; }} */ n) => n.type === 'ExportDefaultDeclaration'); + + + /** @type {{program: import('estree').Program}} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { program: ast } = recast.parse(existingConfig, { parser: (await import('recast/parsers/babel.js')) }); + + /** @type {import('estree').ExportDefaultDeclaration | undefined} */ + // @ts-expect-error because the types don't match, but are expected to be correct here + // as the type needs to be ExportDefaultDeclaration + let defaultExport = ast.body.find(n => n.type === 'ExportDefaultDeclaration'); + if (!defaultExport) { + defaultExport = defaultExportTemplate; + } + else if (defaultExport.declaration.type !== 'ArrayExpression') { + ast.body.push({ + type: 'VariableDeclaration', + kind: 'const', + declarations: [{ + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'oldConfig' }, + // @ts-expect-error because defaultExport's declaration is a ArrayExpression + init: defaultExport.declaration, + }], + }); + defaultExport = defaultExportTemplate; + // @ts-expect-error because defaultExport's declaration is a ArrayExpression + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + defaultExport.declaration.elements.push({ + type: 'SpreadElement', + argument: { type: 'Identifier', name: 'oldConfig' }, + }); + } + + const elementsExpressions = []; + + if (config.presets.length > 0) + elementsExpressions.push(...this.createPresetExpressions(config.presets)); + if (config.configs.length > 0) + elementsExpressions.push(...this.createConfigExpressions(config.configs)); + if (config.rules.length > 0) + elementsExpressions.push(this.createRulesExpression(config.rules)); + + // @ts-expect-error because defaultExport's declaration is a ArrayExpression + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + defaultExport.declaration.elements.push(...elementsExpressions); + + const idx = ast.body.findIndex(n => n.type === 'ExportDefaultDeclaration'); + if (idx > -1) ast.body.splice(idx, 1); + ast.body.push(defaultExport); + + const finalCode = recast.prettyPrint(ast, { parser: (await import('recast/parsers/babel.js')) }).code; + console.log(finalCode); + + } + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8314e7f..c874401 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: specifier: ^2.3.1 version: 2.3.1 devDependencies: + '@types/estree': + specifier: ^1.0.1 + version: 1.0.1 '@types/node': specifier: ^20.4.2 version: 20.4.2