From e0013c1b26699c9de309423b7bb6ca85afdb47f5 Mon Sep 17 00:00:00 2001 From: mizdra Date: Sun, 9 Aug 2026 15:12:43 +0900 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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)) { From aaa33a6ad4896e77abcb2352f6fa371aacd0f66d Mon Sep 17 00:00:00 2001 From: mizdra Date: Wed, 26 Aug 2026 00:50:48 +0900 Subject: [PATCH 09/15] chore(content-mapper): repoint pinned tsgo to the microsoft/TypeScript monorepo Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- .vscode/launch.json | 10 +++++----- .../e2e-test/test-util/lsp-client.ts | 2 +- scripts/setup-tsgo-extension.sh | 12 +++++++----- scripts/setup-tsgo.sh | 15 +++++++++------ scripts/vitest-e2e-test-setup.ts | 2 +- 6 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0759d725..c6caaaa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,7 +79,7 @@ jobs: # commit) with no restore-keys guarantees that. - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: - path: .tmp/typescript-go/built + path: .tmp/typescript/built key: tsgo-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('scripts/setup-tsgo.sh') }} - run: vp test env: diff --git a/.vscode/launch.json b/.vscode/launch.json index 9b8e2ad8..f94fe80d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -191,14 +191,14 @@ } }, { - // 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. + // Launches the TypeScript Native Preview extension built from the pinned + // microsoft/TypeScript commit. The marketplace build predates the content + // mapper support, so the extension must be run from source. "name": "tsgo (7-content-mapper)", "type": "extensionHost", "request": "launch", "args": [ - "--extensionDevelopmentPath=${workspaceFolder}/.tmp/typescript-go/_extension", + "--extensionDevelopmentPath=${workspaceFolder}/.tmp/typescript/packages/vscode-typescript", "--profile-temp", "--skip-welcome", // The extension enables content mappers only in a trusted workspace. Disabling @@ -208,7 +208,7 @@ "--folder-uri=${workspaceFolder}/examples/7-content-mapper", "${workspaceFolder}/examples/7-content-mapper/src/index.ts" ], - "outFiles": ["${workspaceFolder}/.tmp/typescript-go/_extension/dist/**/*.js"], + "outFiles": ["${workspaceFolder}/.tmp/typescript/packages/vscode-typescript/dist/**/*.js"], "preLaunchTask": "prepare content-mapper example", "presentation": { "group": "tsgo" 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 67257099..825efff9 100644 --- a/packages/content-mapper/e2e-test/test-util/lsp-client.ts +++ b/packages/content-mapper/e2e-test/test-util/lsp-client.ts @@ -10,7 +10,7 @@ const tsgoBinPath = process.env['TSGO_BIN'] ?? resolve( import.meta.dirname, - `../../../../.tmp/typescript-go/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, + `../../../../.tmp/typescript/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, ); export interface Position { diff --git a/scripts/setup-tsgo-extension.sh b/scripts/setup-tsgo-extension.sh index 781d631a..3d63f2d6 100755 --- a/scripts/setup-tsgo-extension.sh +++ b/scripts/setup-tsgo-extension.sh @@ -2,18 +2,20 @@ 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. +# the pinned tsgo binary, the VS Code extension (TypeScript Native Preview) built +# from the pinned microsoft/TypeScript commit, and the mapper package symlink for +# the example. cd "$(dirname "$0")/.." -DEST=.tmp/typescript-go +DEST=.tmp/typescript ./scripts/setup-tsgo.sh -# In development mode, the extension resolves the tsgo binary at built/local/tsgo. +# In development mode, the extension resolves the binary at built/local/tsc +# (see packages/vscode-typescript/src/util.ts). GOEXE=$(go env GOEXE) mkdir -p "$DEST/built/local" -cp "$DEST/built/tsgo$GOEXE" "$DEST/built/local/tsgo$GOEXE" +cp "$DEST/built/tsgo$GOEXE" "$DEST/built/local/tsc$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 02ab8f8e..7a46ce4a 100755 --- a/scripts/setup-tsgo.sh +++ b/scripts/setup-tsgo.sh @@ -2,14 +2,17 @@ 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`). +# The content mapper protocol is implemented in microsoft/TypeScript (the TypeScript 7 +# monorepo, which absorbed microsoft/typescript-go). This script pins a commit of its +# main branch. The Go implementation lives in the tsc/ subdirectory, and its main +# package is ./cmd/tsc; the built binary is named tsgo here to avoid confusion with +# the TypeScript 6 tsc. -COMMIT=c18f834e07d992a24cdfbb7cb8bd58812ff3d95e -REPO=https://github.com/andrewbranch/typescript-go.git +COMMIT=8ac035a394c79e693a3a7d74cb170448503ee894 +REPO=https://github.com/microsoft/TypeScript.git cd "$(dirname "$0")/.." -DEST=.tmp/typescript-go +DEST=.tmp/typescript if [ ! -d "$DEST/.git" ]; then mkdir -p "$DEST" @@ -24,5 +27,5 @@ git -C "$DEST" checkout -q "$COMMIT" # 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) +(cd "$DEST/tsc" && go build -o "../built/tsgo$GOEXE" ./cmd/tsc) 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 2db68cd2..b25d3740 100644 --- a/scripts/vitest-e2e-test-setup.ts +++ b/scripts/vitest-e2e-test-setup.ts @@ -7,7 +7,7 @@ import type { TestProject } from 'vite-plus/test/node'; const tsgoBinPath = process.env['TSGO_BIN'] ?? fileURLToPath( - new URL(`../.tmp/typescript-go/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, import.meta.url), + new URL(`../.tmp/typescript/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, import.meta.url), ); function prepare() { From 887fadec7bf81f10e91906d2ce9e2390a8497d5e Mon Sep 17 00:00:00 2001 From: mizdra Date: Wed, 26 Aug 2026 00:56:44 +0900 Subject: [PATCH 10/15] feat(content-mapper): migrate to the content mapper protocol merged into microsoft/TypeScript Co-Authored-By: Claude Fable 5 --- .../e2e-test/test-util/lsp-client.ts | 7 +- packages/content-mapper/package.json | 14 +- packages/content-mapper/src/options.test.ts | 14 +- packages/content-mapper/src/options.ts | 27 ++-- packages/content-mapper/src/protocol.ts | 83 ++++++++---- packages/content-mapper/src/server.test.ts | 120 ++++++++++++------ packages/content-mapper/src/server.ts | 41 ++++-- .../content-mapper/src/transformer.test.ts | 108 ++++++++-------- packages/content-mapper/src/transformer.ts | 10 +- scripts/vitest-e2e-test-setup.ts | 4 +- 10 files changed, 259 insertions(+), 169 deletions(-) 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 825efff9..e7fa7380 100644 --- a/packages/content-mapper/e2e-test/test-util/lsp-client.ts +++ b/packages/content-mapper/e2e-test/test-util/lsp-client.ts @@ -8,10 +8,7 @@ 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/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, - ); + resolve(import.meta.dirname, `../../../../.tmp/typescript/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`); export interface Position { line: number; @@ -249,7 +246,7 @@ export function launchLSPClient(rootDir: string): LSPClient { fileOperations: { willRename: true }, }, }, - initializationOptions: { loadExternalPlugins: true }, + initializationOptions: { runExternalCode: true }, }); send({ method: 'initialized', params: {} }); })(); diff --git a/packages/content-mapper/package.json b/packages/content-mapper/package.json index 95a99765..69c62bda 100644 --- a/packages/content-mapper/package.json +++ b/packages/content-mapper/package.json @@ -21,13 +21,15 @@ "devDependencies": { "typescript": "^6.0.3" }, + "typescript": { + "contentMapper": { + "exec": [ + "node", + "dist/main.js" + ] + } + }, "engines": { "node": ">=22.12.0" - }, - "tsContentMapper": { - "exec": [ - "node", - "dist/main.js" - ] } } diff --git a/packages/content-mapper/src/options.test.ts b/packages/content-mapper/src/options.test.ts index 47bf3e92..05f9b2d4 100644 --- a/packages/content-mapper/src/options.test.ts +++ b/packages/content-mapper/src/options.test.ts @@ -10,7 +10,7 @@ const defaultOptions = { }; test('returns default options when raw options are undefined', () => { - expect(normalizeMapperOptions(undefined)).toEqual({ options: defaultOptions, errors: [] }); + expect(normalizeMapperOptions(undefined)).toEqual({ options: defaultOptions, optionDiagnostics: [] }); }); test('applies boolean options', () => { @@ -30,24 +30,24 @@ test('applies boolean options', () => { dashedIdents: true, container: true, }, - errors: [], + optionDiagnostics: [], }); }); test('ignores unknown keys', () => { - expect(normalizeMapperOptions({ unknown: true })).toEqual({ options: defaultOptions, errors: [] }); + expect(normalizeMapperOptions({ unknown: true })).toEqual({ options: defaultOptions, optionDiagnostics: [] }); }); -test('reports an error and returns default options when raw options are not an object', () => { +test('reports a diagnostic and returns default options when raw options are not an object', () => { expect(normalizeMapperOptions('yes')).toEqual({ options: defaultOptions, - errors: ['Options must be an object.'], + optionDiagnostics: [{ path: [], messageText: 'Options must be an object.', code: 1001 }], }); }); -test('reports an error and keeps the default when an option is not a boolean', () => { +test('reports a diagnostic at the option key and keeps the default when an option is not a boolean', () => { expect(normalizeMapperOptions({ animation: 'yes' })).toEqual({ options: defaultOptions, - errors: ['`animation` must be a boolean.'], + optionDiagnostics: [{ path: ['animation'], messageText: '`animation` must be a boolean.', code: 1002 }], }); }); diff --git a/packages/content-mapper/src/options.ts b/packages/content-mapper/src/options.ts index 965c4405..99e3d5b8 100644 --- a/packages/content-mapper/src/options.ts +++ b/packages/content-mapper/src/options.ts @@ -1,3 +1,5 @@ +import type { OptionDiagnostic } from './protocol.js'; + export interface NormalizedMapperOptions { namedExports: boolean; prioritizeNamedImports: boolean; @@ -8,7 +10,7 @@ export interface NormalizedMapperOptions { export interface NormalizeMapperOptionsResult { options: NormalizedMapperOptions; - errors: string[]; + optionDiagnostics: OptionDiagnostic[]; } const DEFAULT_OPTIONS: NormalizedMapperOptions = { @@ -21,17 +23,20 @@ const DEFAULT_OPTIONS: NormalizedMapperOptions = { const OPTION_KEYS = Object.keys(DEFAULT_OPTIONS) as (keyof NormalizedMapperOptions)[]; +const NOT_AN_OBJECT_CODE = 1001; +const NOT_A_BOOLEAN_CODE = 1002; + /** - * 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. + * Normalizes the raw `options` value of an openProject request. Invalid values fall back to + * the defaults, and an option diagnostic 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 }; + const optionDiagnostics: OptionDiagnostic[] = []; + if (raw === undefined) return { options, optionDiagnostics }; if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - errors.push('Options must be an object.'); - return { options, errors }; + optionDiagnostics.push({ path: [], messageText: 'Options must be an object.', code: NOT_AN_OBJECT_CODE }); + return { options, optionDiagnostics }; } for (const key of OPTION_KEYS) { if (!(key in raw)) continue; @@ -39,8 +44,12 @@ export function normalizeMapperOptions(raw: unknown): NormalizeMapperOptionsResu if (typeof value === 'boolean') { options[key] = value; } else { - errors.push(`\`${key}\` must be a boolean.`); + optionDiagnostics.push({ + path: [key], + messageText: `\`${key}\` must be a boolean.`, + code: NOT_A_BOOLEAN_CODE, + }); } } - return { options, errors }; + return { options, optionDiagnostics }; } diff --git a/packages/content-mapper/src/protocol.ts b/packages/content-mapper/src/protocol.ts index 9e6cc300..bf80bb2a 100644 --- a/packages/content-mapper/src/protocol.ts +++ b/packages/content-mapper/src/protocol.ts @@ -1,10 +1,11 @@ -// Type definitions for the content mapper protocol of TypeScript 7 (microsoft/typescript-go#4712). +// Type definitions for the content mapper protocol of TypeScript 7 +// (microsoft/typescript-go#4712, microsoft/TypeScript#63936). // The wire format is JSON-RPC 2.0 with LSP-style `Content-Length` framing. -export const PROTOCOL_VERSION = 1; export const DIAGNOSTIC_SOURCE = 'cmk'; export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; export interface RequestMessage { jsonrpc: '2.0'; @@ -29,33 +30,62 @@ export interface ResponseError { 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; + /** The prefix of mapper-authored diagnostic codes. Must not collide with other diagnostic sources. */ + diagnosticSource: string; +} + +export interface OpenProjectParams { + /** The absolute path of the project's tsconfig, or an empty string for an inferred project. */ + configFileName: string; + /** An opaque handle assigned by the host. Subsequent transforms reference it. */ + projectHandle: string; + /** The mapper entry's `options` from the project's `contentMappers` configuration. */ + options?: unknown; + compilerOptions: Record; +} + +/** + * The response to an openProject request. `configIdentity` and `watchedFiles` may only be + * returned by mappers that declare `dynamicConfig`, so cmk omits them. + */ +export interface OpenProjectResult { + optionDiagnostics?: OptionDiagnostic[]; +} + +/** An invalid mapper option. `path` locates the value within the mapper entry's options object. */ +export interface OptionDiagnostic { + path: (string | number)[]; + messageText: string; + code: number; +} + +export interface CloseProjectParams { + projectHandle: string; } export interface TransformParams { fileName: string; content: string; - options?: unknown; - projectHandle?: string; - compilerOptions: Record; + /** The handle of an opened project whose options apply to this transform. */ + projectHandle: string; } export interface TransformResult { text: string; - /** How `text` should be parsed. A value of `ts.ScriptKind`. Defaults to TypeScript if omitted. */ - scriptKind?: number; + /** Determines how `text` is parsed. */ + extension: VirtualExtension; mappings?: SpanMapping[]; diagnostics?: MapperDiagnostic[]; } +export type VirtualExtension = '.js' | '.jsx' | '.mjs' | '.cjs' | '.ts' | '.tsx' | '.mts' | '.cts' | '.json'; + /** A mapping between a span in the generated text and a span in the original file. */ export type SpanMapping = [ generatedStart: number, @@ -85,22 +115,21 @@ export const SpanMapFeature = { 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, + References: 1 << 6, + DocumentHighlights: 1 << 7, + Rename: 1 << 8, + CallHierarchy: 1 << 9, + CodeActions: 1 << 10, + Formatting: 1 << 11, + InlayHints: 1 << 12, + SemanticTokens: 1 << 13, + FoldingRanges: 1 << 14, + SelectionRanges: 1 << 15, + LinkedEditing: 1 << 16, + AutoInsert: 1 << 17, + DocumentSymbols: 1 << 18, + CodeLens: 1 << 19, + All: (1 << 20) - 1, } as const; /** A diagnostic reported by the mapper. `start` and `length` are positions in the original file. */ @@ -108,5 +137,5 @@ export interface MapperDiagnostic { messageText: string; start: number; length: number; - code?: number; + code: number; } diff --git a/packages/content-mapper/src/server.test.ts b/packages/content-mapper/src/server.test.ts index f6966d7b..8b0baf45 100644 --- a/packages/content-mapper/src/server.test.ts +++ b/packages/content-mapper/src/server.test.ts @@ -53,7 +53,7 @@ function createInitializeRequest(id: number) { jsonrpc: '2.0', id, method: 'initialize', - params: { protocolVersion: 1, positionEncodings: ['utf-8', 'utf-16'] }, + params: { positionEncodings: ['utf-8', 'utf-16'] }, }; } @@ -61,105 +61,136 @@ function createInitializeResponse(id: number) { return { jsonrpc: '2.0', id, - result: { protocolVersion: 1, positionEncoding: 'utf-16', diagnosticSource: 'cmk' }, + result: { positionEncoding: 'utf-16', diagnosticSource: 'cmk' }, }; } -function createTransformRequest(id: number, content: string) { +function createOpenProjectRequest(id: number, projectHandle: string, options?: unknown) { + return { + jsonrpc: '2.0', + id, + method: 'openProject', + params: { + configFileName: '/tsconfig.json', + projectHandle, + ...(options === undefined ? {} : { options }), + compilerOptions: {}, + }, + }; +} + +function createOpenProjectResponse(id: number) { + return { jsonrpc: '2.0', id, result: {} }; +} + +function createTransformRequest(id: number, content: string, projectHandle = 'p1') { return { jsonrpc: '2.0', id, method: 'transform', - params: { fileName: '/a.module.css', content, compilerOptions: {} }, + params: { fileName: '/a.module.css', content, projectHandle }, }; } -function createTransformResponse(id: number, content: string) { - const { text, mappings, diagnostics } = transformCSS('/a.module.css', content, defaultMapperOptions); +function createTransformResponse(id: number, content: string, options: NormalizedMapperOptions = defaultMapperOptions) { + const { text, mappings, diagnostics } = transformCSS('/a.module.css', content, options); return { jsonrpc: '2.0', id, result: { text, + extension: '.ts', ...(mappings.length > 0 ? { mappings } : {}), ...(diagnostics.length > 0 ? { diagnostics } : {}), }, }; } -test('responds to initialize with protocol version 1, utf-16 encoding, and cmk diagnostic source', async () => { +test('responds to initialize with utf-16 encoding and cmk diagnostic source', async () => { const { input, output, done } = startServer(); writeFrame(input, createInitializeRequest(1)); input.end(); await done; - expect(readResponses(output)).toEqual([ - { - jsonrpc: '2.0', - id: 1, - result: { protocolVersion: 1, positionEncoding: 'utf-16', diagnosticSource: 'cmk' }, - }, - ]); + expect(readResponses(output)).toEqual([createInitializeResponse(1)]); }); -test('responds to transform with generated text and span mappings', async () => { +test('responds to transform with generated text, extension, and span mappings', async () => { const { input, output, done } = startServer(); writeFrame(input, createInitializeRequest(1)); - writeFrame(input, createTransformRequest(2, '.a1 { color: red; }')); + writeFrame(input, createOpenProjectRequest(2, 'p1')); + writeFrame(input, createTransformRequest(3, '.a1 { color: red; }')); input.end(); await done; expect(readResponses(output)).toEqual([ createInitializeResponse(1), - createTransformResponse(2, '.a1 { color: red; }'), + createOpenProjectResponse(2), + createTransformResponse(3, '.a1 { color: red; }'), ]); }); -test('applies mapper options from transform params', async () => { +test('applies the mapper options of the project referenced by the transform', 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: {} }, - }); + writeFrame(input, createOpenProjectRequest(1, 'p1')); + writeFrame(input, createOpenProjectRequest(2, 'p2', { namedExports: true })); + writeFrame(input, createTransformRequest(3, '', 'p1')); + writeFrame(input, createTransformRequest(4, '', 'p2')); input.end(); await done; expect(readResponses(output)).toEqual([ - { jsonrpc: '2.0', id: 1, result: { text: 'declare const styles: {};\nexport default styles;\n' } }, + createOpenProjectResponse(1), + createOpenProjectResponse(2), + createTransformResponse(3, ''), + createTransformResponse(4, '', { ...defaultMapperOptions, namedExports: true }), ]); }); -test('reports option normalization errors as diagnostics at the file head', async () => { - const content = '.a1 { color: red; }'; +test('reports invalid mapper options as optionDiagnostics in the openProject response', async () => { const { input, output, done } = startServer(); - writeFrame(input, { - jsonrpc: '2.0', - id: 1, - method: 'transform', - params: { fileName: '/a.module.css', content, options: { animation: 'yes' }, compilerOptions: {} }, - }); + writeFrame(input, createOpenProjectRequest(1, 'p1', { animation: 'yes' })); input.end(); await done; - const expected = transformCSS('/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 }], + optionDiagnostics: [{ path: ['animation'], messageText: '`animation` must be a boolean.', code: 1002 }], }, }, ]); }); +test('responds with an invalid-params error to a transform with an unopened project handle', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createTransformRequest(1, '')); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + { jsonrpc: '2.0', id: 1, error: { code: -32602, message: 'Unknown project handle: p1' } }, + ]); +}); + +test('releases the project options on closeProject', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createOpenProjectRequest(1, 'p1')); + writeFrame(input, { jsonrpc: '2.0', id: 2, method: 'closeProject', params: { projectHandle: 'p1' } }); + writeFrame(input, createTransformRequest(3, '')); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + createOpenProjectResponse(1), + { jsonrpc: '2.0', id: 2, result: null }, + { jsonrpc: '2.0', id: 3, error: { code: -32602, message: 'Unknown project handle: p1' } }, + ]); +}); + test('responds with method-not-found error to unknown methods', async () => { const { input, output, done } = startServer(); - writeFrame(input, { jsonrpc: '2.0', id: 1, method: 'openProject', params: {} }); + writeFrame(input, { jsonrpc: '2.0', id: 1, method: 'shutdown', params: {} }); input.end(); await done; expect(readResponses(output)).toEqual([ - { jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found: openProject' } }, + { jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found: shutdown' } }, ]); }); @@ -193,11 +224,16 @@ test('reads frame bodies by UTF-8 byte length', async () => { // 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)); + writeFrame(input, createOpenProjectRequest(1, 'p1')); + writeFrame(input, createTransformRequest(2, content)); + writeFrame(input, createInitializeRequest(3)); input.end(); await done; - expect(readResponses(output)).toEqual([createTransformResponse(1, content), createInitializeResponse(2)]); + expect(readResponses(output)).toEqual([ + createOpenProjectResponse(1), + createTransformResponse(2, content), + createInitializeResponse(3), + ]); }); test('resolves when input ends', async () => { diff --git a/packages/content-mapper/src/server.ts b/packages/content-mapper/src/server.ts index ca12e2ed..3ab7eb52 100644 --- a/packages/content-mapper/src/server.ts +++ b/packages/content-mapper/src/server.ts @@ -1,15 +1,18 @@ import type { Readable, Writable } from 'node:stream'; import { ProtocolError } from './error.js'; +import type { NormalizedMapperOptions } from './options.js'; import { normalizeMapperOptions } from './options.js'; import type { + CloseProjectParams, InitializeResult, - MapperDiagnostic, + OpenProjectParams, + OpenProjectResult, RequestMessage, ResponseMessage, TransformParams, TransformResult, } from './protocol.js'; -import { DIAGNOSTIC_SOURCE, METHOD_NOT_FOUND, PROTOCOL_VERSION } from './protocol.js'; +import { DIAGNOSTIC_SOURCE, INVALID_PARAMS, METHOD_NOT_FOUND } from './protocol.js'; import { transformCSS } from './transformer.js'; const HEADER_TERMINATOR = new Uint8Array([0x0d, 0x0a, 0x0d, 0x0a]); // '\r\n\r\n' @@ -70,28 +73,43 @@ function isRequestMessage(message: unknown): message is RequestMessage { return typeof message === 'object' && message !== null && 'method' in message && 'id' in message; } -function createResponse(request: RequestMessage): ResponseMessage { +function createResponse(request: RequestMessage, projects: Map): 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 'openProject': { + const params = request.params as OpenProjectParams; + const { options, optionDiagnostics } = normalizeMapperOptions(params.options); + projects.set(params.projectHandle, options); + const result: OpenProjectResult = optionDiagnostics.length > 0 ? { optionDiagnostics } : {}; + return { jsonrpc: '2.0', id: request.id, result }; + } + case 'closeProject': { + const params = request.params as CloseProjectParams; + projects.delete(params.projectHandle); + return { jsonrpc: '2.0', id: request.id, result: null }; + } case 'transform': { const params = request.params as TransformParams; - const { options, errors } = normalizeMapperOptions(params.options); + const options = projects.get(params.projectHandle); + if (options === undefined) { + return { + jsonrpc: '2.0', + id: request.id, + error: { code: INVALID_PARAMS, message: `Unknown project handle: ${params.projectHandle}` }, + }; + } const output = transformCSS(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, + extension: '.ts', ...(output.mappings.length > 0 ? { mappings: output.mappings } : {}), - ...(diagnostics.length > 0 ? { diagnostics } : {}), + ...(output.diagnostics.length > 0 ? { diagnostics: output.diagnostics } : {}), }; return { jsonrpc: '2.0', id: request.id, result }; } @@ -117,12 +135,13 @@ function encodeFrame(message: ResponseMessage): Uint8Array { export async function runServer(input: Readable, output: Writable): Promise { return new Promise((resolve, reject) => { const decoder = createFrameDecoder(); + const projects = new Map(); 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))); + output.write(encodeFrame(createResponse(message, projects))); } } } catch (error) { diff --git a/packages/content-mapper/src/transformer.test.ts b/packages/content-mapper/src/transformer.test.ts index 8379bb48..53d7671b 100644 --- a/packages/content-mapper/src/transformer.test.ts +++ b/packages/content-mapper/src/transformer.test.ts @@ -35,13 +35,13 @@ test('generates interface declarations for local tokens', () => { === generated === interface Styles { readonly 'foo': string; } - ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #2 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #0 Atom(Definition|TypeDefinition|Implementation|References) interface Styles { readonly 'bar': string; } - ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #5 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|References) declare const styles: Styles; export default styles; " @@ -87,19 +87,19 @@ test('generates indexed access type members for named token importer entries', ( 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) + ^ #6 Atom(Definition|TypeDefinition|Implementation|References) ^^ #5 Verbatim - ^ #4 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) - ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #4 Atom(Definition|TypeDefinition|Implementation|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|References) ^^ #2 Verbatim - ^ #1 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #1 Atom(Definition|TypeDefinition|Implementation|References) interface Styles { readonly 'v3': typeof _import_0.default['v2']; } - ^ #12 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #12 Atom(Definition|TypeDefinition|Implementation|References) ^^ #11 Verbatim - ^ #10 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) - ^ #9 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #10 Atom(Definition|TypeDefinition|Implementation|References) + ^ #9 Atom(Definition|TypeDefinition|Implementation|References) ^^ #8 Verbatim - ^ #7 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #7 Atom(Definition|TypeDefinition|Implementation|References) declare const styles: Styles; export default styles; " @@ -145,18 +145,18 @@ test('generates expression statements for local token references', () => { === generated === interface Styles { readonly 'foo': string; } - ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #2 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #0 Atom(Definition|TypeDefinition|Implementation|References) interface Styles { readonly 'pulse': string; } - ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #5 Atom(Definition|TypeDefinition|Implementation|References) ^^^^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|References) declare const styles: Styles; styles['pulse']; - ^ #8 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #8 Atom(Definition|TypeDefinition|Implementation|References) ^^^^^ #7 Verbatim - ^ #6 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #6 Atom(Definition|TypeDefinition|Implementation|References) export default styles; " `); @@ -178,14 +178,14 @@ test('generates imports and expression statements for external token references' import * as _import_0 from './d.module.css'; ^^^^^^^^^^^^^^^^ #0 Verbatim interface Styles { readonly 'foo': string; } - ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #2 Verbatim - ^ #1 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #1 Atom(Definition|TypeDefinition|Implementation|References) declare const styles: Styles; _import_0.default['baz']; - ^ #6 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #6 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #5 Verbatim - ^ #4 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #4 Atom(Definition|TypeDefinition|Implementation|References) export default styles; " `); @@ -209,13 +209,13 @@ test('generates an interface declaration for every occurrence of a duplicated to === generated === interface Styles { readonly 'foo': string; } - ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #2 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #0 Atom(Definition|TypeDefinition|Implementation|References) interface Styles { readonly 'foo': string; } - ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #5 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|References) declare const styles: Styles; export default styles; " @@ -262,9 +262,9 @@ test('synthesizes quotes for unquoted url() specifiers and maps them as zero-wid === generated === import * as _import_0 from './b.module.css'; - ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #2 Atom(Definition|TypeDefinition|Implementation|References) ^^^^^^^^^^^^^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #0 Atom(Definition|TypeDefinition|Implementation|References) type __BlockErrorType = [0] extends [1 & T] ? {} : T; interface Styles {} declare const styles: Styles & __BlockErrorType; @@ -292,13 +292,13 @@ test('converts parse diagnostics into mapper diagnostics', () => { === generated === interface Styles { readonly 'foo': string; } - ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #2 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #0 Atom(Definition|TypeDefinition|Implementation|References) interface Styles { readonly 'bar': string; } - ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #5 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|References) declare const styles: Styles; export default styles; @@ -377,15 +377,15 @@ describe('namedExports', () => { var _token_0: string; ^^^^^^^^ #1 Alias(All~Rename) export { _token_0 as 'foo' }; - ^ #4 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #4 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #3 Verbatim - ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #2 Atom(Definition|TypeDefinition|Implementation|References) var _token_1: string; ^^^^^^^^ #5 Alias(All~Rename) export { _token_1 as 'bar' }; - ^ #8 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #8 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #7 Verbatim - ^ #6 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #6 Atom(Definition|TypeDefinition|Implementation|References) declare const styles: {}; export default styles; " @@ -428,19 +428,19 @@ describe('namedExports', () => { === generated === export { 'v1' as 'v1', - ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #5 Atom(Definition|TypeDefinition|Implementation|References) ^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) - ^ #2 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|References) + ^ #2 Atom(Definition|TypeDefinition|Implementation|References) ^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #0 Atom(Definition|TypeDefinition|Implementation|References) 'v2' as 'v3', - ^ #11 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #11 Atom(Definition|TypeDefinition|Implementation|References) ^^ #10 Verbatim - ^ #9 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) - ^ #8 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #9 Atom(Definition|TypeDefinition|Implementation|References) + ^ #8 Atom(Definition|TypeDefinition|Implementation|References) ^^ #7 Verbatim - ^ #6 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #6 Atom(Definition|TypeDefinition|Implementation|References) } from './c.module.css'; ^^^^^^^^^^^^^^^^ #12 Verbatim declare const styles: {}; @@ -477,20 +477,20 @@ describe('namedExports', () => { var _token_0: string; ^^^^^^^^ #0 Alias(All~Rename) export { _token_0 as 'foo' }; - ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #2 Verbatim - ^ #1 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #1 Atom(Definition|TypeDefinition|Implementation|References) var _token_1: string; ^^^^^^^^ #4 Alias(All~Rename) export { _token_1 as 'pulse' }; - ^ #7 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #7 Atom(Definition|TypeDefinition|Implementation|References) ^^^^^ #6 Verbatim - ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #5 Atom(Definition|TypeDefinition|Implementation|References) declare const __self: typeof import('./a.module.css'); __self['pulse']; - ^ #10 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #10 Atom(Definition|TypeDefinition|Implementation|References) ^^^^^ #9 Verbatim - ^ #8 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #8 Atom(Definition|TypeDefinition|Implementation|References) declare const styles: {}; export default styles; " @@ -514,15 +514,15 @@ describe('namedExports', () => { var _token_0: string; ^^^^^^^^ #0 Alias(All~Rename) export { _token_0 as 'foo' }; - ^ #3 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #3 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #2 Verbatim - ^ #1 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #1 Atom(Definition|TypeDefinition|Implementation|References) import * as _import_0 from './d.module.css'; ^^^^^^^^^^^^^^^^ #4 Verbatim _import_0['baz']; - ^ #7 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #7 Atom(Definition|TypeDefinition|Implementation|References) ^^^ #6 Verbatim - ^ #5 Atom(Definition|TypeDefinition|Implementation|SourceDefinition|References) + ^ #5 Atom(Definition|TypeDefinition|Implementation|References) declare const styles: {}; export default styles; " diff --git a/packages/content-mapper/src/transformer.ts b/packages/content-mapper/src/transformer.ts index 235b772b..193a8dae 100644 --- a/packages/content-mapper/src/transformer.ts +++ b/packages/content-mapper/src/transformer.ts @@ -28,11 +28,7 @@ export interface TransformOutput { // 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; + SpanMapFeature.Definition | SpanMapFeature.TypeDefinition | SpanMapFeature.Implementation | 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; @@ -328,6 +324,9 @@ function appendImportSpecifier( builder.append(';\n'); } +// Core diagnostics have no code of their own, so they all share one mapper diagnostic code. +const CSS_MODULE_DIAGNOSTIC_CODE = 1000; + function convertDiagnostics(diagnostics: DiagnosticWithLocation[], content: string): MapperDiagnostic[] { return diagnostics .filter((diagnostic) => diagnostic.category === 'error') @@ -335,6 +334,7 @@ function convertDiagnostics(diagnostics: DiagnosticWithLocation[], content: stri messageText: diagnostic.text, start: toOffset(content, diagnostic.start.line, diagnostic.start.column), length: diagnostic.length, + code: CSS_MODULE_DIAGNOSTIC_CODE, })); } diff --git a/scripts/vitest-e2e-test-setup.ts b/scripts/vitest-e2e-test-setup.ts index b25d3740..867edd2e 100644 --- a/scripts/vitest-e2e-test-setup.ts +++ b/scripts/vitest-e2e-test-setup.ts @@ -6,9 +6,7 @@ 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/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, import.meta.url), - ); + fileURLToPath(new URL(`../.tmp/typescript/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, import.meta.url)); function prepare() { if (!existsSync(tsgoBinPath)) { From 546a7ab20b5e74ce14655c2bd09ff45252050279 Mon Sep 17 00:00:00 2001 From: mizdra Date: Wed, 26 Aug 2026 01:13:23 +0900 Subject: [PATCH 11/15] feat(content-mapper): project each token as atom and verbatim spans to fix result mapping and rename Co-Authored-By: Claude Fable 5 --- packages/content-mapper/src/protocol.ts | 34 ++ packages/content-mapper/src/server.test.ts | 3 +- packages/content-mapper/src/server.ts | 1 + packages/content-mapper/src/test/render.ts | 24 +- .../content-mapper/src/test/ts-program.ts | 29 +- .../src/transformer-program.test.ts | 11 +- .../content-mapper/src/transformer.test.ts | 328 +++++++++--------- packages/content-mapper/src/transformer.ts | 171 ++++++--- 8 files changed, 385 insertions(+), 216 deletions(-) diff --git a/packages/content-mapper/src/protocol.ts b/packages/content-mapper/src/protocol.ts index bf80bb2a..1c67bee6 100644 --- a/packages/content-mapper/src/protocol.ts +++ b/packages/content-mapper/src/protocol.ts @@ -81,6 +81,7 @@ export interface TransformResult { /** Determines how `text` is parsed. */ extension: VirtualExtension; mappings?: SpanMapping[]; + diagnosticDirectives?: DiagnosticDirectives; diagnostics?: MapperDiagnostic[]; } @@ -139,3 +140,36 @@ export interface MapperDiagnostic { length: number; code: number; } + +export interface DiagnosticDirectives { + /** Diagnostics reported when an `Expect` directive matches no diagnostic. Unused by `Ignore` directives. */ + unusedExpectDirectiveDiagnostics: UnusedExpectDirectiveDiagnostic[]; + /** Directives whose generated ranges must not overlap each other. */ + directives: MappedDiagnosticDirective[]; +} + +export interface UnusedExpectDirectiveDiagnostic { + messageText: string; + code: number; +} + +/** + * Suppresses (`Ignore`) or expects (`Expect`) TypeScript diagnostics whose start position falls + * within `[generatedStart, generatedEnd)`. `originalStart` and `originalLength` locate the + * directive in the original file for `Expect` reporting. + */ +export type MappedDiagnosticDirective = [ + originalStart: number, + originalLength: number, + generatedStart: number, + generatedEnd: number, + policy: DiagnosticDirectivePolicy, + unusedExpectDirectiveIndex?: number, +]; + +export const DiagnosticDirectivePolicy = { + Ignore: 0, + Expect: 1, +} as const; + +export type DiagnosticDirectivePolicy = (typeof DiagnosticDirectivePolicy)[keyof typeof DiagnosticDirectivePolicy]; diff --git a/packages/content-mapper/src/server.test.ts b/packages/content-mapper/src/server.test.ts index 8b0baf45..da8ab5e5 100644 --- a/packages/content-mapper/src/server.test.ts +++ b/packages/content-mapper/src/server.test.ts @@ -93,7 +93,7 @@ function createTransformRequest(id: number, content: string, projectHandle = 'p1 } function createTransformResponse(id: number, content: string, options: NormalizedMapperOptions = defaultMapperOptions) { - const { text, mappings, diagnostics } = transformCSS('/a.module.css', content, options); + const { text, mappings, diagnosticDirectives, diagnostics } = transformCSS('/a.module.css', content, options); return { jsonrpc: '2.0', id, @@ -101,6 +101,7 @@ function createTransformResponse(id: number, content: string, options: Normalize text, extension: '.ts', ...(mappings.length > 0 ? { mappings } : {}), + ...(diagnosticDirectives ? { diagnosticDirectives } : {}), ...(diagnostics.length > 0 ? { diagnostics } : {}), }, }; diff --git a/packages/content-mapper/src/server.ts b/packages/content-mapper/src/server.ts index 3ab7eb52..666f7d4c 100644 --- a/packages/content-mapper/src/server.ts +++ b/packages/content-mapper/src/server.ts @@ -109,6 +109,7 @@ function createResponse(request: RequestMessage, projects: Map 0 ? { mappings: output.mappings } : {}), + ...(output.diagnosticDirectives ? { diagnosticDirectives: output.diagnosticDirectives } : {}), ...(output.diagnostics.length > 0 ? { diagnostics: output.diagnostics } : {}), }; return { jsonrpc: '2.0', id: request.id, result }; diff --git a/packages/content-mapper/src/test/render.ts b/packages/content-mapper/src/test/render.ts index 986fbee9..efec6be8 100644 --- a/packages/content-mapper/src/test/render.ts +++ b/packages/content-mapper/src/test/render.ts @@ -1,4 +1,4 @@ -import { SpanMapFeature, SpanMapKind } from '../protocol.js'; +import { DiagnosticDirectivePolicy, SpanMapFeature, SpanMapKind } from '../protocol.js'; import type { TransformOutput } from '../transformer.js'; interface Marker { @@ -18,6 +18,11 @@ const KIND_NAMES: Record = { [SpanMapKind.Alias]: 'Alias', }; +const POLICY_NAMES: Record = { + [DiagnosticDirectivePolicy.Ignore]: 'ignore', + [DiagnosticDirectivePolicy.Expect]: 'expect', +}; + function formatFeatures(features: number | undefined): string { if (features === undefined) return ''; const flags = Object.entries(SpanMapFeature).filter(([name]) => name !== 'All'); @@ -75,11 +80,18 @@ export function renderTransformOutput(source: string, output: TransformOutput): 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], - })); + const generatedMarkers: Marker[] = [ + ...output.mappings.map((mapping, i) => ({ + label: `#${i} ${KIND_NAMES[mapping[4]]}${formatFeatures(mapping[5])}`, + offset: mapping[0], + length: mapping[1], + })), + ...(output.diagnosticDirectives?.directives ?? []).map((directive, i) => ({ + label: `${POLICY_NAMES[directive[4]]}#${i}`, + offset: directive[2], + length: directive[3] - directive[2], + })), + ]; 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')}`; diff --git a/packages/content-mapper/src/test/ts-program.ts b/packages/content-mapper/src/test/ts-program.ts index 2b10a85c..1d3a9ed3 100644 --- a/packages/content-mapper/src/test/ts-program.ts +++ b/packages/content-mapper/src/test/ts-program.ts @@ -1,5 +1,6 @@ import ts from 'typescript'; import type { NormalizedMapperOptions } from '../options.js'; +import { DiagnosticDirectivePolicy } from '../protocol.js'; import type { TransformOutput } from '../transformer.js'; import { transformCSS } from '../transformer.js'; @@ -64,16 +65,30 @@ export function checkGeneratedTexts( 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'), - })); + const diagnostics = ts + .getPreEmitDiagnostics(program) + .filter((diagnostic) => !isSuppressedByIgnoreDirective(diagnostic, outputs)) + .map((diagnostic) => ({ + code: diagnostic.code, + fileName: diagnostic.file?.fileName, + start: diagnostic.start, + length: diagnostic.length, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + })); return { outputs, diagnostics }; } +/** Mirrors how tsgo applies `Ignore` diagnostic directives to TypeScript diagnostics. */ +function isSuppressedByIgnoreDirective(diagnostic: ts.Diagnostic, outputs: Record): boolean { + if (diagnostic.file === undefined || diagnostic.start === undefined) return false; + const cssFileName = diagnostic.file.fileName.replace(/\.ts$/u, ''); + const directives = outputs[cssFileName]?.diagnosticDirectives?.directives ?? []; + const { start } = diagnostic; + return directives.some( + (directive) => directive[4] === DiagnosticDirectivePolicy.Ignore && start >= directive[2] && start < directive[3], + ); +} + function resolveSpecifier(containingFile: string, specifier: string): string { const dir = containingFile.slice(0, containingFile.lastIndexOf('/')); if (specifier.startsWith('./')) return `${dir}/${specifier.slice(2)}`; diff --git a/packages/content-mapper/src/transformer-program.test.ts b/packages/content-mapper/src/transformer-program.test.ts index 4f95da40..23bdcd1e 100644 --- a/packages/content-mapper/src/transformer-program.test.ts +++ b/packages/content-mapper/src/transformer-program.test.ts @@ -1,7 +1,7 @@ import dedent from 'dedent'; import { expect, test } from 'vite-plus/test'; import type { NormalizedMapperOptions } from './options.js'; -import { SpanMapKind } from './protocol.js'; +import { SpanMapFeature, SpanMapKind } from './protocol.js'; import { checkGeneratedTexts } from './test/ts-program.js'; const defaultOptions: NormalizedMapperOptions = { @@ -75,7 +75,14 @@ test('reports a missing token error on the token span for named token importer e length: `'missing'`.length, }), ]); - expect(outputs['/a.module.css']!.mappings).toContainEqual([keyStart + 1, 7, 7, 7, SpanMapKind.Verbatim]); + expect(outputs['/a.module.css']!.mappings).toContainEqual([ + keyStart, + `'missing'`.length, + 7, + 'missing'.length, + SpanMapKind.Atom, + SpanMapFeature.All & ~SpanMapFeature.Rename, + ]); }); test('reports a missing token error on the token span for export from entries in named exports mode', () => { diff --git a/packages/content-mapper/src/transformer.test.ts b/packages/content-mapper/src/transformer.test.ts index 53d7671b..90bb4589 100644 --- a/packages/content-mapper/src/transformer.test.ts +++ b/packages/content-mapper/src/transformer.test.ts @@ -25,24 +25,24 @@ test('generates interface declarations for local tokens', () => { expect(result).toMatchInlineSnapshot(` "=== source === .foo {} - ¦ #2 - ¦ #0 - ^^^ #1 + ^^^ #0 + ^^^ #2 .bar {} - ¦ #5 - ¦ #3 - ^^^ #4 + ^^^ #1 + ^^^ #3 === generated === interface Styles { readonly 'foo': string; } - ^ #2 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #0 Atom(All~Rename) interface Styles { readonly 'bar': string; } - ^ #5 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + styles['foo']; + ^^^ #2 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + styles['bar']; + ^^^ #3 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#1 export default styles; " `); @@ -70,37 +70,37 @@ test('generates indexed access type members for named token importer entries', ( "=== source === @value v1, v2 as v3 from './c.module.css'; ^^^^^^^^^^^^^^^^ #0 - ¦ #9 - ¦ #7 - ^^ #8 - ¦ #12 - ¦ #10 - ^^ #11 - ¦ #3 - ¦ #6 - ¦ #1 + ^^ #3 + ^^ #7 + ^^ #4 + ^^ #8 + ^^ #1 ^^ #2 - ¦ #4 ^^ #5 + ^^ #6 === 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|References) - ^^ #5 Verbatim - ^ #4 Atom(Definition|TypeDefinition|Implementation|References) - ^ #3 Atom(Definition|TypeDefinition|Implementation|References) - ^^ #2 Verbatim - ^ #1 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^ #2 Atom(All~Rename) + ^^^^ #1 Atom(All~Rename) interface Styles { readonly 'v3': typeof _import_0.default['v2']; } - ^ #12 Atom(Definition|TypeDefinition|Implementation|References) - ^^ #11 Verbatim - ^ #10 Atom(Definition|TypeDefinition|Implementation|References) - ^ #9 Atom(Definition|TypeDefinition|Implementation|References) - ^^ #8 Verbatim - ^ #7 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^ #4 Atom(All~Rename) + ^^^^ #3 Atom(All~Rename) declare const styles: Styles; + styles['v1']; + ^^ #5 Verbatim(All~Hover) + ^^^^^^^^^^^^^ ignore#0 + _import_0.default['v1']; + ^^ #6 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^ ignore#1 + styles['v3']; + ^^ #7 Verbatim(All~Hover) + ^^^^^^^^^^^^^ ignore#2 + _import_0.default['v2']; + ^^ #8 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^ ignore#3 export default styles; " `); @@ -132,31 +132,31 @@ test('generates expression statements for local token references', () => { expect(result).toMatchInlineSnapshot(` "=== source === .foo { animation-name: pulse; } - ¦ #8 - ¦ #6 - ^^^^^ #7 - ¦ #2 - ¦ #0 - ^^^ #1 + ^^^^^ #4 + ^^^^^ #5 + ^^^ #0 + ^^^ #2 @keyframes pulse {} - ¦ #5 - ¦ #3 - ^^^^^ #4 + ^^^^^ #1 + ^^^^^ #3 === generated === interface Styles { readonly 'foo': string; } - ^ #2 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #0 Atom(All~Rename) interface Styles { readonly 'pulse': string; } - ^ #5 Atom(Definition|TypeDefinition|Implementation|References) - ^^^^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + styles['foo']; + ^^^ #2 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + styles['pulse']; + ^^^^^ #3 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^ ignore#1 styles['pulse']; - ^ #8 Atom(Definition|TypeDefinition|Implementation|References) - ^^^^^ #7 Verbatim - ^ #6 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^^^ #4 Atom(All~Rename) + styles['pulse']; + ^^^^^ #5 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^ ignore#2 export default styles; " `); @@ -167,25 +167,25 @@ test('generates imports and expression statements for external token references' "=== source === .foo { composes: baz from './d.module.css'; } ^^^^^^^^^^^^^^^^ #0 - ¦ #6 - ¦ #4 - ^^^ #5 - ¦ #3 - ¦ #1 + ^^^ #3 + ^^^ #4 + ^^^ #1 ^^^ #2 === generated === import * as _import_0 from './d.module.css'; ^^^^^^^^^^^^^^^^ #0 Verbatim interface Styles { readonly 'foo': string; } - ^ #3 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #2 Verbatim - ^ #1 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + styles['foo']; + ^^^ #2 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 _import_0.default['baz']; - ^ #6 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #5 Verbatim - ^ #4 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #3 Atom(All~Rename) + _import_0.default['baz']; + ^^^ #4 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#1 export default styles; " `); @@ -199,24 +199,24 @@ test('generates an interface declaration for every occurrence of a duplicated to expect(result).toMatchInlineSnapshot(` "=== source === .foo {} - ¦ #2 - ¦ #0 - ^^^ #1 + ^^^ #0 + ^^^ #2 .foo:hover {} - ¦ #5 - ¦ #3 - ^^^ #4 + ^^^ #1 + ^^^ #3 === generated === interface Styles { readonly 'foo': string; } - ^ #2 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #0 Atom(All~Rename) interface Styles { readonly 'foo': string; } - ^ #5 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + styles['foo']; + ^^^ #2 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + styles['foo']; + ^^^ #3 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#1 export default styles; " `); @@ -281,25 +281,25 @@ test('converts parse diagnostics into mapper diagnostics', () => { expect(result).toMatchInlineSnapshot(` "=== source === .foo { color: red; } - ¦ #2 - ¦ #0 - ^^^ #1 + ^^^ #0 + ^^^ #2 .bar { - ¦ #5 - ¦ #3 - ^^^ #4 + ^^^ #1 + ^^^ #3 ^ diag#0 === generated === interface Styles { readonly 'foo': string; } - ^ #2 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #0 Atom(All~Rename) interface Styles { readonly 'bar': string; } - ^ #5 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + styles['foo']; + ^^^ #2 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + styles['bar']; + ^^^ #3 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#1 export default styles; @@ -359,16 +359,15 @@ describe('namedExports', () => { expect(result).toMatchInlineSnapshot(` "=== source === .foo {} - ¦ #4 ^^^ #0 - ¦ #2 - ^^^ #3 + ^^^ #2 + ^^^ #5 .foo:hover {} ^^^ #1 + ^^^ #6 .bar {} - ¦ #8 - ^^^ #5 - ¦ #6 + ^^^ #3 + ^^^ #4 ^^^ #7 === generated === @@ -377,15 +376,21 @@ describe('namedExports', () => { var _token_0: string; ^^^^^^^^ #1 Alias(All~Rename) export { _token_0 as 'foo' }; - ^ #4 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #3 Verbatim - ^ #2 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #2 Atom(All~Rename) var _token_1: string; - ^^^^^^^^ #5 Alias(All~Rename) + ^^^^^^^^ #3 Alias(All~Rename) export { _token_1 as 'bar' }; - ^ #8 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #7 Verbatim - ^ #6 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #4 Atom(All~Rename) + declare const __self: typeof import('./a.module.css'); + __self['foo']; + ^^^ #5 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + __self['foo']; + ^^^ #6 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#1 + __self['bar']; + ^^^ #7 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#2 declare const styles: {}; export default styles; " @@ -411,38 +416,42 @@ describe('namedExports', () => { 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 + ^^^^^^^^^^^^^^^^ #4 + ^^^^^^^^^^^^^^^^ #5 + ^^ #3 + ^^ #8 + ^^ #2 + ^^ #9 + ^^ #0 ^^ #1 - ¦ #3 - ^^ #4 + ^^ #6 + ^^ #7 === generated === export { 'v1' as 'v1', - ^ #5 Atom(Definition|TypeDefinition|Implementation|References) - ^^ #4 Verbatim - ^ #3 Atom(Definition|TypeDefinition|Implementation|References) - ^ #2 Atom(Definition|TypeDefinition|Implementation|References) - ^^ #1 Verbatim - ^ #0 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^ #1 Atom(All~Rename) + ^^^^ #0 Atom(All~Rename) 'v2' as 'v3', - ^ #11 Atom(Definition|TypeDefinition|Implementation|References) - ^^ #10 Verbatim - ^ #9 Atom(Definition|TypeDefinition|Implementation|References) - ^ #8 Atom(Definition|TypeDefinition|Implementation|References) - ^^ #7 Verbatim - ^ #6 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^ #3 Atom(All~Rename) + ^^^^ #2 Atom(All~Rename) } from './c.module.css'; - ^^^^^^^^^^^^^^^^ #12 Verbatim + ^^^^^^^^^^^^^^^^ #4 Verbatim + import * as _import_0 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #5 Verbatim + declare const __self: typeof import('./a.module.css'); + __self['v1']; + ^^ #6 Verbatim(All~Hover) + ^^^^^^^^^^^^^ ignore#0 + _import_0['v1']; + ^^ #7 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^ ignore#1 + __self['v3']; + ^^ #8 Verbatim(All~Hover) + ^^^^^^^^^^^^^ ignore#2 + _import_0['v2']; + ^^ #9 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^ ignore#3 declare const styles: {}; export default styles; " @@ -460,37 +469,37 @@ describe('namedExports', () => { expect(result).toMatchInlineSnapshot(` "=== source === .foo { animation-name: pulse; } - ¦ #10 - ¦ #8 - ^^^^^ #9 - ¦ #3 + ^^^^^ #6 + ^^^^^ #7 ^^^ #0 - ¦ #1 - ^^^ #2 + ^^^ #1 + ^^^ #4 @keyframes pulse {} - ¦ #7 - ^^^^^ #4 - ¦ #5 - ^^^^^ #6 + ^^^^^ #2 + ^^^^^ #3 + ^^^^^ #5 === generated === var _token_0: string; ^^^^^^^^ #0 Alias(All~Rename) export { _token_0 as 'foo' }; - ^ #3 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #2 Verbatim - ^ #1 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #1 Atom(All~Rename) var _token_1: string; - ^^^^^^^^ #4 Alias(All~Rename) + ^^^^^^^^ #2 Alias(All~Rename) export { _token_1 as 'pulse' }; - ^ #7 Atom(Definition|TypeDefinition|Implementation|References) - ^^^^^ #6 Verbatim - ^ #5 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^^^ #3 Atom(All~Rename) declare const __self: typeof import('./a.module.css'); + __self['foo']; + ^^^ #4 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 __self['pulse']; - ^ #10 Atom(Definition|TypeDefinition|Implementation|References) - ^^^^^ #9 Verbatim - ^ #8 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #5 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^ ignore#1 + __self['pulse']; + ^^^^^^^ #6 Atom(All~Rename) + __self['pulse']; + ^^^^^ #7 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^ ignore#2 declare const styles: {}; export default styles; " @@ -501,28 +510,29 @@ describe('namedExports', () => { expect(run(`.foo { composes: baz from './d.module.css'; }`, namedExportsOptions)).toMatchInlineSnapshot(` "=== source === .foo { composes: baz from './d.module.css'; } - ^^^^^^^^^^^^^^^^ #4 - ¦ #7 - ¦ #5 - ^^^ #6 - ¦ #3 + ^^^^^^^^^^^^^^^^ #2 + ^^^ #4 + ^^^ #5 ^^^ #0 - ¦ #1 - ^^^ #2 + ^^^ #1 + ^^^ #3 === generated === var _token_0: string; ^^^^^^^^ #0 Alias(All~Rename) export { _token_0 as 'foo' }; - ^ #3 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #2 Verbatim - ^ #1 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^ #1 Atom(All~Rename) import * as _import_0 from './d.module.css'; - ^^^^^^^^^^^^^^^^ #4 Verbatim + ^^^^^^^^^^^^^^^^ #2 Verbatim + declare const __self: typeof import('./a.module.css'); + __self['foo']; + ^^^ #3 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + _import_0['baz']; + ^^^^^ #4 Atom(All~Rename) _import_0['baz']; - ^ #7 Atom(Definition|TypeDefinition|Implementation|References) - ^^^ #6 Verbatim - ^ #5 Atom(Definition|TypeDefinition|Implementation|References) + ^^^ #5 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^ ignore#1 declare const styles: {}; export default styles; " diff --git a/packages/content-mapper/src/transformer.ts b/packages/content-mapper/src/transformer.ts index 193a8dae..c8e0bd7a 100644 --- a/packages/content-mapper/src/transformer.ts +++ b/packages/content-mapper/src/transformer.ts @@ -15,27 +15,54 @@ import { 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'; +import type { DiagnosticDirectives, MapperDiagnostic, MappedDiagnosticDirective, SpanMapping } from './protocol.js'; +import { DiagnosticDirectivePolicy, SpanMapFeature, SpanMapKind } from './protocol.js'; export interface TransformOutput { text: string; mappings: SpanMapping[]; + diagnosticDirectives?: DiagnosticDirectives; 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. +// Rename edits can only be written back through a Verbatim span, so atom and alias spans +// exclude Rename. A verbatim projection of the same token carries it instead. +const NON_RENAME_FEATURES = SpanMapFeature.All & ~SpanMapFeature.Rename; + +// Hover results from multiple projections of the same original span are concatenated, so +// only the atom projection answers hover. +const NON_HOVER_FEATURES = SpanMapFeature.All & ~SpanMapFeature.Hover; + +// The synthesized quotes around an unquoted url() specifier 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 module. const QUOTE_FEATURES = SpanMapFeature.Definition | SpanMapFeature.TypeDefinition | SpanMapFeature.Implementation | 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[] = []; + const directives: MappedDiagnosticDirective[] = []; + function append(chunk: string): void { + text += chunk; + } + /** Appends `'name'` as a single atom, mapping the quote-inclusive literal to `loc`. */ + function appendAtomTokenName(name: string, loc: Location): void { + mappings.push([text.length, name.length + 2, loc.start.offset, name.length, SpanMapKind.Atom, NON_RENAME_FEATURES]); + text += `'${name}'`; + } + /** Appends `'name'`, mapping only the name verbatim to `loc` and leaving the quotes unmapped. */ + function appendVerbatimTokenName(name: string, loc: Location): void { + mappings.push([ + text.length + 1, + name.length, + loc.start.offset, + name.length, + SpanMapKind.Verbatim, + NON_HOVER_FEATURES, + ]); + text += `'${name}'`; + } 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]); @@ -43,17 +70,31 @@ function createTextBuilder() { text += `'${value}'`; } return { - append(chunk: string): void { - text += chunk; + append, + appendAtomTokenName, + /** Appends `['name'];`, mapping the quote-inclusive literal to `loc` as a single atom. */ + appendAtomElementAccessStatement(object: string, name: string, loc: Location): void { + append(`${object}[`); + appendAtomTokenName(name, loc); + append('];\n'); }, - /** Appends `'name'`, mapping the name to `loc` and the quotes to its boundaries. */ - appendTokenName(name: string, loc: Location): void { - appendQuoted(name, loc); + /** + * Appends `['name'];`, mapping only the name verbatim to `loc`. TypeScript + * diagnostics on the statement are suppressed, because the statement always duplicates + * an atom projection that already reports them. + */ + appendVerbatimElementAccessStatement(object: string, name: string, loc: Location): void { + const start = text.length; + append(`${object}[`); + appendVerbatimTokenName(name, loc); + append('];'); + directives.push([loc.start.offset, name.length, start, text.length, DiagnosticDirectivePolicy.Ignore]); + append('\n'); }, /** * 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. + * counterpart in the CSS, so they are mapped as zero-width spans. */ appendSpecifier(from: string, fromLoc: Location, quote: '"' | "'" | undefined): void { if (quote === undefined) { @@ -75,8 +116,8 @@ function createTextBuilder() { ]); text += name; }, - build(): { text: string; mappings: SpanMapping[] } { - return { text, mappings }; + build(): { text: string; mappings: SpanMapping[]; directives: MappedDiagnosticDirective[] } { + return { text, mappings, directives }; }, }; } @@ -113,6 +154,10 @@ function specifierQuote(content: string, fromLoc: Location): '"' | "'" | undefin * token becomes an ordinary type error, which tsgo maps back to the CSS through the * returned span mappings. * + * Every token occurrence is projected twice: an atom-mapped quote-inclusive literal in a + * declaration or type position that whole-literal result spans map back through, and a + * verbatim-mapped literal in an expression statement that rename edits write back through. + * * A non-module CSS file becomes an empty module, so that importing it for its side effects * type-checks while it exports nothing. */ @@ -147,7 +192,7 @@ export function transformCSS(fileName: string, content: string, options: Normali ? isValidTokenName(reference.name, options) : isImportableSpecifier(reference.from) && reference.entries.length > 0, ); - const { text, mappings } = options.namedExports + const { text, mappings, directives } = options.namedExports ? buildNamedExportsText( fileName, content, @@ -157,7 +202,12 @@ export function transformCSS(fileName: string, content: string, options: Normali options.prioritizeNamedImports, ) : buildDefaultExportText(content, localTokens, tokenImporters, tokenReferences); - return { text, mappings, diagnostics: convertDiagnostics(cssModule.diagnostics, content) }; + return { + text, + mappings, + ...(directives.length > 0 ? { diagnosticDirectives: { unusedExpectDirectiveDiagnostics: [], directives } } : {}), + diagnostics: convertDiagnostics(cssModule.diagnostics, content), + }; } function buildDefaultExportText( @@ -165,7 +215,7 @@ function buildDefaultExportText( localTokens: Token[], tokenImporters: TokenImporter[], tokenReferences: TokenReference[], -): { text: string; mappings: SpanMapping[] } { +): { text: string; mappings: SpanMapping[]; directives: MappedDiagnosticDirective[] } { const builder = createTextBuilder(); const importerBindings = new Map(); const referenceBindings = new Map(); @@ -199,7 +249,7 @@ function buildDefaultExportText( let hasMembers = false; for (const token of localTokens) { builder.append('interface Styles { readonly '); - builder.appendTokenName(token.name, token.loc); + builder.appendAtomTokenName(token.name, token.loc); builder.append(': string; }\n'); hasMembers = true; } @@ -208,9 +258,9 @@ function buildDefaultExportText( 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.appendAtomTokenName(entry.localName ?? entry.name, entry.localLoc ?? entry.loc); builder.append(`: typeof ${binding}.default[`); - builder.appendTokenName(entry.name, entry.loc); + builder.appendAtomTokenName(entry.name, entry.loc); builder.append(']; }\n'); hasMembers = true; } @@ -221,17 +271,30 @@ function buildDefaultExportText( builder.append(` & __BlockErrorType`); } builder.append(';\n'); + for (const token of localTokens) { + builder.appendVerbatimElementAccessStatement('styles', token.name, token.loc); + } + for (const tokenImporter of tokenImporters) { + if (tokenImporter.type !== 'named') continue; + const binding = importerBindings.get(tokenImporter)!; + for (const entry of tokenImporter.entries) { + builder.appendVerbatimElementAccessStatement( + 'styles', + entry.localName ?? entry.name, + entry.localLoc ?? entry.loc, + ); + builder.appendVerbatimElementAccessStatement(`${binding}.default`, entry.name, entry.loc); + } + } for (const reference of tokenReferences) { if (reference.type === 'local') { - builder.append('styles['); - builder.appendTokenName(reference.name, reference.loc); - builder.append('];\n'); + builder.appendAtomElementAccessStatement('styles', reference.name, reference.loc); + builder.appendVerbatimElementAccessStatement('styles', reference.name, reference.loc); } 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.appendAtomElementAccessStatement(`${binding}.default`, entry.name, entry.loc); + builder.appendVerbatimElementAccessStatement(`${binding}.default`, entry.name, entry.loc); } } } @@ -246,7 +309,7 @@ function buildNamedExportsText( tokenImporters: TokenImporter[], tokenReferences: TokenReference[], prioritizeNamedImports: boolean, -): { text: string; mappings: SpanMapping[] } { +): { text: string; mappings: SpanMapping[]; directives: MappedDiagnosticDirective[] } { const builder = createTextBuilder(); let isModule = false; const groups = Object.groupBy(localTokens, (token) => token.name); @@ -259,29 +322,37 @@ function buildNamedExportsText( builder.append(': string;\n'); } builder.append(`export { ${alias} as `); - builder.appendTokenName(name, tokens[0]!.loc); + builder.appendAtomTokenName(name, tokens[0]!.loc); builder.append(' };\n'); isModule = true; } + const importerBindings = new Map(); + let importCount = 0; for (const tokenImporter of tokenImporters) { if (tokenImporter.type === 'all') { builder.append('export * from '); + appendImportSpecifier(builder, content, tokenImporter); } else { builder.append('export {\n'); for (const entry of tokenImporter.entries) { builder.append(' '); - builder.appendTokenName(entry.name, entry.loc); + builder.appendAtomTokenName(entry.name, entry.loc); builder.append(' as '); - builder.appendTokenName(entry.localName ?? entry.name, entry.localLoc ?? entry.loc); + builder.appendAtomTokenName(entry.localName ?? entry.name, entry.localLoc ?? entry.loc); builder.append(',\n'); } builder.append('} from '); + appendImportSpecifier(builder, content, tokenImporter); + if (tokenImporter.entries.length > 0) { + const binding = `_import_${importCount++}`; + importerBindings.set(tokenImporter, binding); + builder.append(`import * as ${binding} from `); + appendImportSpecifier(builder, content, tokenImporter); + } } - 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++}`; @@ -290,20 +361,38 @@ function buildNamedExportsText( appendImportSpecifier(builder, content, reference); isModule = true; } - if (tokenReferences.some((reference) => reference.type === 'local')) { + const needsSelf = + localTokens.length > 0 || + importerBindings.size > 0 || + tokenReferences.some((reference) => reference.type === 'local'); + if (needsSelf) { builder.append(`declare const __self: typeof import('./${basename(fileName)}');\n`); } + for (const token of localTokens) { + builder.appendVerbatimElementAccessStatement('__self', token.name, token.loc); + } + for (const tokenImporter of tokenImporters) { + if (tokenImporter.type !== 'named') continue; + const binding = importerBindings.get(tokenImporter); + if (binding === undefined) continue; + for (const entry of tokenImporter.entries) { + builder.appendVerbatimElementAccessStatement( + '__self', + entry.localName ?? entry.name, + entry.localLoc ?? entry.loc, + ); + builder.appendVerbatimElementAccessStatement(binding, entry.name, entry.loc); + } + } for (const reference of tokenReferences) { if (reference.type === 'local') { - builder.append('__self['); - builder.appendTokenName(reference.name, reference.loc); - builder.append('];\n'); + builder.appendAtomElementAccessStatement('__self', reference.name, reference.loc); + builder.appendVerbatimElementAccessStatement('__self', reference.name, reference.loc); } else { const binding = referenceBindings.get(reference)!; for (const entry of reference.entries) { - builder.append(`${binding}[`); - builder.appendTokenName(entry.name, entry.loc); - builder.append('];\n'); + builder.appendAtomElementAccessStatement(binding, entry.name, entry.loc); + builder.appendVerbatimElementAccessStatement(binding, entry.name, entry.loc); } } } From e0f9bcc1b959d485be50d09cfb3cef03ac7b65e3 Mon Sep 17 00:00:00 2001 From: mizdra Date: Wed, 26 Aug 2026 01:46:55 +0900 Subject: [PATCH 12/15] feat(content-mapper): rework named-exports projections and anchor the styles binding to fix rename and definition Co-Authored-By: Claude Fable 5 --- .../content-mapper/src/transformer.test.ts | 114 +++++++++++------- packages/content-mapper/src/transformer.ts | 42 +++++-- 2 files changed, 104 insertions(+), 52 deletions(-) diff --git a/packages/content-mapper/src/transformer.test.ts b/packages/content-mapper/src/transformer.test.ts index 90bb4589..e9740888 100644 --- a/packages/content-mapper/src/transformer.test.ts +++ b/packages/content-mapper/src/transformer.test.ts @@ -26,10 +26,11 @@ test('generates interface declarations for local tokens', () => { "=== source === .foo {} ^^^ #0 - ^^^ #2 + ^^^ #3 + ¦ #2 .bar {} ^^^ #1 - ^^^ #3 + ^^^ #4 === generated === interface Styles { readonly 'foo': string; } @@ -37,11 +38,12 @@ test('generates interface declarations for local tokens', () => { interface Styles { readonly 'bar': string; } ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + ^^^^^^ #2 Atom(Definition) styles['foo']; - ^^^ #2 Verbatim(All~Hover) + ^^^ #3 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 styles['bar']; - ^^^ #3 Verbatim(All~Hover) + ^^^ #4 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#1 export default styles; " @@ -53,6 +55,7 @@ test('generates a namespace import and an intersection type for all token import "=== source === @import './b.module.css'; ^^^^^^^^^^^^^^^^ #0 + ¦ #1 === generated === import * as _import_0 from './b.module.css'; @@ -60,6 +63,7 @@ test('generates a namespace import and an intersection type for all token import type __BlockErrorType = [0] extends [1 & T] ? {} : T; interface Styles {} declare const styles: Styles & __BlockErrorType; + ^^^^^^ #1 Atom(Definition) export default styles; " `); @@ -71,13 +75,14 @@ test('generates indexed access type members for named token importer entries', ( @value v1, v2 as v3 from './c.module.css'; ^^^^^^^^^^^^^^^^ #0 ^^ #3 - ^^ #7 + ^^ #8 ^^ #4 - ^^ #8 + ^^ #9 ^^ #1 ^^ #2 - ^^ #5 ^^ #6 + ^^ #7 + ¦ #5 === generated === import * as _import_0 from './c.module.css'; @@ -89,17 +94,18 @@ test('generates indexed access type members for named token importer entries', ( ^^^^ #4 Atom(All~Rename) ^^^^ #3 Atom(All~Rename) declare const styles: Styles; + ^^^^^^ #5 Atom(Definition) styles['v1']; - ^^ #5 Verbatim(All~Hover) + ^^ #6 Verbatim(All~Hover) ^^^^^^^^^^^^^ ignore#0 _import_0.default['v1']; - ^^ #6 Verbatim(All~Hover) + ^^ #7 Verbatim(All~Hover) ^^^^^^^^^^^^^^^^^^^^^^^^ ignore#1 styles['v3']; - ^^ #7 Verbatim(All~Hover) + ^^ #8 Verbatim(All~Hover) ^^^^^^^^^^^^^ ignore#2 _import_0.default['v2']; - ^^ #8 Verbatim(All~Hover) + ^^ #9 Verbatim(All~Hover) ^^^^^^^^^^^^^^^^^^^^^^^^ ignore#3 export default styles; " @@ -114,11 +120,13 @@ test('omits imports for URL specifiers and non css module specifiers', () => { expect(result).toMatchInlineSnapshot(` "=== source === @import 'https://example.com/a.module.css'; + ¦ #0 @import './plain.css'; === generated === interface Styles {} declare const styles: Styles; + ^^^^^^ #0 Atom(Definition) export default styles; " `); @@ -132,13 +140,14 @@ test('generates expression statements for local token references', () => { expect(result).toMatchInlineSnapshot(` "=== source === .foo { animation-name: pulse; } - ^^^^^ #4 ^^^^^ #5 + ^^^^^ #6 ^^^ #0 - ^^^ #2 + ^^^ #3 + ¦ #2 @keyframes pulse {} ^^^^^ #1 - ^^^^^ #3 + ^^^^^ #4 === generated === interface Styles { readonly 'foo': string; } @@ -146,16 +155,17 @@ test('generates expression statements for local token references', () => { interface Styles { readonly 'pulse': string; } ^^^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + ^^^^^^ #2 Atom(Definition) styles['foo']; - ^^^ #2 Verbatim(All~Hover) + ^^^ #3 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 styles['pulse']; - ^^^^^ #3 Verbatim(All~Hover) + ^^^^^ #4 Verbatim(All~Hover) ^^^^^^^^^^^^^^^^ ignore#1 styles['pulse']; - ^^^^^^^ #4 Atom(All~Rename) + ^^^^^^^ #5 Atom(All~Rename) styles['pulse']; - ^^^^^ #5 Verbatim(All~Hover) + ^^^^^ #6 Verbatim(All~Hover) ^^^^^^^^^^^^^^^^ ignore#2 export default styles; " @@ -167,10 +177,11 @@ test('generates imports and expression statements for external token references' "=== source === .foo { composes: baz from './d.module.css'; } ^^^^^^^^^^^^^^^^ #0 - ^^^ #3 ^^^ #4 + ^^^ #5 ^^^ #1 - ^^^ #2 + ^^^ #3 + ¦ #2 === generated === import * as _import_0 from './d.module.css'; @@ -178,13 +189,14 @@ test('generates imports and expression statements for external token references' interface Styles { readonly 'foo': string; } ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + ^^^^^^ #2 Atom(Definition) styles['foo']; - ^^^ #2 Verbatim(All~Hover) + ^^^ #3 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 _import_0.default['baz']; - ^^^^^ #3 Atom(All~Rename) + ^^^^^ #4 Atom(All~Rename) _import_0.default['baz']; - ^^^ #4 Verbatim(All~Hover) + ^^^ #5 Verbatim(All~Hover) ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#1 export default styles; " @@ -200,10 +212,11 @@ test('generates an interface declaration for every occurrence of a duplicated to "=== source === .foo {} ^^^ #0 - ^^^ #2 + ^^^ #3 + ¦ #2 .foo:hover {} ^^^ #1 - ^^^ #3 + ^^^ #4 === generated === interface Styles { readonly 'foo': string; } @@ -211,11 +224,12 @@ test('generates an interface declaration for every occurrence of a duplicated to interface Styles { readonly 'foo': string; } ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + ^^^^^^ #2 Atom(Definition) styles['foo']; - ^^^ #2 Verbatim(All~Hover) + ^^^ #3 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 styles['foo']; - ^^^ #3 Verbatim(All~Hover) + ^^^ #4 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#1 export default styles; " @@ -226,10 +240,12 @@ test('generates a default export for an empty file', () => { expect(run('')).toMatchInlineSnapshot(` "=== source === + ¦ #0 === generated === interface Styles {} declare const styles: Styles; + ^^^^^^ #0 Atom(Definition) export default styles; " `); @@ -240,6 +256,7 @@ test('quotes generated specifiers with the original quote character', () => { "=== source === @import "./b.module.css"; ^^^^^^^^^^^^^^^^ #0 + ¦ #1 === generated === import * as _import_0 from "./b.module.css"; @@ -247,6 +264,7 @@ test('quotes generated specifiers with the original quote character', () => { type __BlockErrorType = [0] extends [1 & T] ? {} : T; interface Styles {} declare const styles: Styles & __BlockErrorType; + ^^^^^^ #1 Atom(Definition) export default styles; " `); @@ -259,6 +277,7 @@ test('synthesizes quotes for unquoted url() specifiers and maps them as zero-wid ¦ #2 ¦ #0 ^^^^^^^^^^^^^^ #1 + ¦ #3 === generated === import * as _import_0 from './b.module.css'; @@ -268,6 +287,7 @@ test('synthesizes quotes for unquoted url() specifiers and maps them as zero-wid type __BlockErrorType = [0] extends [1 & T] ? {} : T; interface Styles {} declare const styles: Styles & __BlockErrorType; + ^^^^^^ #3 Atom(Definition) export default styles; " `); @@ -282,10 +302,11 @@ test('converts parse diagnostics into mapper diagnostics', () => { "=== source === .foo { color: red; } ^^^ #0 - ^^^ #2 + ^^^ #3 + ¦ #2 .bar { ^^^ #1 - ^^^ #3 + ^^^ #4 ^ diag#0 === generated === @@ -294,11 +315,12 @@ test('converts parse diagnostics into mapper diagnostics', () => { interface Styles { readonly 'bar': string; } ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; + ^^^^^^ #2 Atom(Definition) styles['foo']; - ^^^ #2 Verbatim(All~Hover) + ^^^ #3 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 styles['bar']; - ^^^ #3 Verbatim(All~Hover) + ^^^ #4 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#1 export default styles; @@ -313,10 +335,12 @@ test('excludes invalid token names and reports diagnostics', () => { "=== source === .__proto__ {} ^^^^^^^^^ diag#0 + ¦ #0 === generated === interface Styles {} declare const styles: Styles; + ^^^^^^ #0 Atom(Definition) export default styles; @@ -329,10 +353,12 @@ test('omits keyframes tokens when animation is false', () => { expect(run('@keyframes pulse {}', { ...defaultOptions, animation: false })).toMatchInlineSnapshot(` "=== source === @keyframes pulse {} + ¦ #0 === generated === interface Styles {} declare const styles: Styles; + ^^^^^^ #0 Atom(Definition) export default styles; " `); @@ -376,12 +402,12 @@ describe('namedExports', () => { var _token_0: string; ^^^^^^^^ #1 Alias(All~Rename) export { _token_0 as 'foo' }; - ^^^^^ #2 Atom(All~Rename) + ^^^ #2 Verbatim var _token_1: string; ^^^^^^^^ #3 Alias(All~Rename) export { _token_1 as 'bar' }; - ^^^^^ #4 Atom(All~Rename) - declare const __self: typeof import('./a.module.css'); + ^^^ #4 Verbatim + import * as __self from './a.module.css'; __self['foo']; ^^^ #5 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 @@ -430,16 +456,16 @@ describe('namedExports', () => { === generated === export { 'v1' as 'v1', - ^^^^ #1 Atom(All~Rename) - ^^^^ #0 Atom(All~Rename) + ^^ #1 Verbatim + ^^ #0 Verbatim 'v2' as 'v3', - ^^^^ #3 Atom(All~Rename) - ^^^^ #2 Atom(All~Rename) + ^^ #3 Verbatim + ^^ #2 Verbatim } from './c.module.css'; ^^^^^^^^^^^^^^^^ #4 Verbatim import * as _import_0 from './c.module.css'; ^^^^^^^^^^^^^^^^ #5 Verbatim - declare const __self: typeof import('./a.module.css'); + import * as __self from './a.module.css'; __self['v1']; ^^ #6 Verbatim(All~Hover) ^^^^^^^^^^^^^ ignore#0 @@ -483,12 +509,12 @@ describe('namedExports', () => { var _token_0: string; ^^^^^^^^ #0 Alias(All~Rename) export { _token_0 as 'foo' }; - ^^^^^ #1 Atom(All~Rename) + ^^^ #1 Verbatim var _token_1: string; ^^^^^^^^ #2 Alias(All~Rename) export { _token_1 as 'pulse' }; - ^^^^^^^ #3 Atom(All~Rename) - declare const __self: typeof import('./a.module.css'); + ^^^^^ #3 Verbatim + import * as __self from './a.module.css'; __self['foo']; ^^^ #4 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 @@ -521,10 +547,10 @@ describe('namedExports', () => { var _token_0: string; ^^^^^^^^ #0 Alias(All~Rename) export { _token_0 as 'foo' }; - ^^^^^ #1 Atom(All~Rename) + ^^^ #1 Verbatim import * as _import_0 from './d.module.css'; ^^^^^^^^^^^^^^^^ #2 Verbatim - declare const __self: typeof import('./a.module.css'); + import * as __self from './a.module.css'; __self['foo']; ^^^ #3 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 diff --git a/packages/content-mapper/src/transformer.ts b/packages/content-mapper/src/transformer.ts index c8e0bd7a..ce146048 100644 --- a/packages/content-mapper/src/transformer.ts +++ b/packages/content-mapper/src/transformer.ts @@ -72,6 +72,15 @@ function createTextBuilder() { return { append, appendAtomTokenName, + /** + * Appends `'name'` as an export name, mapping only the name verbatim to `loc`. Renaming + * a module export rewrites the export name itself but not companion statements, so the + * export name must be the verbatim span that rename edits write back through. + */ + appendVerbatimExportName(name: string, loc: Location): void { + mappings.push([text.length + 1, name.length, loc.start.offset, name.length, SpanMapKind.Verbatim]); + text += `'${name}'`; + }, /** Appends `['name'];`, mapping the quote-inclusive literal to `loc` as a single atom. */ appendAtomElementAccessStatement(object: string, name: string, loc: Location): void { append(`${object}[`); @@ -104,6 +113,14 @@ function createTextBuilder() { text += `${quote}${from}${quote}`; } }, + /** + * Appends `name`, mapping it as a zero-width span at the start of the CSS file so that + * go-to-definition on a binding importing the module lands at the top of the file. + */ + appendModuleAnchor(name: string): void { + mappings.push([text.length, name.length, 0, 0, SpanMapKind.Atom, SpanMapFeature.Definition]); + append(name); + }, /** Appends `name`, mapping it to `loc` as an alias of the original name. */ appendAlias(name: string, loc: Location): void { mappings.push([ @@ -154,9 +171,10 @@ function specifierQuote(content: string, fromLoc: Location): '"' | "'" | undefin * token becomes an ordinary type error, which tsgo maps back to the CSS through the * returned span mappings. * - * Every token occurrence is projected twice: an atom-mapped quote-inclusive literal in a - * declaration or type position that whole-literal result spans map back through, and a - * verbatim-mapped literal in an expression statement that rename edits write back through. + * Every token occurrence is projected twice: a literal in a declaration or export position + * that result spans map back through, and a verbatim-mapped literal in an expression + * statement. Declaration-position literals are atom-mapped including quotes, except export + * names, which are verbatim-mapped because rename edits write back through them. * * A non-module CSS file becomes an empty module, so that importing it for its side effects * type-checks while it exports nothing. @@ -266,7 +284,9 @@ function buildDefaultExportText( } } if (!hasMembers) builder.append('interface Styles {}\n'); - builder.append('declare const styles: Styles'); + builder.append('declare const '); + builder.appendModuleAnchor('styles'); + builder.append(': Styles'); for (const allImporter of allImporters) { builder.append(` & __BlockErrorType`); } @@ -322,7 +342,7 @@ function buildNamedExportsText( builder.append(': string;\n'); } builder.append(`export { ${alias} as `); - builder.appendAtomTokenName(name, tokens[0]!.loc); + builder.appendVerbatimExportName(name, tokens[0]!.loc); builder.append(' };\n'); isModule = true; } @@ -335,10 +355,14 @@ function buildNamedExportsText( } else { builder.append('export {\n'); for (const entry of tokenImporter.entries) { + // Always the explicit `as` form, even for alias-less entries. Renaming the + // propertyName and the localName renames the imported and the exported token + // respectively, and both projections of an alias-less entry combine into a + // full-chain rename of the CSS token. builder.append(' '); - builder.appendAtomTokenName(entry.name, entry.loc); + builder.appendVerbatimExportName(entry.name, entry.loc); builder.append(' as '); - builder.appendAtomTokenName(entry.localName ?? entry.name, entry.localLoc ?? entry.loc); + builder.appendVerbatimExportName(entry.localName ?? entry.name, entry.localLoc ?? entry.loc); builder.append(',\n'); } builder.append('} from '); @@ -366,7 +390,9 @@ function buildNamedExportsText( importerBindings.size > 0 || tokenReferences.some((reference) => reference.type === 'local'); if (needsSelf) { - builder.append(`declare const __self: typeof import('./${basename(fileName)}');\n`); + // A real self-import rather than a `typeof import()` declaration, because renaming a + // module export only propagates to accesses through real import bindings. + builder.append(`import * as __self from './${basename(fileName)}';\n`); } for (const token of localTokens) { builder.appendVerbatimElementAccessStatement('__self', token.name, token.loc); From d1ff0ceab576b930aa8f93af3098af348032e4bf Mon Sep 17 00:00:00 2001 From: mizdra Date: Sat, 29 Aug 2026 01:04:25 +0900 Subject: [PATCH 13/15] test(content-mapper): align transformer test structure with dts-generator tests Co-Authored-By: Claude Fable 5 --- .../content-mapper/src/transformer.test.ts | 1094 ++++++++++------- 1 file changed, 643 insertions(+), 451 deletions(-) diff --git a/packages/content-mapper/src/transformer.test.ts b/packages/content-mapper/src/transformer.test.ts index e9740888..fe3a50be 100644 --- a/packages/content-mapper/src/transformer.test.ts +++ b/packages/content-mapper/src/transformer.test.ts @@ -4,255 +4,672 @@ import type { NormalizedMapperOptions } from './options.js'; import { renderTransformOutput } from './test/render.js'; import { transformCSS } from './transformer.js'; -const defaultOptions: NormalizedMapperOptions = { +const defaultExportOptions: 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 { +const namedExportOptions: NormalizedMapperOptions = { ...defaultExportOptions, namedExports: true }; + +function run(source: string, options: NormalizedMapperOptions): string { return renderTransformOutput(source, transformCSS('/test/a.module.css', source, options)); } -test('generates interface declarations for local tokens', () => { - const result = run(dedent` - .foo {} - .bar {} - `); - expect(result).toMatchInlineSnapshot(` - "=== source === - .foo {} - ^^^ #0 - ^^^ #3 - ¦ #2 - .bar {} - ^^^ #1 - ^^^ #4 +describe('generates an empty module when the CSS module has no tokens', () => { + test('default export', () => { + expect(run('', defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === - === generated === - interface Styles { readonly 'foo': string; } - ^^^^^ #0 Atom(All~Rename) - interface Styles { readonly 'bar': string; } - ^^^^^ #1 Atom(All~Rename) - declare const styles: Styles; - ^^^^^^ #2 Atom(Definition) - styles['foo']; - ^^^ #3 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#0 - styles['bar']; - ^^^ #4 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#1 - export default styles; - " - `); + ¦ #0 + + === generated === + interface Styles {} + declare const styles: Styles; + ^^^^^^ #0 Atom(Definition) + export default styles; + " + `); + }); + test('named export', () => { + expect(run('', namedExportOptions)).toMatchInlineSnapshot(` + "=== source === + + + === generated === + declare const 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 - ¦ #1 +describe('creates an entry for each local token declaration', () => { + const source = dedent` + .a_1 { color: red; } + .a_2 { color: red; } + .a_2 { color: red; } + `; + test('default export', () => { + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + .a_1 { color: red; } + ^^^ #0 + ^^^ #4 + ¦ #3 + .a_2 { color: red; } + ^^^ #1 + ^^^ #5 + .a_2 { color: red; } + ^^^ #2 + ^^^ #6 - === 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; - ^^^^^^ #1 Atom(Definition) - export default styles; - " - `); + === generated === + interface Styles { readonly 'a_1': string; } + ^^^^^ #0 Atom(All~Rename) + interface Styles { readonly 'a_2': string; } + ^^^^^ #1 Atom(All~Rename) + interface Styles { readonly 'a_2': string; } + ^^^^^ #2 Atom(All~Rename) + declare const styles: Styles; + ^^^^^^ #3 Atom(Definition) + styles['a_1']; + ^^^ #4 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + styles['a_2']; + ^^^ #5 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#1 + styles['a_2']; + ^^^ #6 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#2 + export default styles; + " + `); + }); + test('named export', () => { + expect(run(source, namedExportOptions)).toMatchInlineSnapshot(` + "=== source === + .a_1 { color: red; } + ^^^ #0 + ^^^ #1 + ^^^ #5 + .a_2 { color: red; } + ^^^ #2 + ^^^ #4 + ^^^ #6 + .a_2 { color: red; } + ^^^ #3 + ^^^ #7 + + === generated === + var _token_0: string; + ^^^^^^^^ #0 Alias(All~Rename) + export { _token_0 as 'a_1' }; + ^^^ #1 Verbatim + var _token_1: string; + ^^^^^^^^ #2 Alias(All~Rename) + var _token_1: string; + ^^^^^^^^ #3 Alias(All~Rename) + export { _token_1 as 'a_2' }; + ^^^ #4 Verbatim + import * as __self from './a.module.css'; + __self['a_1']; + ^^^ #5 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + __self['a_2']; + ^^^ #6 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#1 + __self['a_2']; + ^^^ #7 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#2 + declare const styles: {}; + 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 - ^^ #3 - ^^ #8 - ^^ #4 - ^^ #9 - ^^ #1 - ^^ #2 - ^^ #6 - ^^ #7 - ¦ #5 +describe('re-exports tokens from an all token importer', () => { + const source = dedent` + @import './b.module.css'; + @import './c.module.css'; + @import './c.module.css'; + `; + test('default export', () => { + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + @import './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 + ¦ #3 + @import './c.module.css'; + ^^^^^^^^^^^^^^^^ #1 + @import './c.module.css'; + ^^^^^^^^^^^^^^^^ #2 - === generated === - import * as _import_0 from './c.module.css'; - ^^^^^^^^^^^^^^^^ #0 Verbatim - interface Styles { readonly 'v1': typeof _import_0.default['v1']; } - ^^^^ #2 Atom(All~Rename) - ^^^^ #1 Atom(All~Rename) - interface Styles { readonly 'v3': typeof _import_0.default['v2']; } - ^^^^ #4 Atom(All~Rename) - ^^^^ #3 Atom(All~Rename) - declare const styles: Styles; - ^^^^^^ #5 Atom(Definition) - styles['v1']; - ^^ #6 Verbatim(All~Hover) - ^^^^^^^^^^^^^ ignore#0 - _import_0.default['v1']; - ^^ #7 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^^^^^^^^^ ignore#1 - styles['v3']; - ^^ #8 Verbatim(All~Hover) - ^^^^^^^^^^^^^ ignore#2 - _import_0.default['v2']; - ^^ #9 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^^^^^^^^^ ignore#3 - export default styles; - " - `); + === generated === + import * as _import_0 from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + import * as _import_1 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #1 Verbatim + import * as _import_2 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #2 Verbatim + type __BlockErrorType = [0] extends [1 & T] ? {} : T; + interface Styles {} + declare const styles: Styles & __BlockErrorType & __BlockErrorType & __BlockErrorType; + ^^^^^^ #3 Atom(Definition) + export default styles; + " + `); + }); + test('named export', () => { + expect(run(source, namedExportOptions)).toMatchInlineSnapshot(` + "=== source === + @import './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 + @import './c.module.css'; + ^^^^^^^^^^^^^^^^ #1 + @import './c.module.css'; + ^^^^^^^^^^^^^^^^ #2 + + === generated === + export * from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + export * from './c.module.css'; + ^^^^^^^^^^^^^^^^ #1 Verbatim + export * from './c.module.css'; + ^^^^^^^^^^^^^^^^ #2 Verbatim + declare const 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'; - ¦ #0 - @import './plain.css'; +describe('re-exports tokens from a named token importer', () => { + const source = dedent` + @value b_1, b_2 as b_alias from './b.module.css'; + @value c_1 from './c.module.css'; + @value c_1 from './c.module.css'; + `; + test('default export', () => { + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + @value b_1, b_2 as b_alias from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 + ^^^^^^^ #5 + ^^^^^^^ #14 + ^^^ #6 + ^^^ #15 + ^^^ #3 + ^^^ #4 + ^^^ #12 + ^^^ #13 + ¦ #11 + @value c_1 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #1 + ^^^ #7 + ^^^ #8 + ^^^ #16 + ^^^ #17 + @value c_1 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #2 + ^^^ #9 + ^^^ #10 + ^^^ #18 + ^^^ #19 - === generated === - interface Styles {} - declare const styles: Styles; - ^^^^^^ #0 Atom(Definition) - export default styles; - " - `); + === generated === + import * as _import_0 from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + import * as _import_1 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #1 Verbatim + import * as _import_2 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #2 Verbatim + interface Styles { readonly 'b_1': typeof _import_0.default['b_1']; } + ^^^^^ #4 Atom(All~Rename) + ^^^^^ #3 Atom(All~Rename) + interface Styles { readonly 'b_alias': typeof _import_0.default['b_2']; } + ^^^^^ #6 Atom(All~Rename) + ^^^^^^^^^ #5 Atom(All~Rename) + interface Styles { readonly 'c_1': typeof _import_1.default['c_1']; } + ^^^^^ #8 Atom(All~Rename) + ^^^^^ #7 Atom(All~Rename) + interface Styles { readonly 'c_1': typeof _import_2.default['c_1']; } + ^^^^^ #10 Atom(All~Rename) + ^^^^^ #9 Atom(All~Rename) + declare const styles: Styles; + ^^^^^^ #11 Atom(Definition) + styles['b_1']; + ^^^ #12 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + _import_0.default['b_1']; + ^^^ #13 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#1 + styles['b_alias']; + ^^^^^^^ #14 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^ ignore#2 + _import_0.default['b_2']; + ^^^ #15 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#3 + styles['c_1']; + ^^^ #16 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#4 + _import_1.default['c_1']; + ^^^ #17 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#5 + styles['c_1']; + ^^^ #18 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#6 + _import_2.default['c_1']; + ^^^ #19 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#7 + export default styles; + " + `); + }); + test('named export', () => { + expect(run(source, namedExportOptions)).toMatchInlineSnapshot(` + "=== source === + @value b_1, b_2 as b_alias from './b.module.css'; + ^^^^^^^^^^^^^^^^ #4 + ^^^^^^^^^^^^^^^^ #5 + ^^^^^^^ #3 + ^^^^^^^ #16 + ^^^ #2 + ^^^ #17 + ^^^ #0 + ^^^ #1 + ^^^ #14 + ^^^ #15 + @value c_1 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #8 + ^^^^^^^^^^^^^^^^ #9 + ^^^ #6 + ^^^ #7 + ^^^ #18 + ^^^ #19 + @value c_1 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #12 + ^^^^^^^^^^^^^^^^ #13 + ^^^ #10 + ^^^ #11 + ^^^ #20 + ^^^ #21 + + === generated === + export { + 'b_1' as 'b_1', + ^^^ #1 Verbatim + ^^^ #0 Verbatim + 'b_2' as 'b_alias', + ^^^^^^^ #3 Verbatim + ^^^ #2 Verbatim + } from './b.module.css'; + ^^^^^^^^^^^^^^^^ #4 Verbatim + import * as _import_0 from './b.module.css'; + ^^^^^^^^^^^^^^^^ #5 Verbatim + export { + 'c_1' as 'c_1', + ^^^ #7 Verbatim + ^^^ #6 Verbatim + } from './c.module.css'; + ^^^^^^^^^^^^^^^^ #8 Verbatim + import * as _import_1 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #9 Verbatim + export { + 'c_1' as 'c_1', + ^^^ #11 Verbatim + ^^^ #10 Verbatim + } from './c.module.css'; + ^^^^^^^^^^^^^^^^ #12 Verbatim + import * as _import_2 from './c.module.css'; + ^^^^^^^^^^^^^^^^ #13 Verbatim + import * as __self from './a.module.css'; + __self['b_1']; + ^^^ #14 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + _import_0['b_1']; + ^^^ #15 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^ ignore#1 + __self['b_alias']; + ^^^^^^^ #16 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^ ignore#2 + _import_0['b_2']; + ^^^ #17 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^ ignore#3 + __self['c_1']; + ^^^ #18 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#4 + _import_1['c_1']; + ^^^ #19 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^ ignore#5 + __self['c_1']; + ^^^ #20 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#6 + _import_2['c_1']; + ^^^ #21 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^ ignore#7 + declare const 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; } - ^^^^^ #5 - ^^^^^ #6 - ^^^ #0 - ^^^ #3 - ¦ #2 - @keyframes pulse {} - ^^^^^ #1 - ^^^^^ #4 +describe('emits token reference statements', () => { + const source = dedent` + @keyframes a_1 {} + .a_2 { animation-name: a_1; } + `; + test('default export', () => { + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + @keyframes a_1 {} + ^^^ #0 + ^^^ #3 + ¦ #2 + .a_2 { animation-name: a_1; } + ^^^ #5 + ^^^ #6 + ^^^ #1 + ^^^ #4 - === generated === - interface Styles { readonly 'foo': string; } - ^^^^^ #0 Atom(All~Rename) - interface Styles { readonly 'pulse': string; } - ^^^^^^^ #1 Atom(All~Rename) - declare const styles: Styles; - ^^^^^^ #2 Atom(Definition) - styles['foo']; - ^^^ #3 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#0 - styles['pulse']; - ^^^^^ #4 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^ ignore#1 - styles['pulse']; - ^^^^^^^ #5 Atom(All~Rename) - styles['pulse']; - ^^^^^ #6 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^ ignore#2 - export default styles; - " - `); + === generated === + interface Styles { readonly 'a_1': string; } + ^^^^^ #0 Atom(All~Rename) + interface Styles { readonly 'a_2': string; } + ^^^^^ #1 Atom(All~Rename) + declare const styles: Styles; + ^^^^^^ #2 Atom(Definition) + styles['a_1']; + ^^^ #3 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + styles['a_2']; + ^^^ #4 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#1 + styles['a_1']; + ^^^^^ #5 Atom(All~Rename) + styles['a_1']; + ^^^ #6 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#2 + export default styles; + " + `); + }); + test('named export', () => { + expect(run(source, namedExportOptions)).toMatchInlineSnapshot(` + "=== source === + @keyframes a_1 {} + ^^^ #0 + ^^^ #1 + ^^^ #4 + .a_2 { animation-name: a_1; } + ^^^ #6 + ^^^ #7 + ^^^ #2 + ^^^ #3 + ^^^ #5 + + === generated === + var _token_0: string; + ^^^^^^^^ #0 Alias(All~Rename) + export { _token_0 as 'a_1' }; + ^^^ #1 Verbatim + var _token_1: string; + ^^^^^^^^ #2 Alias(All~Rename) + export { _token_1 as 'a_2' }; + ^^^ #3 Verbatim + import * as __self from './a.module.css'; + __self['a_1']; + ^^^ #4 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + __self['a_2']; + ^^^ #5 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#1 + __self['a_1']; + ^^^^^ #6 Atom(All~Rename) + __self['a_1']; + ^^^ #7 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#2 + declare const styles: {}; + 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 - ^^^ #4 - ^^^ #5 - ^^^ #1 - ^^^ #3 - ¦ #2 +describe('emits external token reference statements', () => { + // `b_1` and `b_2` share one `from` clause. The `from` clause of `b_3` has the same specifier + // as the first one, but is a separate clause. + const source = `.a_1 { composes: b_1 b_2 from './b.module.css', b_3 from './b.module.css'; }`; + test('default export', () => { + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + .a_1 { composes: b_1 b_2 from './b.module.css', b_3 from './b.module.css'; } + ^^^^^^^^^^^^^^^^ #1 + ^^^ #9 + ^^^ #10 + ^^^^^^^^^^^^^^^^ #0 + ^^^ #7 + ^^^ #8 + ^^^ #5 + ^^^ #6 + ^^^ #2 + ^^^ #4 + ¦ #3 - === generated === - import * as _import_0 from './d.module.css'; - ^^^^^^^^^^^^^^^^ #0 Verbatim - interface Styles { readonly 'foo': string; } - ^^^^^ #1 Atom(All~Rename) - declare const styles: Styles; - ^^^^^^ #2 Atom(Definition) - styles['foo']; - ^^^ #3 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#0 - _import_0.default['baz']; - ^^^^^ #4 Atom(All~Rename) - _import_0.default['baz']; - ^^^ #5 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#1 - export default styles; - " - `); + === generated === + import * as _import_0 from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + import * as _import_1 from './b.module.css'; + ^^^^^^^^^^^^^^^^ #1 Verbatim + interface Styles { readonly 'a_1': string; } + ^^^^^ #2 Atom(All~Rename) + declare const styles: Styles; + ^^^^^^ #3 Atom(Definition) + styles['a_1']; + ^^^ #4 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + _import_0.default['b_1']; + ^^^^^ #5 Atom(All~Rename) + _import_0.default['b_1']; + ^^^ #6 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#1 + _import_0.default['b_2']; + ^^^^^ #7 Atom(All~Rename) + _import_0.default['b_2']; + ^^^ #8 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#2 + _import_1.default['b_3']; + ^^^^^ #9 Atom(All~Rename) + _import_1.default['b_3']; + ^^^ #10 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^^^^^^^^^ ignore#3 + export default styles; + " + `); + }); + test('named export', () => { + expect(run(source, namedExportOptions)).toMatchInlineSnapshot(` + "=== source === + .a_1 { composes: b_1 b_2 from './b.module.css', b_3 from './b.module.css'; } + ^^^^^^^^^^^^^^^^ #3 + ^^^ #9 + ^^^ #10 + ^^^^^^^^^^^^^^^^ #2 + ^^^ #7 + ^^^ #8 + ^^^ #5 + ^^^ #6 + ^^^ #0 + ^^^ #1 + ^^^ #4 + + === generated === + var _token_0: string; + ^^^^^^^^ #0 Alias(All~Rename) + export { _token_0 as 'a_1' }; + ^^^ #1 Verbatim + import * as _import_0 from './b.module.css'; + ^^^^^^^^^^^^^^^^ #2 Verbatim + import * as _import_1 from './b.module.css'; + ^^^^^^^^^^^^^^^^ #3 Verbatim + import * as __self from './a.module.css'; + __self['a_1']; + ^^^ #4 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + _import_0['b_1']; + ^^^^^ #5 Atom(All~Rename) + _import_0['b_1']; + ^^^ #6 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^ ignore#1 + _import_0['b_2']; + ^^^^^ #7 Atom(All~Rename) + _import_0['b_2']; + ^^^ #8 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^ ignore#2 + _import_1['b_3']; + ^^^^^ #9 Atom(All~Rename) + _import_1['b_3']; + ^^^ #10 Verbatim(All~Hover) + ^^^^^^^^^^^^^^^^^ ignore#3 + declare const styles: {}; + 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(` +test('omits external token reference statements whose specifier is a URL', () => { + const source = `.a_1 { composes: b_1 from 'https://example.com/b.module.css'; }`; + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` "=== source === - .foo {} + .a_1 { composes: b_1 from 'https://example.com/b.module.css'; } ^^^ #0 - ^^^ #3 - ¦ #2 - .foo:hover {} - ^^^ #1 - ^^^ #4 + ^^^ #2 + ¦ #1 === generated === - interface Styles { readonly 'foo': string; } + interface Styles { readonly 'a_1': string; } ^^^^^ #0 Atom(All~Rename) - interface Styles { readonly 'foo': string; } - ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; - ^^^^^^ #2 Atom(Definition) - styles['foo']; - ^^^ #3 Verbatim(All~Hover) + ^^^^^^ #1 Atom(Definition) + styles['a_1']; + ^^^ #2 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 - styles['foo']; - ^^^ #4 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#1 export default styles; " `); }); -test('generates a default export for an empty file', () => { - expect(run('')).toMatchInlineSnapshot(` - "=== source === +describe('omits importers whose specifier is a URL or a non-module CSS file', () => { + const source = dedent` + @import 'https://example.com/b.module.css'; + @value c_1 from 'https://example.com/c.module.css'; + @import './d.css'; + `; + test('default export', () => { + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + @import 'https://example.com/b.module.css'; + ¦ #0 + @value c_1 from 'https://example.com/c.module.css'; + @import './d.css'; - ¦ #0 + === generated === + interface Styles {} + declare const styles: Styles; + ^^^^^^ #0 Atom(Definition) + export default styles; + " + `); + }); + test('named export', () => { + expect(run(source, namedExportOptions)).toMatchInlineSnapshot(` + "=== source === + @import 'https://example.com/b.module.css'; + @value c_1 from 'https://example.com/c.module.css'; + @import './d.css'; - === generated === - interface Styles {} - declare const styles: Styles; - ^^^^^^ #0 Atom(Definition) - export default styles; - " - `); + === generated === + declare const styles: {}; + export default styles; + " + `); + }); +}); + +describe('omits tokens whose name fails validateTokenName', () => { + const source = dedent` + .__proto__ { color: red; } + @value __proto__ from './b.module.css'; + @value b_1 as __proto__ from './b.module.css'; + `; + test('default export', () => { + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + .__proto__ { color: red; } + ^^^^^^^^^ diag#0 + ¦ #2 + @value __proto__ from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 + ^^^^^^^^^ diag#1 + @value b_1 as __proto__ from './b.module.css'; + ^^^^^^^^^^^^^^^^ #1 + ^^^^^^^^^ diag#2 + + === generated === + import './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + import './b.module.css'; + ^^^^^^^^^^^^^^^^ #1 Verbatim + interface Styles {} + declare const styles: Styles; + ^^^^^^ #2 Atom(Definition) + export default styles; + + + === diagnostics === + diag#0: \`__proto__\` is not allowed as names. + diag#1: \`__proto__\` is not allowed as names. + diag#2: \`__proto__\` is not allowed as names." + `); + }); + test('named export', () => { + expect(run(source, namedExportOptions)).toMatchInlineSnapshot(` + "=== source === + .__proto__ { color: red; } + ^^^^^^^^^ diag#0 + @value __proto__ from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 + ^^^^^^^^^ diag#1 + @value b_1 as __proto__ from './b.module.css'; + ^^^^^^^^^^^^^^^^ #1 + ^^^^^^^^^ diag#2 + + === generated === + export { + } from './b.module.css'; + ^^^^^^^^^^^^^^^^ #0 Verbatim + export { + } from './b.module.css'; + ^^^^^^^^^^^^^^^^ #1 Verbatim + declare const styles: {}; + export default styles; + + + === diagnostics === + diag#0: \`__proto__\` is not allowed as names. + diag#1: \`__proto__\` is not allowed as names. + diag#2: \`__proto__\` is not allowed as names." + `); + }); }); test('quotes generated specifiers with the original quote character', () => { - expect(run(`@import "./b.module.css";`)).toMatchInlineSnapshot(` + expect(run(`@import "./b.module.css";`, defaultExportOptions)).toMatchInlineSnapshot(` "=== source === @import "./b.module.css"; ^^^^^^^^^^^^^^^^ #0 @@ -271,7 +688,7 @@ 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(` + expect(run(`@import url(./b.module.css);`, defaultExportOptions)).toMatchInlineSnapshot(` "=== source === @import url(./b.module.css); ¦ #2 @@ -294,32 +711,32 @@ test('synthesizes quotes for unquoted url() specifiers and maps them as zero-wid }); test('converts parse diagnostics into mapper diagnostics', () => { - const result = run(dedent` - .foo { color: red; } - .bar { - `); - expect(result).toMatchInlineSnapshot(` + const source = dedent` + .a_1 { color: red; } + .a_2 { + `; + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` "=== source === - .foo { color: red; } + .a_1 { color: red; } ^^^ #0 ^^^ #3 ¦ #2 - .bar { + .a_2 { ^^^ #1 ^^^ #4 ^ diag#0 === generated === - interface Styles { readonly 'foo': string; } + interface Styles { readonly 'a_1': string; } ^^^^^ #0 Atom(All~Rename) - interface Styles { readonly 'bar': string; } + interface Styles { readonly 'a_2': string; } ^^^^^ #1 Atom(All~Rename) declare const styles: Styles; ^^^^^^ #2 Atom(Definition) - styles['foo']; + styles['a_1']; ^^^ #3 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#0 - styles['bar']; + styles['a_2']; ^^^ #4 Verbatim(All~Hover) ^^^^^^^^^^^^^^ ignore#1 export default styles; @@ -330,29 +747,10 @@ test('converts parse diagnostics into mapper diagnostics', () => { `); }); -test('excludes invalid token names and reports diagnostics', () => { - expect(run('.__proto__ {}')).toMatchInlineSnapshot(` - "=== source === - .__proto__ {} - ^^^^^^^^^ diag#0 - ¦ #0 - - === generated === - interface Styles {} - declare const styles: Styles; - ^^^^^^ #0 Atom(Definition) - 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(` + expect(run('@keyframes a_1 {}', { ...defaultExportOptions, animation: false })).toMatchInlineSnapshot(` "=== source === - @keyframes pulse {} + @keyframes a_1 {} ¦ #0 === generated === @@ -365,226 +763,20 @@ 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({ + expect(transformCSS('/test/global.css', `* { margin: 0; }`, defaultExportOptions)).toStrictEqual({ text: 'export {};\n', mappings: [], diagnostics: [], }); }); -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 {} - ^^^ #0 - ^^^ #2 - ^^^ #5 - .foo:hover {} - ^^^ #1 - ^^^ #6 - .bar {} - ^^^ #3 - ^^^ #4 - ^^^ #7 - - === generated === - var _token_0: string; - ^^^^^^^^ #0 Alias(All~Rename) - var _token_0: string; - ^^^^^^^^ #1 Alias(All~Rename) - export { _token_0 as 'foo' }; - ^^^ #2 Verbatim - var _token_1: string; - ^^^^^^^^ #3 Alias(All~Rename) - export { _token_1 as 'bar' }; - ^^^ #4 Verbatim - import * as __self from './a.module.css'; - __self['foo']; - ^^^ #5 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#0 - __self['foo']; - ^^^ #6 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#1 - __self['bar']; - ^^^ #7 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#2 - 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'; - ^^^^^^^^^^^^^^^^ #4 - ^^^^^^^^^^^^^^^^ #5 - ^^ #3 - ^^ #8 - ^^ #2 - ^^ #9 - ^^ #0 - ^^ #1 - ^^ #6 - ^^ #7 - - === generated === - export { - 'v1' as 'v1', - ^^ #1 Verbatim - ^^ #0 Verbatim - 'v2' as 'v3', - ^^ #3 Verbatim - ^^ #2 Verbatim - } from './c.module.css'; - ^^^^^^^^^^^^^^^^ #4 Verbatim - import * as _import_0 from './c.module.css'; - ^^^^^^^^^^^^^^^^ #5 Verbatim - import * as __self from './a.module.css'; - __self['v1']; - ^^ #6 Verbatim(All~Hover) - ^^^^^^^^^^^^^ ignore#0 - _import_0['v1']; - ^^ #7 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^ ignore#1 - __self['v3']; - ^^ #8 Verbatim(All~Hover) - ^^^^^^^^^^^^^ ignore#2 - _import_0['v2']; - ^^ #9 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^ ignore#3 - 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; } - ^^^^^ #6 - ^^^^^ #7 - ^^^ #0 - ^^^ #1 - ^^^ #4 - @keyframes pulse {} - ^^^^^ #2 - ^^^^^ #3 - ^^^^^ #5 - - === generated === - var _token_0: string; - ^^^^^^^^ #0 Alias(All~Rename) - export { _token_0 as 'foo' }; - ^^^ #1 Verbatim - var _token_1: string; - ^^^^^^^^ #2 Alias(All~Rename) - export { _token_1 as 'pulse' }; - ^^^^^ #3 Verbatim - import * as __self from './a.module.css'; - __self['foo']; - ^^^ #4 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#0 - __self['pulse']; - ^^^^^ #5 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^ ignore#1 - __self['pulse']; - ^^^^^^^ #6 Atom(All~Rename) - __self['pulse']; - ^^^^^ #7 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^ ignore#2 - 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'; } - ^^^^^^^^^^^^^^^^ #2 - ^^^ #4 - ^^^ #5 - ^^^ #0 - ^^^ #1 - ^^^ #3 - - === generated === - var _token_0: string; - ^^^^^^^^ #0 Alias(All~Rename) - export { _token_0 as 'foo' }; - ^^^ #1 Verbatim - import * as _import_0 from './d.module.css'; - ^^^^^^^^^^^^^^^^ #2 Verbatim - import * as __self from './a.module.css'; - __self['foo']; - ^^^ #3 Verbatim(All~Hover) - ^^^^^^^^^^^^^^ ignore#0 - _import_0['baz']; - ^^^^^ #4 Atom(All~Rename) - _import_0['baz']; - ^^^ #5 Verbatim(All~Hover) - ^^^^^^^^^^^^^^^^^ ignore#1 - 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 === +test('keeps the generated text a module when prioritizeNamedImports is true', () => { + expect(run('', { ...namedExportOptions, prioritizeNamedImports: true })).toMatchInlineSnapshot(` + "=== source === - === generated === - export {}; - " - `); - }); + === generated === + export {}; + " + `); }); From 513e107a5e2aa251eddf46f6f86d9416120057a1 Mon Sep 17 00:00:00 2001 From: mizdra Date: Sat, 29 Aug 2026 13:49:10 +0900 Subject: [PATCH 14/15] chore(content-mapper): acquire the tsgo binary from the typescript npm nightly instead of building from source Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 8 - .../e2e-test/test-util/lsp-client.ts | 23 ++- packages/content-mapper/package.json | 3 +- pnpm-lock.yaml | 149 ++++++++++++++---- pnpm-workspace.yaml | 3 + scripts/setup-tsgo.sh | 5 +- scripts/vitest-e2e-test-setup.ts | 26 ++- 7 files changed, 162 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6caaaa7..9f6b2f81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,14 +73,6 @@ 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/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 e7fa7380..f80a12fd 100644 --- a/packages/content-mapper/e2e-test/test-util/lsp-client.ts +++ b/packages/content-mapper/e2e-test/test-util/lsp-client.ts @@ -1,14 +1,27 @@ import type { ChildProcessByStdio } from 'node:child_process'; import { spawn } from 'node:child_process'; import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import type { Readable, Writable } from 'node:stream'; import { fileURLToPath, pathToFileURL } from 'node:url'; -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/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`); +// Keep the resolution in sync with `scripts/vitest-e2e-test-setup.ts`. +/** The native tsc binary shipped with the `typescript` npm package. Overridable via the `TSGO_BIN` environment variable. */ +const tsgoBinPath = process.env['TSGO_BIN'] ?? resolveNativeTscBinPath(import.meta.url); + +/** + * Resolves the platform-specific native tsc binary the same way as `typescript/lib/getExePath.js`. + * The `typescript-nightly` alias points at the `typescript` nightly, whose platform package is a + * dependency of the nightly, not of this package, so it must be resolved relative to the nightly + * package to work with pnpm's non-flat `node_modules`. + */ +function resolveNativeTscBinPath(base: string): string { + const typescriptPkgPath = createRequire(base).resolve('typescript-nightly/package.json'); + const platformPkgName = `@typescript/typescript-${process.platform}-${process.arch}`; + const platformPkgPath = createRequire(typescriptPkgPath).resolve(`${platformPkgName}/package.json`); + const binName = process.platform === 'win32' ? 'tsc.exe' : 'tsc'; + return fileURLToPath(new URL(`./lib/${binName}`, pathToFileURL(platformPkgPath))); +} export interface Position { line: number; diff --git a/packages/content-mapper/package.json b/packages/content-mapper/package.json index 69c62bda..9fa4b14f 100644 --- a/packages/content-mapper/package.json +++ b/packages/content-mapper/package.json @@ -19,7 +19,8 @@ "@css-modules-kit/core": "workspace:^" }, "devDependencies": { - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "typescript-nightly": "npm:typescript@7.1.0-dev.20260828.1" }, "typescript": { "contentMapper": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cebc0366..d7af19ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -336,6 +336,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + typescript-nightly: + specifier: npm:typescript@7.1.0-dev.20260828.1 + version: typescript@7.1.0-dev.20260828.1 packages/core: dependencies: @@ -911,88 +914,88 @@ packages: '@emnapi/runtime': ^1.7.1 '@node-rs/crc32-android-arm-eabi@1.10.6': - resolution: {integrity: sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ==, tarball: https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.6.tgz} + resolution: {integrity: sha512-vZAMuJXm3TpWPOkkhxdrofWDv+Q+I2oO7ucLRbXyAPmXFNDhHtBxbO1rk9Qzz+M3eep8ieS4/+jCL1Q0zacNMQ==} engines: {node: '>= 10'} cpu: [arm] os: [android] '@node-rs/crc32-android-arm64@1.10.6': - resolution: {integrity: sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w==, tarball: https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.6.tgz} + resolution: {integrity: sha512-Vl/JbjCinCw/H9gEpZveWCMjxjcEChDcDBM8S4hKay5yyoRCUHJPuKr4sjVDBeOm+1nwU3oOm6Ca8dyblwp4/w==} engines: {node: '>= 10'} cpu: [arm64] os: [android] '@node-rs/crc32-darwin-arm64@1.10.6': - resolution: {integrity: sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==, tarball: https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.6.tgz} + resolution: {integrity: sha512-kARYANp5GnmsQiViA5Qu74weYQ3phOHSYQf0G+U5wB3NB5JmBHnZcOc46Ig21tTypWtdv7u63TaltJQE41noyg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] '@node-rs/crc32-darwin-x64@1.10.6': - resolution: {integrity: sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA==, tarball: https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.6.tgz} + resolution: {integrity: sha512-Q99bevJVMfLTISpkpKBlXgtPUItrvTWKFyiqoKH5IvscZmLV++NH4V13Pa17GTBmv9n18OwzgQY4/SRq6PQNVA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] '@node-rs/crc32-freebsd-x64@1.10.6': - resolution: {integrity: sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg==, tarball: https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.6.tgz} + resolution: {integrity: sha512-66hpawbNjrgnS9EDMErta/lpaqOMrL6a6ee+nlI2viduVOmRZWm9Rg9XdGTK/+c4bQLdtC6jOd+Kp4EyGRYkAg==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] '@node-rs/crc32-linux-arm-gnueabihf@1.10.6': - resolution: {integrity: sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.6.tgz} + resolution: {integrity: sha512-E8Z0WChH7X6ankbVm8J/Yym19Cq3otx6l4NFPS6JW/cWdjv7iw+Sps2huSug+TBprjbcEA+s4TvEwfDI1KScjg==} engines: {node: '>= 10'} cpu: [arm] os: [linux] '@node-rs/crc32-linux-arm64-gnu@1.10.6': - resolution: {integrity: sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.6.tgz} + resolution: {integrity: sha512-LmWcfDbqAvypX0bQjQVPmQGazh4dLiVklkgHxpV4P0TcQ1DT86H/SWpMBMs/ncF8DGuCQ05cNyMv1iddUDugoQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] '@node-rs/crc32-linux-arm64-musl@1.10.6': - resolution: {integrity: sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.6.tgz} + resolution: {integrity: sha512-k8ra/bmg0hwRrIEE8JL1p32WfaN9gDlUUpQRWsbxd1WhjqvXea7kKO6K4DwVxyxlPhBS9Gkb5Urq7Y4mXANzaw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] '@node-rs/crc32-linux-x64-gnu@1.10.6': - resolution: {integrity: sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.6.tgz} + resolution: {integrity: sha512-IfjtqcuFK7JrSZ9mlAFhb83xgium30PguvRjIMI45C3FJwu18bnLk1oR619IYb/zetQT82MObgmqfKOtgemEKw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] '@node-rs/crc32-linux-x64-musl@1.10.6': - resolution: {integrity: sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g==, tarball: https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.6.tgz} + resolution: {integrity: sha512-LbFYsA5M9pNunOweSt6uhxenYQF94v3bHDAQRPTQ3rnjn+mK6IC7YTAYoBjvoJP8lVzcvk9hRj8wp4Jyh6Y80g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] '@node-rs/crc32-wasm32-wasi@1.10.6': - resolution: {integrity: sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==, tarball: https://registry.npmjs.org/@node-rs/crc32-wasm32-wasi/-/crc32-wasm32-wasi-1.10.6.tgz} + resolution: {integrity: sha512-KaejdLgHMPsRaxnM+OG9L9XdWL2TabNx80HLdsCOoX9BVhEkfh39OeahBo8lBmidylKbLGMQoGfIKDjq0YMStw==} engines: {node: '>=14.0.0'} cpu: [wasm32] '@node-rs/crc32-win32-arm64-msvc@1.10.6': - resolution: {integrity: sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ==, tarball: https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.6.tgz} + resolution: {integrity: sha512-x50AXiSxn5Ccn+dCjLf1T7ZpdBiV1Sp5aC+H2ijhJO4alwznvXgWbopPRVhbp2nj0i+Gb6kkDUEyU+508KAdGQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] '@node-rs/crc32-win32-ia32-msvc@1.10.6': - resolution: {integrity: sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw==, tarball: https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.6.tgz} + resolution: {integrity: sha512-DpDxQLaErJF9l36aghe1Mx+cOnYLKYo6qVPqPL9ukJ5rAGLtCdU0C+Zoi3gs9ySm8zmbFgazq/LvmsZYU42aBw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] '@node-rs/crc32-win32-x64-msvc@1.10.6': - resolution: {integrity: sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw==, tarball: https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.6.tgz} + resolution: {integrity: sha512-5B1vXosIIBw1m2Rcnw62IIfH7W9s9f7H7Ma0rRuhT8HR4Xh8QCgw6NJSI2S2MCngsGktYnAhyUvs81b7efTyQw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1542,6 +1545,48 @@ packages: '@typescript/server-harness@0.3.6': resolution: {integrity: sha512-y72UF/Xv7Y0rdidvogsBMl2kA2ZzNR0HNQI+mOm5AlTdWFJP290UyF9Se5RFipS+GrXa+DjbiPHmu90e/x6weg==} + '@typescript/typescript-darwin-arm64@7.1.0-dev.20260828.1': + resolution: {integrity: sha512-6vdBDrQeChwFudD7+8YXLjfGT4WR5XN85BqZaLaNwRQQghiV+eY8qQUu8lqFKMurnysw5Des/2i+GnFz7Aaw2w==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.1.0-dev.20260828.1': + resolution: {integrity: sha512-paJc5Io0pT22lVTNfMhR9DrwAQAZJ2AuHQiMgqnaQuryk7Wk5rRsYZllCNynltXtTdAEdciWFjP4wBMdkrnzwg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-linux-arm64@7.1.0-dev.20260828.1': + resolution: {integrity: sha512-em/FC+QMz55RI4hV4sqWR20pzOkSpJMlyNoHUgPtvnZTTjPPrI4ljZMchCbUDOgV+Lv4YfO8IlUIT6IpXRknuw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.1.0-dev.20260828.1': + resolution: {integrity: sha512-HIJZZWEqy9rw+aNwqb5J0HoPwi7ljHe3ofteWbLoat1EnRAUonuqFad4MLxkmGEaQjAtApvxuBDU7NvMoCnNuw==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-x64@7.1.0-dev.20260828.1': + resolution: {integrity: sha512-Bcp+KkaEQnYXLEvHaFTFxp1XfL+Fpmr2iIrstjnuwiw+P1iEZKVbicFPgjz7gQmxJ4f4r7ZopB7jgHqmg0WFcg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-win32-arm64@7.1.0-dev.20260828.1': + resolution: {integrity: sha512-aPs+oBDkbSjzzfj6H+fzPLF1uDv0FHn3ZLm1pydGtBR/481SRk0Z0pE3ecqSQHq3SoxFp2J35czBoGH4Wpncbw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.1.0-dev.20260828.1': + resolution: {integrity: sha512-hHf6eJwrjZQEd5g84OWgUjcl1mfSPFVzXaklDrco8/NAO4cjXjoWjYy1+XDtL8nztykuq61DL60mpJRfDGwNcw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@typespec/ts-http-runtime@0.3.6': resolution: {integrity: sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==} engines: {node: '>=20.0.0'} @@ -1728,47 +1773,47 @@ packages: engines: {node: '>=22'} '@vscode/vsce-sign-alpine-arm64@2.0.6': - resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz} + resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} cpu: [arm64] os: [alpine] '@vscode/vsce-sign-alpine-x64@2.0.6': - resolution: {integrity: sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz} + resolution: {integrity: sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==} cpu: [x64] os: [alpine] '@vscode/vsce-sign-darwin-arm64@2.0.6': - resolution: {integrity: sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz} + resolution: {integrity: sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==} cpu: [arm64] os: [darwin] '@vscode/vsce-sign-darwin-x64@2.0.6': - resolution: {integrity: sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz} + resolution: {integrity: sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==} cpu: [x64] os: [darwin] '@vscode/vsce-sign-linux-arm64@2.0.6': - resolution: {integrity: sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz} + resolution: {integrity: sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==} cpu: [arm64] os: [linux] '@vscode/vsce-sign-linux-arm@2.0.6': - resolution: {integrity: sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz} + resolution: {integrity: sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==} cpu: [arm] os: [linux] '@vscode/vsce-sign-linux-x64@2.0.6': - resolution: {integrity: sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz} + resolution: {integrity: sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==} cpu: [x64] os: [linux] '@vscode/vsce-sign-win32-arm64@2.0.6': - resolution: {integrity: sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz} + resolution: {integrity: sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==} cpu: [arm64] os: [win32] '@vscode/vsce-sign-win32-x64@2.0.6': - resolution: {integrity: sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==, tarball: https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz} + resolution: {integrity: sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==} cpu: [x64] os: [win32] @@ -2795,71 +2840,71 @@ packages: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==, tarball: https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz} + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==, tarball: https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz} + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==, tarball: https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz} + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==, tarball: https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz} + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==, tarball: https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz} + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz} + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz} + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz} + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz} + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==, tarball: https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz} + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==, tarball: https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz} + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] @@ -3719,6 +3764,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.1.0-dev.20260828.1: + resolution: {integrity: sha512-13Vi1I0Ka8BAYZzKC8z/H6j3i0if6HGecfDmOyBhKqpmG1E4hQpU62yrGgNdgRW/coz91NvqqAM39H3rN/VpxA==} + engines: {node: '>=16.20.0'} + hasBin: true + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -4958,6 +5008,27 @@ snapshots: '@typescript/server-harness@0.3.6': {} + '@typescript/typescript-darwin-arm64@7.1.0-dev.20260828.1': + optional: true + + '@typescript/typescript-darwin-x64@7.1.0-dev.20260828.1': + optional: true + + '@typescript/typescript-linux-arm64@7.1.0-dev.20260828.1': + optional: true + + '@typescript/typescript-linux-arm@7.1.0-dev.20260828.1': + optional: true + + '@typescript/typescript-linux-x64@7.1.0-dev.20260828.1': + optional: true + + '@typescript/typescript-win32-arm64@7.1.0-dev.20260828.1': + optional: true + + '@typescript/typescript-win32-x64@7.1.0-dev.20260828.1': + optional: true + '@typespec/ts-http-runtime@0.3.6': dependencies: http-proxy-agent: 7.0.2(supports-color@8.1.1) @@ -7282,6 +7353,16 @@ snapshots: typescript@6.0.3: {} + typescript@7.1.0-dev.20260828.1: + optionalDependencies: + '@typescript/typescript-darwin-arm64': 7.1.0-dev.20260828.1 + '@typescript/typescript-darwin-x64': 7.1.0-dev.20260828.1 + '@typescript/typescript-linux-arm': 7.1.0-dev.20260828.1 + '@typescript/typescript-linux-arm64': 7.1.0-dev.20260828.1 + '@typescript/typescript-linux-x64': 7.1.0-dev.20260828.1 + '@typescript/typescript-win32-arm64': 7.1.0-dev.20260828.1 + '@typescript/typescript-win32-x64': 7.1.0-dev.20260828.1 + uc.micro@2.1.0: {} underscore@1.13.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 088acc64..ea84355f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,9 @@ allowBuilds: minimumReleaseAge: 4320 minimumReleaseAgeExclude: - '@mizdra/*' + # The typescript nightly (and its native binary packages) pinned for the content-mapper e2e tests + - typescript + - '@typescript/typescript-*' catalog: vite: npm:@voidzero-dev/vite-plus-core@0.2.1 vite-plus: 0.2.1 diff --git a/scripts/setup-tsgo.sh b/scripts/setup-tsgo.sh index 7a46ce4a..3fb0a717 100755 --- a/scripts/setup-tsgo.sh +++ b/scripts/setup-tsgo.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash set -ue -# Builds the tsgo binary used by the content-mapper e2e tests. +# Builds the tsgo binary from source for the VS Code extension dev flow +# (scripts/setup-tsgo-extension.sh), which needs the source checkout to build the +# vscode-typescript extension. The content-mapper e2e tests instead use the native +# tsc binary from the `typescript` npm nightly (see scripts/vitest-e2e-test-setup.ts). # The content mapper protocol is implemented in microsoft/TypeScript (the TypeScript 7 # monorepo, which absorbed microsoft/typescript-go). This script pins a commit of its # main branch. The Go implementation lives in the tsc/ subdirectory, and its main diff --git a/scripts/vitest-e2e-test-setup.ts b/scripts/vitest-e2e-test-setup.ts index 867edd2e..87957d23 100644 --- a/scripts/vitest-e2e-test-setup.ts +++ b/scripts/vitest-e2e-test-setup.ts @@ -1,19 +1,33 @@ -import { execFileSync, execSync } from 'node:child_process'; +import { execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; 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/built/tsgo${process.platform === 'win32' ? '.exe' : ''}`, import.meta.url)); +const tsgoBinPath = process.env['TSGO_BIN'] ?? resolveNativeTscBinPath(); + +/** + * Resolves the platform-specific native tsc binary the same way as `typescript/lib/getExePath.js`. + * The `typescript` nightly is a devDependency of `packages/content-mapper` under the + * `typescript-nightly` alias, and its platform package is a dependency of the nightly, so each + * must be resolved from its dependent package to work with pnpm's non-flat `node_modules`. + */ +function resolveNativeTscBinPath(): string { + const contentMapperPkgPath = fileURLToPath(new URL('../packages/content-mapper/package.json', import.meta.url)); + const typescriptPkgPath = createRequire(contentMapperPkgPath).resolve('typescript-nightly/package.json'); + const platformPkgName = `@typescript/typescript-${process.platform}-${process.arch}`; + const platformPkgPath = createRequire(typescriptPkgPath).resolve(`${platformPkgName}/package.json`); + const binName = process.platform === 'win32' ? 'tsc.exe' : 'tsc'; + return fileURLToPath(new URL(`./lib/${binName}`, pathToFileURL(platformPkgPath))); +} function prepare() { if (!existsSync(tsgoBinPath)) { if (process.env['TSGO_BIN']) { throw new Error(`tsgo binary not found at TSGO_BIN (${tsgoBinPath}).`); } - execFileSync('bash', [fileURLToPath(new URL('./setup-tsgo.sh', import.meta.url))], { stdio: 'inherit' }); + throw new Error(`Native tsc binary not found at ${tsgoBinPath}. Run \`pnpm install\` to install it.`); } execSync('vp run build', { stdio: 'inherit' }); } From 4cadd9a9a450c51b2148798c0daf149983ae4a02 Mon Sep 17 00:00:00 2001 From: mizdra Date: Sat, 29 Aug 2026 15:03:55 +0900 Subject: [PATCH 15/15] chore(content-mapper): copy the extension dev flow's tsgo binary from the npm nightly instead of building it with Go Co-Authored-By: Claude Fable 5 --- scripts/setup-tsgo-extension.sh | 43 +++++++++++++++++++++++++++------ scripts/setup-tsgo.sh | 34 -------------------------- 2 files changed, 35 insertions(+), 42 deletions(-) delete mode 100755 scripts/setup-tsgo.sh diff --git a/scripts/setup-tsgo-extension.sh b/scripts/setup-tsgo-extension.sh index 3d63f2d6..f5d6cea5 100755 --- a/scripts/setup-tsgo-extension.sh +++ b/scripts/setup-tsgo-extension.sh @@ -2,20 +2,47 @@ set -ue # Prepares everything the "tsgo (7-content-mapper)" launch configuration needs: -# the pinned tsgo binary, the VS Code extension (TypeScript Native Preview) built -# from the pinned microsoft/TypeScript commit, and the mapper package symlink for -# the example. +# the VS Code extension (TypeScript Native Preview) built from the pinned +# microsoft/TypeScript commit, the tsgo binary for the extension, and the mapper +# package symlink for the example. The marketplace build of the extension +# predates content mapper support, so the extension must be built from source. +# The tsgo binary is not built from source: it is copied from the `typescript` +# npm nightly (a devDependency of packages/content-mapper under the +# `typescript-nightly` alias). + +COMMIT=8ac035a394c79e693a3a7d74cb170448503ee894 +REPO=https://github.com/microsoft/TypeScript.git cd "$(dirname "$0")/.." DEST=.tmp/typescript -./scripts/setup-tsgo.sh +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" # In development mode, the extension resolves the binary at built/local/tsc -# (see packages/vscode-typescript/src/util.ts). -GOEXE=$(go env GOEXE) -mkdir -p "$DEST/built/local" -cp "$DEST/built/tsgo$GOEXE" "$DEST/built/local/tsc$GOEXE" +# (see packages/vscode-typescript/src/util.ts). Missing it fails the extension +# activation, so copy the binary from the npm nightly there. The npm binary is a +# noembed build that requires the lib.*.d.ts files next to the executable, so +# copy the platform package's whole lib directory. +node - <<'EOF' +const { createRequire } = require('node:module'); +const { chmodSync, cpSync } = require('node:fs'); +const { dirname, join, resolve } = require('node:path'); +const contentMapperPkgPath = resolve('packages/content-mapper/package.json'); +const typescriptPkgPath = createRequire(contentMapperPkgPath).resolve('typescript-nightly/package.json'); +const platformPkgName = `@typescript/typescript-${process.platform}-${process.arch}`; +const platformPkgPath = createRequire(typescriptPkgPath).resolve(`${platformPkgName}/package.json`); +const exeName = process.platform === 'win32' ? 'tsc.exe' : 'tsc'; +cpSync(join(dirname(platformPkgPath), 'lib'), '.tmp/typescript/built/local', { recursive: true }); +chmodSync(join('.tmp/typescript/built/local', exeName), 0o755); +EOF # 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 deleted file mode 100755 index 3fb0a717..00000000 --- a/scripts/setup-tsgo.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -ue - -# Builds the tsgo binary from source for the VS Code extension dev flow -# (scripts/setup-tsgo-extension.sh), which needs the source checkout to build the -# vscode-typescript extension. The content-mapper e2e tests instead use the native -# tsc binary from the `typescript` npm nightly (see scripts/vitest-e2e-test-setup.ts). -# The content mapper protocol is implemented in microsoft/TypeScript (the TypeScript 7 -# monorepo, which absorbed microsoft/typescript-go). This script pins a commit of its -# main branch. The Go implementation lives in the tsc/ subdirectory, and its main -# package is ./cmd/tsc; the built binary is named tsgo here to avoid confusion with -# the TypeScript 6 tsc. - -COMMIT=8ac035a394c79e693a3a7d74cb170448503ee894 -REPO=https://github.com/microsoft/TypeScript.git - -cd "$(dirname "$0")/.." -DEST=.tmp/typescript - -if [ ! -d "$DEST/.git" ]; then - mkdir -p "$DEST" - git -C "$DEST" init -q - git -C "$DEST" remote add origin "$REPO" -fi -if ! git -C "$DEST" cat-file -e "$COMMIT^{commit}" 2>/dev/null; then - git -C "$DEST" fetch --depth 1 origin "$COMMIT" -fi -git -C "$DEST" checkout -q "$COMMIT" - -# 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/tsc" && go build -o "../built/tsgo$GOEXE" ./cmd/tsc) -echo "tsgo built at $DEST/built/tsgo$GOEXE"