Skip to content
Merged
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
89 changes: 89 additions & 0 deletions packages/rstack/tests/config/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
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'),
);

test('preserves single definitions and leaves missing tools absent', async () => {
const app = rs.fn(() => ({}));
const base = { app };
const fmt = {};

expect(await composeConfigLayers([])).toEqual({});
expect(await composeConfigLayers([base])).toBe(base);
const configs = await composeConfigLayers([base, { fmt }]);
expect(configs).toEqual({ app, fmt });
expect(configs.fmt).toBe(fmt);
expect(app).not.toHaveBeenCalled();
});

test('resolves only the requested tool and preserves staged task generators', async () => {
const app = rs.fn(() => ({}));
const fmt = rs.fn(() => ({ semi: false }));
const staged = rs.fn(() => 'rs check');
const configs = await composeConfigLayers([
{ app, fmt, staged: { '*.ts': 'rs lint' } },
{ app: {}, fmt: { singleQuote: true }, staged },
]);

expect(fmt).not.toHaveBeenCalled();
expect(
await resolveFmtConfig({
layers: [configs],
configFilePath: '/project/rstack.config.ts',
cwd: '/other',
}),
).toEqual({
rootPath: '/project',
baseOptions: { semi: false, singleQuote: true },
overrides: [],
ignorePatterns: [],
});
expect(fmt).toHaveBeenCalledExactlyOnceWith();
expect(app).not.toHaveBeenCalled();
expect(configs.staged).toBe(staged);
expect(staged).not.toHaveBeenCalled();
});

test('applies automatic inheritance after merging test definitions', async () => {
const app = rs.fn(() => ({ source: { define: { SHARED: true } } }));
const configs = await composeConfigLayers([
{ app, test: { retry: 1 } },
{
app: { source: { define: { PROJECT: true } } },
test: { testTimeout: 5000 },
},
]);

expect(await resolveConfigLayers([configs], 'test')).toEqual([
{ retry: 1, testTimeout: 5000 },
]);
expect(app).not.toHaveBeenCalled();

const params = { command: 'build', env: 'production' } as const;
const config = await resolveRstestConfig([configs], params);
assert(typeof config.extends === 'function');
expect(config).toEqual({
retry: 1,
testTimeout: 5000,
extends: config.extends,
});
expect(await config.extends(config)).toMatchObject({
source: { define: { SHARED: true, PROJECT: true } },
});
expect(app).toHaveBeenCalledExactlyOnceWith(params);
});
Loading