diff --git a/.changeset/config.json b/.changeset/config.json index 97b7614b..19765b0a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -16,7 +16,7 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [], + "ignore": ["@css-modules-kit/content-mapper"], "privatePackages": { "version": true, "tag": true diff --git a/.gitignore b/.gitignore index e8c9c77b..6df7107e 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,4 @@ Cargo.lock ### User /crates/zed/extension.wasm +/.tmp/ diff --git a/.vscode/launch.json b/.vscode/launch.json index acb774bf..f4d7e221 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -190,6 +190,50 @@ "TSS_DEBUG": "5859" } }, + { + // Launches the TypeScript Native Preview extension and the tsgo binary built from + // the pinned microsoft/TypeScript commit (see scripts/setup-tsgo-extension.sh). + // The marketplace build predates the content mapper support, so the extension + // must be run from source. + "name": "tsgo (7-content-mapper)", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/.tmp/typescript/packages/vscode-typescript", + "--profile-temp", + "--skip-welcome", + // The extension enables content mappers only in a trusted workspace. Disabling + // workspace trust makes VS Code treat every workspace as trusted, which also + // skips the trust dialog on launch. + "--disable-workspace-trust", + "--folder-uri=${workspaceFolder}/examples/7-content-mapper", + "${workspaceFolder}/examples/7-content-mapper/src/index.ts" + ], + "outFiles": ["${workspaceFolder}/.tmp/typescript/packages/vscode-typescript/dist/**/*.js"], + "preLaunchTask": "prepare content-mapper examples", + "presentation": { + "group": "tsgo" + } + }, + { + // See the comments of "tsgo (7-content-mapper)". + "name": "tsgo (8-content-mapper-named-exports)", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/.tmp/typescript/packages/vscode-typescript", + "--profile-temp", + "--skip-welcome", + "--disable-workspace-trust", + "--folder-uri=${workspaceFolder}/examples/8-content-mapper-named-exports", + "${workspaceFolder}/examples/8-content-mapper-named-exports/src/index.ts" + ], + "outFiles": ["${workspaceFolder}/.tmp/typescript/packages/vscode-typescript/dist/**/*.js"], + "preLaunchTask": "prepare content-mapper examples", + "presentation": { + "group": "tsgo" + } + }, { "name": "vscode-test", "type": "extensionHost", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3c5dbaf3..3a6b8ce0 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -36,6 +36,29 @@ "cwd": "${workspaceFolder}/packages/vscode" }, "group": "build" + }, + { + "label": "vp: build - packages/content-mapper", + "type": "shell", + "command": "vp run build", + "options": { + "cwd": "${workspaceFolder}/packages/content-mapper" + }, + "group": "build" + }, + { + "label": "setup tsgo extension", + "type": "shell", + "command": "./scripts/setup-tsgo-extension.sh", + "options": { + "cwd": "${workspaceFolder}" + }, + "group": "build" + }, + { + "label": "prepare content-mapper examples", + "dependsOn": ["vp: build - packages/content-mapper", "setup tsgo extension"], + "group": "build" } ] } diff --git a/examples/7-content-mapper/.vscode/settings.json b/examples/7-content-mapper/.vscode/settings.json new file mode 100644 index 00000000..dca17953 --- /dev/null +++ b/examples/7-content-mapper/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "js/ts.experimental.useTsgo": true, + // Enables the content mapper inspector ("TypeScript: Show Content Mapper Virtual Documents"). + "js/ts.showDebugInfo": true +} diff --git a/examples/7-content-mapper/src/a.module.css b/examples/7-content-mapper/src/a.module.css new file mode 100644 index 00000000..bd963396 --- /dev/null +++ b/examples/7-content-mapper/src/a.module.css @@ -0,0 +1,14 @@ +@import './b.module.css'; +@value primary: #2864f0; + +.a_1 { + color: primary; + composes: b_1 from './b.module.css'; + animation-name: fade-in; +} + +@keyframes fade-in { + from { + opacity: 0; + } +} diff --git a/examples/7-content-mapper/src/b.module.css b/examples/7-content-mapper/src/b.module.css new file mode 100644 index 00000000..9ebb64b8 --- /dev/null +++ b/examples/7-content-mapper/src/b.module.css @@ -0,0 +1,3 @@ +.b_1 { + color: blue; +} diff --git a/examples/7-content-mapper/src/global.css b/examples/7-content-mapper/src/global.css new file mode 100644 index 00000000..cdf90120 --- /dev/null +++ b/examples/7-content-mapper/src/global.css @@ -0,0 +1,3 @@ +* { + margin: 0; +} diff --git a/examples/7-content-mapper/src/index.ts b/examples/7-content-mapper/src/index.ts new file mode 100644 index 00000000..31e3d78c --- /dev/null +++ b/examples/7-content-mapper/src/index.ts @@ -0,0 +1,8 @@ +import './global.css'; +import styles from './a.module.css'; + +styles.a_1; +styles.b_1; +styles.primary; +styles['fade-in']; +styles.unknown; // Expected TS2339 error diff --git a/examples/7-content-mapper/tsconfig.json b/examples/7-content-mapper/tsconfig.json new file mode 100644 index 00000000..7ca8dfe8 --- /dev/null +++ b/examples/7-content-mapper/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "es2015", + "lib": ["ES2015"], + "module": "Preserve", + "moduleResolution": "bundler", + + "noEmit": true, + "incremental": false, + "types": [] // Simplify tsserver.log + }, + "contentMappers": [ + { + "package": "@css-modules-kit/content-mapper", + "extensions": [".css"] + } + ] +} diff --git a/examples/8-content-mapper-named-exports/.vscode/settings.json b/examples/8-content-mapper-named-exports/.vscode/settings.json new file mode 100644 index 00000000..dca17953 --- /dev/null +++ b/examples/8-content-mapper-named-exports/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "js/ts.experimental.useTsgo": true, + // Enables the content mapper inspector ("TypeScript: Show Content Mapper Virtual Documents"). + "js/ts.showDebugInfo": true +} diff --git a/examples/8-content-mapper-named-exports/src/a.module.css b/examples/8-content-mapper-named-exports/src/a.module.css new file mode 100644 index 00000000..bd963396 --- /dev/null +++ b/examples/8-content-mapper-named-exports/src/a.module.css @@ -0,0 +1,14 @@ +@import './b.module.css'; +@value primary: #2864f0; + +.a_1 { + color: primary; + composes: b_1 from './b.module.css'; + animation-name: fade-in; +} + +@keyframes fade-in { + from { + opacity: 0; + } +} diff --git a/examples/8-content-mapper-named-exports/src/b.module.css b/examples/8-content-mapper-named-exports/src/b.module.css new file mode 100644 index 00000000..9ebb64b8 --- /dev/null +++ b/examples/8-content-mapper-named-exports/src/b.module.css @@ -0,0 +1,3 @@ +.b_1 { + color: blue; +} diff --git a/examples/8-content-mapper-named-exports/src/global.css b/examples/8-content-mapper-named-exports/src/global.css new file mode 100644 index 00000000..cdf90120 --- /dev/null +++ b/examples/8-content-mapper-named-exports/src/global.css @@ -0,0 +1,3 @@ +* { + margin: 0; +} diff --git a/examples/8-content-mapper-named-exports/src/index.ts b/examples/8-content-mapper-named-exports/src/index.ts new file mode 100644 index 00000000..ac681913 --- /dev/null +++ b/examples/8-content-mapper-named-exports/src/index.ts @@ -0,0 +1,8 @@ +import './global.css'; +import * as styles from './a.module.css'; + +styles.a_1; +styles.b_1; +styles.primary; +styles['fade-in']; +styles.unknown; // Expected TS2339 error diff --git a/examples/8-content-mapper-named-exports/tsconfig.json b/examples/8-content-mapper-named-exports/tsconfig.json new file mode 100644 index 00000000..fd645729 --- /dev/null +++ b/examples/8-content-mapper-named-exports/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "es2015", + "lib": ["ES2015"], + "module": "Preserve", + "moduleResolution": "bundler", + + "noEmit": true, + "incremental": false, + "types": [] // Simplify tsserver.log + }, + "contentMappers": [ + { + "package": "@css-modules-kit/content-mapper", + "extensions": [".css"], + "options": { + "namedExports": true + } + } + ] +} diff --git a/packages/content-mapper/e2e-test/non-module-css-file.test.ts b/packages/content-mapper/e2e-test/non-module-css-file.test.ts new file mode 100644 index 00000000..284c4b65 --- /dev/null +++ b/packages/content-mapper/e2e-test/non-module-css-file.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'vite-plus/test'; +import { buildTSConfigJSON } from '../src/test/builder.js'; +import { fixtureDir, setupFixture } from './test-util/fixture.js'; +import { launchLSPClient } from './test-util/lsp-client.js'; + +const client = launchLSPClient(fixtureDir); + +// ref: https://github.com/mizdra/css-modules-kit/issues/170 +test('reports no diagnostics for a non-module .css file', async () => { + const { iff } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON(), + 'global.css': `* { margin: 0; }`, + }); + await client.openFiles([iff.paths['global.css']]); + + const diagnostics = await client.sendDocumentDiagnostic(iff.paths['global.css']); + expect(diagnostics).toStrictEqual([]); +}); diff --git a/packages/content-mapper/e2e-test/test-util/fixture.ts b/packages/content-mapper/e2e-test/test-util/fixture.ts new file mode 100644 index 00000000..72f7c56c --- /dev/null +++ b/packages/content-mapper/e2e-test/test-util/fixture.ts @@ -0,0 +1,152 @@ +import { randomUUID } from 'node:crypto'; +import { mkdirSync, realpathSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from '@css-modules-kit/core'; +import { type CreateIFFResult, defineIFFCreator } from '@mizdra/inline-fixture-files'; +import { type FileLocation, type FileSpan, type FileSpanWithContext, formatPath, type Position } from './lsp-client.js'; + +// tmpdir() may be a symlink (e.g. /var -> /private/var on macOS). The paths sent by the LSP +// client must match the ones the server reports back, so the real path is resolved up front. +export const fixtureDir = join( + realpathSync(tmpdir()), + '@css-modules-kit/content-mapper', + process.env['VITEST_POOL_ID']!, +); +mkdirSync(fixtureDir, { recursive: true }); + +const createIFF = defineIFFCreator({ + generateRootDir: () => join(fixtureDir, randomUUID()), + unixStylePath: true, +}); + +const contentMapperDir = resolve(import.meta.dirname, '../..'); + +function findAllMatches(content: string, search: string): number[] { + if (search.length === 0) throw new Error('Empty search string is not allowed.'); + const matches: number[] = []; + let pos = content.indexOf(search); + while (pos !== -1) { + matches.push(pos); + pos = content.indexOf(search, pos + 1); + } + return matches; +} + +function offsetToPosition(content: string, offset: number): Position { + const before = content.slice(0, offset); + const newlineCount = (before.match(/\n/gu) ?? []).length; + const lastNewline = before.lastIndexOf('\n'); + return { + line: newlineCount, + character: before.length - (lastNewline + 1), + }; +} + +type Files = Record; + +export interface GetFileSpanOptions { + /** 0-based index of the match when `search` matches multiple times. */ + index?: number; + /** + * A substring enclosing the `search` match. When given, the returned span also carries + * `contextRange` pointing to the occurrence of `context` that encloses the match. + */ + context?: string; +} + +export interface SetupFixtureResult { + iff: CreateIFFResult; + /** + * Get the absolute path of `file` and the (0-based) line/character of the first character of `search` in it. + * The result can be passed directly to the LSP client. + * + * - If `search` matches exactly once, returns that position. + * - If `search` matches multiple times, an `index` (0-based) must be passed. + * - Throws if `search` does not match, or `index` is out of range. + */ + getFileLocation: (file: string, search: string, index?: number) => FileLocation; + /** + * Get the absolute (path-normalized) path of `file` and the (0-based) start/end range of `search` in it. + * The result can be compared directly with spans returned by the LSP client. + * + * - `range.start` is identical to the position returned by `getFileLocation`. + * - `range.end` points to the position immediately AFTER the last character of `search` + * (exclusive end, matching the LSP convention). + * - Same matching/error semantics as `getFileLocation`. + */ + getFileSpan: (file: string, search: string, options?: GetFileSpanOptions) => FileSpanWithContext; +} + +export async function setupFixture(files: T): Promise> { + // oxlint-disable-next-line typescript/no-explicit-any + const iff = (await createIFF(files)) as any; + + // tsgo resolves the mapper package from the tsconfig directory with node module resolution. + mkdirSync(join(iff.rootDir, 'node_modules/@css-modules-kit'), { recursive: true }); + symlinkSync(contentMapperDir, join(iff.rootDir, 'node_modules/@css-modules-kit/content-mapper'), 'junction'); + + function getFileContent(file: string): string { + const content = files[file]; + if (content === undefined) { + throw new Error(`File "${file}" was not registered in the fixture.`); + } + return content; + } + + function findOffset(file: string, search: string, index?: number): number { + const content = getFileContent(file); + const matches = findAllMatches(content, search); + if (matches.length === 0) { + throw new Error(`Substring ${JSON.stringify(search)} not found in "${file}".`); + } + if (matches.length > 1 && index === undefined) { + throw new Error( + `Substring ${JSON.stringify(search)} matches ${matches.length} times in "${file}". ` + + `Pass a 0-based index as the third argument to disambiguate.`, + ); + } + const target = matches[index ?? 0]; + if (target === undefined) { + throw new Error( + `Index ${index} is out of bounds (only ${matches.length} matches of ${JSON.stringify(search)} in "${file}").`, + ); + } + return target; + } + + function findEnclosingContextOffset(file: string, context: string, start: number, end: number): number { + const content = getFileContent(file); + const enclosing = findAllMatches(content, context).find( + (offset) => offset <= start && end <= offset + context.length, + ); + if (enclosing === undefined) { + throw new Error(`No occurrence of context ${JSON.stringify(context)} encloses the match in "${file}".`); + } + return enclosing; + } + + function getFileLocation(file: string, search: string, index?: number): FileLocation { + return { file: iff.paths[file], position: offsetToPosition(getFileContent(file), findOffset(file, search, index)) }; + } + + function getFileSpan(file: string, search: string, options?: GetFileSpanOptions): FileSpanWithContext { + const content = getFileContent(file); + const start = findOffset(file, search, options?.index); + const end = start + search.length; + const span: FileSpan = { + file: formatPath(iff.paths[file]), + range: { start: offsetToPosition(content, start), end: offsetToPosition(content, end) }, + }; + if (options?.context === undefined) return span; + const contextStart = findEnclosingContextOffset(file, options.context, start, end); + return { + ...span, + contextRange: { + start: offsetToPosition(content, contextStart), + end: offsetToPosition(content, contextStart + options.context.length), + }, + }; + } + + return { iff, getFileLocation, getFileSpan }; +} diff --git a/packages/content-mapper/e2e-test/test-util/lsp-client.ts b/packages/content-mapper/e2e-test/test-util/lsp-client.ts new file mode 100644 index 00000000..6bf28634 --- /dev/null +++ b/packages/content-mapper/e2e-test/test-util/lsp-client.ts @@ -0,0 +1,583 @@ +import type { ChildProcessByStdio } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import type { Readable, Writable } from 'node:stream'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// Keep the resolution in sync with `scripts/vitest-e2e-test-setup.ts`. +/** The native tsc binary shipped with the `typescript` npm package. Overridable via the `TSGO_BIN` environment variable. */ +const tsgoBinPath = process.env['TSGO_BIN'] ?? resolveNativeTscBinPath(import.meta.url); + +/** + * Resolves the platform-specific native tsc binary the same way as `typescript/lib/getExePath.js`. + * The `typescript-nightly` alias points at the `typescript` nightly, whose platform package is a + * dependency of the nightly, not of this package, so it must be resolved relative to the nightly + * package to work with pnpm's non-flat `node_modules`. + */ +function resolveNativeTscBinPath(base: string): string { + const typescriptPkgPath = createRequire(base).resolve('typescript-nightly/package.json'); + const platformPkgName = `@typescript/typescript-${process.platform}-${process.arch}`; + const platformPkgPath = createRequire(typescriptPkgPath).resolve(`${platformPkgName}/package.json`); + const binName = process.platform === 'win32' ? 'tsc.exe' : 'tsc'; + return fileURLToPath(new URL(`./lib/${binName}`, pathToFileURL(platformPkgPath))); +} + +export interface Position { + line: number; + character: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export type FileLocation = { file: string; position: Position }; + +export type FileSpan = { file: string; range: Range }; + +export type FileSpanWithContext = FileSpan & { contextRange?: Range }; + +/** `newText` is kept only when it differs from the requested new name (e.g. `a_1 as renamed`). */ +export type RenameLocation = FileSpan & { newText?: string }; + +export type FileRename = { oldFile: string; newFile: string }; + +export type RenameResult = { locs: RenameLocation[]; fileRenames: FileRename[] }; + +export type TextEdit = { range: Range; newText: string }; + +export type FileTextEdits = { file: string; textEdits: TextEdit[] }; + +export type Diagnostic = { + range: Range; + severity?: number; + code?: number | string; + source?: string; + message: string; +}; + +export const DiagnosticSeverity = { Error: 1, Warning: 2, Information: 3, Hint: 4 } as const; + +export type CompletionEntry = { + name: string; + sortText: string; + source?: string; + insertText?: string; +}; + +export type CompletionDetails = { + additionalTextEdits?: TextEdit[]; +}; + +export type CodeAction = { + title: string; + kind?: string; + edits: FileTextEdits[]; + /** The files that the code action creates. */ + createdFiles: string[]; +}; + +interface LSPLocation { + uri: string; + range: Range; +} + +interface LSPLocationLink { + targetUri: string; + targetRange: Range; + targetSelectionRange: Range; +} + +interface LSPTextDocumentEdit { + textDocument: { uri: string; version: number | null }; + edits: TextEdit[]; +} + +interface LSPRenameFile { + kind: 'rename'; + oldUri: string; + newUri: string; +} + +interface LSPCreateFile { + kind: 'create'; + uri: string; +} + +interface LSPWorkspaceEdit { + changes?: Record; + documentChanges?: (LSPTextDocumentEdit | LSPRenameFile | LSPCreateFile)[]; +} + +interface LSPCompletionItem { + label: string; + labelDetails?: { description?: string }; + sortText?: string; + insertText?: string; + additionalTextEdits?: TextEdit[]; +} + +interface LSPCodeAction { + title: string; + kind?: string; + edit?: LSPWorkspaceEdit; +} + +interface JSONRPCMessage { + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string }; +} + +/** + * A thin client for the tsgo LSP server. + * + * Methods returning language feature results (definitions, references, rename, file rename edits, + * diagnostics, completion, code actions) return normalized values: only the fields relevant to tests are kept, URIs are + * converted to paths normalized with `formatPath`, and the results are sorted so that they can be + * compared with `toStrictEqual`. Spans and text edits are sorted by file path, then by position. + * Duplicates are not removed. + */ +export interface LSPClient { + /** Opens files. A file is opened with its on-disk content unless `content` is given. */ + openFiles(files: readonly (string | { file: string; content: string })[]): Promise; + closeFiles(files: readonly string[]): Promise; + /** Replaces the whole content of an opened file. */ + changeFile(file: string, content: string): Promise; + sendDefinition(args: FileLocation): Promise; + /** Like `sendDefinition`, but each definition also carries the range of its enclosing declaration as `contextRange`. */ + sendDefinitionWithContext(args: FileLocation): Promise; + sendReferences(args: FileLocation): Promise; + sendRename(args: FileLocation, newName?: string): Promise; + /** + * Returns the diagnostics except the ones with the hint severity (e.g. unused locals), which + * tsserver reports as suggestion diagnostics instead of semantic or syntactic diagnostics. + */ + sendDocumentDiagnostic(file: string): Promise; + sendWillRenameFiles(args: { oldFilePath: string; newFilePath: string }): Promise; + /** + * Sets the user preferences by the names that tsserver uses (e.g. `quotePreference`). + * The preferences stay in effect until the next call. + */ + sendConfigure(args: { preferences: Record }): Promise; + sendCompletion(args: FileLocation): Promise; + /** Returns the details of the completion entry with `name` and `source` among the entries at the location. */ + sendCompletionDetails(args: FileLocation & { name: string; source?: string }): Promise; + /** Returns the code actions of the kinds `only` for `range`, as if `range` had the diagnostics with `errorCodes`. */ + sendCodeActions(args: FileSpan & { errorCodes?: number[]; only: string[] }): Promise; +} + +export function formatPath(path: string) { + // On Windows, the server returns paths with '/' instead of '\\' and with a lowercase drive letter. + return path.replaceAll('\\', '/').replace(/^[A-Z]:/u, (drive) => drive.toLowerCase()); +} + +function toFileUri(filePath: string): string { + return pathToFileURL(filePath).toString(); +} + +function toFilePath(uri: string): string { + return formatPath(fileURLToPath(uri)); +} + +function comparePositions(a: Position, b: Position): number { + return a.line - b.line || a.character - b.character; +} + +function compareFileSpans(a: FileSpan, b: FileSpan): number { + return a.file.localeCompare(b.file) || comparePositions(a.range.start, b.range.start); +} + +function normalizeDefinitions( + result: LSPLocation | LSPLocation[] | LSPLocationLink[] | null, + withContext: boolean, +): FileSpanWithContext[] { + if (result === null) return []; + const definitions = Array.isArray(result) ? result : [result]; + return definitions + .map((definition) => + 'targetUri' in definition + ? { + file: toFilePath(definition.targetUri), + range: definition.targetSelectionRange, + ...(withContext ? { contextRange: definition.targetRange } : {}), + } + : { file: toFilePath(definition.uri), range: definition.range }, + ) + .toSorted(compareFileSpans); +} + +function normalizeLocations(locations: readonly LSPLocation[] | null): FileSpan[] { + return (locations ?? []) + .map((location) => ({ file: toFilePath(location.uri), range: location.range })) + .toSorted(compareFileSpans); +} + +function collectTextEdits(edit: LSPWorkspaceEdit | null): FileTextEdits[] { + const textEdits = new Map(); + for (const [uri, edits] of Object.entries(edit?.changes ?? {})) { + textEdits.set(toFilePath(uri), [...edits]); + } + for (const documentChange of edit?.documentChanges ?? []) { + if (!('textDocument' in documentChange)) continue; + const file = toFilePath(documentChange.textDocument.uri); + textEdits.set(file, [...(textEdits.get(file) ?? []), ...documentChange.edits]); + } + return [...textEdits] + .map(([file, edits]) => ({ + file, + textEdits: edits + .map((textEdit) => ({ range: textEdit.range, newText: textEdit.newText })) + .toSorted((a, b) => comparePositions(a.range.start, b.range.start)), + })) + .toSorted((a, b) => a.file.localeCompare(b.file)); +} + +function normalizeRenameResult(edit: LSPWorkspaceEdit | null, newName: string): RenameResult { + const locs = collectTextEdits(edit).flatMap(({ file, textEdits }) => + textEdits.map((textEdit) => ({ + file, + range: textEdit.range, + ...(textEdit.newText === newName ? {} : { newText: textEdit.newText }), + })), + ); + const fileRenames = (edit?.documentChanges ?? []) + .filter( + (documentChange): documentChange is LSPRenameFile => 'kind' in documentChange && documentChange.kind === 'rename', + ) + .map((documentChange) => ({ + oldFile: toFilePath(documentChange.oldUri), + newFile: toFilePath(documentChange.newUri), + })); + return { locs, fileRenames }; +} + +function getCompletionSource(item: LSPCompletionItem): string | undefined { + return item.labelDetails?.description; +} + +function normalizeCompletionEntries(items: readonly LSPCompletionItem[]): CompletionEntry[] { + return items + .map((item) => { + const source = getCompletionSource(item); + return { + name: item.label, + sortText: item.sortText ?? item.label, + ...(source === undefined ? {} : { source }), + ...(item.insertText === undefined ? {} : { insertText: item.insertText }), + }; + }) + .toSorted( + (a, b) => + a.sortText.localeCompare(b.sortText) || + (a.source ?? '').localeCompare(b.source ?? '') || + a.name.localeCompare(b.name), + ); +} + +function normalizeCodeActions(actions: readonly LSPCodeAction[] | null): CodeAction[] { + return (actions ?? []) + .map((action) => ({ + title: action.title, + ...(action.kind === undefined ? {} : { kind: action.kind }), + edits: collectTextEdits(action.edit ?? null), + createdFiles: (action.edit?.documentChanges ?? []) + .filter( + (documentChange): documentChange is LSPCreateFile => + 'kind' in documentChange && documentChange.kind === 'create', + ) + .map((documentChange) => toFilePath(documentChange.uri)), + })) + .toSorted((a, b) => a.title.localeCompare(b.title)); +} + +function normalizeDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] { + return diagnostics + .filter((diagnostic) => diagnostic.severity !== DiagnosticSeverity.Hint) + .map((diagnostic) => ({ + range: diagnostic.range, + ...('severity' in diagnostic ? { severity: diagnostic.severity } : {}), + ...('code' in diagnostic ? { code: diagnostic.code } : {}), + ...('source' in diagnostic ? { source: diagnostic.source } : {}), + message: diagnostic.message, + })) + .toSorted((a, b) => comparePositions(a.range.start, b.range.start)); +} + +const HEADER_TERMINATOR = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]); // '\r\n\r\n' + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a, 0); + result.set(b, a.length); + return result; +} + +function indexOfHeaderTerminator(bytes: Uint8Array): number { + for (let i = 0; i + HEADER_TERMINATOR.length <= bytes.length; i++) { + if (HEADER_TERMINATOR.every((byte, j) => bytes[i + j] === byte)) return i; + } + return -1; +} + +function languageIdOf(filePath: string): string { + if (filePath.endsWith('.tsx')) return 'typescriptreact'; + if (filePath.endsWith('.ts')) return 'typescript'; + if (filePath.endsWith('.css')) return 'css'; + return 'plaintext'; +} + +/** + * The user preferences that are in effect unless a test sets others, by the names that tsserver uses. + * + * `providePrefixAndSuffixTextForRename` defaults to `true` in tsgo, which makes a rename through a + * shorthand export specifier return an edit like `a_1 as renamed`. tsserver defaults to the opposite, + * and the expectations here are shared with the ts-plugin e2e tests, which run with that default. + */ +const DEFAULT_PREFERENCES = { providePrefixAndSuffixTextForRename: false }; + +/** + * Launches a tsgo LSP server shared by all tests in a test file. The server is spawned lazily on + * the first use, and spawned again after it exits unexpectedly, so that a crash fails only the + * requests that were pending. The server exits by itself when the test process closes its stdin. + */ +export function launchLSPClient(rootDir: string): LSPClient { + let proc: ChildProcessByStdio | undefined; + let nextRequestId = 1; + const pendingRequests = new Map< + number | string, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >(); + const documentVersions = new Map(); + let buffer: Uint8Array = new Uint8Array(0); + let contentLength: number | undefined; + let preferences: Record = DEFAULT_PREFERENCES; + + /** The server accepts the preferences by the names that tsserver uses in the `unstable` section of `js/ts`. */ + function getConfiguration(section: string | undefined): unknown { + return section === 'js/ts' ? { unstable: preferences } : null; + } + + function send(message: object): void { + const body = new TextEncoder().encode(JSON.stringify({ jsonrpc: '2.0', ...message })); + const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`); + proc!.stdin.write(concatBytes(header, body)); + } + + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + async function sendRequest(method: string, params: unknown): Promise { + const id = nextRequestId++; + send({ id, method, params }); + return new Promise((resolve, reject) => { + pendingRequests.set(id, { resolve, reject }); + }); + } + + function handleMessage(message: JSONRPCMessage): void { + if (message.id !== undefined && message.method !== undefined) { + // A server-to-client request. The tests need no dynamic capability registration, + // so every request other than `workspace/configuration` is answered with an empty result. + if (message.method === 'workspace/configuration') { + const { items } = message.params as { items: { section?: string }[] }; + send({ id: message.id, result: items.map((item) => getConfiguration(item.section)) }); + } else { + send({ id: message.id, result: null }); + } + } else if (message.id !== undefined) { + const pendingRequest = pendingRequests.get(message.id); + pendingRequests.delete(message.id); + if (message.error) pendingRequest?.reject(new Error(message.error.message)); + else pendingRequest?.resolve(message.result); + } + } + + function handleData(chunk: Uint8Array): void { + buffer = concatBytes(buffer, chunk); + while (true) { + if (contentLength === undefined) { + const headerEnd = indexOfHeaderTerminator(buffer); + if (headerEnd === -1) return; + const header = new TextDecoder().decode(buffer.subarray(0, headerEnd)); + const match = /Content-Length: (\d+)/u.exec(header); + if (match === null) throw new Error(`Invalid header: ${JSON.stringify(header)}`); + contentLength = Number(match[1]); + buffer = buffer.subarray(headerEnd + HEADER_TERMINATOR.length); + } + if (buffer.length < contentLength) return; + const body = new TextDecoder().decode(buffer.subarray(0, contentLength)); + buffer = buffer.subarray(contentLength); + contentLength = undefined; + handleMessage(JSON.parse(body) as JSONRPCMessage); + } + } + + function handleExit(code: number | null, signal: string | null): void { + for (const pendingRequest of pendingRequests.values()) { + pendingRequest.reject(new Error(`The server exited unexpectedly (code: ${code}, signal: ${signal}).`)); + } + pendingRequests.clear(); + documentVersions.clear(); + buffer = new Uint8Array(0); + contentLength = undefined; + started = undefined; + } + + let started: Promise | undefined; + async function ensureStarted(): Promise { + started ??= (async () => { + proc = spawn(tsgoBinPath, ['--lsp', '-stdio'], { stdio: ['pipe', 'pipe', 'inherit'] }); + proc.stdout.on('data', handleData); + proc.on('exit', handleExit); + await sendRequest('initialize', { + processId: process.pid, + rootUri: toFileUri(rootDir), + capabilities: { + workspace: { + configuration: true, + // The server answers a rename request on an import specifier with a file rename + // operation only when the client declares these capabilities. + workspaceEdit: { documentChanges: true, resourceOperations: ['create', 'rename'] }, + fileOperations: { willRename: true }, + }, + textDocument: { + // The server reports the enclosing declaration of a definition only as a part of `LocationLink`. + definition: { linkSupport: true }, + completion: { + completionItem: { + snippetSupport: true, + labelDetailsSupport: true, + resolveSupport: { properties: ['detail', 'documentation', 'additionalTextEdits'] }, + }, + }, + codeAction: { + codeActionLiteralSupport: { codeActionKind: { valueSet: ['quickfix', 'refactor'] } }, + }, + }, + }, + initializationOptions: { runExternalCode: true }, + }); + send({ method: 'initialized', params: {} }); + })(); + return started; + } + + async function sendCompletionRequest(args: FileLocation): Promise { + await ensureStarted(); + const result: LSPCompletionItem[] | { items: LSPCompletionItem[] } | null = await sendRequest( + 'textDocument/completion', + { textDocument: { uri: toFileUri(args.file) }, position: args.position }, + ); + if (result === null) return []; + return Array.isArray(result) ? result : result.items; + } + + async function sendDefinitionRequest(args: FileLocation) { + await ensureStarted(); + return sendRequest('textDocument/definition', { + textDocument: { uri: toFileUri(args.file) }, + position: args.position, + }); + } + + return { + async openFiles(files) { + await ensureStarted(); + for (const entry of files) { + const file = typeof entry === 'string' ? entry : entry.file; + const text = typeof entry === 'string' ? readFileSync(file, 'utf8') : entry.content; + const uri = toFileUri(file); + documentVersions.set(uri, 1); + send({ + method: 'textDocument/didOpen', + params: { textDocument: { uri, languageId: languageIdOf(file), version: 1, text } }, + }); + } + }, + async closeFiles(files) { + await ensureStarted(); + for (const file of files) { + const uri = toFileUri(file); + documentVersions.delete(uri); + send({ method: 'textDocument/didClose', params: { textDocument: { uri } } }); + } + }, + async changeFile(file, content) { + await ensureStarted(); + const uri = toFileUri(file); + const version = (documentVersions.get(uri) ?? 1) + 1; + documentVersions.set(uri, version); + send({ + method: 'textDocument/didChange', + params: { textDocument: { uri, version }, contentChanges: [{ text: content }] }, + }); + }, + sendDefinition: async (args) => normalizeDefinitions(await sendDefinitionRequest(args), false), + sendDefinitionWithContext: async (args) => normalizeDefinitions(await sendDefinitionRequest(args), true), + async sendReferences(args) { + await ensureStarted(); + const result = await sendRequest('textDocument/references', { + textDocument: { uri: toFileUri(args.file) }, + position: args.position, + context: { includeDeclaration: true }, + }); + return normalizeLocations(result); + }, + async sendRename(args, newName = 'renamed') { + await ensureStarted(); + const result = await sendRequest('textDocument/rename', { + textDocument: { uri: toFileUri(args.file) }, + position: args.position, + newName, + }); + return normalizeRenameResult(result, newName); + }, + async sendDocumentDiagnostic(file) { + await ensureStarted(); + const result: { items: Diagnostic[] } = await sendRequest('textDocument/diagnostic', { + textDocument: { uri: toFileUri(file) }, + }); + return normalizeDiagnostics(result.items); + }, + async sendConfigure(args) { + await ensureStarted(); + preferences = { ...DEFAULT_PREFERENCES, ...args.preferences }; + send({ + method: 'workspace/didChangeConfiguration', + params: { settings: { 'js/ts': getConfiguration('js/ts') } }, + }); + }, + async sendCompletion(args) { + return normalizeCompletionEntries(await sendCompletionRequest(args)); + }, + async sendCompletionDetails(args) { + const items = await sendCompletionRequest(args); + const item = items.find((item) => item.label === args.name && getCompletionSource(item) === args.source); + if (item === undefined) throw new Error(`Completion entry ${JSON.stringify(args.name)} is not found.`); + const resolved: LSPCompletionItem = await sendRequest('completionItem/resolve', item); + return resolved.additionalTextEdits === undefined ? {} : { additionalTextEdits: resolved.additionalTextEdits }; + }, + async sendCodeActions(args) { + await ensureStarted(); + const result = await sendRequest('textDocument/codeAction', { + textDocument: { uri: toFileUri(args.file) }, + range: args.range, + context: { + diagnostics: (args.errorCodes ?? []).map((code) => ({ range: args.range, code, source: 'ts', message: '' })), + only: args.only, + }, + }); + return normalizeCodeActions(result); + }, + async sendWillRenameFiles(args) { + await ensureStarted(); + const result = await sendRequest('workspace/willRenameFiles', { + files: [{ oldUri: toFileUri(args.oldFilePath), newUri: toFileUri(args.newFilePath) }], + }); + return collectTextEdits(result); + }, + }; +} diff --git a/packages/content-mapper/package.json b/packages/content-mapper/package.json new file mode 100644 index 00000000..de4ce6c9 --- /dev/null +++ b/packages/content-mapper/package.json @@ -0,0 +1,35 @@ +{ + "name": "@css-modules-kit/content-mapper", + "version": "0.0.0", + "private": true, + "description": "A TypeScript content mapper for CSS Modules", + "license": "MIT", + "author": "mizdra ", + "repository": { + "type": "git", + "url": "https://github.com/mizdra/css-modules-kit.git", + "directory": "packages/content-mapper" + }, + "type": "module", + "sideEffects": false, + "scripts": { + "build": "tsc -b tsconfig.build.json" + }, + "dependencies": { + "@css-modules-kit/core": "workspace:^" + }, + "devDependencies": { + "typescript-nightly": "npm:typescript@7.1.0-dev.20260920.1" + }, + "typescript": { + "contentMapper": { + "exec": [ + "node", + "dist/main.js" + ] + } + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/packages/content-mapper/src/error.ts b/packages/content-mapper/src/error.ts new file mode 100644 index 00000000..ce717a36 --- /dev/null +++ b/packages/content-mapper/src/error.ts @@ -0,0 +1,6 @@ +export class ProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProtocolError'; + } +} diff --git a/packages/content-mapper/src/main.ts b/packages/content-mapper/src/main.ts new file mode 100644 index 00000000..104a0a99 --- /dev/null +++ b/packages/content-mapper/src/main.ts @@ -0,0 +1,3 @@ +import { runServer } from './server.js'; + +await runServer(process.stdin, process.stdout); diff --git a/packages/content-mapper/src/protocol.ts b/packages/content-mapper/src/protocol.ts new file mode 100644 index 00000000..1c67bee6 --- /dev/null +++ b/packages/content-mapper/src/protocol.ts @@ -0,0 +1,175 @@ +// Type definitions for the content mapper protocol of TypeScript 7 +// (microsoft/typescript-go#4712, microsoft/TypeScript#63936). +// The wire format is JSON-RPC 2.0 with LSP-style `Content-Length` framing. + +export const DIAGNOSTIC_SOURCE = 'cmk'; + +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; + +export interface RequestMessage { + jsonrpc: '2.0'; + id: number | string; + method: string; + params?: unknown; +} + +export interface ResponseMessage { + jsonrpc: '2.0'; + id: number | string; + result?: unknown; + error?: ResponseError; +} + +export interface ResponseError { + code: number; + message: string; + data?: unknown; +} + +export type PositionEncoding = 'utf-8' | 'utf-16'; + +export interface InitializeParams { + locale?: string; + positionEncodings: PositionEncoding[]; +} + +export interface InitializeResult { + positionEncoding: PositionEncoding; + /** The prefix of mapper-authored diagnostic codes. Must not collide with other diagnostic sources. */ + diagnosticSource: string; +} + +export interface OpenProjectParams { + /** The absolute path of the project's tsconfig, or an empty string for an inferred project. */ + configFileName: string; + /** An opaque handle assigned by the host. Subsequent transforms reference it. */ + projectHandle: string; + /** The mapper entry's `options` from the project's `contentMappers` configuration. */ + options?: unknown; + compilerOptions: Record; +} + +/** + * The response to an openProject request. `configIdentity` and `watchedFiles` may only be + * returned by mappers that declare `dynamicConfig`, so cmk omits them. + */ +export interface OpenProjectResult { + optionDiagnostics?: OptionDiagnostic[]; +} + +/** An invalid mapper option. `path` locates the value within the mapper entry's options object. */ +export interface OptionDiagnostic { + path: (string | number)[]; + messageText: string; + code: number; +} + +export interface CloseProjectParams { + projectHandle: string; +} + +export interface TransformParams { + fileName: string; + content: string; + /** The handle of an opened project whose options apply to this transform. */ + projectHandle: string; +} + +export interface TransformResult { + text: string; + /** Determines how `text` is parsed. */ + extension: VirtualExtension; + mappings?: SpanMapping[]; + diagnosticDirectives?: DiagnosticDirectives; + diagnostics?: MapperDiagnostic[]; +} + +export type VirtualExtension = '.js' | '.jsx' | '.mjs' | '.cjs' | '.ts' | '.tsx' | '.mts' | '.cts' | '.json'; + +/** A mapping between a span in the generated text and a span in the original file. */ +export type SpanMapping = [ + generatedStart: number, + generatedLength: number, + originalStart: number, + originalLength: number, + kind: SpanMapKind, + features?: number, +]; + +export const SpanMapKind = { + /** Positions correspond 1:1 within the spans. */ + Verbatim: 0, + /** The spans correspond only as a whole. */ + Atom: 1, + /** Like `Atom`, but the spans have unrelated text (e.g. different names). */ + Alias: 2, +} as const; + +export type SpanMapKind = (typeof SpanMapKind)[keyof typeof SpanMapKind]; + +/** Bit flags of language service features enabled for a span. Omitted means all features. */ +export const SpanMapFeature = { + Hover: 1 << 0, + SignatureHelp: 1 << 1, + Completion: 1 << 2, + Definition: 1 << 3, + TypeDefinition: 1 << 4, + Implementation: 1 << 5, + References: 1 << 6, + DocumentHighlights: 1 << 7, + Rename: 1 << 8, + CallHierarchy: 1 << 9, + CodeActions: 1 << 10, + Formatting: 1 << 11, + InlayHints: 1 << 12, + SemanticTokens: 1 << 13, + FoldingRanges: 1 << 14, + SelectionRanges: 1 << 15, + LinkedEditing: 1 << 16, + AutoInsert: 1 << 17, + DocumentSymbols: 1 << 18, + CodeLens: 1 << 19, + All: (1 << 20) - 1, +} as const; + +/** A diagnostic reported by the mapper. `start` and `length` are positions in the original file. */ +export interface MapperDiagnostic { + messageText: string; + start: number; + length: number; + code: number; +} + +export interface DiagnosticDirectives { + /** Diagnostics reported when an `Expect` directive matches no diagnostic. Unused by `Ignore` directives. */ + unusedExpectDirectiveDiagnostics: UnusedExpectDirectiveDiagnostic[]; + /** Directives whose generated ranges must not overlap each other. */ + directives: MappedDiagnosticDirective[]; +} + +export interface UnusedExpectDirectiveDiagnostic { + messageText: string; + code: number; +} + +/** + * Suppresses (`Ignore`) or expects (`Expect`) TypeScript diagnostics whose start position falls + * within `[generatedStart, generatedEnd)`. `originalStart` and `originalLength` locate the + * directive in the original file for `Expect` reporting. + */ +export type MappedDiagnosticDirective = [ + originalStart: number, + originalLength: number, + generatedStart: number, + generatedEnd: number, + policy: DiagnosticDirectivePolicy, + unusedExpectDirectiveIndex?: number, +]; + +export const DiagnosticDirectivePolicy = { + Ignore: 0, + Expect: 1, +} as const; + +export type DiagnosticDirectivePolicy = (typeof DiagnosticDirectivePolicy)[keyof typeof DiagnosticDirectivePolicy]; diff --git a/packages/content-mapper/src/server.test.ts b/packages/content-mapper/src/server.test.ts new file mode 100644 index 00000000..9db2e622 --- /dev/null +++ b/packages/content-mapper/src/server.test.ts @@ -0,0 +1,190 @@ +import { PassThrough } from 'node:stream'; +import { expect, test } from 'vite-plus/test'; +import { runServer } from './server.js'; + +function startServer() { + const input = new PassThrough(); + const output = new PassThrough(); + const done = runServer(input, output); + return { input, output, done }; +} + +function encodeFrame(message: unknown): Uint8Array { + const body = new TextEncoder().encode(JSON.stringify(message)); + const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`); + const frame = new Uint8Array(header.length + body.length); + frame.set(header, 0); + frame.set(body, header.length); + return frame; +} + +function writeFrame(input: PassThrough, message: unknown): void { + input.write(encodeFrame(message)); +} + +// The server responses in tests are ASCII-only, so string offsets equal byte offsets. +function readResponses(output: PassThrough): unknown[] { + // `read()` returns the whole buffer on Node.js 24 or earlier, but one chunk at a time on Node.js 26. + const decoder = new TextDecoder(); + let rest = ''; + for (let chunk = output.read() as Uint8Array | null; chunk !== null; chunk = output.read() as Uint8Array | null) { + rest += decoder.decode(chunk, { stream: true }); + } + const responses: unknown[] = []; + while (rest.length > 0) { + const match = /^Content-Length: (\d+)\r\n\r\n/u.exec(rest); + if (match === null) throw new Error(`Malformed response: ${JSON.stringify(rest)}`); + const bodyStart = match[0].length; + const bodyEnd = bodyStart + Number(match[1]); + responses.push(JSON.parse(rest.slice(bodyStart, bodyEnd))); + rest = rest.slice(bodyEnd); + } + return responses; +} + +function createInitializeRequest(id: number) { + return { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { positionEncodings: ['utf-8', 'utf-16'] }, + }; +} + +function createInitializeResponse(id: number) { + return { + jsonrpc: '2.0', + id, + result: { positionEncoding: 'utf-16', diagnosticSource: 'cmk' }, + }; +} + +function createOpenProjectRequest(id: number, projectHandle: string) { + return { + jsonrpc: '2.0', + id, + method: 'openProject', + params: { configFileName: '/tsconfig.json', projectHandle, compilerOptions: {} }, + }; +} + +function createOpenProjectResponse(id: number) { + return { jsonrpc: '2.0', id, result: {} }; +} + +function createTransformRequest(id: number, content: string, projectHandle = 'p1') { + return { + jsonrpc: '2.0', + id, + method: 'transform', + params: { fileName: '/a.module.css', content, projectHandle }, + }; +} + +function createTransformResponse(id: number) { + return { jsonrpc: '2.0', id, result: { text: 'export {};\n', extension: '.ts' } }; +} + +test('responds to initialize with utf-16 encoding and cmk diagnostic source', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createInitializeRequest(1)); + input.end(); + await done; + expect(readResponses(output)).toEqual([createInitializeResponse(1)]); +}); + +test('responds to transform with an empty module', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createInitializeRequest(1)); + writeFrame(input, createOpenProjectRequest(2, 'p1')); + writeFrame(input, createTransformRequest(3, '.a1 { color: red; }')); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + createInitializeResponse(1), + createOpenProjectResponse(2), + createTransformResponse(3), + ]); +}); + +test('responds with an invalid-params error to a transform with an unopened project handle', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createTransformRequest(1, '')); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + { jsonrpc: '2.0', id: 1, error: { code: -32602, message: 'Unknown project handle: p1' } }, + ]); +}); + +test('releases the project on closeProject', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createOpenProjectRequest(1, 'p1')); + writeFrame(input, { jsonrpc: '2.0', id: 2, method: 'closeProject', params: { projectHandle: 'p1' } }); + writeFrame(input, createTransformRequest(3, '')); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + createOpenProjectResponse(1), + { jsonrpc: '2.0', id: 2, result: null }, + { jsonrpc: '2.0', id: 3, error: { code: -32602, message: 'Unknown project handle: p1' } }, + ]); +}); + +test('responds with method-not-found error to unknown methods', async () => { + const { input, output, done } = startServer(); + writeFrame(input, { jsonrpc: '2.0', id: 1, method: 'shutdown', params: {} }); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + { jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found: shutdown' } }, + ]); +}); + +test('parses a frame split across multiple chunks', async () => { + const { input, output, done } = startServer(); + const frame = encodeFrame(createInitializeRequest(1)); + input.write(frame.subarray(0, 10)); + input.write(frame.subarray(10, 20)); + input.write(frame.subarray(20)); + input.end(); + await done; + expect(readResponses(output)).toEqual([createInitializeResponse(1)]); +}); + +test('parses multiple frames arriving in a single chunk', async () => { + const { input, output, done } = startServer(); + const frame1 = encodeFrame(createInitializeRequest(1)); + const frame2 = encodeFrame(createInitializeRequest(2)); + const chunk = new Uint8Array(frame1.length + frame2.length); + chunk.set(frame1, 0); + chunk.set(frame2, frame1.length); + input.write(chunk); + input.end(); + await done; + expect(readResponses(output)).toEqual([createInitializeResponse(1), createInitializeResponse(2)]); +}); + +test('reads frame bodies by UTF-8 byte length', async () => { + const { input, output, done } = startServer(); + // `あ` is 1 UTF-16 code unit but 3 UTF-8 bytes. If the server measured the body in UTF-16 + // code units, the boundary of the second frame would be misaligned. The `あ` is placed in + // a comment so that the response stays ASCII-only for `readResponses`. + const content = '/* あ */ .a1 { color: red; }'; + writeFrame(input, createOpenProjectRequest(1, 'p1')); + writeFrame(input, createTransformRequest(2, content)); + writeFrame(input, createInitializeRequest(3)); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + createOpenProjectResponse(1), + createTransformResponse(2), + createInitializeResponse(3), + ]); +}); + +test('resolves when input ends', async () => { + const { input, done } = startServer(); + input.end(); + await expect(done).resolves.toBeUndefined(); +}); diff --git a/packages/content-mapper/src/server.ts b/packages/content-mapper/src/server.ts new file mode 100644 index 00000000..d3fbb587 --- /dev/null +++ b/packages/content-mapper/src/server.ts @@ -0,0 +1,144 @@ +import type { Readable, Writable } from 'node:stream'; +import { ProtocolError } from './error.js'; +import type { + CloseProjectParams, + InitializeResult, + OpenProjectParams, + OpenProjectResult, + RequestMessage, + ResponseMessage, + TransformParams, + TransformResult, +} from './protocol.js'; +import { DIAGNOSTIC_SOURCE, INVALID_PARAMS, METHOD_NOT_FOUND } from './protocol.js'; + +const HEADER_TERMINATOR = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]); // '\r\n\r\n' + +interface FrameDecoder { + push(chunk: Uint8Array): string[]; +} + +function createFrameDecoder(): FrameDecoder { + let buffer: Uint8Array = new Uint8Array(0); + let contentLength: number | undefined; + return { + push(chunk: Uint8Array): string[] { + buffer = concatBytes(buffer, chunk); + const frames: string[] = []; + while (true) { + if (contentLength === undefined) { + const headerEnd = indexOfHeaderTerminator(buffer); + if (headerEnd === -1) break; + const header = new TextDecoder().decode(buffer.subarray(0, headerEnd)); + contentLength = parseContentLength(header); + buffer = buffer.subarray(headerEnd + HEADER_TERMINATOR.length); + } + if (buffer.length < contentLength) break; + frames.push(new TextDecoder().decode(buffer.subarray(0, contentLength))); + buffer = buffer.subarray(contentLength); + contentLength = undefined; + } + return frames; + }, + }; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a, 0); + result.set(b, a.length); + return result; +} + +function indexOfHeaderTerminator(bytes: Uint8Array): number { + for (let i = 0; i + HEADER_TERMINATOR.length <= bytes.length; i++) { + if (HEADER_TERMINATOR.every((byte, j) => bytes[i + j] === byte)) return i; + } + return -1; +} + +/** + * @throws {ProtocolError} When the header lacks a valid `Content-Length` field. + */ +function parseContentLength(header: string): number { + const match = /^Content-Length:\s*(\d+)\s*$/mu.exec(header); + if (match === null) throw new ProtocolError(`Invalid header: ${JSON.stringify(header)}`); + return Number(match[1]); +} + +function isRequestMessage(message: unknown): message is RequestMessage { + return typeof message === 'object' && message !== null && 'method' in message && 'id' in message; +} + +function createResponse(request: RequestMessage, projects: Set): ResponseMessage { + switch (request.method) { + case 'initialize': { + const result: InitializeResult = { + positionEncoding: 'utf-16', + diagnosticSource: DIAGNOSTIC_SOURCE, + }; + return { jsonrpc: '2.0', id: request.id, result }; + } + case 'openProject': { + const params = request.params as OpenProjectParams; + projects.add(params.projectHandle); + const result: OpenProjectResult = {}; + return { jsonrpc: '2.0', id: request.id, result }; + } + case 'closeProject': { + const params = request.params as CloseProjectParams; + projects.delete(params.projectHandle); + return { jsonrpc: '2.0', id: request.id, result: null }; + } + case 'transform': { + const params = request.params as TransformParams; + if (!projects.has(params.projectHandle)) { + return { + jsonrpc: '2.0', + id: request.id, + error: { code: INVALID_PARAMS, message: `Unknown project handle: ${params.projectHandle}` }, + }; + } + // TODO: Transform CSS Modules into TypeScript code. For now, every file becomes an empty module. + const result: TransformResult = { text: 'export {};\n', extension: '.ts' }; + return { jsonrpc: '2.0', id: request.id, result }; + } + default: + return { + jsonrpc: '2.0', + id: request.id, + error: { code: METHOD_NOT_FOUND, message: `Method not found: ${request.method}` }, + }; + } +} + +function encodeFrame(message: ResponseMessage): Uint8Array { + const body = new TextEncoder().encode(JSON.stringify(message)); + const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`); + return concatBytes(header, body); +} + +/** + * Reads content mapper protocol requests from `input` and writes responses to `output`. + * @returns A promise that resolves when `input` ends, and rejects on a malformed frame. + */ +export async function runServer(input: Readable, output: Writable): Promise { + return new Promise((resolve, reject) => { + const decoder = createFrameDecoder(); + const projects = new Set(); + input.on('data', (chunk: Uint8Array) => { + try { + for (const frame of decoder.push(chunk)) { + const message: unknown = JSON.parse(frame); + if (isRequestMessage(message)) { + output.write(encodeFrame(createResponse(message, projects))); + } + } + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + input.on('end', () => resolve()); + input.on('error', reject); + }); +} diff --git a/packages/content-mapper/src/test/builder.ts b/packages/content-mapper/src/test/builder.ts new file mode 100644 index 00000000..c08a0a4a --- /dev/null +++ b/packages/content-mapper/src/test/builder.ts @@ -0,0 +1,29 @@ +interface TSConfig { + compilerOptions?: Record; + mapperOptions?: Record; +} + +export function buildTSConfigJSON(args?: TSConfig): string { + return JSON.stringify({ + ...(args?.compilerOptions ? { compilerOptions: args.compilerOptions } : {}), + contentMappers: [ + { + package: '@css-modules-kit/content-mapper', + extensions: ['.css'], + ...(args?.mapperOptions ? { options: args.mapperOptions } : {}), + }, + ], + }); +} + +interface BuildStylesImportOptions { + namedExports: boolean; + quote?: 'single' | 'double'; + name?: string; +} + +export function buildStylesImport(specifier: string, options: BuildStylesImportOptions): string { + const { namedExports, quote = 'single', name = 'styles' } = options; + const q = quote === 'single' ? "'" : '"'; + return namedExports ? `import * as ${name} from ${q}${specifier}${q};` : `import ${name} from ${q}${specifier}${q};`; +} diff --git a/packages/content-mapper/tsconfig.build.json b/packages/content-mapper/tsconfig.build.json new file mode 100644 index 00000000..c65e55a4 --- /dev/null +++ b/packages/content-mapper/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/__snapshots__", "src/test"], + "compilerOptions": { + "target": "ES2022", + "lib": ["ESNext"], + "module": "NodeNext", + + "composite": true, + "outDir": "dist", + "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/. + "sourceMap": true, + "declarationMap": true + }, + "references": [{ "path": "../core/tsconfig.build.json" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d6f2e02..ae5f11a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,6 +327,16 @@ importers: specifier: ^5.7.3 || ^6.0.0 version: 6.0.3 + packages/content-mapper: + dependencies: + '@css-modules-kit/core': + specifier: workspace:^ + version: link:../core + devDependencies: + typescript-nightly: + specifier: npm:typescript@7.1.0-dev.20260920.1 + version: typescript@7.1.0-dev.20260920.1 + packages/core: dependencies: postcss: @@ -901,88 +911,88 @@ packages: '@emnapi/runtime': ^1.7.1 '@node-rs/crc32-android-arm-eabi@1.10.6': - resolution: {integrity: sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ==, tarball: https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.6.tgz} + resolution: {integrity: sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ==} engines: {node: '>= 10'} cpu: [arm] os: [android] '@node-rs/crc32-android-arm64@1.10.6': - resolution: {integrity: sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w==, tarball: https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.6.tgz} + resolution: {integrity: sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w==} engines: {node: '>= 10'} cpu: [arm64] os: [android] '@node-rs/crc32-darwin-arm64@1.10.6': - resolution: {integrity: sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==, tarball: https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.6.tgz} + resolution: {integrity: sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] '@node-rs/crc32-darwin-x64@1.10.6': - resolution: {integrity: sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA==, tarball: https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.6.tgz} + resolution: {integrity: sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] '@node-rs/crc32-freebsd-x64@1.10.6': - resolution: {integrity: sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg==, tarball: https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.6.tgz} + resolution: {integrity: sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] '@node-rs/crc32-linux-arm-gnueabihf@1.10.6': - resolution: {integrity: sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.6.tgz} + resolution: {integrity: sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg==} engines: {node: '>= 10'} cpu: [arm] os: [linux] '@node-rs/crc32-linux-arm64-gnu@1.10.6': - resolution: {integrity: sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.6.tgz} + resolution: {integrity: sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] '@node-rs/crc32-linux-arm64-musl@1.10.6': - resolution: {integrity: sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.6.tgz} + resolution: {integrity: sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] '@node-rs/crc32-linux-x64-gnu@1.10.6': - resolution: {integrity: sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.6.tgz} + resolution: {integrity: sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] '@node-rs/crc32-linux-x64-musl@1.10.6': - resolution: {integrity: sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.6.tgz} + resolution: {integrity: sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] '@node-rs/crc32-wasm32-wasi@1.10.6': - resolution: {integrity: sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==, tarball: https://registry.npmjs.org/@node-rs/crc32-wasm32-wasi/-/crc32-wasm32-wasi-1.10.6.tgz} + resolution: {integrity: sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==} engines: {node: '>=14.0.0'} cpu: [wasm32] '@node-rs/crc32-win32-arm64-msvc@1.10.6': - resolution: {integrity: sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ==, tarball: https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.6.tgz} + resolution: {integrity: sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] '@node-rs/crc32-win32-ia32-msvc@1.10.6': - resolution: {integrity: sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw==, tarball: https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.6.tgz} + resolution: {integrity: sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] '@node-rs/crc32-win32-x64-msvc@1.10.6': - resolution: {integrity: sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw==, tarball: https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.6.tgz} + resolution: {integrity: sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1532,6 +1542,48 @@ packages: '@typescript/server-harness@0.3.6': resolution: {integrity: sha512-y72UF/Xv7Y0rdidvogsBMl2kA2ZzNR0HNQI+mOm5AlTdWFJP290UyF9Se5RFipS+GrXa+DjbiPHmu90e/x6weg==} + '@typescript/typescript-darwin-arm64@7.1.0-dev.20260920.1': + resolution: {integrity: sha512-0KZLj5/VP1f77InjhsIou9wok+bCxZCr4r/SkyQF4GRxl9DqMTLGfEt91Zy5SbjusgZ0QComiqfrdWZY5Z4axw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.1.0-dev.20260920.1': + resolution: {integrity: sha512-+6rUIK5ic1m3/QhmfMdcV/dlI9U+SO6MqtfpAAfTjtOcWHIQGLrtXkLJ1TGJx/VfToxzryZiHjrL3H7MkUVElQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-linux-arm64@7.1.0-dev.20260920.1': + resolution: {integrity: sha512-yoURYqZHC3Tlk02D/tC3DJQH49M+iOVN6krqZDE57fmX0WbD/7/yE4LSR/7wx31lyJbWKSYM/psxNzh6U7b7MA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.1.0-dev.20260920.1': + resolution: {integrity: sha512-niXTqU2j0NdBHMbxaU2JNCLeb7+QxmUZoaTtW2EfaG5ODRjE5tAhXHXJu96G4jDiXiuNAfr7b36L9JBiUy9UjQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-x64@7.1.0-dev.20260920.1': + resolution: {integrity: sha512-V300mgrCdnyFn37jmM97+m9Pb5pUzV/AxBGMZF3UIV3y9meU9ov3QbiOb/qkxF0W4DJZEziod2ID7fPOcfSM2w==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-win32-arm64@7.1.0-dev.20260920.1': + resolution: {integrity: sha512-VG4RUrdKvFXkoDhRejF4VFJBL08oOqOQMYCkjBnwe5Ze7gqv3++pMYs+iC1qUESTNGADcHVKLDUJYB0FjYb62A==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.1.0-dev.20260920.1': + resolution: {integrity: sha512-LoBcpahjQK9AnWoA/s3C1vz1TYxdttG+jm4ndiD3YYwWjNaLURnGLbUvek164SUvEI1y+KH45gL/FqbOZbU9kg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@typespec/ts-http-runtime@0.3.6': resolution: {integrity: sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==} engines: {node: '>=20.0.0'} @@ -1718,47 +1770,47 @@ packages: engines: {node: '>=22'} '@vscode/vsce-sign-alpine-arm64@2.0.6': - resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz} + resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} cpu: [arm64] os: [alpine] '@vscode/vsce-sign-alpine-x64@2.0.6': - resolution: {integrity: sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz} + resolution: {integrity: sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==} cpu: [x64] os: [alpine] '@vscode/vsce-sign-darwin-arm64@2.0.6': - resolution: {integrity: sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz} + resolution: {integrity: sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==} cpu: [arm64] os: [darwin] '@vscode/vsce-sign-darwin-x64@2.0.6': - resolution: {integrity: sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz} + resolution: {integrity: sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==} cpu: [x64] os: [darwin] '@vscode/vsce-sign-linux-arm64@2.0.6': - resolution: {integrity: sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz} + resolution: {integrity: sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==} cpu: [arm64] os: [linux] '@vscode/vsce-sign-linux-arm@2.0.6': - resolution: {integrity: sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz} + resolution: {integrity: sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==} cpu: [arm] os: [linux] '@vscode/vsce-sign-linux-x64@2.0.6': - resolution: {integrity: sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz} + resolution: {integrity: sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==} cpu: [x64] os: [linux] '@vscode/vsce-sign-win32-arm64@2.0.6': - resolution: {integrity: sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz} + resolution: {integrity: sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==} cpu: [arm64] os: [win32] '@vscode/vsce-sign-win32-x64@2.0.6': - resolution: {integrity: sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz} + resolution: {integrity: sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==} cpu: [x64] os: [win32] @@ -2785,71 +2837,71 @@ packages: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==, tarball: https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz} + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==, tarball: https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz} + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==, tarball: https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz} + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==, tarball: https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz} + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==, tarball: https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz} + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz} + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz} + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz} + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz} + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==, tarball: https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz} + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==, tarball: https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz} + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] @@ -3709,6 +3761,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.1.0-dev.20260920.1: + resolution: {integrity: sha512-ljRXAaWoHs11KCWSImmvNRX9OF9ELw60r0oMvh9gmgbZ5sht3PlCFPPxtBMFA1I/SlvqB0Jv0HB+htkb2tTbXg==} + engines: {node: '>=16.20.0'} + hasBin: true + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -4948,6 +5005,27 @@ snapshots: '@typescript/server-harness@0.3.6': {} + '@typescript/typescript-darwin-arm64@7.1.0-dev.20260920.1': + optional: true + + '@typescript/typescript-darwin-x64@7.1.0-dev.20260920.1': + optional: true + + '@typescript/typescript-linux-arm64@7.1.0-dev.20260920.1': + optional: true + + '@typescript/typescript-linux-arm@7.1.0-dev.20260920.1': + optional: true + + '@typescript/typescript-linux-x64@7.1.0-dev.20260920.1': + optional: true + + '@typescript/typescript-win32-arm64@7.1.0-dev.20260920.1': + optional: true + + '@typescript/typescript-win32-x64@7.1.0-dev.20260920.1': + optional: true + '@typespec/ts-http-runtime@0.3.6': dependencies: http-proxy-agent: 7.0.2(supports-color@8.1.1) @@ -7272,6 +7350,16 @@ snapshots: typescript@6.0.3: {} + typescript@7.1.0-dev.20260920.1: + optionalDependencies: + '@typescript/typescript-darwin-arm64': 7.1.0-dev.20260920.1 + '@typescript/typescript-darwin-x64': 7.1.0-dev.20260920.1 + '@typescript/typescript-linux-arm': 7.1.0-dev.20260920.1 + '@typescript/typescript-linux-arm64': 7.1.0-dev.20260920.1 + '@typescript/typescript-linux-x64': 7.1.0-dev.20260920.1 + '@typescript/typescript-win32-arm64': 7.1.0-dev.20260920.1 + '@typescript/typescript-win32-x64': 7.1.0-dev.20260920.1 + uc.micro@2.1.0: {} underscore@1.13.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 088acc64..ea84355f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,9 @@ allowBuilds: minimumReleaseAge: 4320 minimumReleaseAgeExclude: - '@mizdra/*' + # The typescript nightly (and its native binary packages) pinned for the content-mapper e2e tests + - typescript + - '@typescript/typescript-*' catalog: vite: npm:@voidzero-dev/vite-plus-core@0.2.1 vite-plus: 0.2.1 diff --git a/scripts/setup-tsgo-extension.sh b/scripts/setup-tsgo-extension.sh new file mode 100755 index 00000000..f517cbf8 --- /dev/null +++ b/scripts/setup-tsgo-extension.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -ue + +# Prepares everything the "tsgo (...)" launch configurations need: +# the VS Code extension (TypeScript Native Preview) and the tsgo binary built from +# the pinned microsoft/TypeScript commit, and the mapper package symlinks for the +# examples. The marketplace build of the extension predates content mapper support, +# so the extension must be built from source. +# +# The pinned commit is the head of https://github.com/microsoft/TypeScript/pull/64173, +# which adds the content mapper inspector (the commands that show the virtual text +# and the span mappings of a mapped file). The inspector sends custom LSP requests +# that no `typescript` npm nightly handles yet, so the tsgo binary is also built +# from source. The build requires Go. The go command downloads the Go version that +# go.work requires automatically. Once the pull request is released in an npm +# nightly, the binary can be copied from the `typescript-nightly` devDependency of +# packages/content-mapper instead. + +COMMIT=afb2513b5a0f3eff82322d771fe57f612508b0d5 +REPO=https://github.com/microsoft/TypeScript.git + +cd "$(dirname "$0")/.." +DEST=.tmp/typescript + +if [ ! -d "$DEST/.git" ]; then + mkdir -p "$DEST" + git -C "$DEST" init -q + git -C "$DEST" remote add origin "$REPO" +fi +if ! git -C "$DEST" cat-file -e "$COMMIT^{commit}" 2>/dev/null; then + git -C "$DEST" fetch --depth 1 origin "$COMMIT" +fi +git -C "$DEST" checkout -q "$COMMIT" + +# npm ci is slow, so it only runs when the pinned commit changes package-lock.json. +LOCK_HASH=$(git -C "$DEST" rev-parse HEAD:package-lock.json) +LOCK_HASH_FILE="$DEST/node_modules/.package-lock-hash" +if [ "$(cat "$LOCK_HASH_FILE" 2>/dev/null)" != "$LOCK_HASH" ]; then + (cd "$DEST" && npm ci) + echo "$LOCK_HASH" > "$LOCK_HASH_FILE" +fi + +# In development mode, the extension resolves the binary at built/local/tsc +# (see packages/vscode-typescript/src/util.ts). `hereby build` builds it there +# together with the lib.*.d.ts files that the binary requires next to it. +(cd "$DEST" && npx hereby build) +(cd "$DEST" && npm run extension:build) + +# tsgo resolves the mapper package from the tsconfig directory with node module resolution. +for dir in examples/7-content-mapper examples/8-content-mapper-named-exports; do + mkdir -p "$dir/node_modules/@css-modules-kit" + ln -sfn ../../../../packages/content-mapper "$dir/node_modules/@css-modules-kit/content-mapper" +done diff --git a/scripts/vitest-e2e-test-setup.ts b/scripts/vitest-e2e-test-setup.ts index 9fcfbfd3..87957d23 100644 --- a/scripts/vitest-e2e-test-setup.ts +++ b/scripts/vitest-e2e-test-setup.ts @@ -1,9 +1,40 @@ import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import type { TestProject } from 'vite-plus/test/node'; -export default function setup(project: TestProject) { +// Keep the resolution in sync with `packages/content-mapper/e2e-test/test-util/lsp-client.ts`. +const tsgoBinPath = process.env['TSGO_BIN'] ?? resolveNativeTscBinPath(); + +/** + * Resolves the platform-specific native tsc binary the same way as `typescript/lib/getExePath.js`. + * The `typescript` nightly is a devDependency of `packages/content-mapper` under the + * `typescript-nightly` alias, and its platform package is a dependency of the nightly, so each + * must be resolved from its dependent package to work with pnpm's non-flat `node_modules`. + */ +function resolveNativeTscBinPath(): string { + const contentMapperPkgPath = fileURLToPath(new URL('../packages/content-mapper/package.json', import.meta.url)); + const typescriptPkgPath = createRequire(contentMapperPkgPath).resolve('typescript-nightly/package.json'); + const platformPkgName = `@typescript/typescript-${process.platform}-${process.arch}`; + const platformPkgPath = createRequire(typescriptPkgPath).resolve(`${platformPkgName}/package.json`); + const binName = process.platform === 'win32' ? 'tsc.exe' : 'tsc'; + return fileURLToPath(new URL(`./lib/${binName}`, pathToFileURL(platformPkgPath))); +} + +function prepare() { + if (!existsSync(tsgoBinPath)) { + if (process.env['TSGO_BIN']) { + throw new Error(`tsgo binary not found at TSGO_BIN (${tsgoBinPath}).`); + } + throw new Error(`Native tsc binary not found at ${tsgoBinPath}. Run \`pnpm install\` to install it.`); + } execSync('vp run build', { stdio: 'inherit' }); +} + +export default function setup(project: TestProject) { + prepare(); project.onTestsRerun(() => { - execSync('vp run build', { stdio: 'inherit' }); + prepare(); }); } diff --git a/tsconfig.build.json b/tsconfig.build.json index ff90c88c..7b47983c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,6 +6,7 @@ "references": [ { "path": "./packages/core/tsconfig.build.json" }, { "path": "./packages/codegen/tsconfig.build.json" }, + { "path": "./packages/content-mapper/tsconfig.build.json" }, { "path": "./packages/ts-plugin/tsconfig.build.json" }, { "path": "./packages/vscode/tsconfig.build.json" }, { "path": "./packages/stylelint-plugin/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index f2f6ede1..766625da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.base.json", "include": ["**/*", ".changeset/custom-changelog-github.ts"], - "exclude": ["node_modules", "**/dist", "examples"], + "exclude": ["node_modules", "**/dist", "examples", ".tmp"], "compilerOptions": { "target": "ES2022", "lib": ["ESNext"],