Skip to content

Commit 69c94a3

Browse files
committed
refactor(config): compose effective tool definitions from config layers
1 parent 287cf3c commit 69c94a3

5 files changed

Lines changed: 217 additions & 10 deletions

File tree

‎packages/rstack/src/config.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { UserConfig, UserConfigAsyncFn } from '@rspress/core';
1313
import type { RstestConfigExport } from '@rstest/core';
1414
import type { FmtConfigDefinition } from './fmt/types.ts';
1515
import type { StagedConfig } from './staged.ts';
16+
import { composeConfigLayers } from './configLayers.ts';
1617

1718
export type RslintConfigDefinition =
1819
RslintConfig | (() => Promise<RslintConfig>);
@@ -58,6 +59,7 @@ export const normalizeRstackConfig = ({
5859
: { ...configs, lint: normalizeLintConfig(lint) };
5960

6061
export type LoadedRstackConfig = {
62+
/** Effective tool definitions. Configuration factories remain unevaluated. */
6163
configs: Configs;
6264
filePath: string | null;
6365
dependencies: string[];
@@ -251,7 +253,7 @@ export const loadRstackConfig = async ({
251253
});
252254

253255
return {
254-
configs: session.configs,
256+
configs: await composeConfigLayers([session.configs]),
255257
filePath,
256258
dependencies,
257259
};

‎packages/rstack/src/configLayers.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,79 @@ export const resolveStagedConfig = async (
112112
: { ...merged, ...config },
113113
);
114114
};
115+
116+
export const resolveFmtConfigLayers = async (
117+
layers: readonly Configs[],
118+
): Promise<FmtConfig | undefined> => {
119+
const configs = await resolveConfigLayers(layers, 'fmt');
120+
return configs.length > 1
121+
? (Object.assign({}, ...configs) as FmtConfig)
122+
: configs[0];
123+
};
124+
125+
/** Compose effective definitions without executing any tool or task factories. */
126+
export const composeConfigLayers = async (
127+
layers: readonly Configs[],
128+
): Promise<Configs> => {
129+
if (layers.length <= 1) {
130+
return layers[0] ?? {};
131+
}
132+
133+
const configs: Configs = {};
134+
for (const kind of [
135+
'app',
136+
'lib',
137+
'doc',
138+
'test',
139+
'lint',
140+
'fmt',
141+
'staged',
142+
] as const) {
143+
const matchingLayers = layers.filter((layer) => layer[kind] !== undefined);
144+
if (matchingLayers.length === 0) {
145+
continue;
146+
}
147+
if (matchingLayers.length === 1) {
148+
Object.assign(configs, { [kind]: matchingLayers[0][kind] });
149+
continue;
150+
}
151+
152+
switch (kind) {
153+
case 'app':
154+
configs.app = async (params) => {
155+
const { resolveRsbuildConfig } = await import('./rsbuildConfig.ts');
156+
return resolveRsbuildConfig(matchingLayers, params);
157+
};
158+
break;
159+
case 'lib':
160+
configs.lib = async (params) => {
161+
const { resolveRslibConfig } = await import('./rslibConfig.ts');
162+
return resolveRslibConfig(matchingLayers, params);
163+
};
164+
break;
165+
case 'doc':
166+
configs.doc = async () => {
167+
const { resolveRspressConfig } = await import('./rspressConfig.ts');
168+
return resolveRspressConfig(matchingLayers);
169+
};
170+
break;
171+
case 'test':
172+
configs.test = async () => {
173+
const { mergeRstestConfigLayers } = await import('./rstestConfig.ts');
174+
return mergeRstestConfigLayers(matchingLayers);
175+
};
176+
break;
177+
case 'lint':
178+
configs.lint = async () => (await resolveRslintConfig(matchingLayers))!;
179+
break;
180+
case 'fmt':
181+
configs.fmt = async () =>
182+
(await resolveFmtConfigLayers(matchingLayers))!;
183+
break;
184+
case 'staged':
185+
configs.staged = await resolveStagedConfig(matchingLayers);
186+
break;
187+
}
188+
}
189+
return configs;
190+
};

‎packages/rstack/src/fmt/config.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { dirname } from 'node:path';
22
import micromatch from 'micromatch';
33
import type { Configs } from '../config.ts';
4-
import { resolveConfigLayers } from '../configLayers.ts';
4+
import { resolveFmtConfigLayers } from '../configLayers.ts';
55
import { createRelativePathResolver } from './pathHelpers.ts';
66
import type {
77
FmtConfig,
@@ -157,11 +157,7 @@ const resolveFmtConfig = async ({
157157
configFilePath,
158158
cwd,
159159
}: ResolveFmtConfigOptions): Promise<ResolvedFmtConfig> => {
160-
const configs = await resolveConfigLayers(layers, 'fmt');
161-
const config =
162-
configs.length > 1
163-
? (Object.assign({}, ...configs) as FmtConfig)
164-
: configs[0];
160+
const config = await resolveFmtConfigLayers(layers);
165161
const rootPath = configFilePath ? dirname(configFilePath) : cwd;
166162

167163
return normalizeFmtConfig(config, rootPath);

‎packages/rstack/src/rstestConfig.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,20 @@ const extendsConfig = async (
8787
};
8888
};
8989

90+
export const mergeRstestConfigLayers = async (
91+
layers: readonly Configs[],
92+
): Promise<RstestConfig> => {
93+
const configs = await resolveConfigLayers(layers, 'test');
94+
return configs.length > 1
95+
? mergeRstestConfig(...configs)
96+
: (configs[0] ?? {});
97+
};
98+
9099
export const resolveRstestConfig = async (
91100
layers: readonly Configs[],
92101
params: ConfigParams,
93102
): Promise<RstestConfig> => {
94-
const configs = await resolveConfigLayers(layers, 'test');
95-
const testConfig =
96-
configs.length > 1 ? mergeRstestConfig(...configs) : (configs[0] ?? {});
103+
const testConfig = await mergeRstestConfigLayers(layers);
97104
return extendsConfig(layers, testConfig, params);
98105
};
99106

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import assert from 'node:assert/strict';
2+
import { createRequire } from 'node:module';
3+
import type { Configs } from '../../src/config.ts';
4+
import { expect, rs, test } from 'rstack/test';
5+
import {
6+
composeConfigLayers,
7+
resolveConfigLayers,
8+
} from '../../src/configLayers.ts';
9+
import { resolveFmtConfig } from '../../src/fmt/config.ts';
10+
import { resolveRstestConfig } from '../../src/rstestConfig.ts';
11+
12+
// TODO: Import the source directly after upgrading Rstest to fix
13+
// https://github.com/web-infra-dev/rstest/issues/1891.
14+
rs.mock(
15+
'../../src/rstestConfig.ts',
16+
() =>
17+
createRequire(import.meta.url)(
18+
'../../dist/rstestConfig.js',
19+
) as typeof import('../../src/rstestConfig.ts'),
20+
);
21+
22+
const params = { command: 'build', env: 'production' } as const;
23+
24+
test('preserves single definitions and leaves missing tools absent', async () => {
25+
const app = rs.fn(() => ({}));
26+
const base: Configs = { app, lint: [] };
27+
const project: Configs = { fmt: {} };
28+
29+
expect(await composeConfigLayers([])).toEqual({});
30+
expect(await composeConfigLayers([base])).toBe(base);
31+
const configs = await composeConfigLayers([base, project]);
32+
expect(configs).toEqual({ app, lint: base.lint, fmt: project.fmt });
33+
expect(configs.lint).toBe(base.lint);
34+
expect(configs.fmt).toBe(project.fmt);
35+
expect(app).not.toHaveBeenCalled();
36+
});
37+
38+
test('composes tool definitions lazily and resolves each factory once', async () => {
39+
const app = rs.fn(() => ({ source: { define: { APP: true } } }));
40+
const lib = rs.fn(() => ({ lib: [{ format: 'esm' as const }] }));
41+
const doc = rs.fn(() => Promise.resolve({ title: 'Shared' }));
42+
const lint = rs.fn(() =>
43+
Promise.resolve([{ rules: { 'no-debugger': 'error' as const } }]),
44+
);
45+
const fmt = rs.fn(() => ({ semi: false }));
46+
const staged = rs.fn(() => 'rs check');
47+
const configs = await composeConfigLayers([
48+
{ app, lib, doc, lint, fmt, staged: { '*.ts': 'rs lint' } },
49+
{
50+
app: {},
51+
lib: {},
52+
doc: { title: 'Project' },
53+
lint: [],
54+
fmt: { singleQuote: true },
55+
staged,
56+
},
57+
]);
58+
59+
for (const factory of [app, lib, doc, lint, fmt, staged]) {
60+
expect(factory).not.toHaveBeenCalled();
61+
}
62+
expect(await resolveConfigLayers([configs], 'app', params)).toEqual([
63+
{ source: { define: { APP: true } } },
64+
]);
65+
expect(lib).not.toHaveBeenCalled();
66+
expect(doc).not.toHaveBeenCalled();
67+
expect(await resolveConfigLayers([configs], 'lib', params)).toEqual([
68+
{ lib: [{ format: 'esm' }] },
69+
]);
70+
expect(await resolveConfigLayers([configs], 'doc')).toEqual([
71+
{ title: 'Project' },
72+
]);
73+
expect(await resolveConfigLayers([configs], 'lint')).toEqual([
74+
[{ rules: { 'no-debugger': 'error' } }],
75+
]);
76+
expect(
77+
await resolveFmtConfig({
78+
layers: [configs],
79+
configFilePath: '/project/rstack.config.ts',
80+
cwd: '/other',
81+
}),
82+
).toEqual({
83+
rootPath: '/project',
84+
baseOptions: { semi: false, singleQuote: true },
85+
overrides: [],
86+
ignorePatterns: [],
87+
});
88+
expect(configs.staged).toBe(staged);
89+
expect(staged).not.toHaveBeenCalled();
90+
expect(app).toHaveBeenCalledExactlyOnceWith(params);
91+
expect(lib).toHaveBeenCalledExactlyOnceWith(params);
92+
for (const factory of [doc, lint, fmt]) {
93+
expect(factory).toHaveBeenCalledExactlyOnceWith();
94+
}
95+
});
96+
97+
test('defers test inheritance until the consumer resolves the composed config', async () => {
98+
const app = rs.fn(() => ({ source: { define: { SHARED: true } } }));
99+
const configs = await composeConfigLayers([
100+
{ app, test: { retry: 1 } },
101+
{
102+
app: { source: { define: { PROJECT: true } } },
103+
test: { projects: [{ name: 'a' }, { name: 'b' }] },
104+
},
105+
]);
106+
const raw = await resolveConfigLayers([configs], 'test');
107+
expect(raw).toEqual([{ retry: 1, projects: [{ name: 'a' }, { name: 'b' }] }]);
108+
expect(app).not.toHaveBeenCalled();
109+
110+
const config = await resolveRstestConfig([configs], params);
111+
const first = config.projects?.[0];
112+
assert(
113+
first && typeof first !== 'string' && typeof first.extends === 'function',
114+
);
115+
expect(config).toEqual({
116+
retry: 1,
117+
projects: [
118+
{ name: 'a', extends: first.extends },
119+
{ name: 'b', extends: first.extends },
120+
],
121+
});
122+
expect(await first.extends(first)).toMatchObject({
123+
source: { define: { SHARED: true, PROJECT: true } },
124+
});
125+
expect(app).toHaveBeenCalledExactlyOnceWith(params);
126+
});

0 commit comments

Comments
 (0)