diff --git a/index.ts b/index.ts index 5384a205..aac9617f 100644 --- a/index.ts +++ b/index.ts @@ -12,4 +12,5 @@ export { DevServer } from './src/dev_server.ts' export { TestRunner } from './src/test_runner.ts' export { FileBuffer } from './src/file_buffer.ts' export { VirtualFileSystem } from './src/virtual_file_system.ts' +export { CodemodException } from './src/exceptions/codemod_exception.ts' export { SUPPORTED_PACKAGE_MANAGERS } from './src/bundler.ts' diff --git a/src/code_transformer/main.ts b/src/code_transformer/main.ts index 0d1db760..80d9f3f3 100644 --- a/src/code_transformer/main.ts +++ b/src/code_transformer/main.ts @@ -21,6 +21,7 @@ import { } from 'ts-morph' import { RcFileTransformer } from './rc_file_transformer.ts' +import { CodemodException } from '../exceptions/codemod_exception.ts' import type { MiddlewareNode, EnvValidationNode, @@ -97,6 +98,24 @@ export class CodeTransformer { }) } + /** + * Get directories configured in adonisrc.ts, with defaults fallback. + * + * This method reads the adonisrc.ts file and extracts the directories + * configuration. If a directory is not configured, the default value is used. + * + * @returns Object containing directory paths + */ + getDirectories(): Record { + const rcFileTransformer = new RcFileTransformer(this.#cwd, this.project) + + return { + start: rcFileTransformer.getDirectory('start', 'start'), + tests: rcFileTransformer.getDirectory('tests', 'tests'), + policies: rcFileTransformer.getDirectory('policies', 'app/policies'), + } + } + /** * Add a new middleware to the middleware array of the given file * @@ -279,11 +298,18 @@ export class CodeTransformer { * @param definition - Environment validation definition containing variables and comment */ async defineEnvValidations(definition: EnvValidationNode) { + const directories = this.getDirectories() + const filePath = `${directories.start}/env.ts` + /** - * Get the `start/env.ts` source file + * Get the env.ts source file */ - const kernelUrl = join(this.#cwdPath, './start/env.ts') - const file = this.project.getSourceFileOrThrow(kernelUrl) + const envUrl = join(this.#cwdPath, `./${filePath}`) + const file = this.project.getSourceFile(envUrl) + + if (!file) { + throw CodemodException.missingEnvFile(filePath, definition) + } /** * Get the `Env.create` call expression @@ -293,12 +319,12 @@ export class CodeTransformer { .filter((statement) => statement.getExpression().getText() === 'Env.create') if (!callExpressions.length) { - throw new Error(`Cannot find Env.create statement in the file.`) + throw CodemodException.missingEnvCreate(filePath, definition) } const objectLiteralExpression = callExpressions[0].getArguments()[1] if (!Node.isObjectLiteralExpression(objectLiteralExpression)) { - throw new Error(`The second argument of Env.create is not an object literal.`) + throw CodemodException.invalidEnvCreate(filePath, definition) } let shouldAddComment = true @@ -354,21 +380,35 @@ export class CodeTransformer { * @param middleware - Array of middleware entries to add */ async addMiddlewareToStack(stack: 'server' | 'router' | 'named', middleware: MiddlewareNode[]) { + const directories = this.getDirectories() + const filePath = `${directories.start}/kernel.ts` + /** - * Get the `start/kernel.ts` source file + * Get the kernel.ts source file */ - const kernelUrl = join(this.#cwdPath, './start/kernel.ts') - const file = this.project.getSourceFileOrThrow(kernelUrl) + const kernelUrl = join(this.#cwdPath, `./${filePath}`) + const file = this.project.getSourceFile(kernelUrl) + + if (!file) { + throw CodemodException.missingKernelFile(filePath, stack, middleware) + } /** * Process each middleware entry */ - for (const middlewareEntry of middleware) { - if (stack === 'named') { - this.#addToNamedMiddleware(file, middlewareEntry) - } else { - this.#addToMiddlewareArray(file!, `${stack}.use`, middlewareEntry) + try { + for (const middlewareEntry of middleware) { + if (stack === 'named') { + this.#addToNamedMiddleware(file, middlewareEntry) + } else { + this.#addToMiddlewareArray(file, `${stack}.use`, middlewareEntry) + } } + } catch (error) { + if (error instanceof Error) { + throw CodemodException.invalidMiddlewareStack(filePath, stack, middleware, error.message) + } + throw error } file.formatText(this.#editorSettings) @@ -396,11 +436,18 @@ export class CodeTransformer { pluginCall: string, importDeclarations: { isNamed: boolean; module: string; identifier: string }[] ) { + const directories = this.getDirectories() + const filePath = `${directories.tests}/bootstrap.ts` + /** - * Get the `tests/bootstrap.ts` source file + * Get the bootstrap.ts source file */ - const testBootstrapUrl = join(this.#cwdPath, './tests/bootstrap.ts') - const file = this.project.getSourceFileOrThrow(testBootstrapUrl) + const testBootstrapUrl = join(this.#cwdPath, `./${filePath}`) + const file = this.project.getSourceFile(testBootstrapUrl) + + if (!file) { + throw CodemodException.missingJapaBootstrap(filePath, pluginCall, importDeclarations) + } /** * Add the import declarations @@ -437,50 +484,65 @@ export class CodeTransformer { pluginCall: string, importDeclarations: { isNamed: boolean; module: string; identifier: string }[] ) { + const filePath = 'vite.config.ts' + /** * Get the `vite.config.ts` source file */ - const viteConfigTsUrl = join(this.#cwdPath, './vite.config.ts') + const viteConfigTsUrl = join(this.#cwdPath, `./${filePath}`) const file = this.project.getSourceFile(viteConfigTsUrl) if (!file) { - throw new Error( - 'Cannot find vite.config.ts file. Make sure to rename vite.config.js to vite.config.ts' - ) + throw CodemodException.missingViteConfig(filePath, pluginCall, importDeclarations) } - /** - * Add the import declarations - */ - this.#addImportDeclarations(file, importDeclarations) + try { + /** + * Add the import declarations + */ + this.#addImportDeclarations(file, importDeclarations) - /** - * Get the default export options - */ - const defaultExport = file.getDefaultExportSymbol() - if (!defaultExport) { - throw new Error('Cannot find the default export in vite.config.ts') - } + /** + * Get the default export options + */ + const defaultExport = file.getDefaultExportSymbol() + if (!defaultExport) { + throw new Error('Cannot find the default export in vite.config.ts') + } - /** - * Get the options object - * - Either the first argument of `defineConfig` call : `export default defineConfig({})` - * - Or child literal expression of the default export : `export default {}` - */ - const declaration = defaultExport.getDeclarations()[0] - const options = - declaration.getChildrenOfKind(SyntaxKind.ObjectLiteralExpression)[0] || - declaration.getChildrenOfKind(SyntaxKind.CallExpression)[0].getArguments()[0] + /** + * Get the options object + * - Either the first argument of `defineConfig` call : `export default defineConfig({})` + * - Or child literal expression of the default export : `export default {}` + */ + const declaration = defaultExport.getDeclarations()[0] + const options = + declaration.getChildrenOfKind(SyntaxKind.ObjectLiteralExpression)[0] || + declaration.getChildrenOfKind(SyntaxKind.CallExpression)[0].getArguments()[0] - const pluginsArray = options - .getPropertyOrThrow('plugins') - .getFirstChildByKindOrThrow(SyntaxKind.ArrayLiteralExpression) + const pluginsArray = options + .getPropertyOrThrow('plugins') + .getFirstChildByKindOrThrow(SyntaxKind.ArrayLiteralExpression) - /** - * Add plugin call to the plugins array - */ - if (!pluginsArray.getElements().find((element) => element.getText() === pluginCall)) { - pluginsArray.addElement(pluginCall) + /** + * Add plugin call to the plugins array + */ + if (!pluginsArray.getElements().find((element) => element.getText() === pluginCall)) { + pluginsArray.addElement(pluginCall) + } + } catch (error) { + if (error instanceof CodemodException) { + throw error + } + if (error instanceof Error) { + throw CodemodException.invalidViteConfig( + filePath, + pluginCall, + importDeclarations, + error.message + ) + } + throw error } file.formatText(this.#editorSettings) @@ -494,17 +556,31 @@ export class CodeTransformer { * @param policies - Array of bouncer policy entries to add */ async addPolicies(policies: BouncerPolicyNode[]) { + const directories = this.getDirectories() + const filePath = `${directories.policies}/main.ts` + /** - * Get the `app/policies/main.ts` source file + * Get the policies/main.ts source file */ - const kernelUrl = join(this.#cwdPath, './app/policies/main.ts') - const file = this.project.getSourceFileOrThrow(kernelUrl) + const policiesUrl = join(this.#cwdPath, `./${filePath}`) + const file = this.project.getSourceFile(policiesUrl) + + if (!file) { + throw CodemodException.missingPoliciesFile(filePath, policies) + } /** - * Process each middleware entry + * Process each policy entry */ - for (const policy of policies) { - this.#addToPoliciesList(file, policy) + try { + for (const policy of policies) { + this.#addToPoliciesList(file, policy) + } + } catch (error) { + if (error instanceof Error) { + throw CodemodException.invalidPoliciesFile(filePath, policies, error.message) + } + throw error } file.formatText(this.#editorSettings) diff --git a/src/code_transformer/rc_file_transformer.ts b/src/code_transformer/rc_file_transformer.ts index fef932e7..65fdf0a1 100644 --- a/src/code_transformer/rc_file_transformer.ts +++ b/src/code_transformer/rc_file_transformer.ts @@ -19,6 +19,7 @@ import { type ArrayLiteralExpression, } from 'ts-morph' import { type AssemblerRcFile } from '../types/common.ts' +import { CodemodException } from '../exceptions/codemod_exception.ts' const ALLOWED_ENVIRONMENTS = ['web', 'console', 'test', 'repl'] as const @@ -75,11 +76,25 @@ export class RcFileTransformer { * Get the `adonisrc.ts` source file * * @returns The adonisrc.ts source file - * @throws Error if the file cannot be found + * @throws CodemodException if the file cannot be found */ #getRcFileOrThrow() { - const kernelUrl = fileURLToPath(new URL('./adonisrc.ts', this.#cwd)) - return this.#project.getSourceFileOrThrow(kernelUrl) + const filePath = 'adonisrc.ts' + const rcFileUrl = fileURLToPath(new URL(`./${filePath}`, this.#cwd)) + const file = this.#project.getSourceFile(rcFileUrl) + + if (!file) { + throw CodemodException.missingRcFile( + filePath, + `import { defineConfig } from '@adonisjs/core/app' + +export default defineConfig({ + // Add your configuration here +})` + ) + } + + return file } /** @@ -101,7 +116,7 @@ export class RcFileTransformer { * * @param file - The source file to search in * @returns The defineConfig call expression - * @throws Error if defineConfig call cannot be found + * @throws CodemodException if defineConfig call cannot be found */ #locateDefineConfigCallOrThrow(file: SourceFile) { const call = file @@ -109,7 +124,15 @@ export class RcFileTransformer { .find((statement) => statement.getExpression().getText() === 'defineConfig') if (!call) { - throw new Error('Could not locate the defineConfig call.') + throw CodemodException.invalidRcFile( + 'adonisrc.ts', + `import { defineConfig } from '@adonisjs/core/app' + +export default defineConfig({ + // Add your configuration here +})`, + 'Could not locate the defineConfig call.' + ) } return call @@ -443,6 +466,45 @@ export class RcFileTransformer { return this } + /** + * Get a directory value from the directories configuration. + * + * @param key - The directory key to retrieve + * @param defaultValue - The default value if not configured + * @returns The configured directory path or the default value + */ + getDirectory(key: string, defaultValue: string): string { + try { + const file = this.#getRcFileOrThrow() + const defineConfigCall = this.#locateDefineConfigCallOrThrow(file) + const configObject = this.#getDefineConfigObjectOrThrow(defineConfigCall) + + const directoriesProperty = configObject.getProperty('directories') + if (!directoriesProperty || !Node.isPropertyAssignment(directoriesProperty)) { + return defaultValue + } + + const directoriesObject = directoriesProperty.getInitializer() + if (!directoriesObject || !Node.isObjectLiteralExpression(directoriesObject)) { + return defaultValue + } + + const property = directoriesObject.getProperty(key) + if (!property || !Node.isPropertyAssignment(property)) { + return defaultValue + } + + const initializer = property.getInitializer() + if (!initializer || !Node.isStringLiteral(initializer)) { + return defaultValue + } + + return initializer.getLiteralValue() + } catch { + return defaultValue + } + } + /** * Save the adonisrc.ts file with all applied transformations * diff --git a/src/exceptions/codemod_exception.ts b/src/exceptions/codemod_exception.ts new file mode 100644 index 00000000..0561bcbc --- /dev/null +++ b/src/exceptions/codemod_exception.ts @@ -0,0 +1,418 @@ +/* + * @adonisjs/assembler + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { + MiddlewareNode, + EnvValidationNode, + BouncerPolicyNode, +} from '../types/code_transformer.ts' + +/** + * Options for creating a CodemodException + */ +export interface CodemodExceptionOptions { + /** + * Instructions for the user to manually perform the codemod action + */ + instructions?: string + + /** + * The file path that was being modified + */ + filePath?: string +} + +/** + * Custom exception for codemod errors that provides helpful instructions + * to the user when automatic code transformation fails. + * + * @example + * ```ts + * throw CodemodException.missingEnvFile('start/env.ts', { + * variables: { MY_VAR: 'Env.schema.string()' } + * }) + * ``` + */ +export class CodemodException extends Error { + /** + * Instructions for the user to manually perform the codemod action + */ + instructions?: string + + /** + * The file path that was being modified + */ + filePath?: string + + constructor(message: string, options?: CodemodExceptionOptions) { + super(message) + this.name = 'CodemodException' + this.instructions = options?.instructions + this.filePath = options?.filePath + } + + /** + * Format env validations as code string + */ + static #formatEnvValidations(definition: EnvValidationNode): string { + const lines: string[] = [] + + if (definition.leadingComment) { + lines.push(`/*`) + lines.push(`|----------------------------------------------------------`) + lines.push(`| ${definition.leadingComment}`) + lines.push(`|----------------------------------------------------------`) + lines.push(`*/`) + } + + for (const [variable, validation] of Object.entries(definition.variables)) { + lines.push(`${variable}: ${validation},`) + } + + return lines.join('\n') + } + + /** + * Format middleware as code string + */ + static #formatMiddleware( + stack: 'server' | 'router' | 'named', + middleware: MiddlewareNode[] + ): string { + if (stack === 'named') { + const entries = middleware + .filter((m) => m.name) + .map((m) => `${m.name}: () => import('${m.path}')`) + return `export const middleware = router.named({\n ${entries.join(',\n ')}\n})` + } + + const entries = middleware.map((m) => `() => import('${m.path}')`) + return `${stack}.use([\n ${entries.join(',\n ')}\n])` + } + + /** + * Format policies as code string + */ + static #formatPolicies(policies: BouncerPolicyNode[]): string { + const entries = policies.map((p) => `${p.name}: () => import('${p.path}')`) + return `export const policies = {\n ${entries.join(',\n ')}\n}` + } + + /** + * Format Vite plugin as code string + */ + static #formatVitePlugin( + pluginCall: string, + importDeclarations: { isNamed: boolean; module: string; identifier: string }[] + ): string { + const imports = importDeclarations + .map((decl) => + decl.isNamed + ? `import { ${decl.identifier} } from '${decl.module}'` + : `import ${decl.identifier} from '${decl.module}'` + ) + .join('\n') + + return `${imports}\n\nexport default defineConfig({\n plugins: [${pluginCall}]\n})` + } + + /** + * Format Japa plugin as code string + */ + static #formatJapaPlugin( + pluginCall: string, + importDeclarations: { isNamed: boolean; module: string; identifier: string }[] + ): string { + const imports = importDeclarations + .map((decl) => + decl.isNamed + ? `import { ${decl.identifier} } from '${decl.module}'` + : `import ${decl.identifier} from '${decl.module}'` + ) + .join('\n') + + return `${imports}\n\nexport const plugins: Config['plugins'] = [\n ${pluginCall}\n]` + } + + /** + * Creates an exception when start/env.ts file is missing + * + * @param filePath - The path to the missing file + * @param definition - The environment validation definition that was being added + */ + static missingEnvFile(filePath: string, definition: EnvValidationNode): CodemodException { + const code = this.#formatEnvValidations(definition) + return new CodemodException(`Could not find source file at path: "${filePath}"`, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when Env.create is not found in the file + * + * @param filePath - The path to the file being modified + * @param definition - The environment validation definition that was being added + */ + static missingEnvCreate(filePath: string, definition: EnvValidationNode): CodemodException { + const code = this.#formatEnvValidations(definition) + return new CodemodException(`Cannot find Env.create statement in the file.`, { + filePath, + instructions: `Add the following code inside Env.create() in "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when Env.create has invalid structure + * + * @param filePath - The path to the file being modified + * @param definition - The environment validation definition that was being added + */ + static invalidEnvCreate(filePath: string, definition: EnvValidationNode): CodemodException { + const code = this.#formatEnvValidations(definition) + return new CodemodException(`The second argument of Env.create is not an object literal.`, { + filePath, + instructions: `Add the following code inside Env.create() in "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when start/kernel.ts file is missing + * + * @param filePath - The path to the missing file + * @param stack - The middleware stack that was being modified + * @param middleware - The middleware entries that were being added + */ + static missingKernelFile( + filePath: string, + stack: 'server' | 'router' | 'named', + middleware: MiddlewareNode[] + ): CodemodException { + const code = this.#formatMiddleware(stack, middleware) + return new CodemodException(`Could not find source file at path: "${filePath}"`, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when server.use/router.use is not found + * + * @param filePath - The path to the file being modified + * @param stack - The middleware stack that was being modified + * @param middleware - The middleware entries that were being added + */ + static missingMiddlewareStack( + filePath: string, + stack: 'server' | 'router' | 'named', + middleware: MiddlewareNode[] + ): CodemodException { + const code = this.#formatMiddleware(stack, middleware) + const target = stack === 'named' ? 'middleware variable' : `${stack}.use` + return new CodemodException(`Cannot find ${target} statement in the file.`, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when middleware array structure is invalid + * + * @param filePath - The path to the file being modified + * @param stack - The middleware stack that was being modified + * @param middleware - The middleware entries that were being added + * @param reason - The reason why the structure is invalid + */ + static invalidMiddlewareStack( + filePath: string, + stack: 'server' | 'router' | 'named', + middleware: MiddlewareNode[], + reason: string + ): CodemodException { + const code = this.#formatMiddleware(stack, middleware) + return new CodemodException(reason, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when app/policies/main.ts file is missing + * + * @param filePath - The path to the missing file + * @param policies - The policies that were being added + */ + static missingPoliciesFile(filePath: string, policies: BouncerPolicyNode[]): CodemodException { + const code = this.#formatPolicies(policies) + return new CodemodException(`Could not find source file at path: "${filePath}"`, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when policies structure is invalid + * + * @param filePath - The path to the file being modified + * @param policies - The policies that were being added + * @param reason - The reason why the structure is invalid + */ + static invalidPoliciesFile( + filePath: string, + policies: BouncerPolicyNode[], + reason: string + ): CodemodException { + const code = this.#formatPolicies(policies) + return new CodemodException(reason, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when vite.config.ts file is missing + * + * @param filePath - The path to the missing file + * @param pluginCall - The plugin call that was being added + * @param importDeclarations - The import declarations needed for the plugin + */ + static missingViteConfig( + filePath: string, + pluginCall: string, + importDeclarations: { isNamed: boolean; module: string; identifier: string }[] + ): CodemodException { + const code = this.#formatVitePlugin(pluginCall, importDeclarations) + return new CodemodException( + `Cannot find vite.config.ts file. Make sure to rename vite.config.js to vite.config.ts`, + { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + } + ) + } + + /** + * Creates an exception when vite.config.ts structure is invalid + * + * @param filePath - The path to the file being modified + * @param pluginCall - The plugin call that was being added + * @param importDeclarations - The import declarations needed for the plugin + * @param reason - The reason why the structure is invalid + */ + static invalidViteConfig( + filePath: string, + pluginCall: string, + importDeclarations: { isNamed: boolean; module: string; identifier: string }[], + reason: string + ): CodemodException { + const code = this.#formatVitePlugin(pluginCall, importDeclarations) + return new CodemodException(reason, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when tests/bootstrap.ts file is missing + * + * @param filePath - The path to the missing file + * @param pluginCall - The plugin call that was being added + * @param importDeclarations - The import declarations needed for the plugin + */ + static missingJapaBootstrap( + filePath: string, + pluginCall: string, + importDeclarations: { isNamed: boolean; module: string; identifier: string }[] + ): CodemodException { + const code = this.#formatJapaPlugin(pluginCall, importDeclarations) + return new CodemodException(`Could not find source file at path: "${filePath}"`, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when tests/bootstrap.ts structure is invalid + * + * @param filePath - The path to the file being modified + * @param pluginCall - The plugin call that was being added + * @param importDeclarations - The import declarations needed for the plugin + * @param reason - The reason why the structure is invalid + */ + static invalidJapaBootstrap( + filePath: string, + pluginCall: string, + importDeclarations: { isNamed: boolean; module: string; identifier: string }[], + reason: string + ): CodemodException { + const code = this.#formatJapaPlugin(pluginCall, importDeclarations) + return new CodemodException(reason, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${code}`, + }) + } + + /** + * Creates an exception when adonisrc.ts file is missing + * + * @param filePath - The path to the missing file + * @param codeToAdd - The code that should be added manually + */ + static missingRcFile(filePath: string, codeToAdd: string): CodemodException { + return new CodemodException(`Could not find source file at path: "${filePath}"`, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${codeToAdd}`, + }) + } + + /** + * Creates an exception when adonisrc.ts structure is invalid + * + * @param filePath - The path to the file being modified + * @param codeToAdd - The code that should be added manually + * @param reason - The reason why the structure is invalid + */ + static invalidRcFile(filePath: string, codeToAdd: string, reason: string): CodemodException { + return new CodemodException(reason, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${codeToAdd}`, + }) + } + + /** + * Creates an exception when a file required for transformation is not found. + * + * @param filePath - The path to the missing file + * @param codeToAdd - The code that should be added manually + */ + static E_CODEMOD_FILE_NOT_FOUND(filePath: string, codeToAdd: string): CodemodException { + return new CodemodException(`Could not find source file at path: "${filePath}"`, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${codeToAdd}`, + }) + } + + /** + * Creates an exception when the file structure doesn't match expected patterns. + * + * @param message - Description of what structure was expected + * @param filePath - The path to the file being modified + * @param codeToAdd - The code that should be added manually + */ + static E_CODEMOD_INVALID_STRUCTURE( + message: string, + filePath: string, + codeToAdd: string + ): CodemodException { + return new CodemodException(message, { + filePath, + instructions: `Add the following code to "${filePath}":\n\n${codeToAdd}`, + }) + } +} diff --git a/tests/code_transformer.spec.ts b/tests/code_transformer.spec.ts index 3a6cbebf..fff4dcbb 100644 --- a/tests/code_transformer.spec.ts +++ b/tests/code_transformer.spec.ts @@ -12,6 +12,7 @@ import { test } from '@japa/runner' import { readFile } from 'node:fs/promises' import type { FileSystem } from '@japa/file-system' import { CodeTransformer } from '../src/code_transformer/main.ts' +import { CodemodException } from '../src/exceptions/codemod_exception.ts' async function setupFakeAdonisproject(fs: FileSystem) { await Promise.all([ @@ -22,6 +23,50 @@ async function setupFakeAdonisproject(fs: FileSystem) { ]) } +test.group('Code transformer | getDirectories', (group) => { + group.each.setup(async ({ context }) => setupFakeAdonisproject(context.fs)) + + test('return default directories when none configured', async ({ assert, fs }) => { + const transformer = new CodeTransformer(fs.baseUrl) + const directories = transformer.getDirectories() + + assert.equal(directories.start, 'start') + assert.equal(directories.tests, 'tests') + assert.equal(directories.policies, 'app/policies') + }) + + test('return custom directories when configured in adonisrc.ts', async ({ assert, fs }) => { + await fs.create( + 'adonisrc.ts', + dedent` + import { defineConfig } from '@adonisjs/core/app' + + export default defineConfig({ + directories: { + start: 'boot', + tests: 'spec', + } + })` + ) + + const transformer = new CodeTransformer(fs.baseUrl) + const directories = transformer.getDirectories() + + assert.equal(directories.start, 'boot') + assert.equal(directories.tests, 'spec') + assert.equal(directories.policies, 'app/policies') + }) + + test('return defaults when adonisrc.ts is missing', async ({ assert, fs }) => { + await fs.remove('adonisrc.ts') + const transformer = new CodeTransformer(fs.baseUrl) + const directories = transformer.getDirectories() + + assert.equal(directories.start, 'start') + assert.equal(directories.tests, 'tests') + }) +}) + test.group('Code transformer | addMiddlewareToStack', (group) => { group.each.setup(async ({ context }) => setupFakeAdonisproject(context.fs)) @@ -133,6 +178,89 @@ test.group('Code transformer | addMiddlewareToStack', (group) => { const file = await fs.contents('start/kernel.ts') assert.snapshot(file).match() }) + + test('throw CodemodException with instructions when kernel.ts file is missing', async ({ + assert, + fs, + }) => { + await fs.remove('start/kernel.ts') + const transformer = new CodeTransformer(fs.baseUrl) + + try { + await transformer.addMiddlewareToStack('server', [ + { path: '@adonisjs/static/static_middleware' }, + ]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.include(error.message, 'start/kernel.ts') + assert.isDefined(error.instructions) + assert.include(error.instructions, `() => import('@adonisjs/static/static_middleware')`) + } + }) + + test('throw CodemodException with instructions for named middleware when kernel.ts is missing', async ({ + assert, + fs, + }) => { + await fs.remove('start/kernel.ts') + const transformer = new CodeTransformer(fs.baseUrl) + + try { + await transformer.addMiddlewareToStack('named', [ + { name: 'auth', path: '#middleware/auth_middleware' }, + ]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.isDefined(error.instructions) + assert.include(error.instructions, `auth: () => import('#middleware/auth_middleware')`) + } + }) + + test('throw CodemodException with instructions when server.use is missing', async ({ + assert, + fs, + }) => { + await fs.create('start/kernel.ts', `export const foo = {}`) + const transformer = new CodeTransformer(fs.baseUrl) + + try { + await transformer.addMiddlewareToStack('server', [ + { path: '@adonisjs/static/static_middleware' }, + ]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.include(error.message, 'server.use') + assert.isDefined(error.instructions) + assert.include(error.instructions, `() => import('@adonisjs/static/static_middleware')`) + } + }) + + test('use custom start directory from adonisrc.ts', async ({ assert, fs }) => { + await fs.remove('start/kernel.ts') + await fs.create( + 'adonisrc.ts', + dedent` + import { defineConfig } from '@adonisjs/core/app' + + export default defineConfig({ + directories: { + start: 'boot', + } + })` + ) + await fs.create('boot/kernel.ts', await readFile('./tests/fixtures/kernel.txt', 'utf-8')) + + const transformer = new CodeTransformer(fs.baseUrl) + + await transformer.addMiddlewareToStack('server', [ + { path: '@adonisjs/static/static_middleware' }, + ]) + + assert.fileContains('boot/kernel.ts', `() => import('@adonisjs/static/static_middleware')`) + }) }) test.group('Code transformer | defineEnvValidations', (group) => { @@ -234,6 +362,119 @@ test.group('Code transformer | defineEnvValidations', (group) => { " `) }) + + test('throw CodemodException with instructions when env.ts file is missing', async ({ + assert, + fs, + }) => { + await fs.remove('start/env.ts') + const transformer = new CodeTransformer(fs.baseUrl) + + try { + await transformer.defineEnvValidations({ + leadingComment: 'Redis configuration', + variables: { + REDIS_HOST: 'Env.schema.string.optional()', + REDIS_PORT: 'Env.schema.number()', + }, + }) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.include(error.message, 'start/env.ts') + assert.isDefined(error.instructions) + assert.include(error.instructions, 'REDIS_HOST: Env.schema.string.optional()') + assert.include(error.instructions, 'REDIS_PORT: Env.schema.number()') + } + }) + + test('throw CodemodException with instructions when Env.create is missing', async ({ + assert, + fs, + }) => { + await fs.create('start/env.ts', `export const env = {}`) + const transformer = new CodeTransformer(fs.baseUrl) + + try { + await transformer.defineEnvValidations({ + variables: { + MY_VAR: 'Env.schema.string()', + }, + }) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.include(error.message, 'Env.create') + assert.isDefined(error.instructions) + assert.include(error.instructions, 'MY_VAR: Env.schema.string()') + } + }) + + test('use custom start directory from adonisrc.ts', async ({ assert, fs }) => { + await fs.remove('start/env.ts') + await fs.create( + 'adonisrc.ts', + dedent` + import { defineConfig } from '@adonisjs/core/app' + + export default defineConfig({ + directories: { + start: 'boot', + } + })` + ) + await fs.create('boot/env.ts', await readFile('./tests/fixtures/env.txt', 'utf-8')) + + const transformer = new CodeTransformer(fs.baseUrl) + + await transformer.defineEnvValidations({ + variables: { + MY_VAR: 'Env.schema.string()', + }, + }) + + assert.fileContains('boot/env.ts', 'MY_VAR: Env.schema.string()') + }) +}) + +test.group('Code transformer | updateRcFile errors', (group) => { + group.each.setup(async ({ context }) => setupFakeAdonisproject(context.fs)) + + test('throw CodemodException with instructions when adonisrc.ts is missing', async ({ + assert, + fs, + }) => { + await fs.remove('adonisrc.ts') + const transformer = new CodeTransformer(fs.baseUrl) + + try { + await transformer.updateRcFile((rcFile) => { + rcFile.addCommand('#foo/bar.js') + }) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.include(error.message, 'adonisrc.ts') + assert.isDefined(error.instructions) + assert.include(error.instructions, 'defineConfig') + } + }) + + test('throw CodemodException when defineConfig is missing', async ({ assert, fs }) => { + await fs.create('adonisrc.ts', `export default {}`) + const transformer = new CodeTransformer(fs.baseUrl) + + try { + await transformer.updateRcFile((rcFile) => { + rcFile.addCommand('#foo/bar.js') + }) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.include(error.message, 'defineConfig') + assert.isDefined(error.instructions) + } + }) }) test.group('Code transformer | addCommand', (group) => { @@ -701,6 +942,57 @@ test.group('Code transformer | addJapaPlugin', (group) => { " `) }) + + test('throw CodemodException with instructions when bootstrap.ts is missing', async ({ + assert, + fs, + }) => { + const transformer = new CodeTransformer(fs.baseUrl) + + try { + await transformer.addJapaPlugin('fooPlugin()', [ + { identifier: 'fooPlugin', module: '@adonisjs/foo/plugin/japa', isNamed: true }, + ]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.include(error.message, 'tests/bootstrap.ts') + assert.isDefined(error.instructions) + assert.include(error.instructions, `import { fooPlugin } from '@adonisjs/foo/plugin/japa'`) + assert.include(error.instructions, 'fooPlugin()') + } + }) + + test('use custom tests directory from adonisrc.ts', async ({ assert, fs }) => { + await fs.create( + 'adonisrc.ts', + dedent` + import { defineConfig } from '@adonisjs/core/app' + + export default defineConfig({ + directories: { + tests: 'spec', + } + })` + ) + await fs.create( + 'spec/bootstrap.ts', + dedent` + import { assert } from '@japa/assert' + + export const plugins: Config['plugins'] = [ + assert(), + ]` + ) + + const transformer = new CodeTransformer(fs.baseUrl) + + await transformer.addJapaPlugin('fooPlugin()', [ + { identifier: 'fooPlugin', module: '@adonisjs/foo/plugin/japa', isNamed: true }, + ]) + + assert.fileContains('spec/bootstrap.ts', 'fooPlugin()') + }) }) test.group('Code transformer | addPolicies', (group) => { @@ -732,52 +1024,84 @@ test.group('Code transformer | addPolicies', (group) => { `) }) - test('throw error when policies/main.ts file is missing', async ({ fs }) => { + test('throw CodemodException with instructions when policies/main.ts file is missing', async ({ + assert, + fs, + }) => { const transformer = new CodeTransformer(fs.baseUrl) - await transformer.addPolicies([ - { - name: 'PostPolicy', - path: '#policies/post_policy', - }, - { - name: 'UserPolicy', - path: '#policies/user_policy', - }, - ]) - }).throws(/Could not find source file in project at the provided path:/) + try { + await transformer.addPolicies([ + { name: 'PostPolicy', path: '#policies/post_policy' }, + { name: 'UserPolicy', path: '#policies/user_policy' }, + ]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.include(error.message, 'app/policies/main.ts') + assert.isDefined(error.instructions) + assert.include(error.instructions, `PostPolicy: () => import('#policies/post_policy')`) + assert.include(error.instructions, `UserPolicy: () => import('#policies/user_policy')`) + } + }) - test('throw error when policies object is not defined', async ({ fs }) => { + test('throw CodemodException with instructions when policies object is not defined', async ({ + assert, + fs, + }) => { await fs.create('app/policies/main.ts', `export const foo = {}`) const transformer = new CodeTransformer(fs.baseUrl) - await transformer.addPolicies([ - { - name: 'PostPolicy', - path: '#policies/post_policy', - }, - { - name: 'UserPolicy', - path: '#policies/user_policy', - }, - ]) - }).throws(`Expected to find variable declaration named 'policies'.`) + try { + await transformer.addPolicies([{ name: 'PostPolicy', path: '#policies/post_policy' }]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.isDefined(error.instructions) + assert.include(error.instructions, `PostPolicy: () => import('#policies/post_policy')`) + } + }) - test('throw error when policies declaration is not an object', async ({ fs }) => { + test('throw CodemodException with instructions when policies declaration is not an object', async ({ + assert, + fs, + }) => { await fs.create('app/policies/main.ts', `export const policies = []`) const transformer = new CodeTransformer(fs.baseUrl) - await transformer.addPolicies([ - { - name: 'PostPolicy', - path: '#policies/post_policy', - }, - { - name: 'UserPolicy', - path: '#policies/user_policy', - }, - ]) - }).throws(/Expected to find an initializer of kind \'ObjectLiteralExpression\'./) + try { + await transformer.addPolicies([{ name: 'PostPolicy', path: '#policies/post_policy' }]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.isDefined(error.instructions) + assert.include(error.instructions, `PostPolicy: () => import('#policies/post_policy')`) + } + }) + + test('use custom policies directory from adonisrc.ts', async ({ assert, fs }) => { + await fs.create( + 'adonisrc.ts', + dedent` + import { defineConfig } from '@adonisjs/core/app' + + export default defineConfig({ + directories: { + policies: 'domain/policies', + } + })` + ) + await fs.create('domain/policies/main.ts', `export const policies = {}`) + + const transformer = new CodeTransformer(fs.baseUrl) + + await transformer.addPolicies([{ name: 'PostPolicy', path: '#policies/post_policy' }]) + + assert.fileContains( + 'domain/policies/main.ts', + `PostPolicy: () => import('#policies/post_policy')` + ) + }) }) test.group('Code transformer | addVitePlugin', (group) => { @@ -874,25 +1198,63 @@ test.group('Code transformer | addVitePlugin', (group) => { `) }) - test('throw error when vite.config.ts file is missing', async ({ fs }) => { + test('throw CodemodException with instructions when vite.config.ts is missing', async ({ + assert, + fs, + }) => { const transformer = new CodeTransformer(fs.baseUrl) - await transformer.addVitePlugin('vue()', [{ identifier: 'vue', module: 'vue', isNamed: false }]) - }).throws(/Cannot find vite\.config\.ts file/) + try { + await transformer.addVitePlugin('vue()', [ + { identifier: 'vue', module: 'vue', isNamed: false }, + ]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.include(error.message, 'vite.config.ts') + assert.isDefined(error.instructions) + assert.include(error.instructions, `import vue from 'vue'`) + assert.include(error.instructions, 'vue()') + } + }) - test('throw if no default export found', async ({ fs }) => { + test('throw CodemodException with instructions when no default export found', async ({ + assert, + fs, + }) => { await fs.create('vite.config.ts', `export const plugins = []`) const transformer = new CodeTransformer(fs.baseUrl) - await transformer.addVitePlugin('vue()', [{ identifier: 'vue', module: 'vue', isNamed: false }]) - }).throws(/Cannot find the default export/) + try { + await transformer.addVitePlugin('vue()', [ + { identifier: 'vue', module: 'vue', isNamed: false }, + ]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.isDefined(error.instructions) + assert.include(error.instructions, 'vue()') + } + }) - test('throw if plugins property is not found', async ({ fs }) => { + test('throw CodemodException with instructions when plugins property is not found', async ({ + assert, + fs, + }) => { await fs.create('vite.config.ts', `export default {}`) const transformer = new CodeTransformer(fs.baseUrl) - await transformer.addVitePlugin('vue()', [{ identifier: 'vue', module: 'vue', isNamed: false }]) - }).throws(/Expected to find property named 'plugins'/) + try { + await transformer.addVitePlugin('vue()', [ + { identifier: 'vue', module: 'vue', isNamed: false }, + ]) + assert.fail('Should have thrown an error') + } catch (error) { + assert.instanceOf(error, CodemodException) + assert.isDefined(error.instructions) + assert.include(error.instructions, 'vue()') + } + }) }) test.group('Code Transformer | addAssemblerHook', (group) => { diff --git a/tests/codemod_exception.spec.ts b/tests/codemod_exception.spec.ts new file mode 100644 index 00000000..f40a3cb6 --- /dev/null +++ b/tests/codemod_exception.spec.ts @@ -0,0 +1,173 @@ +/* + * @adonisjs/assembler + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { CodemodException } from '../src/exceptions/codemod_exception.ts' + +test.group('CodemodException', () => { + test('should create exception with message and instructions', ({ assert }) => { + const exception = new CodemodException('File not found', { + instructions: 'Add the following code manually', + }) + + assert.equal(exception.message, 'File not found') + assert.equal(exception.instructions, 'Add the following code manually') + assert.instanceOf(exception, Error) + }) + + test('should create exception without instructions', ({ assert }) => { + const exception = new CodemodException('Something went wrong') + + assert.equal(exception.message, 'Something went wrong') + assert.isUndefined(exception.instructions) + }) + + test('should create exception with file path', ({ assert }) => { + const exception = new CodemodException('File not found', { + filePath: 'start/env.ts', + }) + + assert.equal(exception.filePath, 'start/env.ts') + }) +}) + +test.group('CodemodException | Environment', () => { + test('missingEnvFile should include variables in instructions', ({ assert }) => { + const exception = CodemodException.missingEnvFile('start/env.ts', { + variables: { + REDIS_HOST: 'Env.schema.string.optional()', + REDIS_PORT: 'Env.schema.number()', + }, + }) + + assert.include(exception.message, 'start/env.ts') + assert.equal(exception.filePath, 'start/env.ts') + assert.include(exception.instructions!, 'REDIS_HOST: Env.schema.string.optional()') + assert.include(exception.instructions!, 'REDIS_PORT: Env.schema.number()') + }) + + test('missingEnvFile should include leading comment when provided', ({ assert }) => { + const exception = CodemodException.missingEnvFile('start/env.ts', { + leadingComment: 'Redis configuration', + variables: { + REDIS_HOST: 'Env.schema.string()', + }, + }) + + assert.include(exception.instructions!, 'Redis configuration') + }) + + test('missingEnvCreate should indicate Env.create is missing', ({ assert }) => { + const exception = CodemodException.missingEnvCreate('start/env.ts', { + variables: { MY_VAR: 'Env.schema.string()' }, + }) + + assert.include(exception.message, 'Env.create') + assert.include(exception.instructions!, 'MY_VAR: Env.schema.string()') + }) +}) + +test.group('CodemodException | Middleware', () => { + test('missingKernelFile should format server middleware', ({ assert }) => { + const exception = CodemodException.missingKernelFile('start/kernel.ts', 'server', [ + { path: '@adonisjs/static/static_middleware' }, + ]) + + assert.include(exception.message, 'start/kernel.ts') + assert.include(exception.instructions!, `() => import('@adonisjs/static/static_middleware')`) + assert.include(exception.instructions!, 'server.use') + }) + + test('missingKernelFile should format named middleware', ({ assert }) => { + const exception = CodemodException.missingKernelFile('start/kernel.ts', 'named', [ + { name: 'auth', path: '#middleware/auth_middleware' }, + ]) + + assert.include(exception.instructions!, `auth: () => import('#middleware/auth_middleware')`) + assert.include(exception.instructions!, 'router.named') + }) + + test('missingMiddlewareStack should indicate which stack is missing', ({ assert }) => { + const exception = CodemodException.missingMiddlewareStack('start/kernel.ts', 'server', [ + { path: '@adonisjs/cors' }, + ]) + + assert.include(exception.message, 'server.use') + }) +}) + +test.group('CodemodException | Policies', () => { + test('missingPoliciesFile should format policies', ({ assert }) => { + const exception = CodemodException.missingPoliciesFile('app/policies/main.ts', [ + { name: 'PostPolicy', path: '#policies/post_policy' }, + { name: 'UserPolicy', path: '#policies/user_policy' }, + ]) + + assert.include(exception.instructions!, `PostPolicy: () => import('#policies/post_policy')`) + assert.include(exception.instructions!, `UserPolicy: () => import('#policies/user_policy')`) + }) +}) + +test.group('CodemodException | Vite', () => { + test('missingViteConfig should format plugin with imports', ({ assert }) => { + const exception = CodemodException.missingViteConfig('vite.config.ts', 'vue({ foo: 32 })', [ + { identifier: 'vue', module: 'vue', isNamed: false }, + { identifier: 'foo', module: 'foo', isNamed: true }, + ]) + + assert.include(exception.message, 'vite.config.ts') + assert.include(exception.instructions!, `import vue from 'vue'`) + assert.include(exception.instructions!, `import { foo } from 'foo'`) + assert.include(exception.instructions!, 'vue({ foo: 32 })') + }) +}) + +test.group('CodemodException | Japa', () => { + test('missingJapaBootstrap should format plugin with imports', ({ assert }) => { + const exception = CodemodException.missingJapaBootstrap( + 'tests/bootstrap.ts', + 'fooPlugin(app)', + [ + { identifier: 'fooPlugin', module: '@adonisjs/foo/plugin/japa', isNamed: true }, + { identifier: 'app', module: '@adonisjs/core/services/app', isNamed: false }, + ] + ) + + assert.include(exception.instructions!, `import { fooPlugin } from '@adonisjs/foo/plugin/japa'`) + assert.include(exception.instructions!, `import app from '@adonisjs/core/services/app'`) + assert.include(exception.instructions!, 'fooPlugin(app)') + }) +}) + +test.group('CodemodException | Generic', () => { + test('E_CODEMOD_FILE_NOT_FOUND should create exception for missing file', ({ assert }) => { + const exception = CodemodException.E_CODEMOD_FILE_NOT_FOUND( + 'start/env.ts', + 'MY_VAR: Env.schema.string()' + ) + + assert.include(exception.message, 'start/env.ts') + assert.equal(exception.filePath, 'start/env.ts') + assert.include(exception.instructions!, 'MY_VAR: Env.schema.string()') + }) + + test('E_CODEMOD_INVALID_STRUCTURE should create exception for invalid structure', ({ + assert, + }) => { + const exception = CodemodException.E_CODEMOD_INVALID_STRUCTURE( + 'Cannot find Env.create statement', + 'start/env.ts', + 'MY_VAR: Env.schema.string()' + ) + + assert.include(exception.message, 'Cannot find Env.create statement') + assert.equal(exception.filePath, 'start/env.ts') + assert.include(exception.instructions!, 'MY_VAR: Env.schema.string()') + }) +})