Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"ignore": ["@css-modules-kit/content-mapper"],
"privatePackages": {
"version": true,
"tag": true
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,4 @@ Cargo.lock

### User
/crates/zed/extension.wasm
/.tmp/
44 changes: 44 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,50 @@
"TSS_DEBUG": "5859"
}
},
{
// Launches the TypeScript Native Preview extension and the tsgo binary built from
// the pinned microsoft/TypeScript commit (see scripts/setup-tsgo-extension.sh).
// The marketplace build predates the content mapper support, so the extension
// must be run from source.
"name": "tsgo (7-content-mapper)",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/.tmp/typescript/packages/vscode-typescript",
"--profile-temp",
"--skip-welcome",
// The extension enables content mappers only in a trusted workspace. Disabling
// workspace trust makes VS Code treat every workspace as trusted, which also
// skips the trust dialog on launch.
"--disable-workspace-trust",
"--folder-uri=${workspaceFolder}/examples/7-content-mapper",
"${workspaceFolder}/examples/7-content-mapper/src/index.ts"
],
"outFiles": ["${workspaceFolder}/.tmp/typescript/packages/vscode-typescript/dist/**/*.js"],
"preLaunchTask": "prepare content-mapper examples",
"presentation": {
"group": "tsgo"
}
},
{
// See the comments of "tsgo (7-content-mapper)".
"name": "tsgo (8-content-mapper-named-exports)",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/.tmp/typescript/packages/vscode-typescript",
"--profile-temp",
"--skip-welcome",
"--disable-workspace-trust",
"--folder-uri=${workspaceFolder}/examples/8-content-mapper-named-exports",
"${workspaceFolder}/examples/8-content-mapper-named-exports/src/index.ts"
],
"outFiles": ["${workspaceFolder}/.tmp/typescript/packages/vscode-typescript/dist/**/*.js"],
"preLaunchTask": "prepare content-mapper examples",
"presentation": {
"group": "tsgo"
}
},
{
"name": "vscode-test",
"type": "extensionHost",
Expand Down
23 changes: 23 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,29 @@
"cwd": "${workspaceFolder}/packages/vscode"
},
"group": "build"
},
{
"label": "vp: build - packages/content-mapper",
"type": "shell",
"command": "vp run build",
"options": {
"cwd": "${workspaceFolder}/packages/content-mapper"
},
"group": "build"
},
{
"label": "setup tsgo extension",
"type": "shell",
"command": "./scripts/setup-tsgo-extension.sh",
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build"
},
{
"label": "prepare content-mapper examples",
"dependsOn": ["vp: build - packages/content-mapper", "setup tsgo extension"],
"group": "build"
}
]
}
5 changes: 5 additions & 0 deletions examples/7-content-mapper/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"js/ts.experimental.useTsgo": true,
// Enables the content mapper inspector ("TypeScript: Show Content Mapper Virtual Documents").
"js/ts.showDebugInfo": true
}
14 changes: 14 additions & 0 deletions examples/7-content-mapper/src/a.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 3 additions & 0 deletions examples/7-content-mapper/src/b.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.b_1 {
color: blue;
}
3 changes: 3 additions & 0 deletions examples/7-content-mapper/src/global.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* {
margin: 0;
}
8 changes: 8 additions & 0 deletions examples/7-content-mapper/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions examples/7-content-mapper/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
]
}
5 changes: 5 additions & 0 deletions examples/8-content-mapper-named-exports/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"js/ts.experimental.useTsgo": true,
// Enables the content mapper inspector ("TypeScript: Show Content Mapper Virtual Documents").
"js/ts.showDebugInfo": true
}
14 changes: 14 additions & 0 deletions examples/8-content-mapper-named-exports/src/a.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 3 additions & 0 deletions examples/8-content-mapper-named-exports/src/b.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.b_1 {
color: blue;
}
3 changes: 3 additions & 0 deletions examples/8-content-mapper-named-exports/src/global.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* {
margin: 0;
}
8 changes: 8 additions & 0 deletions examples/8-content-mapper-named-exports/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import './global.css';
import * as styles from './a.module.css';

styles.a_1;
styles.b_1;
styles.primary;
styles['fade-in'];
styles.unknown; // Expected TS2339 error
22 changes: 22 additions & 0 deletions examples/8-content-mapper-named-exports/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "es2015",
"lib": ["ES2015"],
"module": "Preserve",
"moduleResolution": "bundler",

"noEmit": true,
"incremental": false,
"types": [] // Simplify tsserver.log
},
"contentMappers": [
{
"package": "@css-modules-kit/content-mapper",
"extensions": [".css"],
"options": {
"namedExports": true
}
}
]
}
18 changes: 18 additions & 0 deletions packages/content-mapper/e2e-test/non-module-css-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, test } from 'vite-plus/test';
import { buildTSConfigJSON } from '../src/test/builder.js';
import { fixtureDir, setupFixture } from './test-util/fixture.js';
import { launchLSPClient } from './test-util/lsp-client.js';

const client = launchLSPClient(fixtureDir);

// ref: https://github.com/mizdra/css-modules-kit/issues/170
test('reports no diagnostics for a non-module .css file', async () => {
const { iff } = await setupFixture({
'tsconfig.json': buildTSConfigJSON(),
'global.css': `* { margin: 0; }`,
});
await client.openFiles([iff.paths['global.css']]);

const diagnostics = await client.sendDocumentDiagnostic(iff.paths['global.css']);
expect(diagnostics).toStrictEqual([]);
});
152 changes: 152 additions & 0 deletions packages/content-mapper/e2e-test/test-util/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { randomUUID } from 'node:crypto';
import { mkdirSync, realpathSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from '@css-modules-kit/core';
import { type CreateIFFResult, defineIFFCreator } from '@mizdra/inline-fixture-files';
import { type FileLocation, type FileSpan, type FileSpanWithContext, formatPath, type Position } from './lsp-client.js';

// tmpdir() may be a symlink (e.g. /var -> /private/var on macOS). The paths sent by the LSP
// client must match the ones the server reports back, so the real path is resolved up front.
export const fixtureDir = join(
realpathSync(tmpdir()),
'@css-modules-kit/content-mapper',
process.env['VITEST_POOL_ID']!,
);
mkdirSync(fixtureDir, { recursive: true });

const createIFF = defineIFFCreator({
generateRootDir: () => join(fixtureDir, randomUUID()),
unixStylePath: true,
});

const contentMapperDir = resolve(import.meta.dirname, '../..');

function findAllMatches(content: string, search: string): number[] {
if (search.length === 0) throw new Error('Empty search string is not allowed.');
const matches: number[] = [];
let pos = content.indexOf(search);
while (pos !== -1) {
matches.push(pos);
pos = content.indexOf(search, pos + 1);
}
return matches;
}

function offsetToPosition(content: string, offset: number): Position {
const before = content.slice(0, offset);
const newlineCount = (before.match(/\n/gu) ?? []).length;
const lastNewline = before.lastIndexOf('\n');
return {
line: newlineCount,
character: before.length - (lastNewline + 1),
};
}

type Files = Record<string, string>;

export interface GetFileSpanOptions {
/** 0-based index of the match when `search` matches multiple times. */
index?: number;
/**
* A substring enclosing the `search` match. When given, the returned span also carries
* `contextRange` pointing to the occurrence of `context` that encloses the match.
*/
context?: string;
}

export interface SetupFixtureResult<T extends Files> {
iff: CreateIFFResult<T>;
/**
* Get the absolute path of `file` and the (0-based) line/character of the first character of `search` in it.
* The result can be passed directly to the LSP client.
*
* - If `search` matches exactly once, returns that position.
* - If `search` matches multiple times, an `index` (0-based) must be passed.
* - Throws if `search` does not match, or `index` is out of range.
*/
getFileLocation: (file: string, search: string, index?: number) => FileLocation;
/**
* Get the absolute (path-normalized) path of `file` and the (0-based) start/end range of `search` in it.
* The result can be compared directly with spans returned by the LSP client.
*
* - `range.start` is identical to the position returned by `getFileLocation`.
* - `range.end` points to the position immediately AFTER the last character of `search`
* (exclusive end, matching the LSP convention).
* - Same matching/error semantics as `getFileLocation`.
*/
getFileSpan: (file: string, search: string, options?: GetFileSpanOptions) => FileSpanWithContext;
}

export async function setupFixture<const T extends Files>(files: T): Promise<SetupFixtureResult<T>> {
// oxlint-disable-next-line typescript/no-explicit-any
const iff = (await createIFF(files)) as any;

// tsgo resolves the mapper package from the tsconfig directory with node module resolution.
mkdirSync(join(iff.rootDir, 'node_modules/@css-modules-kit'), { recursive: true });
symlinkSync(contentMapperDir, join(iff.rootDir, 'node_modules/@css-modules-kit/content-mapper'), 'junction');

function getFileContent(file: string): string {
const content = files[file];
if (content === undefined) {
throw new Error(`File "${file}" was not registered in the fixture.`);
}
return content;
}

function findOffset(file: string, search: string, index?: number): number {
const content = getFileContent(file);
const matches = findAllMatches(content, search);
if (matches.length === 0) {
throw new Error(`Substring ${JSON.stringify(search)} not found in "${file}".`);
}
if (matches.length > 1 && index === undefined) {
throw new Error(
`Substring ${JSON.stringify(search)} matches ${matches.length} times in "${file}". ` +
`Pass a 0-based index as the third argument to disambiguate.`,
);
}
const target = matches[index ?? 0];
if (target === undefined) {
throw new Error(
`Index ${index} is out of bounds (only ${matches.length} matches of ${JSON.stringify(search)} in "${file}").`,
);
}
return target;
}

function findEnclosingContextOffset(file: string, context: string, start: number, end: number): number {
const content = getFileContent(file);
const enclosing = findAllMatches(content, context).find(
(offset) => offset <= start && end <= offset + context.length,
);
if (enclosing === undefined) {
throw new Error(`No occurrence of context ${JSON.stringify(context)} encloses the match in "${file}".`);
}
return enclosing;
}

function getFileLocation(file: string, search: string, index?: number): FileLocation {
return { file: iff.paths[file], position: offsetToPosition(getFileContent(file), findOffset(file, search, index)) };
}

function getFileSpan(file: string, search: string, options?: GetFileSpanOptions): FileSpanWithContext {
const content = getFileContent(file);
const start = findOffset(file, search, options?.index);
const end = start + search.length;
const span: FileSpan = {
file: formatPath(iff.paths[file]),
range: { start: offsetToPosition(content, start), end: offsetToPosition(content, end) },
};
if (options?.context === undefined) return span;
const contextStart = findEnclosingContextOffset(file, options.context, start, end);
return {
...span,
contextRange: {
start: offsetToPosition(content, contextStart),
end: offsetToPosition(content, contextStart + options.context.length),
},
};
}

return { iff, getFileLocation, getFileSpan };
}
Loading
Loading