From e0013c1b26699c9de309423b7bb6ca85afdb47f5 Mon Sep 17 00:00:00 2001 From: mizdra Date: Sun, 9 Aug 2026 15:12:43 +0900 Subject: [PATCH 1/8] feat(content-mapper): add walking skeleton of content mapper server for TypeScript 7 --- .changeset/config.json | 2 +- packages/content-mapper/package.json | 30 ++++ packages/content-mapper/src/error.ts | 6 + packages/content-mapper/src/main.ts | 3 + packages/content-mapper/src/protocol.ts | 112 +++++++++++++++ packages/content-mapper/src/server.test.ts | 143 ++++++++++++++++++++ packages/content-mapper/src/server.ts | 115 ++++++++++++++++ packages/content-mapper/tsconfig.build.json | 17 +++ pnpm-lock.yaml | 6 + tsconfig.build.json | 1 + 10 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 packages/content-mapper/package.json create mode 100644 packages/content-mapper/src/error.ts create mode 100644 packages/content-mapper/src/main.ts create mode 100644 packages/content-mapper/src/protocol.ts create mode 100644 packages/content-mapper/src/server.test.ts create mode 100644 packages/content-mapper/src/server.ts create mode 100644 packages/content-mapper/tsconfig.build.json 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/packages/content-mapper/package.json b/packages/content-mapper/package.json new file mode 100644 index 00000000..1068c385 --- /dev/null +++ b/packages/content-mapper/package.json @@ -0,0 +1,30 @@ +{ + "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:^" + }, + "engines": { + "node": ">=22.12.0" + }, + "tsContentMapper": { + "exec": [ + "node", + "dist/main.js" + ] + } +} 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..9e6cc300 --- /dev/null +++ b/packages/content-mapper/src/protocol.ts @@ -0,0 +1,112 @@ +// Type definitions for the content mapper protocol of TypeScript 7 (microsoft/typescript-go#4712). +// The wire format is JSON-RPC 2.0 with LSP-style `Content-Length` framing. + +export const PROTOCOL_VERSION = 1; +export const DIAGNOSTIC_SOURCE = 'cmk'; + +export const METHOD_NOT_FOUND = -32601; + +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 { + protocolVersion: number; + locale?: string; + positionEncodings: PositionEncoding[]; +} + +export interface InitializeResult { + protocolVersion: number; + positionEncoding: PositionEncoding; + diagnosticSource?: string; +} + +export interface TransformParams { + fileName: string; + content: string; + options?: unknown; + projectHandle?: string; + compilerOptions: Record; +} + +export interface TransformResult { + text: string; + /** How `text` should be parsed. A value of `ts.ScriptKind`. Defaults to TypeScript if omitted. */ + scriptKind?: number; + mappings?: SpanMapping[]; + diagnostics?: MapperDiagnostic[]; +} + +/** 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, + SourceDefinition: 1 << 6, + References: 1 << 7, + DocumentHighlights: 1 << 8, + Rename: 1 << 9, + CallHierarchy: 1 << 10, + CodeActions: 1 << 11, + Formatting: 1 << 12, + InlayHints: 1 << 13, + SemanticTokens: 1 << 14, + FoldingRanges: 1 << 15, + SelectionRanges: 1 << 16, + LinkedEditing: 1 << 17, + AutoInsert: 1 << 18, + DocumentSymbols: 1 << 19, + CodeLens: 1 << 20, + All: (1 << 21) - 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; +} diff --git a/packages/content-mapper/src/server.test.ts b/packages/content-mapper/src/server.test.ts new file mode 100644 index 00000000..095ecea4 --- /dev/null +++ b/packages/content-mapper/src/server.test.ts @@ -0,0 +1,143 @@ +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[] { + const data = (output.read() as Uint8Array | null) ?? new Uint8Array(0); + let rest = new TextDecoder().decode(data); + 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: { protocolVersion: 1, positionEncodings: ['utf-8', 'utf-16'] }, + }; +} + +function createInitializeResponse(id: number) { + return { + jsonrpc: '2.0', + id, + result: { protocolVersion: 1, positionEncoding: 'utf-16', diagnosticSource: 'cmk' }, + }; +} + +function createTransformRequest(id: number, content: string) { + return { + jsonrpc: '2.0', + id, + method: 'transform', + params: { fileName: '/a.module.css', content, compilerOptions: {} }, + }; +} + +function createTransformResponse(id: number) { + return { jsonrpc: '2.0', id, result: { text: 'export {};\n', mappings: [] } }; +} + +test('responds to initialize with protocol version 1, 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([ + { + jsonrpc: '2.0', + id: 1, + result: { protocolVersion: 1, positionEncoding: 'utf-16', diagnosticSource: 'cmk' }, + }, + ]); +}); + +test('responds to transform with fixed text', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createInitializeRequest(1)); + writeFrame(input, createTransformRequest(2, '.a1 { color: red; }')); + input.end(); + await done; + expect(readResponses(output)).toEqual([createInitializeResponse(1), createTransformResponse(2)]); +}); + +test('responds with method-not-found error to unknown methods', async () => { + const { input, output, done } = startServer(); + writeFrame(input, { jsonrpc: '2.0', id: 1, method: 'openProject', params: {} }); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + { jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found: openProject' } }, + ]); +}); + +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. + writeFrame(input, createTransformRequest(1, '.あ { color: red; }')); + writeFrame(input, createInitializeRequest(2)); + input.end(); + await done; + expect(readResponses(output)).toEqual([createTransformResponse(1), createInitializeResponse(2)]); +}); + +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..0ba4bf8d --- /dev/null +++ b/packages/content-mapper/src/server.ts @@ -0,0 +1,115 @@ +import type { Readable, Writable } from 'node:stream'; +import { ProtocolError } from './error.js'; +import type { InitializeResult, RequestMessage, ResponseMessage, TransformResult } from './protocol.js'; +import { DIAGNOSTIC_SOURCE, METHOD_NOT_FOUND, PROTOCOL_VERSION } 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): ResponseMessage { + switch (request.method) { + case 'initialize': { + const result: InitializeResult = { + protocolVersion: PROTOCOL_VERSION, + positionEncoding: 'utf-16', + diagnosticSource: DIAGNOSTIC_SOURCE, + }; + return { jsonrpc: '2.0', id: request.id, result }; + } + case 'transform': { + const result: TransformResult = { text: 'export {};\n', mappings: [] }; + 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(); + 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))); + } + } + } 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/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..32802af8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,6 +327,12 @@ 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 + packages/core: dependencies: postcss: 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" }, From 5cf00345c3eb81cd4633b13d92b77a44fc7591c8 Mon Sep 17 00:00:00 2001 From: mizdra Date: Sun, 9 Aug 2026 22:28:29 +0900 Subject: [PATCH 2/8] feat(content-mapper): transform CSS Modules into typed TypeScript with span mappings --- .changeset/export-token-utilities.md | 5 + packages/content-mapper/package.json | 3 + packages/content-mapper/src/options.test.ts | 53 ++ packages/content-mapper/src/options.ts | 46 ++ packages/content-mapper/src/server.test.ts | 78 ++- packages/content-mapper/src/server.ts | 24 +- packages/content-mapper/src/test/render.ts | 88 +++ .../content-mapper/src/test/ts-program.ts | 81 +++ .../src/transformer-program.test.ts | 117 ++++ .../content-mapper/src/transformer.test.ts | 525 ++++++++++++++++++ packages/content-mapper/src/transformer.ts | 334 +++++++++++ packages/core/src/index.ts | 12 +- pnpm-lock.yaml | 4 + 13 files changed, 1360 insertions(+), 10 deletions(-) create mode 100644 .changeset/export-token-utilities.md create mode 100644 packages/content-mapper/src/options.test.ts create mode 100644 packages/content-mapper/src/options.ts create mode 100644 packages/content-mapper/src/test/render.ts create mode 100644 packages/content-mapper/src/test/ts-program.ts create mode 100644 packages/content-mapper/src/transformer-program.test.ts create mode 100644 packages/content-mapper/src/transformer.test.ts create mode 100644 packages/content-mapper/src/transformer.ts diff --git a/.changeset/export-token-utilities.md b/.changeset/export-token-utilities.md new file mode 100644 index 00000000..6ad17f8d --- /dev/null +++ b/.changeset/export-token-utilities.md @@ -0,0 +1,5 @@ +--- +'@css-modules-kit/core': minor +--- + +feat(core): export `validateTokenName`, `isURLSpecifier`, and token reference types diff --git a/packages/content-mapper/package.json b/packages/content-mapper/package.json index 1068c385..95a99765 100644 --- a/packages/content-mapper/package.json +++ b/packages/content-mapper/package.json @@ -18,6 +18,9 @@ "dependencies": { "@css-modules-kit/core": "workspace:^" }, + "devDependencies": { + "typescript": "^6.0.3" + }, "engines": { "node": ">=22.12.0" }, diff --git a/packages/content-mapper/src/options.test.ts b/packages/content-mapper/src/options.test.ts new file mode 100644 index 00000000..47bf3e92 --- /dev/null +++ b/packages/content-mapper/src/options.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from 'vite-plus/test'; +import { normalizeMapperOptions } from './options.js'; + +const defaultOptions = { + namedExports: false, + prioritizeNamedImports: false, + animation: true, + dashedIdents: false, + container: false, +}; + +test('returns default options when raw options are undefined', () => { + expect(normalizeMapperOptions(undefined)).toEqual({ options: defaultOptions, errors: [] }); +}); + +test('applies boolean options', () => { + expect( + normalizeMapperOptions({ + namedExports: true, + prioritizeNamedImports: true, + animation: false, + dashedIdents: true, + container: true, + }), + ).toEqual({ + options: { + namedExports: true, + prioritizeNamedImports: true, + animation: false, + dashedIdents: true, + container: true, + }, + errors: [], + }); +}); + +test('ignores unknown keys', () => { + expect(normalizeMapperOptions({ unknown: true })).toEqual({ options: defaultOptions, errors: [] }); +}); + +test('reports an error and returns default options when raw options are not an object', () => { + expect(normalizeMapperOptions('yes')).toEqual({ + options: defaultOptions, + errors: ['Options must be an object.'], + }); +}); + +test('reports an error and keeps the default when an option is not a boolean', () => { + expect(normalizeMapperOptions({ animation: 'yes' })).toEqual({ + options: defaultOptions, + errors: ['`animation` must be a boolean.'], + }); +}); diff --git a/packages/content-mapper/src/options.ts b/packages/content-mapper/src/options.ts new file mode 100644 index 00000000..965c4405 --- /dev/null +++ b/packages/content-mapper/src/options.ts @@ -0,0 +1,46 @@ +export interface NormalizedMapperOptions { + namedExports: boolean; + prioritizeNamedImports: boolean; + animation: boolean; + dashedIdents: boolean; + container: boolean; +} + +export interface NormalizeMapperOptionsResult { + options: NormalizedMapperOptions; + errors: string[]; +} + +const DEFAULT_OPTIONS: NormalizedMapperOptions = { + namedExports: false, + prioritizeNamedImports: false, + animation: true, + dashedIdents: false, + container: false, +}; + +const OPTION_KEYS = Object.keys(DEFAULT_OPTIONS) as (keyof NormalizedMapperOptions)[]; + +/** + * Normalizes the raw `options` value of a transform request. Invalid values fall back to + * the defaults, and a human-readable error is collected for each of them. + */ +export function normalizeMapperOptions(raw: unknown): NormalizeMapperOptionsResult { + const options = { ...DEFAULT_OPTIONS }; + const errors: string[] = []; + if (raw === undefined) return { options, errors }; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + errors.push('Options must be an object.'); + return { options, errors }; + } + for (const key of OPTION_KEYS) { + if (!(key in raw)) continue; + const value = (raw as Record)[key]; + if (typeof value === 'boolean') { + options[key] = value; + } else { + errors.push(`\`${key}\` must be a boolean.`); + } + } + return { options, errors }; +} diff --git a/packages/content-mapper/src/server.test.ts b/packages/content-mapper/src/server.test.ts index 095ecea4..8dc3742b 100644 --- a/packages/content-mapper/src/server.test.ts +++ b/packages/content-mapper/src/server.test.ts @@ -1,6 +1,16 @@ import { PassThrough } from 'node:stream'; import { expect, test } from 'vite-plus/test'; +import type { NormalizedMapperOptions } from './options.js'; import { runServer } from './server.js'; +import { transformCSSModule } from './transformer.js'; + +const defaultMapperOptions: NormalizedMapperOptions = { + namedExports: false, + prioritizeNamedImports: false, + animation: true, + dashedIdents: false, + container: false, +}; function startServer() { const input = new PassThrough(); @@ -64,8 +74,17 @@ function createTransformRequest(id: number, content: string) { }; } -function createTransformResponse(id: number) { - return { jsonrpc: '2.0', id, result: { text: 'export {};\n', mappings: [] } }; +function createTransformResponse(id: number, content: string) { + const { text, mappings, diagnostics } = transformCSSModule('/a.module.css', content, defaultMapperOptions); + return { + jsonrpc: '2.0', + id, + result: { + text, + ...(mappings.length > 0 ? { mappings } : {}), + ...(diagnostics.length > 0 ? { diagnostics } : {}), + }, + }; } test('responds to initialize with protocol version 1, utf-16 encoding, and cmk diagnostic source', async () => { @@ -82,13 +101,56 @@ test('responds to initialize with protocol version 1, utf-16 encoding, and cmk d ]); }); -test('responds to transform with fixed text', async () => { +test('responds to transform with generated text and span mappings', async () => { const { input, output, done } = startServer(); writeFrame(input, createInitializeRequest(1)); writeFrame(input, createTransformRequest(2, '.a1 { color: red; }')); input.end(); await done; - expect(readResponses(output)).toEqual([createInitializeResponse(1), createTransformResponse(2)]); + expect(readResponses(output)).toEqual([ + createInitializeResponse(1), + createTransformResponse(2, '.a1 { color: red; }'), + ]); +}); + +test('applies mapper options from transform params', async () => { + const { input, output, done } = startServer(); + writeFrame(input, { + jsonrpc: '2.0', + id: 1, + method: 'transform', + params: { fileName: '/a.module.css', content: '', options: { namedExports: true }, compilerOptions: {} }, + }); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + { jsonrpc: '2.0', id: 1, result: { text: 'declare const styles: {};\nexport default styles;\n' } }, + ]); +}); + +test('reports option normalization errors as diagnostics at the file head', async () => { + const content = '.a1 { color: red; }'; + const { input, output, done } = startServer(); + writeFrame(input, { + jsonrpc: '2.0', + id: 1, + method: 'transform', + params: { fileName: '/a.module.css', content, options: { animation: 'yes' }, compilerOptions: {} }, + }); + input.end(); + await done; + const expected = transformCSSModule('/a.module.css', content, defaultMapperOptions); + expect(readResponses(output)).toEqual([ + { + jsonrpc: '2.0', + id: 1, + result: { + text: expected.text, + mappings: expected.mappings, + diagnostics: [{ messageText: '`animation` must be a boolean.', start: 0, length: 0 }], + }, + }, + ]); }); test('responds with method-not-found error to unknown methods', async () => { @@ -128,12 +190,14 @@ test('parses multiple frames arriving in a single chunk', async () => { 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. - writeFrame(input, createTransformRequest(1, '.あ { color: red; }')); + // 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, createTransformRequest(1, content)); writeFrame(input, createInitializeRequest(2)); input.end(); await done; - expect(readResponses(output)).toEqual([createTransformResponse(1), createInitializeResponse(2)]); + expect(readResponses(output)).toEqual([createTransformResponse(1, content), createInitializeResponse(2)]); }); test('resolves when input ends', async () => { diff --git a/packages/content-mapper/src/server.ts b/packages/content-mapper/src/server.ts index 0ba4bf8d..858b8db9 100644 --- a/packages/content-mapper/src/server.ts +++ b/packages/content-mapper/src/server.ts @@ -1,7 +1,16 @@ import type { Readable, Writable } from 'node:stream'; import { ProtocolError } from './error.js'; -import type { InitializeResult, RequestMessage, ResponseMessage, TransformResult } from './protocol.js'; +import { normalizeMapperOptions } from './options.js'; +import type { + InitializeResult, + MapperDiagnostic, + RequestMessage, + ResponseMessage, + TransformParams, + TransformResult, +} from './protocol.js'; import { DIAGNOSTIC_SOURCE, METHOD_NOT_FOUND, PROTOCOL_VERSION } from './protocol.js'; +import { transformCSSModule } from './transformer.js'; const HEADER_TERMINATOR = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]); // '\r\n\r\n' @@ -72,7 +81,18 @@ function createResponse(request: RequestMessage): ResponseMessage { return { jsonrpc: '2.0', id: request.id, result }; } case 'transform': { - const result: TransformResult = { text: 'export {};\n', mappings: [] }; + const params = request.params as TransformParams; + const { options, errors } = normalizeMapperOptions(params.options); + const output = transformCSSModule(params.fileName, params.content, options); + const diagnostics: MapperDiagnostic[] = [ + ...errors.map((message) => ({ messageText: message, start: 0, length: 0 })), + ...output.diagnostics, + ]; + const result: TransformResult = { + text: output.text, + ...(output.mappings.length > 0 ? { mappings: output.mappings } : {}), + ...(diagnostics.length > 0 ? { diagnostics } : {}), + }; return { jsonrpc: '2.0', id: request.id, result }; } default: diff --git a/packages/content-mapper/src/test/render.ts b/packages/content-mapper/src/test/render.ts new file mode 100644 index 00000000..986fbee9 --- /dev/null +++ b/packages/content-mapper/src/test/render.ts @@ -0,0 +1,88 @@ +import { SpanMapFeature, SpanMapKind } from '../protocol.js'; +import type { TransformOutput } from '../transformer.js'; + +interface Marker { + label: string; + offset: number; + length: number; +} + +interface PositionedMarker extends Marker { + line: number; + column: number; +} + +const KIND_NAMES: Record = { + [SpanMapKind.Verbatim]: 'Verbatim', + [SpanMapKind.Atom]: 'Atom', + [SpanMapKind.Alias]: 'Alias', +}; + +function formatFeatures(features: number | undefined): string { + if (features === undefined) return ''; + const flags = Object.entries(SpanMapFeature).filter(([name]) => name !== 'All'); + const included = flags.filter(([, bit]) => (features & bit) !== 0).map(([name]) => name); + const excluded = flags.filter(([, bit]) => (features & bit) === 0).map(([name]) => name); + if (excluded.length === 0) return '(All)'; + if (excluded.length < included.length) return `(All~${excluded.join('~')})`; + return `(${included.join('|')})`; +} + +function renderMarkerLine(marker: PositionedMarker): string { + const indent = ' '.repeat(marker.column); + const carets = marker.length === 0 ? '¦' : '^'.repeat(marker.length); + return `${indent}${carets} ${marker.label}`; +} + +function offsetToPosition(text: string, offset: number): { line: number; column: number } { + let line = 1; + let lineStart = 0; + for (let i = 0; i < offset; i++) { + if (text[i] === '\n') { + line++; + lineStart = i + 1; + } + } + return { line, column: offset - lineStart }; +} + +function renderTextWithMarkers(text: string, markers: Marker[]): string { + const positioned: PositionedMarker[] = markers.map((m) => { + const { line, column } = offsetToPosition(text, m.offset); + return { ...m, line, column }; + }); + + const markersByLine = Map.groupBy(positioned, (m) => m.line); + + const result: string[] = []; + const lines = text.split('\n'); + for (const [i, line] of lines.entries()) { + result.push(line); + const lineMarkers = (markersByLine.get(i + 1) ?? []).toSorted((a, b) => b.column - a.column); + for (const marker of lineMarkers) { + result.push(renderMarkerLine(marker)); + } + } + return result.join('\n'); +} + +export function renderTransformOutput(source: string, output: TransformOutput): string { + const sourceMarkers: Marker[] = [ + ...output.mappings.map((mapping, i) => ({ label: `#${i}`, offset: mapping[2], length: mapping[3] })), + ...output.diagnostics.map((diagnostic, i) => ({ + label: `diag#${i}`, + offset: diagnostic.start, + length: diagnostic.length, + })), + ]; + const generatedMarkers: Marker[] = output.mappings.map((mapping, i) => ({ + label: `#${i} ${KIND_NAMES[mapping[4]]}${formatFeatures(mapping[5])}`, + offset: mapping[0], + length: mapping[1], + })); + let result = `=== source ===\n${renderTextWithMarkers(source, sourceMarkers)}\n\n=== generated ===\n${renderTextWithMarkers(output.text, generatedMarkers)}`; + if (output.diagnostics.length > 0) { + result += `\n\n=== diagnostics ===\n${output.diagnostics.map((d, i) => `diag#${i}: ${d.messageText}`).join('\n')}`; + } + return result; +} diff --git a/packages/content-mapper/src/test/ts-program.ts b/packages/content-mapper/src/test/ts-program.ts new file mode 100644 index 00000000..abcbe31d --- /dev/null +++ b/packages/content-mapper/src/test/ts-program.ts @@ -0,0 +1,81 @@ +import ts from 'typescript'; +import type { NormalizedMapperOptions } from '../options.js'; +import type { TransformOutput } from '../transformer.js'; +import { transformCSSModule } from '../transformer.js'; + +export interface SimplifiedTsDiagnostic { + code: number; + fileName: string | undefined; + start: number | undefined; + length: number | undefined; + message: string; +} + +const COMPILER_OPTIONS: ts.CompilerOptions = { + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noUncheckedIndexedAccess: true, + noPropertyAccessFromIndexSignature: true, + noImplicitReturns: true, + exactOptionalPropertyTypes: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ES2022, + noEmit: true, + skipLibCheck: true, +}; + +/** + * Type-checks the generated text of the given CSS Modules with an in-memory program. + * Each CSS module is registered as `.ts`, and import specifiers resolve to + * those files, mimicking how tsgo resolves `.module.css` imports via a content mapper. + */ +export function checkGeneratedTexts( + cssFiles: Record, + options: NormalizedMapperOptions, +): { outputs: Record; diagnostics: SimplifiedTsDiagnostic[] } { + const outputs: Record = {}; + const tsFiles = new Map(); + for (const [fileName, source] of Object.entries(cssFiles)) { + const output = transformCSSModule(fileName, source, options); + outputs[fileName] = output; + tsFiles.set(`${fileName}.ts`, output.text); + } + const baseHost = ts.createCompilerHost(COMPILER_OPTIONS); + const host: ts.CompilerHost = { + ...baseHost, + fileExists: (fileName) => tsFiles.has(fileName) || baseHost.fileExists(fileName), + readFile: (fileName) => tsFiles.get(fileName) ?? baseHost.readFile(fileName), + getSourceFile: (fileName, languageVersionOrOptions) => + tsFiles.has(fileName) + ? ts.createSourceFile(fileName, tsFiles.get(fileName)!, languageVersionOrOptions) + : baseHost.getSourceFile(fileName, languageVersionOrOptions), + resolveModuleNameLiterals: (literals, containingFile) => + literals.map((literal) => { + const resolvedFileName = `${resolveSpecifier(containingFile, literal.text)}.ts`; + if (tsFiles.has(resolvedFileName)) { + return { + resolvedModule: { resolvedFileName, extension: ts.Extension.Ts, isExternalLibraryImport: false }, + }; + } + return { resolvedModule: undefined }; + }), + writeFile: () => {}, + }; + const program = ts.createProgram([...tsFiles.keys()], COMPILER_OPTIONS, host); + const diagnostics = ts.getPreEmitDiagnostics(program).map((diagnostic) => ({ + code: diagnostic.code, + fileName: diagnostic.file?.fileName, + start: diagnostic.start, + length: diagnostic.length, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + })); + return { outputs, diagnostics }; +} + +function resolveSpecifier(containingFile: string, specifier: string): string { + const dir = containingFile.slice(0, containingFile.lastIndexOf('/')); + if (specifier.startsWith('./')) return `${dir}/${specifier.slice(2)}`; + return specifier; +} diff --git a/packages/content-mapper/src/transformer-program.test.ts b/packages/content-mapper/src/transformer-program.test.ts new file mode 100644 index 00000000..4f95da40 --- /dev/null +++ b/packages/content-mapper/src/transformer-program.test.ts @@ -0,0 +1,117 @@ +import dedent from 'dedent'; +import { expect, test } from 'vite-plus/test'; +import type { NormalizedMapperOptions } from './options.js'; +import { SpanMapKind } from './protocol.js'; +import { checkGeneratedTexts } from './test/ts-program.js'; + +const defaultOptions: NormalizedMapperOptions = { + namedExports: false, + prioritizeNamedImports: false, + animation: true, + dashedIdents: false, + container: false, +}; +const namedExportsOptions: NormalizedMapperOptions = { ...defaultOptions, namedExports: true }; + +const fullFixture = { + '/a.module.css': dedent` + @import './b.module.css'; + @value v1, v2 as v3 from './c.module.css'; + .foo { animation-name: pulse; } + .bar { composes: baz from './d.module.css'; } + @keyframes pulse {} + `, + '/b.module.css': '.b1 { color: red; }', + '/c.module.css': dedent` + @value v1: red; + @value v2: blue; + `, + '/d.module.css': '.baz { color: red; }', +}; + +test('produces no ts diagnostics for generated text under strict compiler options', () => { + const { diagnostics } = checkGeneratedTexts(fullFixture, defaultOptions); + expect(diagnostics).toEqual([]); +}); + +test('produces no ts diagnostics for generated text under strict compiler options in named exports mode', () => { + const { diagnostics } = checkGeneratedTexts(fullFixture, namedExportsOptions); + expect(diagnostics).toEqual([]); +}); + +test('reports a module resolution error on the specifier span for unresolvable specifiers', () => { + const { outputs, diagnostics } = checkGeneratedTexts( + { '/a.module.css': `@import './missing.module.css';` }, + defaultOptions, + ); + const text = outputs['/a.module.css']!.text; + const specifierStart = text.indexOf(`'./missing.module.css'`); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: 2307, + fileName: '/a.module.css.ts', + start: specifierStart, + length: `'./missing.module.css'`.length, + }), + ]); + expect(outputs['/a.module.css']!.mappings).toContainEqual([specifierStart, 22, 8, 22, SpanMapKind.Verbatim]); +}); + +test('reports a missing token error on the token span for named token importer entries', () => { + const { outputs, diagnostics } = checkGeneratedTexts( + { + '/a.module.css': `@value missing from './b.module.css';`, + '/b.module.css': '.b1 { color: red; }', + }, + defaultOptions, + ); + const text = outputs['/a.module.css']!.text; + const keyStart = text.indexOf(`default['missing']`) + 'default['.length; + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: 2339, + fileName: '/a.module.css.ts', + start: keyStart, + length: `'missing'`.length, + }), + ]); + expect(outputs['/a.module.css']!.mappings).toContainEqual([keyStart + 1, 7, 7, 7, SpanMapKind.Verbatim]); +}); + +test('reports a missing token error on the token span for export from entries in named exports mode', () => { + const { outputs, diagnostics } = checkGeneratedTexts( + { + '/a.module.css': `@value missing from './b.module.css';`, + '/b.module.css': '.b1 { color: red; }', + }, + namedExportsOptions, + ); + const text = outputs['/a.module.css']!.text; + const nameStart = text.indexOf(`'missing'`); + // TS2614 (not TS2305) because the generated text of b.module.css also has a default export. + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: 2614, + fileName: '/a.module.css.ts', + start: nameStart, + length: `'missing'`.length, + }), + ]); +}); + +test('reports an implicit any error for local token references to unknown tokens', () => { + const { outputs, diagnostics } = checkGeneratedTexts( + { '/a.module.css': '.foo { animation-name: missing; }' }, + defaultOptions, + ); + const text = outputs['/a.module.css']!.text; + const expressionStart = text.indexOf(`styles['missing']`); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: 7053, + fileName: '/a.module.css.ts', + start: expressionStart, + length: `styles['missing']`.length, + }), + ]); +}); diff --git a/packages/content-mapper/src/transformer.test.ts b/packages/content-mapper/src/transformer.test.ts new file mode 100644 index 00000000..52d68236 --- /dev/null +++ b/packages/content-mapper/src/transformer.test.ts @@ -0,0 +1,525 @@ +import dedent from 'dedent'; +import { describe, expect, test } from 'vite-plus/test'; +import type { NormalizedMapperOptions } from './options.js'; +import { renderTransformOutput } from './test/render.js'; +import { transformCSSModule } from './transformer.js'; + +const defaultOptions: NormalizedMapperOptions = { + namedExports: false, + prioritizeNamedImports: false, + animation: true, + dashedIdents: false, + container: false, +}; +const namedExportsOptions: NormalizedMapperOptions = { ...defaultOptions, namedExports: true }; + +function run(source: string, options: NormalizedMapperOptions = defaultOptions): string { + return renderTransformOutput(source, transformCSSModule('/test/a.module.css', source, options)); +} + +test('generates interface declarations for local tokens', () => { + const result = run(dedent` + .foo {} + .bar {} + `); + expect(result).toMatchInlineSnapshot(` + "=== source === + .foo {} + ¦ #2 + ¦ #0 + ^^^ #1 + .bar {} + ¦ #5 + ¦ #3 + ^^^ #4 + + === generated === + interface Styles { readonly 'foo': string; } + ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #1 Verbatim + ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + interface Styles { readonly 'bar': string; } + ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #4 Verbatim + ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const styles: Styles; + export default styles; + " + `); +}); + +test('generates a namespace import and an intersection type for all token importers', () => { + expect(run(`@import './b.module.css';`)).toMatchInlineSnapshot(` + "=== source === + @import './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 + + === generated === + import * as _import_0 from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + type __BlockErrorType = [0] extends [1 & T] ? {} : T; + interface Styles {} + declare const styles: Styles & __BlockErrorType; + export default styles; + " + `); +}); + +test('generates indexed access type members for named token importer entries', () => { + expect(run(`@value v1, v2 as v3 from './c.module.css';`)).toMatchInlineSnapshot(` + "=== source === + @value v1, v2 as v3 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #0 + ¦ #9 + ¦ #7 + ^^ #8 + ¦ #12 + ¦ #10 + ^^ #11 + ¦ #3 + ¦ #6 + ¦ #1 + ^^ #2 + ¦ #4 + ^^ #5 + + === generated === + import * as _import_0 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + interface Styles { readonly 'v1': typeof _import_0.default['v1']; } + ^ #6 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^ #5 Verbatim + ^ #4 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^ #2 Verbatim + ^ #1 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + interface Styles { readonly 'v3': typeof _import_0.default['v2']; } + ^ #12 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^ #11 Verbatim + ^ #10 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #9 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^ #8 Verbatim + ^ #7 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const styles: Styles; + export default styles; + " + `); +}); + +test('omits imports for URL specifiers and non css module specifiers', () => { + const result = run(dedent` + @import 'https://example.com/a.module.css'; + @import './plain.css'; + `); + expect(result).toMatchInlineSnapshot(` + "=== source === + @import 'https://example.com/a.module.css'; + @import './plain.css'; + + === generated === + interface Styles {} + declare const styles: Styles; + export default styles; + " + `); +}); + +test('generates expression statements for local token references', () => { + const result = run(dedent` + .foo { animation-name: pulse; } + @keyframes pulse {} + `); + expect(result).toMatchInlineSnapshot(` + "=== source === + .foo { animation-name: pulse; } + ¦ #8 + ¦ #6 + ^^^^^ #7 + ¦ #2 + ¦ #0 + ^^^ #1 + @keyframes pulse {} + ¦ #5 + ¦ #3 + ^^^^^ #4 + + === generated === + interface Styles { readonly 'foo': string; } + ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #1 Verbatim + ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + interface Styles { readonly 'pulse': string; } + ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^^^ #4 Verbatim + ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const styles: Styles; + styles['pulse']; + ^ #8 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^^^ #7 Verbatim + ^ #6 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + export default styles; + " + `); +}); + +test('generates imports and expression statements for external token references', () => { + expect(run(`.foo { composes: baz from './d.module.css'; }`)).toMatchInlineSnapshot(` + "=== source === + .foo { composes: baz from './d.module.css'; } + ^^^^^^^^^^^^^^^^ #0 + ¦ #6 + ¦ #4 + ^^^ #5 + ¦ #3 + ¦ #1 + ^^^ #2 + + === generated === + import * as _import_0 from './d.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + interface Styles { readonly 'foo': string; } + ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #2 Verbatim + ^ #1 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const styles: Styles; + _import_0.default['baz']; + ^ #6 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #5 Verbatim + ^ #4 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + export default styles; + " + `); +}); + +test('generates an interface declaration for every occurrence of a duplicated token name', () => { + const result = run(dedent` + .foo {} + .foo:hover {} + `); + expect(result).toMatchInlineSnapshot(` + "=== source === + .foo {} + ¦ #2 + ¦ #0 + ^^^ #1 + .foo:hover {} + ¦ #5 + ¦ #3 + ^^^ #4 + + === generated === + interface Styles { readonly 'foo': string; } + ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #1 Verbatim + ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + interface Styles { readonly 'foo': string; } + ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #4 Verbatim + ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const styles: Styles; + export default styles; + " + `); +}); + +test('generates a default export for an empty file', () => { + expect(run('')).toMatchInlineSnapshot(` + "=== source === + + + === generated === + interface Styles {} + declare const styles: Styles; + export default styles; + " + `); +}); + +test('quotes generated specifiers with the original quote character', () => { + expect(run(`@import "./b.module.css";`)).toMatchInlineSnapshot(` + "=== source === + @import "./b.module.css"; + ^^^^^^^^^^^^^^^^ #0 + + === generated === + import * as _import_0 from "./b.module.css"; + ^^^^^^^^^^^^^^^^ #0 Verbatim + type __BlockErrorType = [0] extends [1 & T] ? {} : T; + interface Styles {} + declare const styles: Styles & __BlockErrorType; + export default styles; + " + `); +}); + +test('converts parse diagnostics into mapper diagnostics', () => { + const result = run(dedent` + .foo { color: red; } + .bar { + `); + expect(result).toMatchInlineSnapshot(` + "=== source === + .foo { color: red; } + ¦ #2 + ¦ #0 + ^^^ #1 + .bar { + ¦ #5 + ¦ #3 + ^^^ #4 + ^ diag#0 + + === generated === + interface Styles { readonly 'foo': string; } + ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #1 Verbatim + ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + interface Styles { readonly 'bar': string; } + ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #4 Verbatim + ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const styles: Styles; + export default styles; + + + === diagnostics === + diag#0: Unclosed block" + `); +}); + +test('excludes invalid token names and reports diagnostics', () => { + expect(run('.__proto__ {}')).toMatchInlineSnapshot(` + "=== source === + .__proto__ {} + ^^^^^^^^^ diag#0 + + === generated === + interface Styles {} + declare const styles: Styles; + export default styles; + + + === diagnostics === + diag#0: \`__proto__\` is not allowed as names." + `); +}); + +test('omits keyframes tokens when animation is false', () => { + expect(run('@keyframes pulse {}', { ...defaultOptions, animation: false })).toMatchInlineSnapshot(` + "=== source === + @keyframes pulse {} + + === generated === + interface Styles {} + declare const styles: Styles; + export default styles; + " + `); +}); + +describe('namedExports', () => { + test('generates var declarations and export clauses for local tokens', () => { + const result = run( + dedent` + .foo {} + .foo:hover {} + .bar {} + `, + namedExportsOptions, + ); + expect(result).toMatchInlineSnapshot(` + "=== source === + .foo {} + ¦ #4 + ^^^ #0 + ¦ #2 + ^^^ #3 + .foo:hover {} + ^^^ #1 + .bar {} + ¦ #8 + ^^^ #5 + ¦ #6 + ^^^ #7 + + === generated === + var _token_0: string; + ^^^^^^^^ #0 Alias(All~Rename) + var _token_0: string; + ^^^^^^^^ #1 Alias(All~Rename) + export { _token_0 as 'foo' }; + ^ #4 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #3 Verbatim + ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + var _token_1: string; + ^^^^^^^^ #5 Alias(All~Rename) + export { _token_1 as 'bar' }; + ^ #8 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #7 Verbatim + ^ #6 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const styles: {}; + export default styles; + " + `); + }); + + test('generates export star for all token importers', () => { + expect(run(`@import './b.module.css';`, namedExportsOptions)).toMatchInlineSnapshot(` + "=== source === + @import './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 + + === generated === + export * from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + declare const styles: {}; + export default styles; + " + `); + }); + + test('generates export from clauses for named token importer entries', () => { + expect(run(`@value v1, v2 as v3 from './c.module.css';`, namedExportsOptions)).toMatchInlineSnapshot(` + "=== source === + @value v1, v2 as v3 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #12 + ¦ #11 + ¦ #9 + ^^ #10 + ¦ #8 + ¦ #6 + ^^ #7 + ¦ #2 + ¦ #5 + ¦ #0 + ^^ #1 + ¦ #3 + ^^ #4 + + === generated === + export { + 'v1' as 'v1', + ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^ #4 Verbatim + ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^ #1 Verbatim + ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + 'v2' as 'v3', + ^ #11 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^ #10 Verbatim + ^ #9 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #8 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^ #7 Verbatim + ^ #6 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + } from './c.module.css'; + ^^^^^^^^^^^^^^^^ #12 Verbatim + declare const styles: {}; + export default styles; + " + `); + }); + + test('generates self references for local token references', () => { + const result = run( + dedent` + .foo { animation-name: pulse; } + @keyframes pulse {} + `, + namedExportsOptions, + ); + expect(result).toMatchInlineSnapshot(` + "=== source === + .foo { animation-name: pulse; } + ¦ #10 + ¦ #8 + ^^^^^ #9 + ¦ #3 + ^^^ #0 + ¦ #1 + ^^^ #2 + @keyframes pulse {} + ¦ #7 + ^^^^^ #4 + ¦ #5 + ^^^^^ #6 + + === generated === + var _token_0: string; + ^^^^^^^^ #0 Alias(All~Rename) + export { _token_0 as 'foo' }; + ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #2 Verbatim + ^ #1 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + var _token_1: string; + ^^^^^^^^ #4 Alias(All~Rename) + export { _token_1 as 'pulse' }; + ^ #7 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^^^ #6 Verbatim + ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const __self: typeof import('./a.module.css'); + __self['pulse']; + ^ #10 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^^^ #9 Verbatim + ^ #8 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const styles: {}; + export default styles; + " + `); + }); + + test('generates namespace element accesses for external token references', () => { + expect(run(`.foo { composes: baz from './d.module.css'; }`, namedExportsOptions)).toMatchInlineSnapshot(` + "=== source === + .foo { composes: baz from './d.module.css'; } + ^^^^^^^^^^^^^^^^ #4 + ¦ #7 + ¦ #5 + ^^^ #6 + ¦ #3 + ^^^ #0 + ¦ #1 + ^^^ #2 + + === generated === + var _token_0: string; + ^^^^^^^^ #0 Alias(All~Rename) + export { _token_0 as 'foo' }; + ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #2 Verbatim + ^ #1 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + import * as _import_0 from './d.module.css'; + ^^^^^^^^^^^^^^^^ #4 Verbatim + _import_0['baz']; + ^ #7 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^ #6 Verbatim + ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + declare const styles: {}; + export default styles; + " + `); + }); + + test('generates a dummy default export when prioritizeNamedImports is false', () => { + expect(run('', namedExportsOptions)).toMatchInlineSnapshot(` + "=== source === + + + === generated === + declare const styles: {}; + export default styles; + " + `); + }); + + test('keeps the generated text a module when prioritizeNamedImports is true', () => { + expect(run('', { ...namedExportsOptions, prioritizeNamedImports: true })).toMatchInlineSnapshot(` + "=== source === + + + === generated === + export {}; + " + `); + }); +}); diff --git a/packages/content-mapper/src/transformer.ts b/packages/content-mapper/src/transformer.ts new file mode 100644 index 00000000..09c75a53 --- /dev/null +++ b/packages/content-mapper/src/transformer.ts @@ -0,0 +1,334 @@ +import type { + DiagnosticWithLocation, + Location, + NamedTokenImporterEntry, + Token, + TokenImporter, + TokenReference, +} from '@css-modules-kit/core'; +import { + basename, + CSS_MODULE_EXTENSION, + isURLSpecifier, + parseCSSModule, + validateTokenName, +} from '@css-modules-kit/core'; +import type { NormalizedMapperOptions } from './options.js'; +import type { MapperDiagnostic, SpanMapping } from './protocol.js'; +import { SpanMapFeature, SpanMapKind } from './protocol.js'; + +export interface TransformOutput { + text: string; + mappings: SpanMapping[]; + diagnostics: MapperDiagnostic[]; +} + +// The quotes around a generated token name have no counterpart in the CSS, so they are +// mapped as zero-width spans. Only definition-style features are enabled for them so that +// requests on the whole string literal still resolve to the token. +const QUOTE_FEATURES = + SpanMapFeature.Definition | + SpanMapFeature.TypeDefinition | + SpanMapFeature.Implementation | + SpanMapFeature.SourceDefinition | + SpanMapFeature.References; + +// Rename edits can only be written back through a Verbatim span, so alias spans exclude Rename. +const NON_RENAME_FEATURES = SpanMapFeature.All & ~SpanMapFeature.Rename; + +function createTextBuilder() { + let text = ''; + const mappings: SpanMapping[] = []; + return { + append(chunk: string): void { + text += chunk; + }, + /** Appends `'name'`, mapping the name to `loc` and the quotes to its boundaries. */ + appendTokenName(name: string, loc: Location): void { + mappings.push([text.length, 1, loc.start.offset, 0, SpanMapKind.Atom, QUOTE_FEATURES]); + mappings.push([text.length + 1, name.length, loc.start.offset, name.length, SpanMapKind.Verbatim]); + mappings.push([text.length + 1 + name.length, 1, loc.end.offset, 0, SpanMapKind.Atom, QUOTE_FEATURES]); + text += `'${name}'`; + }, + /** Appends the quoted specifier, mapping it (quotes included) to the original. */ + appendSpecifier(from: string, fromLoc: Location, quote: string): void { + mappings.push([text.length, from.length + 2, fromLoc.start.offset - 1, from.length + 2, SpanMapKind.Verbatim]); + text += `${quote}${from}${quote}`; + }, + /** Appends `name`, mapping it to `loc` as an alias of the original name. */ + appendAlias(name: string, loc: Location): void { + mappings.push([ + text.length, + name.length, + loc.start.offset, + loc.end.offset - loc.start.offset, + SpanMapKind.Alias, + NON_RENAME_FEATURES, + ]); + text += name; + }, + build(): { text: string; mappings: SpanMapping[] } { + return { text, mappings }; + }, + }; +} + +type TextBuilder = ReturnType; + +function isValidTokenName(name: string, options: NormalizedMapperOptions): boolean { + return validateTokenName(name, { namedExports: options.namedExports }) === undefined; +} + +function isValidEntry(entry: NamedTokenImporterEntry, options: NormalizedMapperOptions): boolean { + return ( + isValidTokenName(entry.name, options) && + (entry.localName === undefined || isValidTokenName(entry.localName, options)) + ); +} + +/** Specifiers that resolve to other CSS Modules. URL imports and plain CSS imports are left to bundlers. */ +function isImportableSpecifier(from: string): boolean { + return !isURLSpecifier(from) && from.endsWith(CSS_MODULE_EXTENSION); +} + +/** Verbatim mapping requires identical text, so the generated specifier reuses the original quote character. */ +function specifierQuote(content: string, fromLoc: Location): string { + const quote = content[fromLoc.start.offset - 1]; + return quote === '"' ? '"' : "'"; +} + +/** + * Transforms a CSS Module into TypeScript text for the content mapper protocol. + * The generated text delegates most validation to the TypeScript checker: importing a + * missing file or referencing a missing token becomes an ordinary type error, which tsgo + * maps back to the CSS through the returned span mappings. + */ +export function transformCSSModule( + fileName: string, + content: string, + options: NormalizedMapperOptions, +): TransformOutput { + const cssModule = parseCSSModule(content, { + fileName, + includeSyntaxError: true, + animation: options.animation, + dashedIdents: options.dashedIdents, + container: options.container, + namedExports: options.namedExports, + }); + const localTokens = cssModule.localTokens.filter((token) => isValidTokenName(token.name, options)); + const tokenImporters = cssModule.tokenImporters + .filter((tokenImporter) => isImportableSpecifier(tokenImporter.from)) + .map((tokenImporter) => + tokenImporter.type === 'named' + ? { ...tokenImporter, entries: tokenImporter.entries.filter((entry) => isValidEntry(entry, options)) } + : tokenImporter, + ); + const tokenReferences = cssModule.tokenReferences + .map((reference) => + reference.type === 'external' + ? { ...reference, entries: reference.entries.filter((entry) => isValidTokenName(entry.name, options)) } + : reference, + ) + .filter((reference) => + reference.type === 'local' + ? isValidTokenName(reference.name, options) + : isImportableSpecifier(reference.from) && reference.entries.length > 0, + ); + const { text, mappings } = options.namedExports + ? buildNamedExportsText( + fileName, + content, + localTokens, + tokenImporters, + tokenReferences, + options.prioritizeNamedImports, + ) + : buildDefaultExportText(content, localTokens, tokenImporters, tokenReferences); + return { text, mappings, diagnostics: convertDiagnostics(cssModule.diagnostics, content) }; +} + +function buildDefaultExportText( + content: string, + localTokens: Token[], + tokenImporters: TokenImporter[], + tokenReferences: TokenReference[], +): { text: string; mappings: SpanMapping[] } { + const builder = createTextBuilder(); + const importerBindings = new Map(); + const referenceBindings = new Map(); + let importCount = 0; + for (const tokenImporter of tokenImporters) { + if (tokenImporter.type === 'all' || tokenImporter.entries.length > 0) { + const binding = `_import_${importCount++}`; + importerBindings.set(tokenImporter, binding); + builder.append(`import * as ${binding} from `); + } else { + // A side-effect import keeps module resolution errors even when no entry is usable. + builder.append('import '); + } + appendImportSpecifier(builder, content, tokenImporter); + } + for (const reference of tokenReferences) { + if (reference.type !== 'external') continue; + const binding = `_import_${importCount++}`; + referenceBindings.set(reference, binding); + builder.append(`import * as ${binding} from `); + appendImportSpecifier(builder, content, reference); + } + const allImporters = tokenImporters.filter((tokenImporter) => tokenImporter.type === 'all'); + if (allImporters.length > 0) { + // Maps an `any`-typed module (e.g. an unresolvable import) to `{}` so that it does not + // absorb the other intersection members. + builder.append('type __BlockErrorType = [0] extends [1 & T] ? {} : T;\n'); + } + // Each token occurrence gets its own interface declaration so that duplicated names + // merge instead of colliding, while every occurrence stays a declaration. + let hasMembers = false; + for (const token of localTokens) { + builder.append('interface Styles { readonly '); + builder.appendTokenName(token.name, token.loc); + builder.append(': string; }\n'); + hasMembers = true; + } + for (const tokenImporter of tokenImporters) { + if (tokenImporter.type !== 'named') continue; + const binding = importerBindings.get(tokenImporter)!; + for (const entry of tokenImporter.entries) { + builder.append('interface Styles { readonly '); + builder.appendTokenName(entry.localName ?? entry.name, entry.localLoc ?? entry.loc); + builder.append(`: typeof ${binding}.default[`); + builder.appendTokenName(entry.name, entry.loc); + builder.append(']; }\n'); + hasMembers = true; + } + } + if (!hasMembers) builder.append('interface Styles {}\n'); + builder.append('declare const styles: Styles'); + for (const allImporter of allImporters) { + builder.append(` & __BlockErrorType`); + } + builder.append(';\n'); + for (const reference of tokenReferences) { + if (reference.type === 'local') { + builder.append('styles['); + builder.appendTokenName(reference.name, reference.loc); + builder.append('];\n'); + } else { + const binding = referenceBindings.get(reference)!; + for (const entry of reference.entries) { + builder.append(`${binding}.default[`); + builder.appendTokenName(entry.name, entry.loc); + builder.append('];\n'); + } + } + } + builder.append('export default styles;\n'); + return builder.build(); +} + +function buildNamedExportsText( + fileName: string, + content: string, + localTokens: Token[], + tokenImporters: TokenImporter[], + tokenReferences: TokenReference[], + prioritizeNamedImports: boolean, +): { text: string; mappings: SpanMapping[] } { + const builder = createTextBuilder(); + let isModule = false; + const groups = Object.groupBy(localTokens, (token) => token.name); + for (const [index, [name, tokens]] of Object.entries(groups).entries()) { + if (tokens === undefined) continue; + const alias = `_token_${index}`; + for (const token of tokens) { + builder.append('var '); + builder.appendAlias(alias, token.loc); + builder.append(': string;\n'); + } + builder.append(`export { ${alias} as `); + builder.appendTokenName(name, tokens[0]!.loc); + builder.append(' };\n'); + isModule = true; + } + for (const tokenImporter of tokenImporters) { + if (tokenImporter.type === 'all') { + builder.append('export * from '); + } else { + builder.append('export {\n'); + for (const entry of tokenImporter.entries) { + builder.append(' '); + builder.appendTokenName(entry.name, entry.loc); + builder.append(' as '); + builder.appendTokenName(entry.localName ?? entry.name, entry.localLoc ?? entry.loc); + builder.append(',\n'); + } + builder.append('} from '); + } + appendImportSpecifier(builder, content, tokenImporter); + isModule = true; + } + const referenceBindings = new Map(); + let importCount = 0; + for (const reference of tokenReferences) { + if (reference.type !== 'external') continue; + const binding = `_import_${importCount++}`; + referenceBindings.set(reference, binding); + builder.append(`import * as ${binding} from `); + appendImportSpecifier(builder, content, reference); + isModule = true; + } + if (tokenReferences.some((reference) => reference.type === 'local')) { + builder.append(`declare const __self: typeof import('./${basename(fileName)}');\n`); + } + for (const reference of tokenReferences) { + if (reference.type === 'local') { + builder.append('__self['); + builder.appendTokenName(reference.name, reference.loc); + builder.append('];\n'); + } else { + const binding = referenceBindings.get(reference)!; + for (const entry of reference.entries) { + builder.append(`${binding}[`); + builder.appendTokenName(entry.name, entry.loc); + builder.append('];\n'); + } + } + } + if (!prioritizeNamedImports) { + builder.append('declare const styles: {};\nexport default styles;\n'); + isModule = true; + } + if (!isModule) builder.append('export {};\n'); + return builder.build(); +} + +function appendImportSpecifier( + builder: TextBuilder, + content: string, + importer: { from: string; fromLoc: Location }, +): void { + builder.appendSpecifier(importer.from, importer.fromLoc, specifierQuote(content, importer.fromLoc)); + builder.append(';\n'); +} + +function convertDiagnostics(diagnostics: DiagnosticWithLocation[], content: string): MapperDiagnostic[] { + return diagnostics + .filter((diagnostic) => diagnostic.category === 'error') + .map((diagnostic) => ({ + messageText: diagnostic.text, + start: toOffset(content, diagnostic.start.line, diagnostic.start.column), + length: diagnostic.length, + })); +} + +/** Converts a 1-based line/column position into a UTF-16 offset. */ +function toOffset(text: string, line: number, column: number): number { + let lineStart = 0; + for (let currentLine = 1; currentLine < line; currentLine++) { + const newlineIndex = text.indexOf('\n', lineStart); + if (newlineIndex === -1) break; + lineStart = newlineIndex + 1; + } + return Math.min(lineStart + column - 1, text.length); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 44add0cb..713310b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,10 @@ export { type TokenImporter, type NamedTokenImporter, type NamedTokenImporterEntry, + type TokenReference, + type LocalTokenReference, + type ExternalTokenReference, + type ExternalTokenReferenceEntry, type Resolver, type MatchesPattern, type ExportBuilder, @@ -37,5 +41,11 @@ export { export { checkCSSModule, type CheckerArgs } from './checker.js'; export { createExportBuilder } from './export-builder.js'; export { join, resolve, relative, dirname, basename, parse } from './path.js'; -export { findUsedTokenNames } from './util.js'; +export { + findUsedTokenNames, + isURLSpecifier, + validateTokenName, + type ValidateTokenNameOptions, + type TokenNameViolation, +} from './util.js'; export { convertDiagnostic, convertDiagnosticWithLocation, convertSystemError } from './diagnostic.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32802af8..cebc0366 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,6 +332,10 @@ importers: '@css-modules-kit/core': specifier: workspace:^ version: link:../core + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 packages/core: dependencies: From c3549d1456159adf08b15c710ea61ec25c65258c Mon Sep 17 00:00:00 2001 From: mizdra Date: Mon, 10 Aug 2026 01:23:32 +0900 Subject: [PATCH 3/8] fix(content-mapper): map synthesized quotes of unquoted url() specifiers as zero-width spans --- .../content-mapper/src/transformer.test.ts | 21 +++++++++++++ packages/content-mapper/src/transformer.ts | 31 +++++++++++++------ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/content-mapper/src/transformer.test.ts b/packages/content-mapper/src/transformer.test.ts index 52d68236..6be8d045 100644 --- a/packages/content-mapper/src/transformer.test.ts +++ b/packages/content-mapper/src/transformer.test.ts @@ -252,6 +252,27 @@ test('quotes generated specifiers with the original quote character', () => { `); }); +test('synthesizes quotes for unquoted url() specifiers and maps them as zero-width spans', () => { + expect(run(`@import url(./b.module.css);`)).toMatchInlineSnapshot(` + "=== source === + @import url(./b.module.css); + ¦ #2 + ¦ #0 + ^^^^^^^^^^^^^^ #1 + + === generated === + import * as _import_0 from './b.module.css'; + ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^^^^^^^^^^^^^^ #1 Verbatim + ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + type __BlockErrorType = [0] extends [1 & T] ? {} : T; + interface Styles {} + declare const styles: Styles & __BlockErrorType; + export default styles; + " + `); +}); + test('converts parse diagnostics into mapper diagnostics', () => { const result = run(dedent` .foo { color: red; } diff --git a/packages/content-mapper/src/transformer.ts b/packages/content-mapper/src/transformer.ts index 09c75a53..b5a98349 100644 --- a/packages/content-mapper/src/transformer.ts +++ b/packages/content-mapper/src/transformer.ts @@ -39,21 +39,32 @@ const NON_RENAME_FEATURES = SpanMapFeature.All & ~SpanMapFeature.Rename; function createTextBuilder() { let text = ''; const mappings: SpanMapping[] = []; + function appendQuoted(value: string, loc: Location): void { + mappings.push([text.length, 1, loc.start.offset, 0, SpanMapKind.Atom, QUOTE_FEATURES]); + mappings.push([text.length + 1, value.length, loc.start.offset, value.length, SpanMapKind.Verbatim]); + mappings.push([text.length + 1 + value.length, 1, loc.end.offset, 0, SpanMapKind.Atom, QUOTE_FEATURES]); + text += `'${value}'`; + } return { append(chunk: string): void { text += chunk; }, /** Appends `'name'`, mapping the name to `loc` and the quotes to its boundaries. */ appendTokenName(name: string, loc: Location): void { - mappings.push([text.length, 1, loc.start.offset, 0, SpanMapKind.Atom, QUOTE_FEATURES]); - mappings.push([text.length + 1, name.length, loc.start.offset, name.length, SpanMapKind.Verbatim]); - mappings.push([text.length + 1 + name.length, 1, loc.end.offset, 0, SpanMapKind.Atom, QUOTE_FEATURES]); - text += `'${name}'`; + appendQuoted(name, loc); }, - /** Appends the quoted specifier, mapping it (quotes included) to the original. */ - appendSpecifier(from: string, fromLoc: Location, quote: string): void { - mappings.push([text.length, from.length + 2, fromLoc.start.offset - 1, from.length + 2, SpanMapKind.Verbatim]); - text += `${quote}${from}${quote}`; + /** + * Appends the quoted specifier. When the original is quoted, the whole literal is mapped + * verbatim. Otherwise (e.g. `url(./a.module.css)`), the synthesized quotes have no + * counterpart in the CSS, so they are mapped as zero-width spans like token name quotes. + */ + appendSpecifier(from: string, fromLoc: Location, quote: '"' | "'" | undefined): void { + if (quote === undefined) { + appendQuoted(from, fromLoc); + } else { + mappings.push([text.length, from.length + 2, fromLoc.start.offset - 1, from.length + 2, SpanMapKind.Verbatim]); + text += `${quote}${from}${quote}`; + } }, /** Appends `name`, mapping it to `loc` as an alias of the original name. */ appendAlias(name: string, loc: Location): void { @@ -92,9 +103,9 @@ function isImportableSpecifier(from: string): boolean { } /** Verbatim mapping requires identical text, so the generated specifier reuses the original quote character. */ -function specifierQuote(content: string, fromLoc: Location): string { +function specifierQuote(content: string, fromLoc: Location): '"' | "'" | undefined { const quote = content[fromLoc.start.offset - 1]; - return quote === '"' ? '"' : "'"; + return quote === '"' || quote === "'" ? quote : undefined; } /** From 0b158559ab77ce7bc88d3eae2553bfa0ae07ab4c Mon Sep 17 00:00:00 2001 From: mizdra Date: Mon, 10 Aug 2026 01:58:18 +0900 Subject: [PATCH 4/8] test(content-mapper): add e2e tests ported from ts-plugin --- .gitignore | 1 + .../e2e-test/diagnostics.test.ts | 78 ++++ .../e2e-test/file-events.test.ts | 60 +++ .../e2e-test/find-all-references.test.ts | 325 +++++++++++++++ .../e2e-test/go-to-definition.test.ts | 370 ++++++++++++++++++ .../e2e-test/invalid-css-syntax.test.ts | 52 +++ .../e2e-test/rename-file.test.ts | 101 +++++ .../e2e-test/rename-symbol.test.ts | 295 ++++++++++++++ .../e2e-test/test-util/builder.ts | 29 ++ .../e2e-test/test-util/fixture.ts | 118 ++++++ .../e2e-test/test-util/lsp-client.ts | 319 +++++++++++++++ scripts/setup-tsgo.sh | 25 ++ scripts/vitest-e2e-test-setup.ts | 22 +- tsconfig.json | 2 +- 14 files changed, 1793 insertions(+), 4 deletions(-) create mode 100644 packages/content-mapper/e2e-test/diagnostics.test.ts create mode 100644 packages/content-mapper/e2e-test/file-events.test.ts create mode 100644 packages/content-mapper/e2e-test/find-all-references.test.ts create mode 100644 packages/content-mapper/e2e-test/go-to-definition.test.ts create mode 100644 packages/content-mapper/e2e-test/invalid-css-syntax.test.ts create mode 100644 packages/content-mapper/e2e-test/rename-file.test.ts create mode 100644 packages/content-mapper/e2e-test/rename-symbol.test.ts create mode 100644 packages/content-mapper/e2e-test/test-util/builder.ts create mode 100644 packages/content-mapper/e2e-test/test-util/fixture.ts create mode 100644 packages/content-mapper/e2e-test/test-util/lsp-client.ts create mode 100755 scripts/setup-tsgo.sh 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/packages/content-mapper/e2e-test/diagnostics.test.ts b/packages/content-mapper/e2e-test/diagnostics.test.ts new file mode 100644 index 00000000..1884a370 --- /dev/null +++ b/packages/content-mapper/e2e-test/diagnostics.test.ts @@ -0,0 +1,78 @@ +import dedent from 'dedent'; +import { describe, expect, test } from 'vite-plus/test'; +import { buildStylesImport, buildTSConfigJSON } from './test-util/builder.js'; +import { fixtureDir, setupFixture } from './test-util/fixture.js'; +import { launchLSPClient } from './test-util/lsp-client.js'; + +const client = launchLSPClient(fixtureDir); + +describe.each([{ namedExports: false }, { namedExports: true }])('namedExports: $namedExports', ({ namedExports }) => { + test('reports an unknown property access on a styles binding', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.unknown; + `, + 'a.module.css': `.a_1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const report = await client.sendDocumentDiagnostic(iff.paths['index.ts']); + + expect(report.items).toStrictEqual([ + expect.objectContaining({ code: 2339, range: getRange('index.ts', 'unknown') }), + ]); + }); + + test('provides the mapper-generated type on the styles binding', async () => { + const { iff } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + type Expected = { a_1: string }; + export const _t: Expected = styles; + `, + 'a.module.css': `.a_1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const report = await client.sendDocumentDiagnostic(iff.paths['index.ts']); + + expect(report.items).toStrictEqual([]); + }); + + // NOTE: Unlike ts-plugin, which reports its own "Cannot import module" diagnostic on the bare + // path, the unresolvable import is reported by TypeScript itself (TS2307) on the quoted + // specifier. + test('reports a semantic diagnostic on a CSS module file', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@import './unresolvable.module.css';`, + }); + await client.openFile(iff.paths['a.module.css']); + + const report = await client.sendDocumentDiagnostic(iff.paths['a.module.css']); + + expect(report.items).toStrictEqual([ + expect.objectContaining({ code: 2307, range: getRange('a.module.css', `'./unresolvable.module.css'`) }), + ]); + }); + + test('reports a syntactic diagnostic on a CSS module file', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const report = await client.sendDocumentDiagnostic(iff.paths['a.module.css']); + + expect(report.items).toStrictEqual([ + expect.objectContaining({ + message: '`@value` is a invalid syntax.', + range: getRange('a.module.css', '@value;'), + }), + ]); + }); +}); diff --git a/packages/content-mapper/e2e-test/file-events.test.ts b/packages/content-mapper/e2e-test/file-events.test.ts new file mode 100644 index 00000000..9ae9a28d --- /dev/null +++ b/packages/content-mapper/e2e-test/file-events.test.ts @@ -0,0 +1,60 @@ +import dedent from 'dedent'; +import { describe, expect, test } from 'vite-plus/test'; +import { buildStylesImport, buildTSConfigJSON } from './test-util/builder.js'; +import { fixtureDir, setupFixture } from './test-util/fixture.js'; +import { launchLSPClient } from './test-util/lsp-client.js'; + +const client = launchLSPClient(fixtureDir); + +describe.each([{ namedExports: false }, { namedExports: true }])('namedExports: $namedExports', ({ namedExports }) => { + describe('when adding a CSS module', () => { + test("updates the importer's diagnostic when a CSS module is added", async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + }); + await client.openFile(iff.paths['index.ts']); + + const before = await client.sendDocumentDiagnostic(iff.paths['index.ts']); + expect(before.items).toStrictEqual([ + expect.objectContaining({ code: 2307, range: getRange('index.ts', `'./a.module.css'`) }), + ]); + + await iff.addFixtures({ 'a.module.css': '.a_1 { color: red; }' }); + await client.openFile(iff.join('a.module.css')); + + const after = await client.sendDocumentDiagnostic(iff.paths['index.ts']); + expect(after.items).toStrictEqual([]); + }); + }); + + describe('when updating a CSS module', () => { + test("updates the importer's diagnostic when a CSS module is modified", async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': '', + }); + await client.openFile(iff.paths['index.ts']); + + const before = await client.sendDocumentDiagnostic(iff.paths['index.ts']); + expect(before.items).toStrictEqual([expect.objectContaining({ code: 2339, range: getRange('index.ts', 'a_1') })]); + + await client.openFile(iff.paths['a.module.css']); + await client.changeFile(iff.paths['a.module.css'], `.a_1 {}`); + + const after = await client.sendDocumentDiagnostic(iff.paths['index.ts']); + expect(after.items).toStrictEqual([]); + }); + }); + + describe('when removing a CSS module', () => { + test.todo("updates the importer's diagnostic when a CSS module is removed"); + }); +}); diff --git a/packages/content-mapper/e2e-test/find-all-references.test.ts b/packages/content-mapper/e2e-test/find-all-references.test.ts new file mode 100644 index 00000000..08fb0545 --- /dev/null +++ b/packages/content-mapper/e2e-test/find-all-references.test.ts @@ -0,0 +1,325 @@ +import dedent from 'dedent'; +import { describe, expect, test } from 'vite-plus/test'; +import { buildStylesImport, buildTSConfigJSON } from './test-util/builder.js'; +import { fixtureDir, setupFixture } from './test-util/fixture.js'; +import { launchLSPClient, normalizeLocations, toFileUri } from './test-util/lsp-client.js'; + +const client = launchLSPClient(fixtureDir); + +describe.each([{ namedExports: false }, { namedExports: true }])('namedExports: $namedExports', ({ namedExports }) => { + describe('for a TS-side import statement', () => { + test('from the styles binding', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + styles.a_2; + `, + 'a.module.css': dedent` + .a_1 { color: red; } + .a_2 { color: red; } + `, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendReferences(iff.paths['index.ts'], getPosition('index.ts', 'styles', 0)); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'styles', 0) }, + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'styles', 1) }, + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'styles', 2) }, + ]), + ); + }); + }); + + describe('for a token definition', () => { + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': `.a_1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendReferences(iff.paths['index.ts'], getPosition('index.ts', 'a_1')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'a_1') }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1') }, + ]), + ); + }); + + test('from a TS-side styles[]', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles['a-1']; + `, + 'a.module.css': `.a-1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendReferences(iff.paths['index.ts'], getPosition('index.ts', 'a-1')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'a-1') }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a-1') }, + ]), + ); + }); + + test('when the token is declared multiple times', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': dedent` + .a_1 { color: red; } + .a_1 { color: red; } + `, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendReferences(iff.paths['index.ts'], getPosition('index.ts', 'a_1')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'a_1') }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 0) }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 1) }, + ]), + ); + }); + + test('from a CSS-side token definition', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': `.a_1 { color: red; }`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendReferences(iff.paths['a.module.css'], getPosition('a.module.css', 'a_1')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'a_1') }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1') }, + ]), + ); + }); + }); + + describe('for an all token importer', () => { + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.b_1; + `, + 'a.module.css': `@import './b.module.css';`, + 'b.module.css': `.b_1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendReferences(iff.paths['index.ts'], getPosition('index.ts', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'b_1') }, + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]), + ); + }); + }); + + describe('for a named token importer', () => { + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.b_1; + `, + 'a.module.css': `@value b_1 from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendReferences(iff.paths['index.ts'], getPosition('index.ts', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'b_1') }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'b_1') }, + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]), + ); + }); + + // NOTE: The expectation matches ts-plugin, which also returns the paired `b_1`. + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.b_alias; + `, + 'a.module.css': `@value b_1 as b_alias from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendReferences(iff.paths['index.ts'], getPosition('index.ts', 'b_alias')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['index.ts']), range: getRange('index.ts', 'b_alias') }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'b_1') }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'b_alias') }, + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]), + ); + }); + + test('from a CSS-side ', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendReferences(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'b_1') }, + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]), + ); + }); + + // NOTE: The expectation matches ts-plugin, which also returns the paired `b_alias`. + test('from a CSS-side with alias', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 as b_alias from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendReferences(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'b_1') }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'b_alias') }, + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]), + ); + }); + + // NOTE: The expectation matches ts-plugin, which also returns the paired `b_1`. + test('from a CSS-side ', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 as b_alias from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendReferences(iff.paths['a.module.css'], getPosition('a.module.css', 'b_alias')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'b_1') }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'b_alias') }, + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]), + ); + }); + }); + + describe('for a local token reference', () => { + test('from a token definition', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': dedent` + @keyframes a_1 { from {} to {} } + .a_2 { animation-name: a_1; } + .a_3 { animation-name: a_1; } + `, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendReferences(iff.paths['a.module.css'], getPosition('a.module.css', 'a_1', 0)); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 0) }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 1) }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 2) }, + ]), + ); + }); + + test('from a local token reference', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': dedent` + @keyframes a_1 { from {} to {} } + .a_2 { animation-name: a_1; } + .a_3 { animation-name: a_1; } + `, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendReferences(iff.paths['a.module.css'], getPosition('a.module.css', 'a_1', 1)); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 0) }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 1) }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 2) }, + ]), + ); + }); + }); + + describe('for an external token reference', () => { + test('from an external token reference', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `.a_1 { composes: b_1 from './b.module.css'; }`, + 'b.module.css': `.b_1 { color: red; }`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendReferences(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual( + normalizeLocations([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'b_1') }, + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]), + ); + }); + }); +}); diff --git a/packages/content-mapper/e2e-test/go-to-definition.test.ts b/packages/content-mapper/e2e-test/go-to-definition.test.ts new file mode 100644 index 00000000..fe5c39eb --- /dev/null +++ b/packages/content-mapper/e2e-test/go-to-definition.test.ts @@ -0,0 +1,370 @@ +import dedent from 'dedent'; +import { describe, expect, test } from 'vite-plus/test'; +import { buildStylesImport, buildTSConfigJSON } from './test-util/builder.js'; +import { fixtureDir, setupFixture } from './test-util/fixture.js'; +import type { Location } from './test-util/lsp-client.js'; +import { launchLSPClient, normalizeLocations, toFileUri } from './test-util/lsp-client.js'; + +const client = launchLSPClient(fixtureDir); + +function fileStartLocation(filePath: string): Location { + return { uri: toFileUri(filePath), range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }; +} + +describe.each([{ namedExports: false }, { namedExports: true }])('namedExports: $namedExports', ({ namedExports }) => { + describe('for a TS-side import statement', () => { + test('from the styles binding', async () => { + const { iff, getPosition } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': buildStylesImport('./a.module.css', { namedExports }), + 'a.module.css': '', + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendDefinition(iff.paths['index.ts'], getPosition('index.ts', 'styles')); + + expect(normalizeLocations(locations)).toStrictEqual([fileStartLocation(iff.paths['a.module.css'])]); + }); + + test('from the import specifier', async () => { + const { iff, getPosition } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': buildStylesImport('./a.module.css', { namedExports }), + 'a.module.css': '', + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendDefinition(iff.paths['index.ts'], getPosition('index.ts', "'./a.module.css'")); + + expect(normalizeLocations(locations)).toStrictEqual([fileStartLocation(iff.paths['a.module.css'])]); + }); + }); + + describe('for a token definition', () => { + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': `.a_1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendDefinition(iff.paths['index.ts'], getPosition('index.ts', 'a_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1') }, + ]); + }); + + test('from a TS-side styles[]', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles['a-1']; + `, + 'a.module.css': `.a-1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendDefinition(iff.paths['index.ts'], getPosition('index.ts', 'a-1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a-1') }, + ]); + }); + + test('when the token is declared multiple times', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': dedent` + .a_1 { color: red; } + .a_1 { color: red; } + `, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendDefinition(iff.paths['index.ts'], getPosition('index.ts', 'a_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 0) }, + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 1) }, + ]); + }); + + test('from a CSS-side token definition', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': `.a_1 { color: red; }`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'a_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1') }, + ]); + }); + }); + + describe('for an all token importer', () => { + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.b_1; + `, + 'a.module.css': `@import './b.module.css';`, + 'b.module.css': `.b_1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendDefinition(iff.paths['index.ts'], getPosition('index.ts', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]); + }); + + test('from a CSS-side specifier', async () => { + const { iff, getPosition } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': buildStylesImport('./a.module.css', { namedExports }), + 'a.module.css': `@import './b.module.css';`, + 'b.module.css': '', + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition( + iff.paths['a.module.css'], + getPosition('a.module.css', "'./b.module.css'"), + ); + + expect(normalizeLocations(locations)).toStrictEqual([fileStartLocation(iff.paths['b.module.css'])]); + }); + + test('from inside a CSS-side url() specifier', async () => { + const { iff, getPosition } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': buildStylesImport('./a.module.css', { namedExports }), + 'a.module.css': `@import url(./b.module.css);`, + 'b.module.css': '', + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition( + iff.paths['a.module.css'], + getPosition('a.module.css', './b.module.css'), + ); + + expect(normalizeLocations(locations)).toStrictEqual([fileStartLocation(iff.paths['b.module.css'])]); + }); + }); + + describe('for a named token importer', () => { + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.b_1; + `, + 'a.module.css': `@value b_1 from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendDefinition(iff.paths['index.ts'], getPosition('index.ts', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]); + }); + + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.b_alias; + `, + 'a.module.css': `@value b_1 as b_alias from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendDefinition(iff.paths['index.ts'], getPosition('index.ts', 'b_alias')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]); + }); + + test('from a CSS-side specifier', async () => { + const { iff, getPosition } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': buildStylesImport('./a.module.css', { namedExports }), + 'a.module.css': `@value b_1 from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition( + iff.paths['a.module.css'], + getPosition('a.module.css', "'./b.module.css'"), + ); + + expect(normalizeLocations(locations)).toStrictEqual([fileStartLocation(iff.paths['b.module.css'])]); + }); + + test('from a CSS-side ', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]); + }); + + test('from a CSS-side with alias', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 as b_alias from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]); + }); + + test('from a CSS-side ', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 as b_alias from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'b_alias')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]); + }); + }); + + describe('for a local token reference', () => { + test('from a local token reference', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': dedent` + @keyframes a_1 { from {} to {} } + .a_2 { animation-name: a_1; } + `, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'a_1', 1)); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 0) }, + ]); + }); + + test('from each in a multi-value local token reference', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': dedent` + @keyframes a_1 { from {} to {} } + @keyframes a_2 { from {} to {} } + .a_3 { animation-name: a_1, a_2; } + `, + }); + await client.openFile(iff.paths['a.module.css']); + + const a1Locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'a_1', 1)); + expect(normalizeLocations(a1Locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1', 0) }, + ]); + + const a2Locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'a_2', 1)); + expect(normalizeLocations(a2Locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_2', 0) }, + ]); + }); + + test('from a kebab-case local token reference', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': dedent` + @keyframes a-1 { from {} to {} } + .a_2 { animation-name: a-1; } + `, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'a-1', 1)); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a-1', 0) }, + ]); + }); + + test('from a local token reference whose target is imported', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': dedent` + @import './b.module.css'; + .a_1 { animation-name: b_1; } + `, + 'b.module.css': `@keyframes b_1 { from {} to {} }`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]); + }); + }); + + describe('for an external token reference', () => { + test('from an external token reference', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `.a_1 { composes: b_1 from './b.module.css'; }`, + 'b.module.css': `.b_1 { color: red; }`, + }); + await client.openFile(iff.paths['a.module.css']); + + const locations = await client.sendDefinition(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['b.module.css']), range: getRange('b.module.css', 'b_1') }, + ]); + }); + }); +}); diff --git a/packages/content-mapper/e2e-test/invalid-css-syntax.test.ts b/packages/content-mapper/e2e-test/invalid-css-syntax.test.ts new file mode 100644 index 00000000..fbd67f75 --- /dev/null +++ b/packages/content-mapper/e2e-test/invalid-css-syntax.test.ts @@ -0,0 +1,52 @@ +import dedent from 'dedent'; +import { describe, expect, test } from 'vite-plus/test'; +import { buildStylesImport, buildTSConfigJSON } from './test-util/builder.js'; +import { fixtureDir, setupFixture } from './test-util/fixture.js'; +import { launchLSPClient, normalizeLocations, toFileUri } from './test-util/lsp-client.js'; + +const client = launchLSPClient(fixtureDir); + +describe.each([{ namedExports: false }, { namedExports: true }])('namedExports: $namedExports', ({ namedExports }) => { + test('resolves Go to Definition on a valid token even when later rules contain invalid syntax', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': dedent` + .a_1 { color: red; } + .a_2 { + `, + }); + await client.openFile(iff.paths['index.ts']); + + const locations = await client.sendDefinition(iff.paths['index.ts'], getPosition('index.ts', 'a_1')); + + expect(normalizeLocations(locations)).toStrictEqual([ + { uri: toFileUri(iff.paths['a.module.css']), range: getRange('a.module.css', 'a_1') }, + ]); + }); + + // NOTE: Unlike ts-plugin, which leaves syntax errors to the CSS language server, the mapper + // reports them itself via `includeSyntaxError`. + test('reports a syntax error diagnostic for a CSS module with parse errors', async () => { + const { iff } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': dedent` + .a_1 { color: red; } + .a_2 { + `, + }); + await client.openFile(iff.paths['a.module.css']); + + const report = await client.sendDocumentDiagnostic(iff.paths['a.module.css']); + + expect(report.items).toStrictEqual([ + expect.objectContaining({ + message: 'Unclosed block', + range: { start: { line: 1, character: 0 }, end: { line: 1, character: 1 } }, + }), + ]); + }); +}); diff --git a/packages/content-mapper/e2e-test/rename-file.test.ts b/packages/content-mapper/e2e-test/rename-file.test.ts new file mode 100644 index 00000000..10b463b0 --- /dev/null +++ b/packages/content-mapper/e2e-test/rename-file.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vite-plus/test'; +import { buildStylesImport, buildTSConfigJSON } from './test-util/builder.js'; +import { fixtureDir, setupFixture } from './test-util/fixture.js'; +import { launchLSPClient, normalizeFileRenames, normalizeWorkspaceEdit, toFileUri } from './test-util/lsp-client.js'; + +const client = launchLSPClient(fixtureDir); + +describe.each([{ namedExports: false }, { namedExports: true }])('namedExports: $namedExports', ({ namedExports }) => { + describe('returns a file rename operation so editors can initiate a file rename from a CSS specifier', () => { + test('from all token importer', async () => { + const { iff, getPosition } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@import './b.module.css';`, + 'b.module.css': '', + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendRename( + iff.paths['a.module.css'], + getPosition('a.module.css', 'b.module.css'), + 'bb.module.css', + ); + + expect(normalizeFileRenames(edit)).toStrictEqual([ + { kind: 'rename', oldUri: toFileUri(iff.paths['b.module.css']), newUri: toFileUri(iff.join('bb.module.css')) }, + ]); + }); + + test('from named token importer', async () => { + const { iff, getPosition } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendRename( + iff.paths['a.module.css'], + getPosition('a.module.css', 'b.module.css'), + 'bb.module.css', + ); + + expect(normalizeFileRenames(edit)).toStrictEqual([ + { kind: 'rename', oldUri: toFileUri(iff.paths['b.module.css']), newUri: toFileUri(iff.join('bb.module.css')) }, + ]); + }); + }); + + describe('rewrites the import specifier when a CSS module is renamed', () => { + test('from `import ... from` in TS', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': buildStylesImport('./a.module.css', { namedExports }), + 'a.module.css': '', + }); + await client.openFile(iff.paths['index.ts']); + + const edit = await client.sendWillRenameFiles(iff.paths['a.module.css'], iff.join('aa.module.css')); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['index.ts'])]: [ + { range: getRange('index.ts', './a.module.css'), newText: './aa.module.css' }, + ], + }); + }); + + test('from all token importer', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@import './b.module.css';`, + 'b.module.css': '', + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendWillRenameFiles(iff.paths['b.module.css'], iff.join('bb.module.css')); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['a.module.css'])]: [ + { range: getRange('a.module.css', './b.module.css'), newText: './bb.module.css' }, + ], + }); + }); + + test('from named token importer', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendWillRenameFiles(iff.paths['b.module.css'], iff.join('bb.module.css')); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['a.module.css'])]: [ + { range: getRange('a.module.css', './b.module.css'), newText: './bb.module.css' }, + ], + }); + }); + }); +}); diff --git a/packages/content-mapper/e2e-test/rename-symbol.test.ts b/packages/content-mapper/e2e-test/rename-symbol.test.ts new file mode 100644 index 00000000..24e14e68 --- /dev/null +++ b/packages/content-mapper/e2e-test/rename-symbol.test.ts @@ -0,0 +1,295 @@ +import dedent from 'dedent'; +import { describe, expect, test } from 'vite-plus/test'; +import { buildStylesImport, buildTSConfigJSON } from './test-util/builder.js'; +import { fixtureDir, setupFixture } from './test-util/fixture.js'; +import { launchLSPClient, normalizeWorkspaceEdit, toFileUri } from './test-util/lsp-client.js'; + +const client = launchLSPClient(fixtureDir); + +describe.each([{ namedExports: false }, { namedExports: true }])('namedExports: $namedExports', ({ namedExports }) => { + describe('for a token definition', () => { + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': `.a_1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const edit = await client.sendRename(iff.paths['index.ts'], getPosition('index.ts', 'a_1'), 'a_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['index.ts'])]: [{ range: getRange('index.ts', 'a_1'), newText: 'a_renamed' }], + [toFileUri(iff.paths['a.module.css'])]: [{ range: getRange('a.module.css', 'a_1'), newText: 'a_renamed' }], + }); + }); + + test('from a TS-side styles[]', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles['a-1']; + `, + 'a.module.css': `.a-1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const edit = await client.sendRename(iff.paths['index.ts'], getPosition('index.ts', 'a-1'), 'a_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['index.ts'])]: [{ range: getRange('index.ts', 'a-1'), newText: 'a_renamed' }], + [toFileUri(iff.paths['a.module.css'])]: [{ range: getRange('a.module.css', 'a-1'), newText: 'a_renamed' }], + }); + }); + + test('when the token is declared multiple times', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': dedent` + .a_1 { color: red; } + .a_1 { color: red; } + `, + }); + await client.openFile(iff.paths['index.ts']); + + const edit = await client.sendRename(iff.paths['index.ts'], getPosition('index.ts', 'a_1'), 'a_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['index.ts'])]: [{ range: getRange('index.ts', 'a_1'), newText: 'a_renamed' }], + [toFileUri(iff.paths['a.module.css'])]: [ + { range: getRange('a.module.css', 'a_1', 0), newText: 'a_renamed' }, + { range: getRange('a.module.css', 'a_1', 1), newText: 'a_renamed' }, + ], + }); + }); + + test('from a CSS-side token definition', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': `.a_1 { color: red; }`, + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendRename(iff.paths['a.module.css'], getPosition('a.module.css', 'a_1'), 'a_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['index.ts'])]: [{ range: getRange('index.ts', 'a_1'), newText: 'a_renamed' }], + [toFileUri(iff.paths['a.module.css'])]: [{ range: getRange('a.module.css', 'a_1'), newText: 'a_renamed' }], + }); + }); + }); + + describe('for an all token importer', () => { + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.b_1; + `, + 'a.module.css': `@import './b.module.css';`, + 'b.module.css': `.b_1 { color: red; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const edit = await client.sendRename(iff.paths['index.ts'], getPosition('index.ts', 'b_1'), 'b_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['index.ts'])]: [{ range: getRange('index.ts', 'b_1'), newText: 'b_renamed' }], + [toFileUri(iff.paths['b.module.css'])]: [{ range: getRange('b.module.css', 'b_1'), newText: 'b_renamed' }], + }); + }); + }); + + describe('for a named token importer', () => { + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.b_1; + `, + 'a.module.css': `@value b_1 from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['index.ts']); + + const edit = await client.sendRename(iff.paths['index.ts'], getPosition('index.ts', 'b_1'), 'b_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['index.ts'])]: [{ range: getRange('index.ts', 'b_1'), newText: 'b_renamed' }], + [toFileUri(iff.paths['a.module.css'])]: [{ range: getRange('a.module.css', 'b_1'), newText: 'b_renamed' }], + [toFileUri(iff.paths['b.module.css'])]: [{ range: getRange('b.module.css', 'b_1'), newText: 'b_renamed' }], + }); + }); + + // NOTE: The expectation matches ts-plugin, which also rewrites the paired `b_1`. + test('from a TS-side styles.', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.b_alias; + `, + 'a.module.css': `@value b_1 as b_alias from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['index.ts']); + + const edit = await client.sendRename(iff.paths['index.ts'], getPosition('index.ts', 'b_alias'), 'b_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['index.ts'])]: [{ range: getRange('index.ts', 'b_alias'), newText: 'b_renamed' }], + [toFileUri(iff.paths['a.module.css'])]: [ + { range: getRange('a.module.css', 'b_1'), newText: 'b_renamed' }, + { range: getRange('a.module.css', 'b_alias'), newText: 'b_renamed' }, + ], + [toFileUri(iff.paths['b.module.css'])]: [{ range: getRange('b.module.css', 'b_1'), newText: 'b_renamed' }], + }); + }); + + test('from a CSS-side ', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendRename(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1'), 'b_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['a.module.css'])]: [{ range: getRange('a.module.css', 'b_1'), newText: 'b_renamed' }], + [toFileUri(iff.paths['b.module.css'])]: [{ range: getRange('b.module.css', 'b_1'), newText: 'b_renamed' }], + }); + }); + + // NOTE: The expectation matches ts-plugin, which also rewrites the paired `b_alias`. + test('from a CSS-side with alias', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 as b_alias from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendRename(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1'), 'b_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['a.module.css'])]: [ + { range: getRange('a.module.css', 'b_1'), newText: 'b_renamed' }, + { range: getRange('a.module.css', 'b_alias'), newText: 'b_renamed' }, + ], + [toFileUri(iff.paths['b.module.css'])]: [{ range: getRange('b.module.css', 'b_1'), newText: 'b_renamed' }], + }); + }); + + // NOTE: The expectation matches ts-plugin, which also rewrites the paired `b_1`. + test('from a CSS-side ', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `@value b_1 as b_alias from './b.module.css';`, + 'b.module.css': `@value b_1: red;`, + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendRename( + iff.paths['a.module.css'], + getPosition('a.module.css', 'b_alias'), + 'b_renamed', + ); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['a.module.css'])]: [ + { range: getRange('a.module.css', 'b_1'), newText: 'b_renamed' }, + { range: getRange('a.module.css', 'b_alias'), newText: 'b_renamed' }, + ], + [toFileUri(iff.paths['b.module.css'])]: [{ range: getRange('b.module.css', 'b_1'), newText: 'b_renamed' }], + }); + }); + }); + + describe('for a local token reference', () => { + test('from a token definition', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': dedent` + @keyframes a_1 { from {} to {} } + .a_2 { animation-name: a_1; } + .a_3 { animation-name: a_1; } + `, + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendRename( + iff.paths['a.module.css'], + getPosition('a.module.css', 'a_1', 0), + 'a_renamed', + ); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['a.module.css'])]: [ + { range: getRange('a.module.css', 'a_1', 0), newText: 'a_renamed' }, + { range: getRange('a.module.css', 'a_1', 1), newText: 'a_renamed' }, + { range: getRange('a.module.css', 'a_1', 2), newText: 'a_renamed' }, + ], + }); + }); + + test('from a local token reference', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': dedent` + @keyframes a_1 { from {} to {} } + .a_2 { animation-name: a_1; } + .a_3 { animation-name: a_1; } + `, + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendRename( + iff.paths['a.module.css'], + getPosition('a.module.css', 'a_1', 1), + 'a_renamed', + ); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['a.module.css'])]: [ + { range: getRange('a.module.css', 'a_1', 0), newText: 'a_renamed' }, + { range: getRange('a.module.css', 'a_1', 1), newText: 'a_renamed' }, + { range: getRange('a.module.css', 'a_1', 2), newText: 'a_renamed' }, + ], + }); + }); + }); + + describe('for an external token reference', () => { + test('from an external token reference', async () => { + const { iff, getPosition, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ mapperOptions: { namedExports } }), + 'a.module.css': `.a_1 { composes: b_1 from './b.module.css'; }`, + 'b.module.css': `.b_1 { color: red; }`, + }); + await client.openFile(iff.paths['a.module.css']); + + const edit = await client.sendRename(iff.paths['a.module.css'], getPosition('a.module.css', 'b_1'), 'b_renamed'); + + expect(normalizeWorkspaceEdit(edit)).toStrictEqual({ + [toFileUri(iff.paths['a.module.css'])]: [{ range: getRange('a.module.css', 'b_1'), newText: 'b_renamed' }], + [toFileUri(iff.paths['b.module.css'])]: [{ range: getRange('b.module.css', 'b_1'), newText: 'b_renamed' }], + }); + }); + }); +}); diff --git a/packages/content-mapper/e2e-test/test-util/builder.ts b/packages/content-mapper/e2e-test/test-util/builder.ts new file mode 100644 index 00000000..6ce5ebc5 --- /dev/null +++ b/packages/content-mapper/e2e-test/test-util/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: ['.module.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/e2e-test/test-util/fixture.ts b/packages/content-mapper/e2e-test/test-util/fixture.ts new file mode 100644 index 00000000..f325ca54 --- /dev/null +++ b/packages/content-mapper/e2e-test/test-util/fixture.ts @@ -0,0 +1,118 @@ +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 { Position, Range } from './lsp-client.js'; + +// tmpdir() may be a symlink (e.g. /var -> /private/var on macOS). The file URIs 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 SetupFixtureResult { + iff: CreateIFFResult; + /** + * Get the (0-based) line/character position of the first character of `search` in `file`, + * matching the LSP convention. + * + * - 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. + */ + getPosition: (file: string, search: string, index?: number) => Position; + /** + * Get the (0-based) start/end range of `search` in `file`. + * + * - `start` is identical to `getPosition(file, search, index)`. + * - `end` points to the position immediately AFTER the last character of `search` + * (exclusive end, matching the LSP convention). + * - Same matching/error semantics as `getPosition`. + */ + getRange: (file: string, search: string, index?: number) => Range; +} + +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 getPosition(file: string, search: string, index?: number): Position { + const content = files[file]; + if (content === undefined) { + throw new Error(`File "${file}" was not registered in the fixture.`); + } + 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 offsetToPosition(content, target); + } + + function getRange(file: string, search: string, index?: number): Range { + const start = getPosition(file, search, index); + const lines = search.split('\n'); + if (lines.length === 1) { + return { start, end: { line: start.line, character: start.character + search.length } }; + } + const lastLine = lines[lines.length - 1] ?? ''; + return { + start, + end: { + line: start.line + lines.length - 1, + character: lastLine.length, + }, + }; + } + + return { iff, getPosition, getRange }; +} 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..b5027a9e --- /dev/null +++ b/packages/content-mapper/e2e-test/test-util/lsp-client.ts @@ -0,0 +1,319 @@ +import type { ChildProcessByStdio } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import type { Readable, Writable } from 'node:stream'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolve } from '@css-modules-kit/core'; + +/** The tsgo binary built by `scripts/setup-tsgo.sh`. Overridable via the `TSGO_BIN` environment variable. */ +const tsgoBinPath = + process.env['TSGO_BIN'] ?? resolve(import.meta.dirname, '../../../../.tmp/typescript-go/built/tsgo'); + +export interface Position { + line: number; + character: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export interface Location { + uri: string; + range: Range; +} + +export interface TextEdit { + range: Range; + newText: string; +} + +export interface TextDocumentEdit { + textDocument: { uri: string; version: number | null }; + edits: TextEdit[]; +} + +export interface RenameFile { + kind: 'rename'; + oldUri: string; + newUri: string; +} + +export interface WorkspaceEdit { + changes?: Record; + documentChanges?: (TextDocumentEdit | RenameFile)[]; +} + +export interface Diagnostic { + range: Range; + severity?: number; + code?: number | string; + source?: string; + message: string; +} + +export interface FullDocumentDiagnosticReport { + kind: string; + items: Diagnostic[]; +} + +interface JSONRPCMessage { + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string }; +} + +export function toFileUri(filePath: string): string { + return pathToFileURL(filePath).toString(); +} + +/** The server percent-encodes characters like `@` that `pathToFileURL` leaves as-is. */ +function normalizeFileUri(uri: string): string { + return toFileUri(fileURLToPath(uri)); +} + +export function normalizeLocations(locations: readonly Location[]): Location[] { + return locations + .map((location) => ({ ...location, uri: normalizeFileUri(location.uri) })) + .toSorted( + (a, b) => + a.uri.localeCompare(b.uri) || + a.range.start.line - b.range.start.line || + a.range.start.character - b.range.start.character, + ); +} + +/** + * Flattens the text edits in `changes` and `documentChanges` into a per-file record, sorted so + * that assertions do not depend on the server's edit order. File operations like {@link RenameFile} + * are not text edits and are extracted by {@link normalizeFileRenames} instead. + */ +export function normalizeWorkspaceEdit(edit: WorkspaceEdit | null): Record | null { + if (edit === null) return null; + const changes: Record = {}; + for (const [uri, edits] of Object.entries(edit.changes ?? {})) { + changes[normalizeFileUri(uri)] = edits; + } + for (const documentChange of edit.documentChanges ?? []) { + if (!('textDocument' in documentChange)) continue; + const uri = normalizeFileUri(documentChange.textDocument.uri); + changes[uri] = [...(changes[uri] ?? []), ...documentChange.edits]; + } + for (const [uri, edits] of Object.entries(changes)) { + changes[uri] = edits.toSorted( + (a, b) => a.range.start.line - b.range.start.line || a.range.start.character - b.range.start.character, + ); + } + return changes; +} + +/** Extracts the file rename operations from `documentChanges`. */ +export function normalizeFileRenames(edit: WorkspaceEdit | null): RenameFile[] | null { + if (edit === null) return null; + const renames: RenameFile[] = []; + for (const documentChange of edit.documentChanges ?? []) { + if ('kind' in documentChange && documentChange.kind === 'rename') { + renames.push({ + kind: 'rename', + oldUri: normalizeFileUri(documentChange.oldUri), + newUri: normalizeFileUri(documentChange.newUri), + }); + } + } + return renames; +} + +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'; +} + +export interface LSPClient { + /** Opens `filePath` with its on-disk content so that subsequent requests can reference it. */ + openFile(filePath: string): Promise; + /** Replaces the whole content of an opened file. */ + changeFile(filePath: string, text: string): Promise; + sendDefinition(filePath: string, position: Position): Promise; + sendReferences(filePath: string, position: Position): Promise; + sendRename(filePath: string, position: Position, newName: string): Promise; + sendDocumentDiagnostic(filePath: string): Promise; + sendWillRenameFiles(oldFilePath: string, newFilePath: string): Promise; +} + +/** + * Launches a tsgo LSP server shared by all tests in a test file. The server is spawned lazily on + * the first use, so a module-level client does not require the tsgo binary in skipped test files. + * 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; + + 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)); + } + + 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 configuration or dynamic capability + // registration, so every request is answered with an empty result. + if (message.method === 'workspace/configuration') { + send({ id: message.id, result: (message.params as { items: unknown[] }).items.map(() => null) }); + } 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); + } + } + + let started: Promise | undefined; + async function ensureStarted(): Promise { + started ??= (async () => { + proc = spawn(tsgoBinPath, ['--lsp', '-stdio'], { stdio: ['pipe', 'pipe', 'inherit'] }); + proc.stdout.on('data', handleData); + 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: ['rename'] }, + fileOperations: { willRename: true }, + }, + }, + initializationOptions: { loadExternalPlugins: true }, + }); + send({ method: 'initialized', params: {} }); + })(); + return started; + } + + return { + async openFile(filePath) { + await ensureStarted(); + const uri = toFileUri(filePath); + documentVersions.set(uri, 1); + send({ + method: 'textDocument/didOpen', + params: { + textDocument: { uri, languageId: languageIdOf(filePath), version: 1, text: readFileSync(filePath, 'utf8') }, + }, + }); + }, + async changeFile(filePath, text) { + await ensureStarted(); + const uri = toFileUri(filePath); + const version = (documentVersions.get(uri) ?? 1) + 1; + documentVersions.set(uri, version); + send({ + method: 'textDocument/didChange', + params: { textDocument: { uri, version }, contentChanges: [{ text }] }, + }); + }, + async sendDefinition(filePath, position) { + await ensureStarted(); + const result = await sendRequest('textDocument/definition', { + textDocument: { uri: toFileUri(filePath) }, + position, + }); + if (result === null) return []; + return Array.isArray(result) ? (result as Location[]) : [result as Location]; + }, + async sendReferences(filePath, position) { + await ensureStarted(); + const result = await sendRequest('textDocument/references', { + textDocument: { uri: toFileUri(filePath) }, + position, + context: { includeDeclaration: true }, + }); + return (result as Location[] | null) ?? []; + }, + async sendRename(filePath, position, newName) { + await ensureStarted(); + const result = await sendRequest('textDocument/rename', { + textDocument: { uri: toFileUri(filePath) }, + position, + newName, + }); + return result as WorkspaceEdit | null; + }, + async sendDocumentDiagnostic(filePath) { + await ensureStarted(); + const result = await sendRequest('textDocument/diagnostic', { + textDocument: { uri: toFileUri(filePath) }, + }); + return result as FullDocumentDiagnosticReport; + }, + async sendWillRenameFiles(oldFilePath, newFilePath) { + await ensureStarted(); + const result = await sendRequest('workspace/willRenameFiles', { + files: [{ oldUri: toFileUri(oldFilePath), newUri: toFileUri(newFilePath) }], + }); + return result as WorkspaceEdit | null; + }, + }; +} diff --git a/scripts/setup-tsgo.sh b/scripts/setup-tsgo.sh new file mode 100755 index 00000000..1e45c232 --- /dev/null +++ b/scripts/setup-tsgo.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -ue + +# Builds the tsgo binary used by the content-mapper e2e tests. +# The content mapper protocol is implemented in an unmerged PR (microsoft/typescript-go#4712), +# so this script pins a commit of its head branch (andrewbranch/typescript-go `content-mappers`). + +COMMIT=bddd2162710e50281fa838456a875fd59ee7c91f +REPO=https://github.com/andrewbranch/typescript-go.git + +cd "$(dirname "$0")/.." +DEST=.tmp/typescript-go + +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" + +(cd "$DEST" && go build -o built/tsgo ./cmd/tsgo) +echo "tsgo built at $DEST/built/tsgo" diff --git a/scripts/vitest-e2e-test-setup.ts b/scripts/vitest-e2e-test-setup.ts index 9fcfbfd3..6a0f4bdf 100644 --- a/scripts/vitest-e2e-test-setup.ts +++ b/scripts/vitest-e2e-test-setup.ts @@ -1,9 +1,25 @@ -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } 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'] ?? fileURLToPath(new URL('../.tmp/typescript-go/built/tsgo', import.meta.url)); + +function prepare() { + if (!existsSync(tsgoBinPath)) { + if (process.env['TSGO_BIN']) { + throw new Error(`tsgo binary not found at TSGO_BIN (${tsgoBinPath}).`); + } + execFileSync('bash', [fileURLToPath(new URL('./setup-tsgo.sh', import.meta.url))], { stdio: 'inherit' }); + } 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.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"], From beb19e66a903efabce08dc0125aed0cb9dc60808 Mon Sep 17 00:00:00 2001 From: mizdra Date: Tue, 11 Aug 2026 13:14:59 +0900 Subject: [PATCH 5/8] feat(content-mapper): transform non-module CSS files into empty modules --- .../e2e-test/non-module-css-file.test.ts | 31 +++++++++++++++++++ .../e2e-test/test-util/builder.ts | 2 +- packages/content-mapper/src/server.test.ts | 6 ++-- packages/content-mapper/src/server.ts | 4 +-- .../content-mapper/src/test/ts-program.ts | 4 +-- .../content-mapper/src/transformer.test.ts | 12 +++++-- packages/content-mapper/src/transformer.ts | 23 ++++++++------ 7 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 packages/content-mapper/e2e-test/non-module-css-file.test.ts 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..fcda6b00 --- /dev/null +++ b/packages/content-mapper/e2e-test/non-module-css-file.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from 'vite-plus/test'; +import { buildTSConfigJSON } from './test-util/builder.js'; +import { fixtureDir, setupFixture } from './test-util/fixture.js'; +import { launchLSPClient } from './test-util/lsp-client.js'; + +const client = launchLSPClient(fixtureDir); + +test('resolves an import of a non-module CSS file', async () => { + const { iff } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON(), + 'index.ts': `import './global.css';`, + 'global.css': `* { margin: 0; }`, + }); + await client.openFile(iff.paths['index.ts']); + + const report = await client.sendDocumentDiagnostic(iff.paths['index.ts']); + + expect(report.items).toStrictEqual([]); +}); + +test('reports no diagnostics for a non-module CSS file with parse errors', async () => { + const { iff } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON(), + 'global.css': `* {`, + }); + await client.openFile(iff.paths['global.css']); + + const report = await client.sendDocumentDiagnostic(iff.paths['global.css']); + + expect(report.items).toStrictEqual([]); +}); diff --git a/packages/content-mapper/e2e-test/test-util/builder.ts b/packages/content-mapper/e2e-test/test-util/builder.ts index 6ce5ebc5..c08a0a4a 100644 --- a/packages/content-mapper/e2e-test/test-util/builder.ts +++ b/packages/content-mapper/e2e-test/test-util/builder.ts @@ -9,7 +9,7 @@ export function buildTSConfigJSON(args?: TSConfig): string { contentMappers: [ { package: '@css-modules-kit/content-mapper', - extensions: ['.module.css'], + extensions: ['.css'], ...(args?.mapperOptions ? { options: args.mapperOptions } : {}), }, ], diff --git a/packages/content-mapper/src/server.test.ts b/packages/content-mapper/src/server.test.ts index 8dc3742b..f6966d7b 100644 --- a/packages/content-mapper/src/server.test.ts +++ b/packages/content-mapper/src/server.test.ts @@ -2,7 +2,7 @@ import { PassThrough } from 'node:stream'; import { expect, test } from 'vite-plus/test'; import type { NormalizedMapperOptions } from './options.js'; import { runServer } from './server.js'; -import { transformCSSModule } from './transformer.js'; +import { transformCSS } from './transformer.js'; const defaultMapperOptions: NormalizedMapperOptions = { namedExports: false, @@ -75,7 +75,7 @@ function createTransformRequest(id: number, content: string) { } function createTransformResponse(id: number, content: string) { - const { text, mappings, diagnostics } = transformCSSModule('/a.module.css', content, defaultMapperOptions); + const { text, mappings, diagnostics } = transformCSS('/a.module.css', content, defaultMapperOptions); return { jsonrpc: '2.0', id, @@ -139,7 +139,7 @@ test('reports option normalization errors as diagnostics at the file head', asyn }); input.end(); await done; - const expected = transformCSSModule('/a.module.css', content, defaultMapperOptions); + const expected = transformCSS('/a.module.css', content, defaultMapperOptions); expect(readResponses(output)).toEqual([ { jsonrpc: '2.0', diff --git a/packages/content-mapper/src/server.ts b/packages/content-mapper/src/server.ts index 858b8db9..ca12e2ed 100644 --- a/packages/content-mapper/src/server.ts +++ b/packages/content-mapper/src/server.ts @@ -10,7 +10,7 @@ import type { TransformResult, } from './protocol.js'; import { DIAGNOSTIC_SOURCE, METHOD_NOT_FOUND, PROTOCOL_VERSION } from './protocol.js'; -import { transformCSSModule } from './transformer.js'; +import { transformCSS } from './transformer.js'; const HEADER_TERMINATOR = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]); // '\r\n\r\n' @@ -83,7 +83,7 @@ function createResponse(request: RequestMessage): ResponseMessage { case 'transform': { const params = request.params as TransformParams; const { options, errors } = normalizeMapperOptions(params.options); - const output = transformCSSModule(params.fileName, params.content, options); + const output = transformCSS(params.fileName, params.content, options); const diagnostics: MapperDiagnostic[] = [ ...errors.map((message) => ({ messageText: message, start: 0, length: 0 })), ...output.diagnostics, diff --git a/packages/content-mapper/src/test/ts-program.ts b/packages/content-mapper/src/test/ts-program.ts index abcbe31d..2b10a85c 100644 --- a/packages/content-mapper/src/test/ts-program.ts +++ b/packages/content-mapper/src/test/ts-program.ts @@ -1,7 +1,7 @@ import ts from 'typescript'; import type { NormalizedMapperOptions } from '../options.js'; import type { TransformOutput } from '../transformer.js'; -import { transformCSSModule } from '../transformer.js'; +import { transformCSS } from '../transformer.js'; export interface SimplifiedTsDiagnostic { code: number; @@ -38,7 +38,7 @@ export function checkGeneratedTexts( const outputs: Record = {}; const tsFiles = new Map(); for (const [fileName, source] of Object.entries(cssFiles)) { - const output = transformCSSModule(fileName, source, options); + const output = transformCSS(fileName, source, options); outputs[fileName] = output; tsFiles.set(`${fileName}.ts`, output.text); } diff --git a/packages/content-mapper/src/transformer.test.ts b/packages/content-mapper/src/transformer.test.ts index 6be8d045..8379bb48 100644 --- a/packages/content-mapper/src/transformer.test.ts +++ b/packages/content-mapper/src/transformer.test.ts @@ -2,7 +2,7 @@ import dedent from 'dedent'; import { describe, expect, test } from 'vite-plus/test'; import type { NormalizedMapperOptions } from './options.js'; import { renderTransformOutput } from './test/render.js'; -import { transformCSSModule } from './transformer.js'; +import { transformCSS } from './transformer.js'; const defaultOptions: NormalizedMapperOptions = { namedExports: false, @@ -14,7 +14,7 @@ const defaultOptions: NormalizedMapperOptions = { const namedExportsOptions: NormalizedMapperOptions = { ...defaultOptions, namedExports: true }; function run(source: string, options: NormalizedMapperOptions = defaultOptions): string { - return renderTransformOutput(source, transformCSSModule('/test/a.module.css', source, options)); + return renderTransformOutput(source, transformCSS('/test/a.module.css', source, options)); } test('generates interface declarations for local tokens', () => { @@ -338,6 +338,14 @@ test('omits keyframes tokens when animation is false', () => { `); }); +test('generates an empty module for a non-module CSS file', () => { + expect(transformCSS('/test/global.css', `* { margin: 0; }`, defaultOptions)).toStrictEqual({ + text: 'export {};\n', + mappings: [], + diagnostics: [], + }); +}); + describe('namedExports', () => { test('generates var declarations and export clauses for local tokens', () => { const result = run( diff --git a/packages/content-mapper/src/transformer.ts b/packages/content-mapper/src/transformer.ts index b5a98349..235b772b 100644 --- a/packages/content-mapper/src/transformer.ts +++ b/packages/content-mapper/src/transformer.ts @@ -9,6 +9,7 @@ import type { import { basename, CSS_MODULE_EXTENSION, + isCSSModuleFile, isURLSpecifier, parseCSSModule, validateTokenName, @@ -109,16 +110,20 @@ function specifierQuote(content: string, fromLoc: Location): '"' | "'" | undefin } /** - * Transforms a CSS Module into TypeScript text for the content mapper protocol. - * The generated text delegates most validation to the TypeScript checker: importing a - * missing file or referencing a missing token becomes an ordinary type error, which tsgo - * maps back to the CSS through the returned span mappings. + * Transforms a CSS file into TypeScript text for the content mapper protocol. + * + * A CSS Module becomes a module exporting its tokens. The generated text delegates most + * validation to the TypeScript checker: importing a missing file or referencing a missing + * token becomes an ordinary type error, which tsgo maps back to the CSS through the + * returned span mappings. + * + * A non-module CSS file becomes an empty module, so that importing it for its side effects + * type-checks while it exports nothing. */ -export function transformCSSModule( - fileName: string, - content: string, - options: NormalizedMapperOptions, -): TransformOutput { +export function transformCSS(fileName: string, content: string, options: NormalizedMapperOptions): TransformOutput { + if (!isCSSModuleFile(fileName)) { + return { text: 'export {};\n', mappings: [], diagnostics: [] }; + } const cssModule = parseCSSModule(content, { fileName, includeSyntaxError: true, From b410ae12e986d2b1e9c12e9e9a79c200156dba5a Mon Sep 17 00:00:00 2001 From: mizdra Date: Tue, 11 Aug 2026 14:08:53 +0900 Subject: [PATCH 6/8] chore(content-mapper): update pinned tsgo commit to latest content-mappers tip --- scripts/setup-tsgo.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/setup-tsgo.sh b/scripts/setup-tsgo.sh index 1e45c232..0d6b2647 100755 --- a/scripts/setup-tsgo.sh +++ b/scripts/setup-tsgo.sh @@ -5,7 +5,7 @@ set -ue # The content mapper protocol is implemented in an unmerged PR (microsoft/typescript-go#4712), # so this script pins a commit of its head branch (andrewbranch/typescript-go `content-mappers`). -COMMIT=bddd2162710e50281fa838456a875fd59ee7c91f +COMMIT=c18f834e07d992a24cdfbb7cb8bd58812ff3d95e REPO=https://github.com/andrewbranch/typescript-go.git cd "$(dirname "$0")/.." From 69d2c46df96869532776c29440dee751c2b3ea11 Mon Sep 17 00:00:00 2001 From: mizdra Date: Tue, 11 Aug 2026 14:46:31 +0900 Subject: [PATCH 7/8] chore(content-mapper): add VS Code launch config for manual verification with the tsgo extension --- .vscode/launch.json | 24 +++++++++++++++++ .vscode/tasks.json | 23 ++++++++++++++++ .../7-content-mapper/.vscode/settings.json | 3 +++ examples/7-content-mapper/src/a.module.css | 14 ++++++++++ examples/7-content-mapper/src/b.module.css | 3 +++ examples/7-content-mapper/src/global.css | 3 +++ examples/7-content-mapper/src/index.ts | 8 ++++++ examples/7-content-mapper/tsconfig.json | 19 ++++++++++++++ scripts/setup-tsgo-extension.sh | 26 +++++++++++++++++++ 9 files changed, 123 insertions(+) create mode 100644 examples/7-content-mapper/.vscode/settings.json create mode 100644 examples/7-content-mapper/src/a.module.css create mode 100644 examples/7-content-mapper/src/b.module.css create mode 100644 examples/7-content-mapper/src/global.css create mode 100644 examples/7-content-mapper/src/index.ts create mode 100644 examples/7-content-mapper/tsconfig.json create mode 100755 scripts/setup-tsgo-extension.sh diff --git a/.vscode/launch.json b/.vscode/launch.json index acb774bf..9b8e2ad8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -190,6 +190,30 @@ "TSS_DEBUG": "5859" } }, + { + // Launches the TypeScript Native Preview extension built from the content mapper + // PR branch (microsoft/typescript-go#4712). The marketplace build cannot enable + // content mappers, so the extension must be run from the PR branch's source. + "name": "tsgo (7-content-mapper)", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/.tmp/typescript-go/_extension", + "--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-go/_extension/dist/**/*.js"], + "preLaunchTask": "prepare content-mapper example", + "presentation": { + "group": "tsgo" + } + }, { "name": "vscode-test", "type": "extensionHost", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3c5dbaf3..21582a09 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 example", + "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..eb3b23d5 --- /dev/null +++ b/examples/7-content-mapper/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "js/ts.experimental.useTsgo": 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/scripts/setup-tsgo-extension.sh b/scripts/setup-tsgo-extension.sh new file mode 100755 index 00000000..491eb894 --- /dev/null +++ b/scripts/setup-tsgo-extension.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -ue + +# Prepares everything the "tsgo (7-content-mapper)" launch configuration needs: +# the pinned tsgo binary, the PR-branch VS Code extension (TypeScript Native Preview), +# and the mapper package symlink for the example. + +cd "$(dirname "$0")/.." +DEST=.tmp/typescript-go + +./scripts/setup-tsgo.sh + +# In development mode, the extension resolves the tsgo binary at built/local/tsgo. +mkdir -p "$DEST/built/local" +cp "$DEST/built/tsgo" "$DEST/built/local/tsgo" + +# npm ci is slow, so it only runs on the first setup. Re-run it manually if the +# pinned commit changes package-lock.json. +if [ ! -d "$DEST/node_modules" ]; then + (cd "$DEST" && npm ci) +fi +(cd "$DEST" && npm run extension:build) + +# tsgo resolves the mapper package from the tsconfig directory with node module resolution. +mkdir -p examples/7-content-mapper/node_modules/@css-modules-kit +ln -sfn ../../../../packages/content-mapper examples/7-content-mapper/node_modules/@css-modules-kit/content-mapper From 0781f25248d680a53fa01c3b8182d500ebf06b34 Mon Sep 17 00:00:00 2001 From: mizdra Date: Tue, 11 Aug 2026 14:53:45 +0900 Subject: [PATCH 8/8] chore(content-mapper): support Windows tsgo binary and cache it in CI --- .github/workflows/ci.yml | 8 ++++++++ packages/content-mapper/e2e-test/test-util/lsp-client.ts | 6 +++++- scripts/setup-tsgo-extension.sh | 3 ++- scripts/setup-tsgo.sh | 7 +++++-- scripts/vitest-e2e-test-setup.ts | 5 ++++- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f6b2f81..0759d725 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,14 @@ jobs: tsconfig.tsbuildinfo key: test-tools-${{ runner.arch }}-${{ runner.os }}-node-${{ matrix.node }}-stylelint-${{ matrix.stylelint-version }}-${{ github.sha }} restore-keys: test-tools-${{ runner.arch }}-${{ runner.os }}-node-${{ matrix.node }}-stylelint-${{ matrix.stylelint-version }} + # The tsgo binary built by scripts/setup-tsgo.sh, used by the content-mapper e2e tests. + # The e2e test setup skips the build when the binary exists, so a stale binary must + # never be restored. Keying on the hash of setup-tsgo.sh (which contains the pinned + # commit) with no restore-keys guarantees that. + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: .tmp/typescript-go/built + key: tsgo-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('scripts/setup-tsgo.sh') }} - run: vp test env: STYLELINT_VERSION: ${{ matrix.stylelint-version }} diff --git a/packages/content-mapper/e2e-test/test-util/lsp-client.ts b/packages/content-mapper/e2e-test/test-util/lsp-client.ts index b5027a9e..67257099 100644 --- a/packages/content-mapper/e2e-test/test-util/lsp-client.ts +++ b/packages/content-mapper/e2e-test/test-util/lsp-client.ts @@ -7,7 +7,11 @@ import { resolve } from '@css-modules-kit/core'; /** The tsgo binary built by `scripts/setup-tsgo.sh`. Overridable via the `TSGO_BIN` environment variable. */ const tsgoBinPath = - process.env['TSGO_BIN'] ?? resolve(import.meta.dirname, '../../../../.tmp/typescript-go/built/tsgo'); + process.env['TSGO_BIN'] ?? + resolve( + import.meta.dirname, + `../../../../.tmp/typescript-go/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, + ); export interface Position { line: number; diff --git a/scripts/setup-tsgo-extension.sh b/scripts/setup-tsgo-extension.sh index 491eb894..781d631a 100755 --- a/scripts/setup-tsgo-extension.sh +++ b/scripts/setup-tsgo-extension.sh @@ -11,8 +11,9 @@ DEST=.tmp/typescript-go ./scripts/setup-tsgo.sh # In development mode, the extension resolves the tsgo binary at built/local/tsgo. +GOEXE=$(go env GOEXE) mkdir -p "$DEST/built/local" -cp "$DEST/built/tsgo" "$DEST/built/local/tsgo" +cp "$DEST/built/tsgo$GOEXE" "$DEST/built/local/tsgo$GOEXE" # npm ci is slow, so it only runs on the first setup. Re-run it manually if the # pinned commit changes package-lock.json. diff --git a/scripts/setup-tsgo.sh b/scripts/setup-tsgo.sh index 0d6b2647..02ab8f8e 100755 --- a/scripts/setup-tsgo.sh +++ b/scripts/setup-tsgo.sh @@ -21,5 +21,8 @@ if ! git -C "$DEST" cat-file -e "$COMMIT^{commit}" 2>/dev/null; then fi git -C "$DEST" checkout -q "$COMMIT" -(cd "$DEST" && go build -o built/tsgo ./cmd/tsgo) -echo "tsgo built at $DEST/built/tsgo" +# GOEXE is '.exe' on Windows and empty elsewhere. The extensionless name does not work on +# Windows because process spawning resolves executables by appending '.exe'. +GOEXE=$(go env GOEXE) +(cd "$DEST" && go build -o "built/tsgo$GOEXE" ./cmd/tsgo) +echo "tsgo built at $DEST/built/tsgo$GOEXE" diff --git a/scripts/vitest-e2e-test-setup.ts b/scripts/vitest-e2e-test-setup.ts index 6a0f4bdf..2db68cd2 100644 --- a/scripts/vitest-e2e-test-setup.ts +++ b/scripts/vitest-e2e-test-setup.ts @@ -5,7 +5,10 @@ import type { TestProject } from 'vite-plus/test/node'; // Keep the resolution in sync with `packages/content-mapper/e2e-test/test-util/lsp-client.ts`. const tsgoBinPath = - process.env['TSGO_BIN'] ?? fileURLToPath(new URL('../.tmp/typescript-go/built/tsgo', import.meta.url)); + process.env['TSGO_BIN'] ?? + fileURLToPath( + new URL(`../.tmp/typescript-go/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, import.meta.url), + ); function prepare() { if (!existsSync(tsgoBinPath)) {