Skip to content

Commit ff53507

Browse files
authored
Refactor the runner by introducing Project (#276)
* test: remove test cases for runners covered by other modules * refactor: implement `Project` * test: add test for `Project`
1 parent 412f33e commit ff53507

File tree

7 files changed

+455
-376
lines changed

7 files changed

+455
-376
lines changed

packages/codegen/bin/cmk.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ try {
1818
process.exit(0);
1919
}
2020

21-
await runCMK(args, logger);
21+
const success = await runCMK(args, logger);
22+
if (!success) {
23+
process.exit(1);
24+
}
2225
} catch (e) {
2326
logger.logError(e);
2427
process.exit(1);
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { access, chmod } from 'node:fs/promises';
2+
import { TsConfigFileNotFoundError } from '@css-modules-kit/core';
3+
import { describe, expect, test } from 'vitest';
4+
import { ReadCSSModuleFileError } from './error.js';
5+
import { createProject } from './project.js';
6+
import { formatDiagnostics } from './test/diagnostic.js';
7+
import { createIFF } from './test/fixture.js';
8+
9+
describe('createProject', () => {
10+
test('creates project', async () => {
11+
const iff = await createIFF({
12+
'tsconfig.json': '{}',
13+
});
14+
const project = createProject({ project: iff.rootDir });
15+
expect(project.config.dtsOutDir).toContain('generated');
16+
});
17+
test('throws TsConfigFileNotFoundError when tsconfig.json does not exist', async () => {
18+
const iff = await createIFF({});
19+
expect(() => createProject({ project: iff.rootDir })).toThrow(TsConfigFileNotFoundError);
20+
});
21+
test.runIf(process.platform !== 'win32')(
22+
'throws ReadCSSModuleFileError when a CSS module file cannot be read',
23+
async () => {
24+
const iff = await createIFF({
25+
'tsconfig.json': '{}',
26+
'src/a.module.css': '.a1 { color: red; }',
27+
});
28+
await chmod(iff.paths['src/a.module.css'], 0o200); // Remove read permission
29+
expect(() => createProject({ project: iff.rootDir })).toThrow(ReadCSSModuleFileError);
30+
},
31+
);
32+
});
33+
34+
describe('getDiagnostics', () => {
35+
test('returns empty array when no diagnostics', async () => {
36+
const iff = await createIFF({
37+
'tsconfig.json': '{}',
38+
'src/a.module.css': '.a_1 { color: red; }',
39+
});
40+
const project = createProject({ project: iff.rootDir });
41+
const diagnostics = project.getDiagnostics();
42+
expect(diagnostics).toEqual([]);
43+
});
44+
test('returns project diagnostics', async () => {
45+
const iff = await createIFF({
46+
'tsconfig.json': '{ "cmkOptions": { "dtsOutDir": 1 } }',
47+
});
48+
const project = createProject({ project: iff.rootDir });
49+
const diagnostics = project.getDiagnostics();
50+
expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(`
51+
[
52+
{
53+
"category": "error",
54+
"text": "\`dtsOutDir\` in <rootDir>/tsconfig.json must be a string.",
55+
},
56+
{
57+
"category": "error",
58+
"text": "The file specified in tsconfig.json not found.",
59+
},
60+
]
61+
`);
62+
});
63+
test('returns syntactic diagnostics', async () => {
64+
const iff = await createIFF({
65+
'tsconfig.json': '{}',
66+
'src/a.module.css': '.a_1 {',
67+
'src/b.module.css': '.a_2 { color }',
68+
});
69+
const project = createProject({ project: iff.rootDir });
70+
const diagnostics = project.getDiagnostics();
71+
expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(`
72+
[
73+
{
74+
"category": "error",
75+
"fileName": "<rootDir>/src/a.module.css",
76+
"length": 1,
77+
"start": {
78+
"column": 1,
79+
"line": 1,
80+
},
81+
"text": "Unclosed block",
82+
},
83+
{
84+
"category": "error",
85+
"fileName": "<rootDir>/src/b.module.css",
86+
"length": 5,
87+
"start": {
88+
"column": 8,
89+
"line": 1,
90+
},
91+
"text": "Unknown word color",
92+
},
93+
]
94+
`);
95+
});
96+
97+
test('returns semantic diagnostics', async () => {
98+
const iff = await createIFF({
99+
'tsconfig.json': '{}',
100+
'src/a.module.css': `@import './non-existent-1.module.css';`,
101+
'src/b.module.css': `@import './non-existent-2.module.css';`,
102+
});
103+
const project = createProject({ project: iff.rootDir });
104+
const diagnostics = project.getDiagnostics();
105+
expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(`
106+
[
107+
{
108+
"category": "error",
109+
"fileName": "<rootDir>/src/a.module.css",
110+
"length": 27,
111+
"start": {
112+
"column": 10,
113+
"line": 1,
114+
},
115+
"text": "Cannot import module './non-existent-1.module.css'",
116+
},
117+
{
118+
"category": "error",
119+
"fileName": "<rootDir>/src/b.module.css",
120+
"length": 27,
121+
"start": {
122+
"column": 10,
123+
"line": 1,
124+
},
125+
"text": "Cannot import module './non-existent-2.module.css'",
126+
},
127+
]
128+
`);
129+
});
130+
test('skips semantic diagnostics when project or syntactic diagnostics exist', async () => {
131+
const iff = await createIFF({
132+
'tsconfig.json': '{ "cmkOptions": { "dtsOutDir": 1 } }',
133+
'src/a.module.css': '.a_1 {',
134+
'src/b.module.css': `@import './non-existent.module.css';`,
135+
});
136+
const project = createProject({ project: iff.rootDir });
137+
const diagnostics = project.getDiagnostics();
138+
expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(`
139+
[
140+
{
141+
"category": "error",
142+
"text": "\`dtsOutDir\` in <rootDir>/tsconfig.json must be a string.",
143+
},
144+
{
145+
"category": "error",
146+
"fileName": "<rootDir>/src/a.module.css",
147+
"length": 1,
148+
"start": {
149+
"column": 1,
150+
"line": 1,
151+
},
152+
"text": "Unclosed block",
153+
},
154+
]
155+
`);
156+
});
157+
});
158+
159+
describe('emitDtsFiles', () => {
160+
test('emits .d.ts files', async () => {
161+
const iff = await createIFF({
162+
'tsconfig.json': '{}',
163+
'src/a.module.css': '.a1 { color: red; }',
164+
'src/b.module.css': '.b1 { color: blue; }',
165+
});
166+
const project = createProject({ project: iff.rootDir });
167+
await project.emitDtsFiles();
168+
expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(`
169+
"// @ts-nocheck
170+
declare const styles = {
171+
a1: '' as readonly string,
172+
};
173+
export default styles;
174+
"
175+
`);
176+
expect(await iff.readFile('generated/src/b.module.css.d.ts')).toMatchInlineSnapshot(`
177+
"// @ts-nocheck
178+
declare const styles = {
179+
b1: '' as readonly string,
180+
};
181+
export default styles;
182+
"
183+
`);
184+
});
185+
test('does not emit .d.ts files for files not matched by `pattern`', async () => {
186+
const iff = await createIFF({
187+
'tsconfig.json': '{}',
188+
'src/a.module.css': '.a1 { color: red; }',
189+
'src/b.css': '.b1 { color: blue; }',
190+
});
191+
const project = createProject({ project: iff.rootDir });
192+
await project.emitDtsFiles();
193+
expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(`
194+
"// @ts-nocheck
195+
declare const styles = {
196+
a1: '' as readonly string,
197+
};
198+
export default styles;
199+
"
200+
`);
201+
await expect(access(iff.join('generated/src/a.module.css.d.ts'))).resolves.not.toThrow();
202+
await expect(access(iff.join('generated/src/b.css.d.ts'))).rejects.toThrow();
203+
});
204+
});

packages/codegen/src/project.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { readFileSync } from 'node:fs';
2+
import type { CSSModule, Diagnostic } from '@css-modules-kit/core';
3+
import {
4+
checkCSSModule,
5+
type CMKConfig,
6+
createExportBuilder,
7+
createMatchesPattern,
8+
createResolver,
9+
generateDts,
10+
getFileNamesByPattern,
11+
parseCSSModule,
12+
readConfigFile,
13+
} from '@css-modules-kit/core';
14+
import ts from 'typescript';
15+
import { writeDtsFile } from './dts-writer.js';
16+
import { ReadCSSModuleFileError } from './error.js';
17+
18+
interface ProjectArgs {
19+
project: string;
20+
}
21+
22+
interface Project {
23+
config: CMKConfig;
24+
// TODO: Implement these methods later for watch mode
25+
// /** Whether the file matches the wildcard patterns in `include` / `exclude` options */
26+
// isWildcardMatchedFile(fileName: string): boolean;
27+
// /**
28+
// * Add a file to the project.
29+
// * @throws {ReadCSSModuleFileError}
30+
// */
31+
// addFile(fileName: string): void;
32+
// /**
33+
// * Update a file in the project.
34+
// * @throws {ReadCSSModuleFileError}
35+
// */
36+
// updateFile(fileName: string): void;
37+
// /** Remove a file from the project. */
38+
// removeFile(fileName: string): void;
39+
/**
40+
* Get all diagnostics.
41+
* Including three types of diagnostics: project diagnostics, syntactic diagnostics, and semantic diagnostics.
42+
* - Project diagnostics: For example, it includes configuration errors in tsconfig.json or warnings when there are no target files.
43+
* - Syntactic diagnostics: Syntax errors in CSS Module files.
44+
* - Semantic diagnostics: Errors related to the use of imports and exports in CSS module files.
45+
* If there are any project diagnostics or syntactic diagnostics, semantic diagnostics will be skipped.
46+
*/
47+
getDiagnostics(): Diagnostic[];
48+
/**
49+
* Emit .d.ts files for all project files.
50+
* @throws {WriteDtsFileError}
51+
*/
52+
emitDtsFiles(): Promise<void>;
53+
}
54+
55+
/**
56+
* Create a Project instance.
57+
* Project is like a facade that calls core operations such as loading settings, parsing CSS Module files, and performing checks.
58+
* The parsing and checking results are cached, and methods are also provided to clear the cache when files change.
59+
* @throws {TsConfigFileNotFoundError}
60+
* @throws {ReadCSSModuleFileError}
61+
*/
62+
export function createProject(args: ProjectArgs): Project {
63+
const config = readConfigFile(args.project);
64+
65+
const getCanonicalFileName = (fileName: string) =>
66+
ts.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase();
67+
const moduleResolutionCache = ts.createModuleResolutionCache(
68+
config.basePath,
69+
getCanonicalFileName,
70+
config.compilerOptions,
71+
);
72+
const resolver = createResolver(config.compilerOptions, moduleResolutionCache);
73+
const matchesPattern = createMatchesPattern(config);
74+
75+
const parseStageCache = new Map<string, CSSModule>();
76+
const checkStageCache = new Map<string, Diagnostic[]>();
77+
const getCSSModule = (path: string) => parseStageCache.get(path);
78+
const exportBuilder = createExportBuilder({ getCSSModule, matchesPattern, resolver });
79+
80+
for (const fileName of getFileNamesByPattern(config)) {
81+
// NOTE: Files may be deleted between executing `getFileNamesByPattern` and `tryParseCSSModule`.
82+
// Therefore, `tryParseCSSModule` may return `undefined`.
83+
const cssModule = tryParseCSSModule(fileName);
84+
if (cssModule) parseStageCache.set(fileName, cssModule);
85+
}
86+
87+
/**
88+
* @throws {ReadCSSModuleFileError}
89+
*/
90+
function tryParseCSSModule(fileName: string): CSSModule | undefined {
91+
let text: string;
92+
try {
93+
// NOTE: We are not using asynchronous APIs for the following reasons:
94+
//
95+
// - Asynchronous APIs are slow in Node.js.
96+
// - https://github.com/nodejs/performance/issues/151
97+
// - Handling race conditions is cumbersome.
98+
// - Using an asynchronous API makes `addFile` asynchronous too.
99+
// - If `deleteFile` runs while `addFile` is executing, a race condition occurs.
100+
// - Avoiding this requires something like a mutex. However, implementing that is cumbersome.
101+
text = readFileSync(fileName, 'utf-8');
102+
} catch (error) {
103+
if (isNodeJSSystemError(error) && error.code === 'ENOENT') {
104+
return undefined;
105+
}
106+
throw new ReadCSSModuleFileError(fileName, error);
107+
}
108+
return parseCSSModule(text, { fileName, includeSyntaxError: true, keyframes: config.keyframes });
109+
}
110+
111+
function getDiagnostics(): Diagnostic[] {
112+
const diagnostics: Diagnostic[] = [...getProjectDiagnostics(), ...getSyntacticDiagnostics()];
113+
// If there are project or syntactic diagnostics, skip semantic diagnostics
114+
if (diagnostics.length > 0) return diagnostics;
115+
diagnostics.push(...getSemanticDiagnostics());
116+
return diagnostics;
117+
}
118+
119+
function getProjectDiagnostics() {
120+
const diagnostics: Diagnostic[] = [];
121+
diagnostics.push(...config.diagnostics);
122+
if (parseStageCache.size === 0) {
123+
diagnostics.push({
124+
category: 'error',
125+
text: `The file specified in tsconfig.json not found.`,
126+
});
127+
}
128+
return diagnostics;
129+
}
130+
131+
function getSyntacticDiagnostics() {
132+
return Array.from(parseStageCache.values()).flatMap(({ diagnostics }) => diagnostics);
133+
}
134+
135+
function getSemanticDiagnostics() {
136+
const allDiagnostics: Diagnostic[] = [];
137+
for (const cssModule of parseStageCache.values()) {
138+
let diagnostics = checkStageCache.get(cssModule.fileName);
139+
if (!diagnostics) {
140+
diagnostics = checkCSSModule(cssModule, config, exportBuilder, matchesPattern, resolver, getCSSModule);
141+
checkStageCache.set(cssModule.fileName, diagnostics);
142+
}
143+
allDiagnostics.push(...diagnostics);
144+
}
145+
return allDiagnostics;
146+
}
147+
148+
/**
149+
* @throws {WriteDtsFileError}
150+
*/
151+
async function emitDtsFiles(): Promise<void> {
152+
const promises: Promise<void>[] = [];
153+
for (const cssModule of parseStageCache.values()) {
154+
const dts = generateDts(cssModule, { resolver, matchesPattern }, { ...config, forTsPlugin: false });
155+
promises.push(
156+
writeDtsFile(dts.text, cssModule.fileName, {
157+
outDir: config.dtsOutDir,
158+
basePath: config.basePath,
159+
arbitraryExtensions: config.arbitraryExtensions,
160+
}),
161+
);
162+
}
163+
await Promise.all(promises);
164+
}
165+
166+
return {
167+
config,
168+
getDiagnostics,
169+
emitDtsFiles,
170+
};
171+
}
172+
173+
function isNodeJSSystemError(error: unknown): error is NodeJS.ErrnoException {
174+
return typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string';
175+
}

0 commit comments

Comments
 (0)