diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index 9f1ec1e..0721654 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -13,6 +13,7 @@ import type { UserConfig, UserConfigAsyncFn } from '@rspress/core'; import type { RstestConfigExport } from '@rstest/core'; import type { FmtConfigDefinition } from './fmt/types.ts'; import type { StagedConfig } from './staged.ts'; +import { composeConfigLayers } from './configLayers.ts'; export type RslintConfigDefinition = RslintConfig | (() => Promise); @@ -58,6 +59,7 @@ export const normalizeRstackConfig = ({ : { ...configs, lint: normalizeLintConfig(lint) }; export type LoadedRstackConfig = { + /** Effective tool definitions. Configuration factories remain unevaluated. */ configs: Configs; filePath: string | null; dependencies: string[]; @@ -251,7 +253,7 @@ export const loadRstackConfig = async ({ }); return { - configs: session.configs, + configs: await composeConfigLayers([session.configs]), filePath, dependencies, }; diff --git a/packages/rstack/src/configLayers.ts b/packages/rstack/src/configLayers.ts index 8795a91..bc46628 100644 --- a/packages/rstack/src/configLayers.ts +++ b/packages/rstack/src/configLayers.ts @@ -112,3 +112,79 @@ export const resolveStagedConfig = async ( : { ...merged, ...config }, ); }; + +export const resolveFmtConfigLayers = async ( + layers: readonly Configs[], +): Promise => { + const configs = await resolveConfigLayers(layers, 'fmt'); + return configs.length > 1 + ? (Object.assign({}, ...configs) as FmtConfig) + : configs[0]; +}; + +/** Compose effective definitions without executing any tool or task factories. */ +export const composeConfigLayers = async ( + layers: readonly Configs[], +): Promise => { + if (layers.length <= 1) { + return layers[0] ?? {}; + } + + const configs: Configs = {}; + for (const kind of [ + 'app', + 'lib', + 'doc', + 'test', + 'lint', + 'fmt', + 'staged', + ] as const) { + const matchingLayers = layers.filter((layer) => layer[kind] !== undefined); + if (matchingLayers.length === 0) { + continue; + } + if (matchingLayers.length === 1) { + Object.assign(configs, { [kind]: matchingLayers[0][kind] }); + continue; + } + + switch (kind) { + case 'app': + configs.app = async (params) => { + const { resolveRsbuildConfig } = await import('./rsbuildConfig.ts'); + return resolveRsbuildConfig(matchingLayers, params); + }; + break; + case 'lib': + configs.lib = async (params) => { + const { resolveRslibConfig } = await import('./rslibConfig.ts'); + return resolveRslibConfig(matchingLayers, params); + }; + break; + case 'doc': + configs.doc = async () => { + const { resolveRspressConfig } = await import('./rspressConfig.ts'); + return resolveRspressConfig(matchingLayers); + }; + break; + case 'test': + configs.test = async () => { + const { mergeRstestConfigLayers } = await import('./rstestConfig.ts'); + return mergeRstestConfigLayers(matchingLayers); + }; + break; + case 'lint': + configs.lint = async () => (await resolveRslintConfig(matchingLayers))!; + break; + case 'fmt': + configs.fmt = async () => + (await resolveFmtConfigLayers(matchingLayers))!; + break; + case 'staged': + configs.staged = await resolveStagedConfig(matchingLayers); + break; + } + } + return configs; +}; diff --git a/packages/rstack/src/fmt/config.ts b/packages/rstack/src/fmt/config.ts index 54d735b..1d9ba7b 100644 --- a/packages/rstack/src/fmt/config.ts +++ b/packages/rstack/src/fmt/config.ts @@ -1,7 +1,7 @@ import { dirname } from 'node:path'; import micromatch from 'micromatch'; import type { Configs } from '../config.ts'; -import { resolveConfigLayers } from '../configLayers.ts'; +import { resolveFmtConfigLayers } from '../configLayers.ts'; import { createRelativePathResolver } from './pathHelpers.ts'; import type { FmtConfig, @@ -157,11 +157,7 @@ const resolveFmtConfig = async ({ configFilePath, cwd, }: ResolveFmtConfigOptions): Promise => { - const configs = await resolveConfigLayers(layers, 'fmt'); - const config = - configs.length > 1 - ? (Object.assign({}, ...configs) as FmtConfig) - : configs[0]; + const config = await resolveFmtConfigLayers(layers); const rootPath = configFilePath ? dirname(configFilePath) : cwd; return normalizeFmtConfig(config, rootPath); diff --git a/packages/rstack/src/rstestConfig.ts b/packages/rstack/src/rstestConfig.ts index c69bdac..d02868b 100644 --- a/packages/rstack/src/rstestConfig.ts +++ b/packages/rstack/src/rstestConfig.ts @@ -87,13 +87,20 @@ const extendsConfig = async ( }; }; +export const mergeRstestConfigLayers = async ( + layers: readonly Configs[], +): Promise => { + const configs = await resolveConfigLayers(layers, 'test'); + return configs.length > 1 + ? mergeRstestConfig(...configs) + : (configs[0] ?? {}); +}; + export const resolveRstestConfig = async ( layers: readonly Configs[], params: ConfigParams, ): Promise => { - const configs = await resolveConfigLayers(layers, 'test'); - const testConfig = - configs.length > 1 ? mergeRstestConfig(...configs) : (configs[0] ?? {}); + const testConfig = await mergeRstestConfigLayers(layers); return extendsConfig(layers, testConfig, params); }; diff --git a/packages/rstack/tests/config/compose.test.ts b/packages/rstack/tests/config/compose.test.ts new file mode 100644 index 0000000..2985d11 --- /dev/null +++ b/packages/rstack/tests/config/compose.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import type { Configs } from '../../src/config.ts'; +import { expect, rs, test } from 'rstack/test'; +import { + composeConfigLayers, + resolveConfigLayers, +} from '../../src/configLayers.ts'; +import { resolveFmtConfig } from '../../src/fmt/config.ts'; +import { resolveRstestConfig } from '../../src/rstestConfig.ts'; + +// TODO: Import the source directly after upgrading Rstest to fix +// https://github.com/web-infra-dev/rstest/issues/1891. +rs.mock( + '../../src/rstestConfig.ts', + () => + createRequire(import.meta.url)( + '../../dist/rstestConfig.js', + ) as typeof import('../../src/rstestConfig.ts'), +); + +const params = { command: 'build', env: 'production' } as const; + +test('preserves single definitions and leaves missing tools absent', async () => { + const app = rs.fn(() => ({})); + const base: Configs = { app, lint: [] }; + const project: Configs = { fmt: {} }; + + expect(await composeConfigLayers([])).toEqual({}); + expect(await composeConfigLayers([base])).toBe(base); + const configs = await composeConfigLayers([base, project]); + expect(configs).toEqual({ app, lint: base.lint, fmt: project.fmt }); + expect(configs.lint).toBe(base.lint); + expect(configs.fmt).toBe(project.fmt); + expect(app).not.toHaveBeenCalled(); +}); + +test('composes tool definitions lazily and resolves each factory once', async () => { + const app = rs.fn(() => ({ source: { define: { APP: true } } })); + const lib = rs.fn(() => ({ lib: [{ format: 'esm' as const }] })); + const doc = rs.fn(() => Promise.resolve({ title: 'Shared' })); + const lint = rs.fn(() => + Promise.resolve([{ rules: { 'no-debugger': 'error' as const } }]), + ); + const fmt = rs.fn(() => ({ semi: false })); + const staged = rs.fn(() => 'rs check'); + const configs = await composeConfigLayers([ + { app, lib, doc, lint, fmt, staged: { '*.ts': 'rs lint' } }, + { + app: {}, + lib: {}, + doc: { title: 'Project' }, + lint: [], + fmt: { singleQuote: true }, + staged, + }, + ]); + + for (const factory of [app, lib, doc, lint, fmt, staged]) { + expect(factory).not.toHaveBeenCalled(); + } + expect(await resolveConfigLayers([configs], 'app', params)).toEqual([ + { source: { define: { APP: true } } }, + ]); + expect(lib).not.toHaveBeenCalled(); + expect(doc).not.toHaveBeenCalled(); + expect(await resolveConfigLayers([configs], 'lib', params)).toEqual([ + { lib: [{ format: 'esm' }] }, + ]); + expect(await resolveConfigLayers([configs], 'doc')).toEqual([ + { title: 'Project' }, + ]); + expect(await resolveConfigLayers([configs], 'lint')).toEqual([ + [{ rules: { 'no-debugger': 'error' } }], + ]); + expect( + await resolveFmtConfig({ + layers: [configs], + configFilePath: '/project/rstack.config.ts', + cwd: '/other', + }), + ).toEqual({ + rootPath: '/project', + baseOptions: { semi: false, singleQuote: true }, + overrides: [], + ignorePatterns: [], + }); + expect(configs.staged).toBe(staged); + expect(staged).not.toHaveBeenCalled(); + expect(app).toHaveBeenCalledExactlyOnceWith(params); + expect(lib).toHaveBeenCalledExactlyOnceWith(params); + for (const factory of [doc, lint, fmt]) { + expect(factory).toHaveBeenCalledExactlyOnceWith(); + } +}); + +test('defers test inheritance until the consumer resolves the composed config', async () => { + const app = rs.fn(() => ({ source: { define: { SHARED: true } } })); + const configs = await composeConfigLayers([ + { app, test: { retry: 1 } }, + { + app: { source: { define: { PROJECT: true } } }, + test: { projects: [{ name: 'a' }, { name: 'b' }] }, + }, + ]); + const raw = await resolveConfigLayers([configs], 'test'); + expect(raw).toEqual([{ retry: 1, projects: [{ name: 'a' }, { name: 'b' }] }]); + expect(app).not.toHaveBeenCalled(); + + const config = await resolveRstestConfig([configs], params); + const first = config.projects?.[0]; + assert( + first && typeof first !== 'string' && typeof first.extends === 'function', + ); + expect(config).toEqual({ + retry: 1, + projects: [ + { name: 'a', extends: first.extends }, + { name: 'b', extends: first.extends }, + ], + }); + expect(await first.extends(first)).toMatchObject({ + source: { define: { SHARED: true, PROJECT: true } }, + }); + expect(app).toHaveBeenCalledExactlyOnceWith(params); +});