diff --git a/.changeset/modern-tables-dress.md b/.changeset/modern-tables-dress.md new file mode 100644 index 00000000..005a1b6b --- /dev/null +++ b/.changeset/modern-tables-dress.md @@ -0,0 +1,5 @@ +--- +'@css-modules-kit/codegen': minor +--- + +feat: add `--watch` option diff --git a/.vscode/launch.json b/.vscode/launch.json index 1d90a437..53b5ed76 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,19 @@ "group": "codegen" } }, + { + "name": "codegen: watch mode (1-basic)", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/examples/1-basic", + "program": "${workspaceFolder}/packages/codegen/bin/cmk.mjs", + "args": ["--watch"], + "console": "integratedTerminal", + "preLaunchTask": "npm: build - packages/codegen", + "presentation": { + "group": "codegen" + } + }, { "name": "codegen (2-named-exports)", "type": "node", diff --git a/package-lock.json b/package-lock.json index 9dbda13a..b3fc2307 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12400,7 +12400,8 @@ "version": "0.6.0", "license": "MIT", "dependencies": { - "@css-modules-kit/core": "^0.6.0" + "@css-modules-kit/core": "^0.6.0", + "chokidar": "^4.0.3" }, "bin": { "cmk": "bin/cmk.mjs" @@ -12412,6 +12413,34 @@ "typescript": "^5.7.3" } }, + "packages/codegen/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "packages/codegen/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "packages/core": { "name": "@css-modules-kit/core", "version": "0.6.0", diff --git a/packages/codegen/README.md b/packages/codegen/README.md index b9676cf1..202f2d0f 100644 --- a/packages/codegen/README.md +++ b/packages/codegen/README.md @@ -41,6 +41,7 @@ Options: --project, -p The path to its configuration file, or to a folder with a 'tsconfig.json'. --pretty Enable color and formatting in output to make errors easier to read. --clean Remove the output directory before generating files. [default: false] + --watch, -w Watch for changes and regenerate files. [default: false] ``` ## Configuration diff --git a/packages/codegen/bin/cmk.mjs b/packages/codegen/bin/cmk.mjs index ffafb643..f138f48d 100755 --- a/packages/codegen/bin/cmk.mjs +++ b/packages/codegen/bin/cmk.mjs @@ -1,7 +1,15 @@ #!/usr/bin/env node /* eslint-disable n/no-process-exit */ -import { createLogger, parseCLIArgs, printHelpText, printVersion, runCMK, shouldBePretty } from '../dist/index.js'; +import { + createLogger, + parseCLIArgs, + printHelpText, + printVersion, + runCMK, + runCMKInWatchMode, + shouldBePretty, +} from '../dist/index.js'; const cwd = process.cwd(); let logger = createLogger(cwd, shouldBePretty(undefined)); @@ -18,9 +26,17 @@ try { process.exit(0); } - const success = await runCMK(args, logger); - if (!success) { - process.exit(1); + // Normal mode and watch mode behave differently when errors occur. + // - Normal mode: Outputs errors to the terminal and exits the process with exit code 1. + // - Watch mode: Outputs errors to the terminal but does not terminate the process. Continues watching the file. + if (args.watch) { + const watcher = await runCMKInWatchMode(args, logger); + process.on('SIGINT', () => watcher.close()); + } else { + const success = await runCMK(args, logger); + if (!success) { + process.exit(1); + } } } catch (e) { logger.logError(e); diff --git a/packages/codegen/package.json b/packages/codegen/package.json index 6f5411e1..4379e72c 100644 --- a/packages/codegen/package.json +++ b/packages/codegen/package.json @@ -38,7 +38,8 @@ "dist" ], "dependencies": { - "@css-modules-kit/core": "^0.6.0" + "@css-modules-kit/core": "^0.6.0", + "chokidar": "^4.0.3" }, "peerDependencies": { "typescript": "^5.7.3" diff --git a/packages/codegen/src/cli.test.ts b/packages/codegen/src/cli.test.ts index 5ade05c9..329ddbf0 100644 --- a/packages/codegen/src/cli.test.ts +++ b/packages/codegen/src/cli.test.ts @@ -14,6 +14,7 @@ describe('parseCLIArgs', () => { project: resolve(cwd), pretty: undefined, clean: false, + watch: false, }); }); it('should parse --help option', () => { @@ -41,6 +42,11 @@ describe('parseCLIArgs', () => { expect(parseCLIArgs(['--clean'], cwd).clean).toBe(true); expect(parseCLIArgs(['--no-clean'], cwd).clean).toBe(false); }); + it('should parse --watch option', () => { + expect(parseCLIArgs(['--watch'], cwd).watch).toBe(true); + expect(parseCLIArgs(['--no-watch'], cwd).watch).toBe(false); + expect(parseCLIArgs(['-w'], cwd).watch).toBe(true); + }); it('should throw ParseCLIArgsError for invalid options', () => { expect(() => parseCLIArgs(['--invalid-option'], cwd)).toThrow(ParseCLIArgsError); }); diff --git a/packages/codegen/src/cli.ts b/packages/codegen/src/cli.ts index 048aabf2..9130985d 100644 --- a/packages/codegen/src/cli.ts +++ b/packages/codegen/src/cli.ts @@ -12,6 +12,7 @@ Options: --project, -p The path to its configuration file, or to a folder with a 'tsconfig.json'. --pretty Enable color and formatting in output to make errors easier to read. --clean Remove the output directory before generating files. [default: false] + --watch, -w Watch for changes and regenerate files. [default: false] `; export function printHelpText(): void { @@ -30,6 +31,7 @@ export interface ParsedArgs { project: string; pretty: boolean | undefined; clean: boolean; + watch: boolean; } /** @@ -46,6 +48,7 @@ export function parseCLIArgs(args: string[], cwd: string): ParsedArgs { project: { type: 'string', short: 'p', default: '.' }, pretty: { type: 'boolean' }, clean: { type: 'boolean', default: false }, + watch: { type: 'boolean', short: 'w', default: false }, }, allowNegative: true, }); @@ -55,6 +58,7 @@ export function parseCLIArgs(args: string[], cwd: string): ParsedArgs { project: resolve(cwd, values.project), pretty: values.pretty, clean: values.clean, + watch: values.watch, }; } catch (cause) { throw new ParseCLIArgsError(cause); diff --git a/packages/codegen/src/error.ts b/packages/codegen/src/error.ts index fc5f62ef..dd21d127 100644 --- a/packages/codegen/src/error.ts +++ b/packages/codegen/src/error.ts @@ -17,3 +17,9 @@ export class ReadCSSModuleFileError extends SystemError { super('READ_CSS_MODULE_FILE_ERROR', `Failed to read CSS Module file ${fileName}.`, cause); } } + +export class WatchInitializationError extends SystemError { + constructor(cause: unknown) { + super('WATCH_INITIALIZATION_ERROR', `Failed to initialize file watcher.`, cause); + } +} diff --git a/packages/codegen/src/index.ts b/packages/codegen/src/index.ts index 7fd3f775..4ad7f0d6 100644 --- a/packages/codegen/src/index.ts +++ b/packages/codegen/src/index.ts @@ -1,4 +1,4 @@ -export { runCMK } from './runner.js'; +export { runCMK, runCMKInWatchMode } from './runner.js'; export { type Logger, createLogger } from './logger/logger.js'; export { WriteDtsFileError, ReadCSSModuleFileError } from './error.js'; export { parseCLIArgs, printHelpText, printVersion } from './cli.js'; diff --git a/packages/codegen/src/logger/formatter.test.ts b/packages/codegen/src/logger/formatter.test.ts index 5aec5ca7..ea92e898 100644 --- a/packages/codegen/src/logger/formatter.test.ts +++ b/packages/codegen/src/logger/formatter.test.ts @@ -1,7 +1,7 @@ import dedent from 'dedent'; import ts from 'typescript'; -import { describe, expect, it } from 'vitest'; -import { formatDiagnostics } from './formatter'; +import { describe, expect, test } from 'vitest'; +import { formatDiagnostics, formatTime } from './formatter'; describe('formatDiagnostics', () => { const file = ts.createSourceFile( @@ -38,7 +38,7 @@ describe('formatDiagnostics', () => { }, ]; - it('formats diagnostics with color and context when pretty is true', () => { + test('formats diagnostics with color and context when pretty is true', () => { const result = formatDiagnostics(diagnostics, host, true); expect(result).toMatchInlineSnapshot(` "test.module.css:1:2 - error: \`a_1\` is not allowed @@ -55,7 +55,7 @@ describe('formatDiagnostics', () => { `); }); - it('formats diagnostics without color and context when pretty is false', () => { + test('formats diagnostics without color and context when pretty is false', () => { const result = formatDiagnostics(diagnostics, host, false); expect(result).toMatchInlineSnapshot(` "test.module.css(1,2): error: \`a_1\` is not allowed @@ -66,3 +66,13 @@ describe('formatDiagnostics', () => { `); }); }); + +test('formatTime', () => { + const date = new Date('2023-01-01T00:00:00Z'); + expect(formatTime(date, true)).toMatchInlineSnapshot(` + "[12:00:00 AM]" + `); + expect(formatTime(date, false)).toMatchInlineSnapshot(` + "[12:00:00 AM]" + `); +}); diff --git a/packages/codegen/src/logger/formatter.ts b/packages/codegen/src/logger/formatter.ts index 6d8210f5..914c9ab4 100644 --- a/packages/codegen/src/logger/formatter.ts +++ b/packages/codegen/src/logger/formatter.ts @@ -1,5 +1,8 @@ import ts from 'typescript'; +const GRAY = '\u001b[90m'; +const RESET = '\u001b[0m'; + export function formatDiagnostics( diagnostics: ts.Diagnostic[], host: ts.FormatDiagnosticsHost, @@ -12,3 +15,12 @@ export function formatDiagnostics( } return result; } + +export function formatTime(date: Date, pretty: boolean): string { + const text = date.toLocaleTimeString('en-US', { timeZone: 'UTC' }); + if (pretty) { + return `[${GRAY}${text}${RESET}]`; + } else { + return `[${text}]`; + } +} diff --git a/packages/codegen/src/logger/logger.test.ts b/packages/codegen/src/logger/logger.test.ts index 2929b715..9655f740 100644 --- a/packages/codegen/src/logger/logger.test.ts +++ b/packages/codegen/src/logger/logger.test.ts @@ -7,6 +7,10 @@ import { createLogger } from './logger.js'; const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); +const date = new Date('2023-01-01T00:00:00Z'); +vi.useRealTimers(); +vi.setSystemTime(date); + const cwd = '/app'; describe('createLogger', () => { @@ -58,5 +62,7 @@ describe('createLogger', () => { const logger = createLogger(cwd, false); logger.logMessage('message'); expect(stdoutWriteSpy).toHaveBeenCalledWith('message\n'); + logger.logMessage('message with time', { time: true }); + expect(stdoutWriteSpy).toHaveBeenCalledWith('[12:00:00 AM] message with time\n'); }); }); diff --git a/packages/codegen/src/logger/logger.ts b/packages/codegen/src/logger/logger.ts index 789fe473..a41af787 100644 --- a/packages/codegen/src/logger/logger.ts +++ b/packages/codegen/src/logger/logger.ts @@ -2,12 +2,13 @@ import { inspect } from 'node:util'; import type { DiagnosticSourceFile } from '@css-modules-kit/core'; import { convertDiagnostic, convertSystemError, type Diagnostic, SystemError } from '@css-modules-kit/core'; import ts from 'typescript'; -import { formatDiagnostics } from './formatter.js'; +import { formatDiagnostics, formatTime } from './formatter.js'; export interface Logger { logDiagnostics(diagnostics: Diagnostic[]): void; logError(error: unknown): void; - logMessage(message: string): void; + logMessage(message: string, options?: { time?: boolean }): void; + clearScreen(): void; } export function createLogger(cwd: string, pretty: boolean): Logger { @@ -42,8 +43,12 @@ export function createLogger(cwd: string, pretty: boolean): Logger { process.stderr.write(`${inspect(error, { colors: pretty })}\n`); } }, - logMessage(message: string): void { - process.stdout.write(`${message}\n`); + logMessage(message: string, options?: { time?: boolean }): void { + const header = options?.time ? `${formatTime(new Date(), pretty)} ` : ''; + process.stdout.write(`${header}${message}\n`); + }, + clearScreen(): void { + process.stdout.write('\x1B[2J\x1B[3J\x1B[H'); }, }; } diff --git a/packages/codegen/src/project.test.ts b/packages/codegen/src/project.test.ts index a7616450..180478b3 100644 --- a/packages/codegen/src/project.test.ts +++ b/packages/codegen/src/project.test.ts @@ -1,5 +1,6 @@ -import { access, chmod } from 'node:fs/promises'; +import { access, chmod, rm, writeFile } from 'node:fs/promises'; import { TsConfigFileNotFoundError } from '@css-modules-kit/core'; +import dedent from 'dedent'; import { describe, expect, test } from 'vitest'; import { ReadCSSModuleFileError } from './error.js'; import { createProject } from './project.js'; @@ -31,6 +32,356 @@ describe('createProject', () => { ); }); +test('isWildcardMatchedFile', async () => { + const iff = await createIFF({ + 'tsconfig.json': dedent` + { + "include": ["src"], + "exclude": ["src/excluded"] + } + `, + }); + const project = createProject({ project: iff.rootDir }); + expect(project.isWildcardMatchedFile(iff.join('src/a.module.css'))).toBe(true); + expect(project.isWildcardMatchedFile(iff.join('src/excluded/b.module.css'))).toBe(false); + expect(project.isWildcardMatchedFile(iff.join('c.module.css'))).toBe(false); +}); + +describe('addFile', () => { + test('The diagnostics of the added file are reported, and .d.ts file is emitted', async () => { + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src': {}, + }); + const project = createProject({ project: iff.rootDir }); + + // Even if the file is added, diagnostics will not change until notified by `addFile`. + await writeFile(iff.join('src/a.module.css'), '.a_1 {'); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "text": "The file specified in tsconfig.json not found.", + }, + ] + `); + + project.addFile(iff.join('src/a.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/a.module.css", + "length": 1, + "start": { + "column": 1, + "line": 1, + }, + "text": "Unclosed block", + }, + ] + `); + + await project.emitDtsFiles(); + await expect(access(iff.join('generated/src/a.module.css.d.ts'))).resolves.not.toThrow(); + }); + test('changes diagnostics in files that import it directly or indirectly', async () => { + // This test case suggests the following facts: + // - The check stage cache for files that directly import the added file should be invalidated. + // - The check stage cache for files that indirectly import the added file should also be invalidated. + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/b.module.css': '@import "./a.module.css";', // directly + 'src/c.module.css': '@value a_1 from "./b.module.css";', // indirectly + }); + const project = createProject({ project: iff.rootDir }); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/b.module.css", + "length": 14, + "start": { + "column": 10, + "line": 1, + }, + "text": "Cannot import module './a.module.css'", + }, + { + "category": "error", + "fileName": "/src/c.module.css", + "length": 3, + "start": { + "column": 8, + "line": 1, + }, + "text": "Module './b.module.css' has no exported token 'a_1'.", + }, + ] + `); + await writeFile(iff.join('src/a.module.css'), '@value a_1: red;'); + project.addFile(iff.join('src/a.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(`[]`); + }); + test('changes the resolution results of import specifiers in other files', async () => { + // This test case suggests the following facts: + // - The check stage cache for files that import other files should be invalidated. + // - Only independent files that do not import any other files can keep the check stage cache. + const iff = await createIFF({ + 'tsconfig.json': dedent` + { + "compilerOptions": { + "paths": { + "@/a.module.css": ["src/a-1.module.css", "src/a-2.module.css"] + } + } + } + `, + 'src/a-2.module.css': '@value a_2: red;', + 'src/b.module.css': '@import "@/a.module.css";', + 'src/c.module.css': '@value a_2 from "./b.module.css";', + }); + const project = createProject({ project: iff.rootDir }); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(`[]`); + await writeFile(iff.join('src/a-1.module.css'), '@value a_1: red;'); + project.addFile(iff.join('src/a-1.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/c.module.css", + "length": 3, + "start": { + "column": 8, + "line": 1, + }, + "text": "Module './b.module.css' has no exported token 'a_2'.", + }, + ] + `); + }); +}); + +describe('updateFile', () => { + test('The new diagnostics of the changed file are reported, and new .d.ts file is emitted', async () => { + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/a.module.css': '', + }); + const project = createProject({ project: iff.rootDir }); + + // Even if the file is updated, diagnostics will not change until notified by `updateFile`. + await writeFile(iff.join('src/a.module.css'), '.a_1 {'); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(`[]`); + + // New syntactic diagnostics are reported + project.updateFile(iff.join('src/a.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/a.module.css", + "length": 1, + "start": { + "column": 1, + "line": 1, + }, + "text": "Unclosed block", + }, + ] + `); + + // New .d.ts file is emitted + await project.emitDtsFiles(); + expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` + "// @ts-nocheck + declare const styles = { + a_1: '' as readonly string, + }; + export default styles; + " + `); + + // New semantic diagnostics are reported + await writeFile(iff.join('src/a.module.css'), `@import './non-existent.module.css';`); + project.updateFile(iff.join('src/a.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/a.module.css", + "length": 25, + "start": { + "column": 10, + "line": 1, + }, + "text": "Cannot import module './non-existent.module.css'", + }, + ] + `); + }); + test('changes diagnostics in files that import it directly or indirectly', async () => { + // This test case suggests the following facts: + // - The resolution cache should be invalidated. + // - The check stage cache for files that directly import the changed file should be invalidated. + // - The check stage cache for files that indirectly import the changed file should also be invalidated. + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/a.module.css': '', + 'src/b.module.css': dedent` + @value a_1 from "./a.module.css"; + @import "./a.module.css"; + `, + 'src/c.module.css': '@value a_2 from "./b.module.css";', + }); + const project = createProject({ project: iff.rootDir }); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/b.module.css", + "length": 3, + "start": { + "column": 8, + "line": 1, + }, + "text": "Module './a.module.css' has no exported token 'a_1'.", + }, + { + "category": "error", + "fileName": "/src/c.module.css", + "length": 3, + "start": { + "column": 8, + "line": 1, + }, + "text": "Module './b.module.css' has no exported token 'a_2'.", + }, + ] + `); + await writeFile(iff.join('src/a.module.css'), '@value a_1: red; @value a_2: blue;'); + project.updateFile(iff.join('src/a.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(`[]`); + }); +}); + +describe('removeFile', () => { + test('The diagnostics of the removed file are not reported, and .d.ts file is not emitted', async () => { + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/a.module.css': '.a_1 {', + }); + const project = createProject({ project: iff.rootDir }); + + // Even if the file is deleted, diagnostics will not change until notified by `removeFile`. + await rm(iff.join('src/a.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/a.module.css", + "length": 1, + "start": { + "column": 1, + "line": 1, + }, + "text": "Unclosed block", + }, + ] + `); + + project.removeFile(iff.join('src/a.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "text": "The file specified in tsconfig.json not found.", + }, + ] + `); + + await project.emitDtsFiles(); + await expect(access(iff.join('generated/src/a.module.css.d.ts'))).rejects.toThrow(); + }); + test('changes diagnostics in files that import it directly or indirectly', async () => { + // This test case suggests the following facts: + // - The check stage cache for files that directly import the changed file should be invalidated. + // - The check stage cache for files that indirectly import the changed file should also be invalidated. + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/a.module.css': '@value a_1: red;', + 'src/b.module.css': '@import "./a.module.css";', // directly + 'src/c.module.css': '@value a_1 from "./b.module.css";', // indirectly + }); + const project = createProject({ project: iff.rootDir }); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(`[]`); + await rm(iff.join('src/a.module.css')); + project.removeFile(iff.join('src/a.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/b.module.css", + "length": 14, + "start": { + "column": 10, + "line": 1, + }, + "text": "Cannot import module './a.module.css'", + }, + { + "category": "error", + "fileName": "/src/c.module.css", + "length": 3, + "start": { + "column": 8, + "line": 1, + }, + "text": "Module './b.module.css' has no exported token 'a_1'.", + }, + ] + `); + }); + test('changes the resolution results of import specifiers in other files', async () => { + // This test case suggests the following facts: + // - The resolution cache should be invalidated. + // - The check stage cache for files that import the removed file should be invalidated. + const iff = await createIFF({ + 'tsconfig.json': dedent` + { + "compilerOptions": { + "paths": { + "@/a.module.css": ["src/a-1.module.css", "src/a-2.module.css"] + } + } + } + `, + 'src/a-1.module.css': '@value a_1: red;', + 'src/a-2.module.css': '@value a_2: red;', + 'src/b.module.css': '@import "@/a.module.css";', + 'src/c.module.css': '@value a_2 from "./b.module.css";', + }); + const project = createProject({ project: iff.rootDir }); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/c.module.css", + "length": 3, + "start": { + "column": 8, + "line": 1, + }, + "text": "Module './b.module.css' has no exported token 'a_2'.", + }, + ] + `); + await rm(iff.join('src/a-1.module.css')); + project.removeFile(iff.join('src/a-1.module.css')); + expect(formatDiagnostics(project.getDiagnostics(), iff.rootDir)).toMatchInlineSnapshot(`[]`); + }); +}); + describe('getDiagnostics', () => { test('returns empty array when no diagnostics', async () => { const iff = await createIFF({ diff --git a/packages/codegen/src/project.ts b/packages/codegen/src/project.ts index b6a4f9a3..736f01a2 100644 --- a/packages/codegen/src/project.ts +++ b/packages/codegen/src/project.ts @@ -19,23 +19,22 @@ interface ProjectArgs { project: string; } -interface Project { +export interface Project { config: CMKConfig; - // TODO: Implement these methods later for watch mode - // /** Whether the file matches the wildcard patterns in `include` / `exclude` options */ - // isWildcardMatchedFile(fileName: string): boolean; - // /** - // * Add a file to the project. - // * @throws {ReadCSSModuleFileError} - // */ - // addFile(fileName: string): void; - // /** - // * Update a file in the project. - // * @throws {ReadCSSModuleFileError} - // */ - // updateFile(fileName: string): void; - // /** Remove a file from the project. */ - // removeFile(fileName: string): void; + /** Whether the file matches the wildcard patterns in `include` / `exclude` options */ + isWildcardMatchedFile(fileName: string): boolean; + /** + * Add a file to the project. + * @throws {ReadCSSModuleFileError} + */ + addFile(fileName: string): void; + /** + * Update a file in the project. + * @throws {ReadCSSModuleFileError} + */ + updateFile(fileName: string): void; + /** Remove a file from the project. */ + removeFile(fileName: string): void; /** * Get all diagnostics. * Including three types of diagnostics: project diagnostics, syntactic diagnostics, and semantic diagnostics. @@ -72,16 +71,64 @@ export function createProject(args: ProjectArgs): Project { const resolver = createResolver(config.compilerOptions, moduleResolutionCache); const matchesPattern = createMatchesPattern(config); - const parseStageCache = new Map(); - const checkStageCache = new Map(); - const getCSSModule = (path: string) => parseStageCache.get(path); + const cssModuleMap = new Map(); + const semanticDiagnosticsMap = new Map(); + // Tracks whether .d.ts has been emitted after the last change + const emittedSet = new Set(); + const getCSSModule = (path: string) => cssModuleMap.get(path); const exportBuilder = createExportBuilder({ getCSSModule, matchesPattern, resolver }); for (const fileName of getFileNamesByPattern(config)) { // NOTE: Files may be deleted between executing `getFileNamesByPattern` and `tryParseCSSModule`. // Therefore, `tryParseCSSModule` may return `undefined`. const cssModule = tryParseCSSModule(fileName); - if (cssModule) parseStageCache.set(fileName, cssModule); + if (cssModule) cssModuleMap.set(fileName, cssModule); + } + + /** + * @throws {ReadCSSModuleFileError} + */ + function addFile(fileName: string) { + if (cssModuleMap.has(fileName)) return; + + const cssModule = tryParseCSSModule(fileName); + if (!cssModule) return; + cssModuleMap.set(fileName, cssModule); + + // TODO: Delete only the minimum amount of check stage cache + moduleResolutionCache.clear(); + exportBuilder.clearCache(); + semanticDiagnosticsMap.clear(); + } + + /** + * @throws {ReadCSSModuleFileError} + */ + function updateFile(fileName: string) { + if (!cssModuleMap.has(fileName)) return; + + const cssModule = tryParseCSSModule(fileName); + if (!cssModule) return; + cssModuleMap.set(fileName, cssModule); + + // TODO: Delete only the minimum amount of check stage cache + exportBuilder.clearCache(); + semanticDiagnosticsMap.clear(); + + emittedSet.delete(fileName); + } + + function removeFile(fileName: string) { + if (!cssModuleMap.has(fileName)) return; + + cssModuleMap.delete(fileName); + + // TODO: Delete only the minimum amount of check stage cache + moduleResolutionCache.clear(); + exportBuilder.clearCache(); + semanticDiagnosticsMap.clear(); + + emittedSet.delete(fileName); } /** @@ -119,7 +166,7 @@ export function createProject(args: ProjectArgs): Project { function getProjectDiagnostics() { const diagnostics: Diagnostic[] = []; diagnostics.push(...config.diagnostics); - if (parseStageCache.size === 0) { + if (cssModuleMap.size === 0) { diagnostics.push({ category: 'error', text: `The file specified in tsconfig.json not found.`, @@ -129,16 +176,16 @@ export function createProject(args: ProjectArgs): Project { } function getSyntacticDiagnostics() { - return Array.from(parseStageCache.values()).flatMap(({ diagnostics }) => diagnostics); + return Array.from(cssModuleMap.values()).flatMap(({ diagnostics }) => diagnostics); } function getSemanticDiagnostics() { const allDiagnostics: Diagnostic[] = []; - for (const cssModule of parseStageCache.values()) { - let diagnostics = checkStageCache.get(cssModule.fileName); + for (const cssModule of cssModuleMap.values()) { + let diagnostics = semanticDiagnosticsMap.get(cssModule.fileName); if (!diagnostics) { diagnostics = checkCSSModule(cssModule, config, exportBuilder, matchesPattern, resolver, getCSSModule); - checkStageCache.set(cssModule.fileName, diagnostics); + semanticDiagnosticsMap.set(cssModule.fileName, diagnostics); } allDiagnostics.push(...diagnostics); } @@ -150,13 +197,16 @@ export function createProject(args: ProjectArgs): Project { */ async function emitDtsFiles(): Promise { const promises: Promise[] = []; - for (const cssModule of parseStageCache.values()) { + for (const cssModule of cssModuleMap.values()) { + if (emittedSet.has(cssModule.fileName)) continue; const dts = generateDts(cssModule, { resolver, matchesPattern }, { ...config, forTsPlugin: false }); promises.push( writeDtsFile(dts.text, cssModule.fileName, { outDir: config.dtsOutDir, basePath: config.basePath, arbitraryExtensions: config.arbitraryExtensions, + }).then(() => { + emittedSet.add(cssModule.fileName); }), ); } @@ -165,6 +215,10 @@ export function createProject(args: ProjectArgs): Project { return { config, + isWildcardMatchedFile: (fileName) => matchesPattern(fileName), + addFile, + updateFile, + removeFile, getDiagnostics, emitDtsFiles, }; diff --git a/packages/codegen/src/runner.test.ts b/packages/codegen/src/runner.test.ts index 0e5a0009..0931f0e8 100644 --- a/packages/codegen/src/runner.test.ts +++ b/packages/codegen/src/runner.test.ts @@ -1,7 +1,8 @@ -import { access, writeFile } from 'node:fs/promises'; +import { access, rm, writeFile } from 'node:fs/promises'; import dedent from 'dedent'; -import { describe, expect, test } from 'vitest'; -import { runCMK } from './runner.js'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { Watcher } from './runner.js'; +import { runCMK, runCMKInWatchMode } from './runner.js'; import { formatDiagnostics } from './test/diagnostic.js'; import { fakeParsedArgs } from './test/faker.js'; import { createIFF } from './test/fixture.js'; @@ -94,3 +95,208 @@ describe('runCMK', () => { await expect(access(iff.join('generated/src/old.module.css.d.ts'))).rejects.toThrow(); }); }); + +describe('runCMKInWatchMode', () => { + let watcher: Watcher | null = null; + afterEach(async () => { + if (watcher) { + await watcher.close(); + // eslint-disable-next-line require-atomic-updates + watcher = null; + } + }); + test('emits .d.ts files', async () => { + const iff = await createIFF({ + 'tsconfig.json': dedent` + { + "cmkOptions": { "dtsOutDir": "generated" } + } + `, + 'src/a.module.css': '.a_1 { color: red; }', + 'src/b.module.css': '.b_1 { color: blue; }', + }); + watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), createLoggerSpy()); + expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` + "// @ts-nocheck + declare const styles = { + a_1: '' as readonly string, + }; + export default styles; + " + `); + expect(await iff.readFile('generated/src/b.module.css.d.ts')).toMatchInlineSnapshot(` + "// @ts-nocheck + declare const styles = { + b_1: '' as readonly string, + }; + export default styles; + " + `); + }); + test('reports diagnostics if errors are found', async () => { + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/a.module.css': '.a_1 {', + 'src/b.module.css': '.b_1 { color: red; }', + }); + const loggerSpy = createLoggerSpy(); + watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy); + expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1); + expect(formatDiagnostics(loggerSpy.logDiagnostics.mock.calls[0]![0], iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/a.module.css", + "length": 1, + "start": { + "column": 1, + "line": 1, + }, + "text": "Unclosed block", + }, + ] + `); + }); + test('emits .d.ts files even if there are diagnostics', async () => { + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/a.module.css': '.a_1 {', + 'src/b.module.css': '.b_1 { color: red; }', + }); + const loggerSpy = createLoggerSpy(); + watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy); + expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1); + await expect(access(iff.join('generated/src/a.module.css.d.ts'))).resolves.not.toThrow(); + await expect(access(iff.join('generated/src/b.module.css.d.ts'))).resolves.not.toThrow(); + }); + test('removes output directory before emitting files when `clean` is true', async () => { + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/a.module.css': '.a_1 { color: red; }', + 'generated/src/old.module.css.d.ts': '', + }); + watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir, clean: true }), createLoggerSpy()); + await expect(access(iff.join('generated/src/a.module.css.d.ts'))).resolves.not.toThrow(); + await expect(access(iff.join('generated/src/old.module.css.d.ts'))).rejects.toThrow(); + }); + test('reports system error occurs during watching', async () => { + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/a.module.css': '.a_1 { color: red; }', + }); + const loggerSpy = createLoggerSpy(); + watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy); + + await vi.waitFor(() => { + expect(loggerSpy.logMessage).toHaveBeenCalledWith('Found 0 errors. Watching for file changes.', { time: true }); + }); + + // Error when adding a file + vi.spyOn(watcher.project, 'addFile').mockImplementationOnce(() => { + throw new Error('test error'); + }); + await writeFile(iff.join('src/b.module.css'), '.b_1 { color: red; }'); + await vi.waitFor(() => { + expect(loggerSpy.logError).toHaveBeenCalledTimes(1); + }); + + // Error when changing a file + vi.spyOn(watcher.project, 'updateFile').mockImplementationOnce(() => { + throw new Error('test error'); + }); + await writeFile(iff.join('src/a.module.css'), '.a_1 { color: blue; }'); + await vi.waitFor(() => { + expect(loggerSpy.logError).toHaveBeenCalledTimes(2); + }); + + // Error when emitting files + vi.spyOn(watcher.project, 'emitDtsFiles').mockImplementationOnce(() => { + throw new Error('test error'); + }); + await writeFile(iff.join('src/a.module.css'), '.a_1 { color: yellow; }'); + await vi.waitFor(() => { + expect(loggerSpy.logError).toHaveBeenCalledTimes(3); + }); + }); + test('reports diagnostics and emits files on changes', async () => { + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src/a.module.css': '.a_1 { color: red; }', + }); + const loggerSpy = createLoggerSpy(); + watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy); + + // Add a file + await writeFile(iff.join('src/b.module.css'), '.b_1 {'); + await vi.waitFor(async () => { + expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1); + expect(formatDiagnostics(loggerSpy.logDiagnostics.mock.calls[0]![0], iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/b.module.css", + "length": 1, + "start": { + "column": 1, + "line": 1, + }, + "text": "Unclosed block", + }, + ] + `); + expect(await iff.readFile('generated/src/b.module.css.d.ts')).contain('b_1'); + }); + + // Change a file + loggerSpy.logDiagnostics.mockClear(); + await writeFile(iff.join('src/b.module.css'), '.b_2 {'); + await vi.waitFor(async () => { + expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1); + expect(formatDiagnostics(loggerSpy.logDiagnostics.mock.calls[0]![0], iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/src/b.module.css", + "length": 1, + "start": { + "column": 1, + "line": 1, + }, + "text": "Unclosed block", + }, + ] + `); + expect(await iff.readFile('generated/src/b.module.css.d.ts')).contain('b_2'); + }); + + // Remove a file + loggerSpy.logDiagnostics.mockClear(); + await rm(iff.join('src/b.module.css')); + await vi.waitFor(() => { + expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(0); + }); + }); + test('batches rapid file changes', async () => { + const iff = await createIFF({ + 'tsconfig.json': '{}', + 'src': {}, + }); + const loggerSpy = createLoggerSpy(); + watcher = await runCMKInWatchMode(fakeParsedArgs({ project: iff.rootDir }), loggerSpy); + loggerSpy.logDiagnostics.mockClear(); + + // Make rapid changes + const promises = [ + writeFile(iff.join('src/a.module.css'), '.a_1 {'), + writeFile(iff.join('src/b.module.css'), '.b_1 {'), + writeFile(iff.join('src/c.module.css'), '.c_1 {'), + ]; + expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(0); + await Promise.all(promises); + await vi.waitFor(() => { + expect(loggerSpy.logDiagnostics).toHaveBeenCalledTimes(1); + // Diagnostics for three files are reported at once. + expect(formatDiagnostics(loggerSpy.logDiagnostics.mock.calls[0]![0], iff.rootDir)).length(3); + }); + }); +}); diff --git a/packages/codegen/src/runner.ts b/packages/codegen/src/runner.ts index 9bdc29dd..74125203 100644 --- a/packages/codegen/src/runner.ts +++ b/packages/codegen/src/runner.ts @@ -1,12 +1,21 @@ +import type { Stats } from 'node:fs'; import { rm } from 'node:fs/promises'; +import chokidar, { type FSWatcher } from 'chokidar'; +import { WatchInitializationError } from './error.js'; import type { Logger } from './logger/logger.js'; -import { createProject } from './project.js'; +import { createProject, type Project } from './project.js'; interface RunnerArgs { project: string; clean: boolean; } +export interface Watcher { + /** Exported for testing purposes */ + project: Project; + close(): Promise; +} + /** * Run css-modules-kit .d.ts generation. * @param project The absolute path to the project directory or the path to `tsconfig.json`. @@ -27,3 +36,140 @@ export async function runCMK(args: RunnerArgs, logger: Logger): Promise } return true; } + +/** + * Run css-modules-kit .d.ts generation in watch mode. + * + * The promise resolves when the initial diagnostics report, emit, and watcher initialization are complete. + * If an error occurs before the promise resolves, the promise will be rejected. If an error occurs + * during file watching, the promise will not be rejected. Errors are reported through the logger. + * + * NOTE: For implementation simplicity, config file changes are not watched. + * @param project The absolute path to the project directory or the path to `tsconfig.json`. + * @throws {TsConfigFileNotFoundError} + * @throws {ReadCSSModuleFileError} + * @throws {WriteDtsFileError} + * @throws {WatchInitializationError} + */ +export async function runCMKInWatchMode(args: RunnerArgs, logger: Logger): Promise { + let initialized = false; + const fsWatchers: FSWatcher[] = []; + const project = createProject(args); + let emitAndReportDiagnosticsTimer: NodeJS.Timeout | undefined = undefined; + + if (args.clean) { + await rm(project.config.dtsOutDir, { recursive: true, force: true }); + } + await emitAndReportDiagnostics(); + + // Watch project files and report diagnostics on changes + const readyPromises: Promise[] = []; + for (const wildcardDirectory of project.config.wildcardDirectories) { + const { promise, resolve, reject } = promiseWithResolvers(); + readyPromises.push(promise); + fsWatchers.push( + chokidar + .watch(wildcardDirectory.fileName, { + ignored: (fileName: string, stats?: Stats) => { + // The ignored function is called twice for the same path. The first time with stats undefined, + // and the second time with stats provided. + // In the first call, we can't determine if the path is a directory or file. + // So we include it in the watch target considering it might be a directory. + if (!stats) return false; + + // In the second call, we include directories or files that match wildcards in the watch target. + // However, `dtsOutDir` is excluded from the watch target. + if (stats.isDirectory()) { + return fileName === project.config.dtsOutDir; + } else { + return !project.isWildcardMatchedFile(fileName); + } + }, + ignoreInitial: true, + ...(wildcardDirectory.recursive ? {} : { depth: 0 }), + }) + .on('add', (fileName) => { + try { + project.addFile(fileName); + } catch (e) { + logger.logError(e); + return; + } + scheduleEmitAndReportDiagnostics(); + }) + .on('change', (fileName) => { + try { + project.updateFile(fileName); + } catch (e) { + logger.logError(e); + return; + } + scheduleEmitAndReportDiagnostics(); + }) + .on('unlink', (fileName: string) => { + project.removeFile(fileName); + scheduleEmitAndReportDiagnostics(); + }) + // eslint-disable-next-line no-loop-func + .on('error', (e) => { + if (!initialized) { + reject(new WatchInitializationError(e)); + } else { + logger.logError(e); + } + }) + .on('ready', () => resolve()), + ); + } + await Promise.all(readyPromises); + initialized = true; + + function scheduleEmitAndReportDiagnostics() { + // Switching between git branches results in numerous file changes occurring rapidly. + // Reporting diagnostics for each file change would overwhelm users. + // Therefore, we batch the processing. + + if (emitAndReportDiagnosticsTimer !== undefined) clearTimeout(emitAndReportDiagnosticsTimer); + + emitAndReportDiagnosticsTimer = setTimeout(() => { + emitAndReportDiagnosticsTimer = undefined; + emitAndReportDiagnostics().catch(logger.logError.bind(logger)); + }, 250); + } + + /** + * @throws {WriteDtsFileError} + */ + async function emitAndReportDiagnostics() { + logger.clearScreen(); + await project.emitDtsFiles(); + const diagnostics = project.getDiagnostics(); + if (diagnostics.length > 0) { + logger.logDiagnostics(diagnostics); + } + logger.logMessage( + `Found ${diagnostics.length} error${diagnostics.length === 1 ? '' : 's'}. Watching for file changes.`, + { time: true }, + ); + } + + async function close() { + await Promise.all(fsWatchers.map(async (watcher) => watcher.close())); + } + + return { project, close }; +} + +function promiseWithResolvers() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { + promise, + resolve: resolve as unknown as (value: T) => void, + reject: reject as unknown as (reason?: unknown) => void, + }; +} diff --git a/packages/codegen/src/test/faker.ts b/packages/codegen/src/test/faker.ts index 908cacf7..9dec93be 100644 --- a/packages/codegen/src/test/faker.ts +++ b/packages/codegen/src/test/faker.ts @@ -7,6 +7,7 @@ export function fakeParsedArgs(args?: Partial): ParsedArgs { project: '.', pretty: undefined, clean: false, + watch: false, ...args, }; } diff --git a/packages/codegen/src/test/logger.ts b/packages/codegen/src/test/logger.ts index 0158ec21..53425c7b 100644 --- a/packages/codegen/src/test/logger.ts +++ b/packages/codegen/src/test/logger.ts @@ -6,5 +6,6 @@ export function createLoggerSpy() { logDiagnostics: vi.fn(), logError: vi.fn(), logMessage: vi.fn(), + clearScreen: vi.fn(), } satisfies Logger; }