import { Logger, colors as cliColors } from '@poppinss/cliui'; import { pascalCase, kebabCase } from 'change-case'; import type { Rule } from 'eslint'; import type { JSONSchema4 } from 'json-schema'; import { execSync } from 'node:child_process'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { format } from 'prettier'; import { generateTypeFromSchema } from './json-schema-to-ts'; import { buildJSDoc, prettierConfig, type Plugin } from './utils'; const logger = new Logger(); const colors = cliColors.ansi(); /** * Build the rule description to append to the JSDoc. */ function buildDescription(description = ''): string { description = description.charAt(0).toUpperCase() + description.slice(1); if (description.length > 0 && !description.endsWith('.')) { description += '.'; } return description.replace('*/', ''); } export async function RuleFile( plugin: Plugin, pluginDirectory: string, ruleName: string, rule: Rule.RuleModule, ) { let content = ''; const rulePath = resolve(pluginDirectory, `${ruleName}.d.ts`); const ruleNamePascalCase = pascalCase(ruleName); const schema: JSONSchema4 | JSONSchema4[] | undefined = rule.meta?.schema; const isSchemaArray = Array.isArray(schema); const mainSchema = isSchemaArray ? schema[0] : schema; const sideSchema = isSchemaArray && schema.length > 1 ? schema[1] : undefined; const thirdSchema = isSchemaArray && schema.length > 2 ? schema[2] : undefined; /** * Generate a JSDoc with the rule description and `@see` url. */ function generateTypeJsDoc(): string { const { meta } = rule; const seeDocLink: string = meta?.docs?.url ? `@see [${ruleName}](${meta.docs.url})` : ''; return buildJSDoc([ buildDescription(rule.meta?.docs?.description), ' ', rule.meta?.deprecated ? '@deprecated' : '', rule.meta?.deprecated ? ' ' : '', seeDocLink, ]); } /** * Generate a type from a JSON schema and append it to the file content. */ async function appendJsonSchemaType( schema: JSONSchema4, comment: string, ): Promise { const type = await generateTypeFromSchema(schema, ruleNamePascalCase + comment); const jsdoc = buildJSDoc([`${comment}.`]); content += `\n${jsdoc}`; content += `\n${type}\n`; } /** * Scoped rule name ESLint config uses. */ function prefixedRuleName(): string { const { prefix, name } = plugin; const rulePrefix = (prefix ?? kebabCase(name)) + '/'; return name === 'ESLint' ? ruleName : `${rulePrefix}${ruleName}`; } /** * Append the `import type { RuleConfig } from '../rule-config'` at the top of the file. */ const nestedDepth = ruleName.split('/').length; content += `import type { RuleConfig } from '${'../'.repeat(nestedDepth)}rule-config'\n\n`; /** * Generate and append types for the rule schemas. */ if (thirdSchema) { await appendJsonSchemaType(thirdSchema, 'Setting'); } if (sideSchema) { await appendJsonSchemaType(sideSchema, 'Config'); } if (mainSchema) { await appendJsonSchemaType(mainSchema, 'Option'); } if (mainSchema) { /** * Append the rule type options to the file content. */ let type: string = ''; if (!isSchemaArray) { type = `${ruleNamePascalCase}Option`; } else if (thirdSchema) { type = `[${ruleNamePascalCase}Option?, ${ruleNamePascalCase}Config?, ${ruleNamePascalCase}Setting?]`; } else if (sideSchema) { type = `[${ruleNamePascalCase}Option?, ${ruleNamePascalCase}Config?]`; } else if (mainSchema) { type = `[${ruleNamePascalCase}Option?]`; } content += buildJSDoc(['Options.']) + '\n'; content += `export type ${ruleNamePascalCase}Options = ${type}\n\n`; } /** * Append the rule config type to the file content. */ content += generateTypeJsDoc() + '\n'; content += `export type ${ruleNamePascalCase}RuleConfig = RuleConfig<${mainSchema ? `${ruleNamePascalCase}Options` : '[]'}>;\n\n`; /** * Append the final rule interface to the file content. */ content += generateTypeJsDoc() + '\n'; content += `export interface ${ruleNamePascalCase}Rule {`; content += `${generateTypeJsDoc()}\n`; content += `'${prefixedRuleName()}': ${ruleNamePascalCase}RuleConfig;`; content += '}'; content = await format(content, prettierConfig); /** * Create the directory of the rule file if it doesn't exist. */ const subPath = dirname(rulePath); if (!existsSync(subPath)) { mkdirSync(subPath, { recursive: true }); } writeFileSync(rulePath, content); /** * Apply a patch to the generated content if a diff file exists for it. * * Must be called after `generate()`. */ const pathParts = rulePath.split('/'); const diffFile = resolve( __dirname, './diffs/rules', pathParts.at(-2) ?? '', `${pathParts.at(-1) ?? ''}.diff`, ); if (existsSync(diffFile)) { logger.logUpdate(colors.yellow(` 🧹 Adjusting ${prefixedRuleName()}`)); logger.logUpdatePersist(); execSync(`git apply ${diffFile}`); } }