Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/rstack/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RslintConfig>);
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -251,7 +253,7 @@ export const loadRstackConfig = async ({
});

return {
configs: session.configs,
configs: await composeConfigLayers([session.configs]),
filePath,
dependencies,
};
Expand Down
76 changes: 76 additions & 0 deletions packages/rstack/src/configLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,79 @@ export const resolveStagedConfig = async (
: { ...merged, ...config },
);
};

export const resolveFmtConfigLayers = async (
layers: readonly Configs[],
): Promise<FmtConfig | undefined> => {
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<Configs> => {
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;
};
8 changes: 2 additions & 6 deletions packages/rstack/src/fmt/config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -157,11 +157,7 @@ const resolveFmtConfig = async ({
configFilePath,
cwd,
}: ResolveFmtConfigOptions): Promise<ResolvedFmtConfig> => {
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);
Expand Down
13 changes: 10 additions & 3 deletions packages/rstack/src/rstestConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,20 @@ const extendsConfig = async (
};
};

export const mergeRstestConfigLayers = async (
layers: readonly Configs[],
): Promise<RstestConfig> => {
const configs = await resolveConfigLayers(layers, 'test');
return configs.length > 1
? mergeRstestConfig(...configs)
: (configs[0] ?? {});
};

export const resolveRstestConfig = async (
layers: readonly Configs[],
params: ConfigParams,
): Promise<RstestConfig> => {
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);
};

Expand Down
126 changes: 126 additions & 0 deletions packages/rstack/tests/config/compose.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading