diff --git a/src/errors.ts b/src/errors.ts index ca1a517..bfb1f4b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -133,3 +133,27 @@ export const E_INVALID_HOOKS_VALUE = createError<[eventName: string, value: stri 'Expected hooks for event "%s" to be an array of dynamic imports. Instead received "%s"', 'E_INVALID_HOOKS_VALUE' ) + +/** + * The exception is raised when the presets property is not an array + */ +export const E_INVALID_PRESETS_VALUE = createError<[value: string]>( + 'Expected presets to be an array of functions. Instead received "%s"', + 'E_INVALID_PRESETS_VALUE' +) + +/** + * The exception is raised when a preset is not a function + */ +export const E_INVALID_PRESET_FUNCTION = createError<[index: number, value: string]>( + 'Expected preset at index %s to be a function. Instead received "%s"', + 'E_INVALID_PRESET_FUNCTION' +) + +/** + * The exception is raised when a preset throws an error during execution + */ +export const E_PRESET_EXECUTION_ERROR = createError<[index: number, message: string]>( + 'Preset at index %s failed to execute: %s', + 'E_PRESET_EXECUTION_ERROR' +) diff --git a/src/rc_file/parser.ts b/src/rc_file/parser.ts index 4df5853..9432592 100644 --- a/src/rc_file/parser.ts +++ b/src/rc_file/parser.ts @@ -12,7 +12,14 @@ import globParent from 'glob-parent' import * as errors from '../errors.js' import { directories } from '../directories.js' -import type { AppEnvironments, MetaFileNode, PreloadNode, ProviderNode, RcFile } from '../types.js' +import type { + AppEnvironments, + MetaFileNode, + PreloadNode, + PresetFn, + ProviderNode, + RcFile, +} from '../types.js' const KNOWN_ASSEMBLER_HOOKS = [ 'buildStarting', @@ -198,11 +205,37 @@ export class RcFileParser { }) } + /** + * Apply presets functions to the raw rcFile before parsing + */ + #applyPresets(): void { + const presets: PresetFn[] = this.#rcFile.raw?.presets || [] + if (presets.length === 0) return + + if (!Array.isArray(presets)) { + throw new errors.E_INVALID_PRESETS_VALUE([inspect(presets)]) + } + + presets.forEach((preset, index) => { + if (typeof preset !== 'function') { + throw new errors.E_INVALID_PRESET_FUNCTION([index, inspect(preset)]) + } + + try { + preset({ rcFile: this.#rcFile }) + } catch (error) { + throw new errors.E_PRESET_EXECUTION_ERROR([index, error.message]) + } + }) + } + /** * Parse and validate file contents and merge them with defaults */ parse(): RcFile { - return { + this.#applyPresets() + + const rcFile = { typescript: this.#rcFile.typescript, preloads: this.#getPreloads(), metaFiles: this.#getMetaFiles(), @@ -219,5 +252,7 @@ export class RcFileParser { experimental: this.#rcFile.experimental, raw: this.#rcFile.raw, } + + return rcFile } } diff --git a/src/types.ts b/src/types.ts index aa66c3e..86a5bb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -202,6 +202,11 @@ export type RcFile = { * RcFile input is the partial copy of the RcFile */ export interface RcFileInput { + /** + * List of presets to apply to the configuration + */ + presets?: PresetFn[] + typescript?: RcFile['typescript'] directories?: Partial & { [key: string]: string } preloads?: (PreloadNode | PreloadNode['file'])[] @@ -283,3 +288,8 @@ export interface ContainerProviderContract { * will call this function */ export type Importer = (moduleIdentifier: string, options?: ImportCallOptions) => any + +/** + * AdonisRC Preset function type + */ +export type PresetFn = (options: { rcFile: RcFile }) => void diff --git a/tests/rc_file/preset.spec.ts b/tests/rc_file/preset.spec.ts new file mode 100644 index 0000000..a80a742 --- /dev/null +++ b/tests/rc_file/preset.spec.ts @@ -0,0 +1,191 @@ +/* + * @adonisjs/application + * + * (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 { directories } from '../../src/directories.js' +import { RcFileParser } from '../../src/rc_file/parser.js' +import { type PresetFn } from '../../src/types.ts' + +test.group('Rc Parser | presets', () => { + test('mutate the RcFile using the preset function', ({ assert }) => { + const provider = async () => ({}) + const onBuildCompleted = async () => ({}) as any + const commands = async () => {} + + function bunPreset(): PresetFn { + return function ({ rcFile }) { + rcFile.hooks ??= {} + rcFile.hooks.buildFinished = [...(rcFile.hooks?.buildFinished || []), onBuildCompleted] + + rcFile.commands.push(commands) + rcFile.providers.push({ environment: ['web'], file: provider }) + rcFile.tests.timeout = 3000 + } + } + + const preset = bunPreset() + const parser = new RcFileParser({ presets: [preset] }) + + assert.deepEqual(parser.parse(), { + raw: { presets: [preset] }, + typescript: true, + preloads: [], + directories, + metaFiles: [], + commands: [commands], + commandsAliases: {}, + providers: [{ environment: ['web'], file: provider }], + experimental: {}, + hooks: { + buildFinished: [onBuildCompleted], + }, + tests: { + suites: [], + timeout: 3000, + forceExit: true, + }, + }) + }) + + test('apply multiple presets in order', ({ assert }) => { + const provider1 = async () => ({}) + const provider2 = async () => ({}) + const command1 = async () => {} + const command2 = async () => {} + + function firstPreset(): PresetFn { + return function ({ rcFile }) { + rcFile.commands.push(command1) + rcFile.providers.push({ file: provider1, environment: ['web'] }) + rcFile.typescript = false + } + } + + function secondPreset(): PresetFn { + return function ({ rcFile }) { + rcFile.commands.push(command2) + rcFile.providers.push({ file: provider2, environment: ['console'] }) + rcFile.tests.forceExit = false + } + } + + const preset1 = firstPreset() + const preset2 = secondPreset() + const parser = new RcFileParser({ presets: [preset1, preset2] }) + + assert.deepEqual(parser.parse(), { + raw: { presets: [preset1, preset2] }, + typescript: false, + preloads: [], + directories, + metaFiles: [], + commands: [command1, command2], + commandsAliases: {}, + providers: [ + { environment: ['web'], file: provider1 }, + { environment: ['console'], file: provider2 }, + ], + experimental: {}, + hooks: {}, + tests: { + suites: [], + timeout: 2000, + forceExit: false, + }, + }) + }) + + test('preset modifications get also validated', ({ assert }) => { + function invalidPreset(): PresetFn { + return function ({ rcFile }) { + // This should throw during validation because file is not a function + rcFile.providers.push({ file: 'invalid-string' as any, environment: ['web'] }) + } + } + + const preset = invalidPreset() + const parser = new RcFileParser({ presets: [preset] }) + + const fn = () => parser.parse() + assert.throws( + fn, + "Invalid provider entry \"{ file: 'invalid-string', environment: [ 'web' ] }\". The file property must be a function" + ) + }) + + test('multiple presets can modify same property arrays', ({ assert }) => { + const provider1 = async () => ({}) + const provider2 = async () => ({}) + const command1 = async () => {} + const command2 = async () => {} + + function firstPreset(): PresetFn { + return function ({ rcFile }) { + rcFile.providers.push({ file: provider1, environment: ['web'] }) + rcFile.commands.push(command1) + } + } + + function secondPreset(): PresetFn { + return function ({ rcFile }) { + rcFile.providers.push({ file: provider2, environment: ['console'] }) + rcFile.commands.push(command2) + } + } + + const preset1 = firstPreset() + const preset2 = secondPreset() + const parser = new RcFileParser({ + presets: [preset1, preset2], + providers: [async () => ({}) as any], + commands: [async () => {}], + }) + + const result = parser.parse() + + assert.lengthOf(result.providers, 3) + assert.lengthOf(result.commands, 3) + }) + + test('throw error when presets is not an array', ({ assert }) => { + const parser = new RcFileParser({ presets: 'invalid' }) + + const fn = () => parser.parse() + assert.throws( + fn, + 'Expected presets to be an array of functions. Instead received "\'invalid\'"' + ) + }) + + test('throw error when preset is not a function', ({ assert }) => { + const parser = new RcFileParser({ presets: ['invalid', () => {}] }) + + const fn = () => parser.parse() + assert.throws(fn, 'Expected preset at index 0 to be a function. Instead received "\'invalid\'"') + }) + + test('throw error with correct index when multiple presets and one fails', ({ assert }) => { + function workingPreset(): PresetFn { + return function () {} + } + + function failingPreset(): PresetFn { + return function () { + throw new Error('Second preset failed') + } + } + + const preset1 = workingPreset() + const preset2 = failingPreset() + const parser = new RcFileParser({ presets: [preset1, preset2] }) + + const fn = () => parser.parse() + assert.throws(fn, 'Preset at index 1 failed to execute: Second preset failed') + }) +})