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/.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/.gitignore b/.gitignore index e8c9c77b..6df7107e 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,4 @@ Cargo.lock ### User /crates/zed/extension.wasm +/.tmp/ diff --git a/.vscode/launch.json b/.vscode/launch.json index acb774bf..f94fe80d 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 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/packages/vscode-typescript", + "--profile-temp", + "--skip-welcome", + // The extension enables content mappers only in a trusted workspace. Disabling + // workspace trust makes VS Code treat every workspace as trusted, which also + // skips the trust dialog on launch. + "--disable-workspace-trust", + "--folder-uri=${workspaceFolder}/examples/7-content-mapper", + "${workspaceFolder}/examples/7-content-mapper/src/index.ts" + ], + "outFiles": ["${workspaceFolder}/.tmp/typescript/packages/vscode-typescript/dist/**/*.js"], + "preLaunchTask": "prepare content-mapper 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/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/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/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..c08a0a4a --- /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: ['.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..f80a12fd --- /dev/null +++ b/packages/content-mapper/e2e-test/test-util/lsp-client.ts @@ -0,0 +1,333 @@ +import type { ChildProcessByStdio } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import type { Readable, Writable } from 'node:stream'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// Keep the resolution in sync with `scripts/vitest-e2e-test-setup.ts`. +/** The native tsc binary shipped with the `typescript` npm package. Overridable via the `TSGO_BIN` environment variable. */ +const tsgoBinPath = process.env['TSGO_BIN'] ?? resolveNativeTscBinPath(import.meta.url); + +/** + * Resolves the platform-specific native tsc binary the same way as `typescript/lib/getExePath.js`. + * The `typescript-nightly` alias points at the `typescript` nightly, whose platform package is a + * dependency of the nightly, not of this package, so it must be resolved relative to the nightly + * package to work with pnpm's non-flat `node_modules`. + */ +function resolveNativeTscBinPath(base: string): string { + const typescriptPkgPath = createRequire(base).resolve('typescript-nightly/package.json'); + const platformPkgName = `@typescript/typescript-${process.platform}-${process.arch}`; + const platformPkgPath = createRequire(typescriptPkgPath).resolve(`${platformPkgName}/package.json`); + const binName = process.platform === 'win32' ? 'tsc.exe' : 'tsc'; + return fileURLToPath(new URL(`./lib/${binName}`, pathToFileURL(platformPkgPath))); +} + +export interface Position { + line: number; + character: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export 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: { runExternalCode: 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/packages/content-mapper/package.json b/packages/content-mapper/package.json new file mode 100644 index 00000000..9fa4b14f --- /dev/null +++ b/packages/content-mapper/package.json @@ -0,0 +1,36 @@ +{ + "name": "@css-modules-kit/content-mapper", + "version": "0.0.0", + "private": true, + "description": "A TypeScript content mapper for CSS Modules", + "license": "MIT", + "author": "mizdra ", + "repository": { + "type": "git", + "url": "https://github.com/mizdra/css-modules-kit.git", + "directory": "packages/content-mapper" + }, + "type": "module", + "sideEffects": false, + "scripts": { + "build": "tsc -b tsconfig.build.json" + }, + "dependencies": { + "@css-modules-kit/core": "workspace:^" + }, + "devDependencies": { + "typescript": "^6.0.3", + "typescript-nightly": "npm:typescript@7.1.0-dev.20260828.1" + }, + "typescript": { + "contentMapper": { + "exec": [ + "node", + "dist/main.js" + ] + } + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/packages/content-mapper/src/error.ts b/packages/content-mapper/src/error.ts new file mode 100644 index 00000000..ce717a36 --- /dev/null +++ b/packages/content-mapper/src/error.ts @@ -0,0 +1,6 @@ +export class ProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProtocolError'; + } +} diff --git a/packages/content-mapper/src/main.ts b/packages/content-mapper/src/main.ts new file mode 100644 index 00000000..104a0a99 --- /dev/null +++ b/packages/content-mapper/src/main.ts @@ -0,0 +1,3 @@ +import { runServer } from './server.js'; + +await runServer(process.stdin, process.stdout); diff --git a/packages/content-mapper/src/options.test.ts b/packages/content-mapper/src/options.test.ts new file mode 100644 index 00000000..05f9b2d4 --- /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, optionDiagnostics: [] }); +}); + +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, + }, + optionDiagnostics: [], + }); +}); + +test('ignores unknown keys', () => { + expect(normalizeMapperOptions({ unknown: true })).toEqual({ options: defaultOptions, optionDiagnostics: [] }); +}); + +test('reports a diagnostic and returns default options when raw options are not an object', () => { + expect(normalizeMapperOptions('yes')).toEqual({ + options: defaultOptions, + optionDiagnostics: [{ path: [], messageText: 'Options must be an object.', code: 1001 }], + }); +}); + +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, + 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 new file mode 100644 index 00000000..99e3d5b8 --- /dev/null +++ b/packages/content-mapper/src/options.ts @@ -0,0 +1,55 @@ +import type { OptionDiagnostic } from './protocol.js'; + +export interface NormalizedMapperOptions { + namedExports: boolean; + prioritizeNamedImports: boolean; + animation: boolean; + dashedIdents: boolean; + container: boolean; +} + +export interface NormalizeMapperOptionsResult { + options: NormalizedMapperOptions; + optionDiagnostics: OptionDiagnostic[]; +} + +const DEFAULT_OPTIONS: NormalizedMapperOptions = { + namedExports: false, + prioritizeNamedImports: false, + animation: true, + dashedIdents: false, + container: false, +}; + +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 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 optionDiagnostics: OptionDiagnostic[] = []; + if (raw === undefined) return { options, optionDiagnostics }; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + 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; + const value = (raw as Record)[key]; + if (typeof value === 'boolean') { + options[key] = value; + } else { + optionDiagnostics.push({ + path: [key], + messageText: `\`${key}\` must be a boolean.`, + code: NOT_A_BOOLEAN_CODE, + }); + } + } + return { options, optionDiagnostics }; +} diff --git a/packages/content-mapper/src/protocol.ts b/packages/content-mapper/src/protocol.ts new file mode 100644 index 00000000..1c67bee6 --- /dev/null +++ b/packages/content-mapper/src/protocol.ts @@ -0,0 +1,175 @@ +// Type definitions for the content mapper protocol of TypeScript 7 +// (microsoft/typescript-go#4712, microsoft/TypeScript#63936). +// The wire format is JSON-RPC 2.0 with LSP-style `Content-Length` framing. + +export const DIAGNOSTIC_SOURCE = 'cmk'; + +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; + +export interface RequestMessage { + jsonrpc: '2.0'; + id: number | string; + method: string; + params?: unknown; +} + +export interface ResponseMessage { + jsonrpc: '2.0'; + id: number | string; + result?: unknown; + error?: ResponseError; +} + +export interface ResponseError { + code: number; + message: string; + data?: unknown; +} + +export type PositionEncoding = 'utf-8' | 'utf-16'; + +export interface InitializeParams { + locale?: string; + positionEncodings: PositionEncoding[]; +} + +export interface InitializeResult { + positionEncoding: PositionEncoding; + /** The prefix of mapper-authored diagnostic codes. Must not collide with other diagnostic sources. */ + diagnosticSource: string; +} + +export interface OpenProjectParams { + /** The absolute path of the project's tsconfig, or an empty string for an inferred project. */ + configFileName: string; + /** An opaque handle assigned by the host. Subsequent transforms reference it. */ + projectHandle: string; + /** The mapper entry's `options` from the project's `contentMappers` configuration. */ + options?: unknown; + compilerOptions: Record; +} + +/** + * The response to an openProject request. `configIdentity` and `watchedFiles` may only be + * returned by mappers that declare `dynamicConfig`, so cmk omits them. + */ +export interface OpenProjectResult { + optionDiagnostics?: OptionDiagnostic[]; +} + +/** An invalid mapper option. `path` locates the value within the mapper entry's options object. */ +export interface OptionDiagnostic { + path: (string | number)[]; + messageText: string; + code: number; +} + +export interface CloseProjectParams { + projectHandle: string; +} + +export interface TransformParams { + fileName: string; + content: string; + /** The handle of an opened project whose options apply to this transform. */ + projectHandle: string; +} + +export interface TransformResult { + text: string; + /** Determines how `text` is parsed. */ + extension: VirtualExtension; + mappings?: SpanMapping[]; + diagnosticDirectives?: DiagnosticDirectives; + diagnostics?: MapperDiagnostic[]; +} + +export type VirtualExtension = '.js' | '.jsx' | '.mjs' | '.cjs' | '.ts' | '.tsx' | '.mts' | '.cts' | '.json'; + +/** A mapping between a span in the generated text and a span in the original file. */ +export type SpanMapping = [ + generatedStart: number, + generatedLength: number, + originalStart: number, + originalLength: number, + kind: SpanMapKind, + features?: number, +]; + +export const SpanMapKind = { + /** Positions correspond 1:1 within the spans. */ + Verbatim: 0, + /** The spans correspond only as a whole. */ + Atom: 1, + /** Like `Atom`, but the spans have unrelated text (e.g. different names). */ + Alias: 2, +} as const; + +export type SpanMapKind = (typeof SpanMapKind)[keyof typeof SpanMapKind]; + +/** Bit flags of language service features enabled for a span. Omitted means all features. */ +export const SpanMapFeature = { + Hover: 1 << 0, + SignatureHelp: 1 << 1, + Completion: 1 << 2, + Definition: 1 << 3, + TypeDefinition: 1 << 4, + Implementation: 1 << 5, + References: 1 << 6, + DocumentHighlights: 1 << 7, + Rename: 1 << 8, + CallHierarchy: 1 << 9, + CodeActions: 1 << 10, + Formatting: 1 << 11, + InlayHints: 1 << 12, + SemanticTokens: 1 << 13, + FoldingRanges: 1 << 14, + SelectionRanges: 1 << 15, + LinkedEditing: 1 << 16, + AutoInsert: 1 << 17, + DocumentSymbols: 1 << 18, + CodeLens: 1 << 19, + All: (1 << 20) - 1, +} as const; + +/** A diagnostic reported by the mapper. `start` and `length` are positions in the original file. */ +export interface MapperDiagnostic { + messageText: string; + start: number; + length: number; + code: number; +} + +export interface DiagnosticDirectives { + /** Diagnostics reported when an `Expect` directive matches no diagnostic. Unused by `Ignore` directives. */ + unusedExpectDirectiveDiagnostics: UnusedExpectDirectiveDiagnostic[]; + /** Directives whose generated ranges must not overlap each other. */ + directives: MappedDiagnosticDirective[]; +} + +export interface UnusedExpectDirectiveDiagnostic { + messageText: string; + code: number; +} + +/** + * Suppresses (`Ignore`) or expects (`Expect`) TypeScript diagnostics whose start position falls + * within `[generatedStart, generatedEnd)`. `originalStart` and `originalLength` locate the + * directive in the original file for `Expect` reporting. + */ +export type MappedDiagnosticDirective = [ + originalStart: number, + originalLength: number, + generatedStart: number, + generatedEnd: number, + policy: DiagnosticDirectivePolicy, + unusedExpectDirectiveIndex?: number, +]; + +export const DiagnosticDirectivePolicy = { + Ignore: 0, + Expect: 1, +} as const; + +export type DiagnosticDirectivePolicy = (typeof DiagnosticDirectivePolicy)[keyof typeof DiagnosticDirectivePolicy]; diff --git a/packages/content-mapper/src/server.test.ts b/packages/content-mapper/src/server.test.ts new file mode 100644 index 00000000..da8ab5e5 --- /dev/null +++ b/packages/content-mapper/src/server.test.ts @@ -0,0 +1,244 @@ +import { PassThrough } from 'node:stream'; +import { expect, test } from 'vite-plus/test'; +import type { NormalizedMapperOptions } from './options.js'; +import { runServer } from './server.js'; +import { transformCSS } from './transformer.js'; + +const defaultMapperOptions: NormalizedMapperOptions = { + namedExports: false, + prioritizeNamedImports: false, + animation: true, + dashedIdents: false, + container: false, +}; + +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: { positionEncodings: ['utf-8', 'utf-16'] }, + }; +} + +function createInitializeResponse(id: number) { + return { + jsonrpc: '2.0', + id, + result: { positionEncoding: 'utf-16', diagnosticSource: 'cmk' }, + }; +} + +function createOpenProjectRequest(id: number, projectHandle: string, 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, projectHandle }, + }; +} + +function createTransformResponse(id: number, content: string, options: NormalizedMapperOptions = defaultMapperOptions) { + const { text, mappings, diagnosticDirectives, diagnostics } = transformCSS('/a.module.css', content, options); + return { + jsonrpc: '2.0', + id, + result: { + text, + extension: '.ts', + ...(mappings.length > 0 ? { mappings } : {}), + ...(diagnosticDirectives ? { diagnosticDirectives } : {}), + ...(diagnostics.length > 0 ? { diagnostics } : {}), + }, + }; +} + +test('responds to initialize with utf-16 encoding and cmk diagnostic source', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createInitializeRequest(1)); + input.end(); + await done; + expect(readResponses(output)).toEqual([createInitializeResponse(1)]); +}); + +test('responds to transform with generated text, extension, and span mappings', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createInitializeRequest(1)); + writeFrame(input, createOpenProjectRequest(2, 'p1')); + writeFrame(input, createTransformRequest(3, '.a1 { color: red; }')); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + createInitializeResponse(1), + createOpenProjectResponse(2), + createTransformResponse(3, '.a1 { color: red; }'), + ]); +}); + +test('applies the mapper options of the project referenced by the transform', async () => { + const { input, output, done } = startServer(); + 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([ + createOpenProjectResponse(1), + createOpenProjectResponse(2), + createTransformResponse(3, ''), + createTransformResponse(4, '', { ...defaultMapperOptions, namedExports: true }), + ]); +}); + +test('reports invalid mapper options as optionDiagnostics in the openProject response', async () => { + const { input, output, done } = startServer(); + writeFrame(input, createOpenProjectRequest(1, 'p1', { animation: 'yes' })); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + { + jsonrpc: '2.0', + id: 1, + result: { + 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: 'shutdown', params: {} }); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + { jsonrpc: '2.0', id: 1, error: { code: -32601, message: 'Method not found: shutdown' } }, + ]); +}); + +test('parses a frame split across multiple chunks', async () => { + const { input, output, done } = startServer(); + const frame = encodeFrame(createInitializeRequest(1)); + input.write(frame.subarray(0, 10)); + input.write(frame.subarray(10, 20)); + input.write(frame.subarray(20)); + input.end(); + await done; + expect(readResponses(output)).toEqual([createInitializeResponse(1)]); +}); + +test('parses multiple frames arriving in a single chunk', async () => { + const { input, output, done } = startServer(); + const frame1 = encodeFrame(createInitializeRequest(1)); + const frame2 = encodeFrame(createInitializeRequest(2)); + const chunk = new Uint8Array(frame1.length + frame2.length); + chunk.set(frame1, 0); + chunk.set(frame2, frame1.length); + input.write(chunk); + input.end(); + await done; + expect(readResponses(output)).toEqual([createInitializeResponse(1), createInitializeResponse(2)]); +}); + +test('reads frame bodies by UTF-8 byte length', async () => { + const { input, output, done } = startServer(); + // `あ` is 1 UTF-16 code unit but 3 UTF-8 bytes. If the server measured the body in UTF-16 + // code units, the boundary of the second frame would be misaligned. The `あ` is placed in + // a comment so that the response stays ASCII-only for `readResponses`. + const content = '/* あ */ .a1 { color: red; }'; + writeFrame(input, createOpenProjectRequest(1, 'p1')); + writeFrame(input, createTransformRequest(2, content)); + writeFrame(input, createInitializeRequest(3)); + input.end(); + await done; + expect(readResponses(output)).toEqual([ + createOpenProjectResponse(1), + createTransformResponse(2, content), + createInitializeResponse(3), + ]); +}); + +test('resolves when input ends', async () => { + const { input, done } = startServer(); + input.end(); + await expect(done).resolves.toBeUndefined(); +}); diff --git a/packages/content-mapper/src/server.ts b/packages/content-mapper/src/server.ts new file mode 100644 index 00000000..666f7d4c --- /dev/null +++ b/packages/content-mapper/src/server.ts @@ -0,0 +1,155 @@ +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, + OpenProjectParams, + OpenProjectResult, + RequestMessage, + ResponseMessage, + TransformParams, + TransformResult, +} 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' + +interface FrameDecoder { + push(chunk: Uint8Array): string[]; +} + +function createFrameDecoder(): FrameDecoder { + let buffer: Uint8Array = new Uint8Array(0); + let contentLength: number | undefined; + return { + push(chunk: Uint8Array): string[] { + buffer = concatBytes(buffer, chunk); + const frames: string[] = []; + while (true) { + if (contentLength === undefined) { + const headerEnd = indexOfHeaderTerminator(buffer); + if (headerEnd === -1) break; + const header = new TextDecoder().decode(buffer.subarray(0, headerEnd)); + contentLength = parseContentLength(header); + buffer = buffer.subarray(headerEnd + HEADER_TERMINATOR.length); + } + if (buffer.length < contentLength) break; + frames.push(new TextDecoder().decode(buffer.subarray(0, contentLength))); + buffer = buffer.subarray(contentLength); + contentLength = undefined; + } + return frames; + }, + }; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a, 0); + result.set(b, a.length); + return result; +} + +function indexOfHeaderTerminator(bytes: Uint8Array): number { + for (let i = 0; i + HEADER_TERMINATOR.length <= bytes.length; i++) { + if (HEADER_TERMINATOR.every((byte, j) => bytes[i + j] === byte)) return i; + } + return -1; +} + +/** + * @throws {ProtocolError} When the header lacks a valid `Content-Length` field. + */ +function parseContentLength(header: string): number { + const match = /^Content-Length:\s*(\d+)\s*$/mu.exec(header); + if (match === null) throw new ProtocolError(`Invalid header: ${JSON.stringify(header)}`); + return Number(match[1]); +} + +function isRequestMessage(message: unknown): message is RequestMessage { + return typeof message === 'object' && message !== null && 'method' in message && 'id' in message; +} + +function createResponse(request: RequestMessage, projects: Map): ResponseMessage { + switch (request.method) { + case 'initialize': { + const result: InitializeResult = { + positionEncoding: 'utf-16', + diagnosticSource: DIAGNOSTIC_SOURCE, + }; + return { jsonrpc: '2.0', id: request.id, result }; + } + case 'openProject': { + const params = request.params as OpenProjectParams; + 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 = 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 result: TransformResult = { + text: output.text, + extension: '.ts', + ...(output.mappings.length > 0 ? { mappings: output.mappings } : {}), + ...(output.diagnosticDirectives ? { diagnosticDirectives: output.diagnosticDirectives } : {}), + ...(output.diagnostics.length > 0 ? { diagnostics: output.diagnostics } : {}), + }; + return { jsonrpc: '2.0', id: request.id, result }; + } + default: + return { + jsonrpc: '2.0', + id: request.id, + error: { code: METHOD_NOT_FOUND, message: `Method not found: ${request.method}` }, + }; + } +} + +function encodeFrame(message: ResponseMessage): Uint8Array { + const body = new TextEncoder().encode(JSON.stringify(message)); + const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`); + return concatBytes(header, body); +} + +/** + * Reads content mapper protocol requests from `input` and writes responses to `output`. + * @returns A promise that resolves when `input` ends, and rejects on a malformed frame. + */ +export async function runServer(input: Readable, output: Writable): Promise { + return new Promise((resolve, reject) => { + const decoder = createFrameDecoder(); + const projects = new 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, projects))); + } + } + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + input.on('end', () => resolve()); + input.on('error', reject); + }); +} diff --git a/packages/content-mapper/src/test/render.ts b/packages/content-mapper/src/test/render.ts new file mode 100644 index 00000000..efec6be8 --- /dev/null +++ b/packages/content-mapper/src/test/render.ts @@ -0,0 +1,100 @@ +import { DiagnosticDirectivePolicy, 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', +}; + +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'); + 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], + })), + ...(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')}`; + } + 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..1d3a9ed3 --- /dev/null +++ b/packages/content-mapper/src/test/ts-program.ts @@ -0,0 +1,96 @@ +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'; + +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 = transformCSS(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) + .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)}`; + 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..23bdcd1e --- /dev/null +++ b/packages/content-mapper/src/transformer-program.test.ts @@ -0,0 +1,124 @@ +import dedent from 'dedent'; +import { expect, test } from 'vite-plus/test'; +import type { NormalizedMapperOptions } from './options.js'; +import { SpanMapFeature, 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, + `'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', () => { + 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..fe3a50be --- /dev/null +++ b/packages/content-mapper/src/transformer.test.ts @@ -0,0 +1,782 @@ +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 { transformCSS } from './transformer.js'; + +const defaultExportOptions: NormalizedMapperOptions = { + namedExports: false, + prioritizeNamedImports: false, + animation: true, + dashedIdents: false, + container: false, +}; + +const namedExportOptions: NormalizedMapperOptions = { ...defaultExportOptions, namedExports: true }; + +function run(source: string, options: NormalizedMapperOptions): string { + return renderTransformOutput(source, transformCSS('/test/a.module.css', source, options)); +} + +describe('generates an empty module when the CSS module has no tokens', () => { + test('default export', () => { + expect(run('', defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + + ¦ #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; + " + `); + }); +}); + +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 === + 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; + " + `); + }); +}); + +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 './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; + " + `); + }); +}); + +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 === + 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; + " + `); + }); +}); + +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 '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; + " + `); + }); +}); + +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 './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('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 === + .a_1 { composes: b_1 from 'https://example.com/b.module.css'; } + ^^^ #0 + ^^^ #2 + ¦ #1 + + === generated === + interface Styles { readonly 'a_1': string; } + ^^^^^ #0 Atom(All~Rename) + declare const styles: Styles; + ^^^^^^ #1 Atom(Definition) + styles['a_1']; + ^^^ #2 Verbatim(All~Hover) + ^^^^^^^^^^^^^^ ignore#0 + export default styles; + " + `); +}); + +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'; + + === 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 === + 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";`, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + @import "./b.module.css"; + ^^^^^^^^^^^^^^^^ #0 + ¦ #1 + + === 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; + " + `); +}); + +test('synthesizes quotes for unquoted url() specifiers and maps them as zero-width spans', () => { + expect(run(`@import url(./b.module.css);`, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + @import url(./b.module.css); + ¦ #2 + ¦ #0 + ^^^^^^^^^^^^^^ #1 + ¦ #3 + + === generated === + import * as _import_0 from './b.module.css'; + ^ #2 Atom(Definition|TypeDefinition|Implementation|References) + ^^^^^^^^^^^^^^ #1 Verbatim + ^ #0 Atom(Definition|TypeDefinition|Implementation|References) + type __BlockErrorType = [0] extends [1 & T] ? {} : T; + interface Styles {} + declare const styles: Styles & __BlockErrorType; + ^^^^^^ #3 Atom(Definition) + export default styles; + " + `); +}); + +test('converts parse diagnostics into mapper diagnostics', () => { + const source = dedent` + .a_1 { color: red; } + .a_2 { + `; + expect(run(source, defaultExportOptions)).toMatchInlineSnapshot(` + "=== source === + .a_1 { color: red; } + ^^^ #0 + ^^^ #3 + ¦ #2 + .a_2 { + ^^^ #1 + ^^^ #4 + ^ diag#0 + + === 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 + export default styles; + + + === diagnostics === + diag#0: Unclosed block" + `); +}); + +test('omits keyframes tokens when animation is false', () => { + expect(run('@keyframes a_1 {}', { ...defaultExportOptions, animation: false })).toMatchInlineSnapshot(` + "=== source === + @keyframes a_1 {} + ¦ #0 + + === generated === + interface Styles {} + declare const styles: Styles; + ^^^^^^ #0 Atom(Definition) + export default styles; + " + `); +}); + +test('generates an empty module for a non-module CSS file', () => { + expect(transformCSS('/test/global.css', `* { margin: 0; }`, defaultExportOptions)).toStrictEqual({ + text: 'export {};\n', + mappings: [], + diagnostics: [], + }); +}); + +test('keeps the generated text a module when prioritizeNamedImports is true', () => { + expect(run('', { ...namedExportOptions, 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..ce146048 --- /dev/null +++ b/packages/content-mapper/src/transformer.ts @@ -0,0 +1,465 @@ +import type { + DiagnosticWithLocation, + Location, + NamedTokenImporterEntry, + Token, + TokenImporter, + TokenReference, +} from '@css-modules-kit/core'; +import { + basename, + CSS_MODULE_EXTENSION, + isCSSModuleFile, + isURLSpecifier, + parseCSSModule, + validateTokenName, +} from '@css-modules-kit/core'; +import type { NormalizedMapperOptions } from './options.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[]; +} + +// 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; + +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]); + mappings.push([text.length + 1 + value.length, 1, loc.end.offset, 0, SpanMapKind.Atom, QUOTE_FEATURES]); + text += `'${value}'`; + } + 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}[`); + appendAtomTokenName(name, loc); + append('];\n'); + }, + /** + * 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. + */ + 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 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([ + 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[]; directives: MappedDiagnosticDirective[] } { + return { text, mappings, directives }; + }, + }; +} + +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): '"' | "'" | undefined { + const quote = content[fromLoc.start.offset - 1]; + return quote === '"' || quote === "'" ? quote : undefined; +} + +/** + * 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. + * + * 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. + */ +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, + 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, directives } = options.namedExports + ? buildNamedExportsText( + fileName, + content, + localTokens, + tokenImporters, + tokenReferences, + options.prioritizeNamedImports, + ) + : buildDefaultExportText(content, localTokens, tokenImporters, tokenReferences); + return { + text, + mappings, + ...(directives.length > 0 ? { diagnosticDirectives: { unusedExpectDirectiveDiagnostics: [], directives } } : {}), + diagnostics: convertDiagnostics(cssModule.diagnostics, content), + }; +} + +function buildDefaultExportText( + content: string, + localTokens: Token[], + tokenImporters: TokenImporter[], + tokenReferences: TokenReference[], +): { text: string; mappings: SpanMapping[]; directives: MappedDiagnosticDirective[] } { + 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.appendAtomTokenName(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.appendAtomTokenName(entry.localName ?? entry.name, entry.localLoc ?? entry.loc); + builder.append(`: typeof ${binding}.default[`); + builder.appendAtomTokenName(entry.name, entry.loc); + builder.append(']; }\n'); + hasMembers = true; + } + } + if (!hasMembers) builder.append('interface Styles {}\n'); + builder.append('declare const '); + builder.appendModuleAnchor('styles'); + builder.append(': Styles'); + for (const allImporter of allImporters) { + 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.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.appendAtomElementAccessStatement(`${binding}.default`, entry.name, entry.loc); + builder.appendVerbatimElementAccessStatement(`${binding}.default`, entry.name, entry.loc); + } + } + } + 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[]; directives: MappedDiagnosticDirective[] } { + 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.appendVerbatimExportName(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) { + // 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.appendVerbatimExportName(entry.name, entry.loc); + builder.append(' as '); + builder.appendVerbatimExportName(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); + } + } + isModule = true; + } + const referenceBindings = new Map(); + 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; + } + const needsSelf = + localTokens.length > 0 || + importerBindings.size > 0 || + tokenReferences.some((reference) => reference.type === 'local'); + if (needsSelf) { + // 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); + } + 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.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.appendAtomElementAccessStatement(binding, entry.name, entry.loc); + builder.appendVerbatimElementAccessStatement(binding, entry.name, entry.loc); + } + } + } + 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'); +} + +// 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') + .map((diagnostic) => ({ + messageText: diagnostic.text, + start: toOffset(content, diagnostic.start.line, diagnostic.start.column), + length: diagnostic.length, + code: CSS_MODULE_DIAGNOSTIC_CODE, + })); +} + +/** 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/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/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 7d6f2e02..d7af19ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,6 +327,19 @@ importers: specifier: ^5.7.3 || ^6.0.0 version: 6.0.3 + packages/content-mapper: + dependencies: + '@css-modules-kit/core': + specifier: workspace:^ + version: link:../core + devDependencies: + typescript: + 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: postcss: @@ -901,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] @@ -1532,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'} @@ -1718,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] @@ -2785,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] @@ -3709,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==} @@ -4948,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) @@ -7272,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-extension.sh b/scripts/setup-tsgo-extension.sh new file mode 100755 index 00000000..f5d6cea5 --- /dev/null +++ b/scripts/setup-tsgo-extension.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -ue + +# Prepares everything the "tsgo (7-content-mapper)" launch configuration needs: +# 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 + +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). 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. +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 diff --git a/scripts/vitest-e2e-test-setup.ts b/scripts/vitest-e2e-test-setup.ts index 9fcfbfd3..87957d23 100644 --- a/scripts/vitest-e2e-test-setup.ts +++ b/scripts/vitest-e2e-test-setup.ts @@ -1,9 +1,40 @@ import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import type { TestProject } from 'vite-plus/test/node'; -export default function setup(project: TestProject) { +// Keep the resolution in sync with `packages/content-mapper/e2e-test/test-util/lsp-client.ts`. +const tsgoBinPath = process.env['TSGO_BIN'] ?? resolveNativeTscBinPath(); + +/** + * Resolves the platform-specific native tsc binary the same way as `typescript/lib/getExePath.js`. + * The `typescript` nightly is a devDependency of `packages/content-mapper` under the + * `typescript-nightly` alias, and its platform package is a dependency of the nightly, so each + * must be resolved from its dependent package to work with pnpm's non-flat `node_modules`. + */ +function resolveNativeTscBinPath(): string { + const contentMapperPkgPath = fileURLToPath(new URL('../packages/content-mapper/package.json', import.meta.url)); + const typescriptPkgPath = createRequire(contentMapperPkgPath).resolve('typescript-nightly/package.json'); + const platformPkgName = `@typescript/typescript-${process.platform}-${process.arch}`; + const platformPkgPath = createRequire(typescriptPkgPath).resolve(`${platformPkgName}/package.json`); + const binName = process.platform === 'win32' ? 'tsc.exe' : 'tsc'; + return fileURLToPath(new URL(`./lib/${binName}`, pathToFileURL(platformPkgPath))); +} + +function prepare() { + if (!existsSync(tsgoBinPath)) { + if (process.env['TSGO_BIN']) { + throw new Error(`tsgo binary not found at TSGO_BIN (${tsgoBinPath}).`); + } + throw new Error(`Native tsc binary not found at ${tsgoBinPath}. Run \`pnpm install\` to install it.`); + } execSync('vp run build', { stdio: 'inherit' }); +} + +export default function setup(project: TestProject) { + prepare(); project.onTestsRerun(() => { - execSync('vp run build', { stdio: 'inherit' }); + prepare(); }); } diff --git a/tsconfig.build.json b/tsconfig.build.json index ff90c88c..7b47983c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,6 +6,7 @@ "references": [ { "path": "./packages/core/tsconfig.build.json" }, { "path": "./packages/codegen/tsconfig.build.json" }, + { "path": "./packages/content-mapper/tsconfig.build.json" }, { "path": "./packages/ts-plugin/tsconfig.build.json" }, { "path": "./packages/vscode/tsconfig.build.json" }, { "path": "./packages/stylelint-plugin/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index f2f6ede1..766625da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.base.json", "include": ["**/*", ".changeset/custom-changelog-github.ts"], - "exclude": ["node_modules", "**/dist", "examples"], + "exclude": ["node_modules", "**/dist", "examples", ".tmp"], "compilerOptions": { "target": "ES2022", "lib": ["ESNext"],