Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/content-mapper-rename-symbol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@css-modules-kit/content-mapper': minor
---

feat: support Rename Symbol for tokens
30 changes: 15 additions & 15 deletions packages/content-mapper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,21 +85,21 @@ See [Glossary](https://github.com/mizdra/css-modules-kit/blob/main/AGENTS.md#glo

The following features target the syntax that is supported in the table above.

| Feature | Status | Notes |
| ------------------------------------------------------------------ | --------- | --------------------------------------------------------------------------------------------- |
| Go to Definition | Supported | |
| Find All References | Supported | |
| Rename Symbol | Planned | Currently, you can rename a token in a TypeScript file, but it does not update `*.module.css` |
| Update import statements when moving `*.module.css` | Supported | |
| Diagnostics for invalid token names | Supported | |
| Diagnostics for unknown tokens on `styles` | Supported | |
| Diagnostics for CSS syntax errors | Supported | |
| Diagnostics for unknown token references and unresolved specifiers | Planned | |
| Definition Preview by Hover | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |
| Create CSS Module file for current file | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |
| Complete `className={...}` instead of `className="..."` | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |
| Prioritize the `styles` import for the current component | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |
| Add missing CSS rule | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |
| Feature | Status | Notes |
| ------------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------- |
| Go to Definition | Supported | |
| Find All References | Supported | |
| Rename Symbol | Supported | |
| Update import statements when moving `*.module.css` | Supported | |
| Diagnostics for invalid token names | Supported | |
| Diagnostics for unknown tokens on `styles` | Supported | |
| Diagnostics for CSS syntax errors | Supported | |
| Diagnostics for unknown token references and unresolved specifiers | Planned | |
| Definition Preview by Hover | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |
| Create CSS Module file for current file | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |
| Complete `className={...}` instead of `className="..."` | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |
| Prioritize the `styles` import for the current component | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |
| Add missing CSS rule | Undecided | Not implemented because it [needs LSP middleware](#features-that-need-lsp-middleware) |

### Configuration

Expand Down
89 changes: 89 additions & 0 deletions packages/content-mapper/e2e-test/rename-symbol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import dedent from 'dedent';
import { expect, test } from 'vite-plus/test';
import { buildTSConfigJSON } from '../src/test/builder.js';
import { fixtureDir, setupFixture } from './test-util/fixture.js';
import { launchLanguageClient } from './test-util/language-client.js';

const client = await launchLanguageClient(fixtureDir);

test("renames the token definition, the TS-side styles.<token>, and styles['<token>']", async () => {
const { iff, getFileLocation, getFileSpan } = await setupFixture({
'tsconfig.json': buildTSConfigJSON(),
'index.ts': dedent`
import styles from './a.module.css';
styles.a_1;
styles['a_1'];
`,
'a.module.css': `.a_1 { color: red; }`,
});
await client.sendDidOpen([iff.paths['index.ts'], iff.paths['a.module.css']]);

const cases = [
['from the TS-side styles.<token>', getFileLocation('index.ts', 'a_1', 0)],
["from the TS-side styles['<token>']", getFileLocation('index.ts', 'a_1', 1)],
['from the token definition', getFileLocation('a.module.css', 'a_1')],
] as const;
const expected = [
getFileSpan('a.module.css', 'a_1'),
getFileSpan('index.ts', 'a_1', { index: 0 }),
getFileSpan('index.ts', 'a_1', { index: 1 }),
];
for (const [name, origin] of cases) {
const locations = await client.sendRename(origin);
expect.soft(locations, name).toStrictEqual(expected);
}
});

test('renames every token definition when the token is defined multiple times', async () => {
const { iff, getFileLocation, getFileSpan } = await setupFixture({
'tsconfig.json': buildTSConfigJSON(),
'index.ts': dedent`
import styles from './a.module.css';
styles.a_1;
`,
'a.module.css': dedent`
.a_1 { color: red; }
.a_1 { color: red; }
`,
});
await client.sendDidOpen([iff.paths['index.ts'], iff.paths['a.module.css']]);

const cases = [
['from the TS-side styles.<token>', getFileLocation('index.ts', 'a_1')],
['from the first token definition', getFileLocation('a.module.css', 'a_1', 0)],
['from the second token definition', getFileLocation('a.module.css', 'a_1', 1)],
] as const;
const expected = [
getFileSpan('a.module.css', 'a_1', { index: 0 }),
getFileSpan('a.module.css', 'a_1', { index: 1 }),
getFileSpan('index.ts', 'a_1'),
];
for (const [name, origin] of cases) {
const locations = await client.sendRename(origin);
expect.soft(locations, name).toStrictEqual(expected);
}
});

// TODO(tsgo): The placeholder includes the quotes, such as `'a_1'` (microsoft/TypeScript#65444).
test.fails('returns the token name without quotes as the placeholder of prepareRename', async () => {
const { iff, getFileLocation, getFileSpan } = await setupFixture({
'tsconfig.json': buildTSConfigJSON(),
'index.ts': dedent`
import styles from './a.module.css';
styles.a_1;
styles['a_1'];
`,
'a.module.css': `.a_1 { color: red; }`,
});
await client.sendDidOpen([iff.paths['index.ts'], iff.paths['a.module.css']]);

const cases = [
['from the TS-side styles.<token>', 'index.ts', 0],
["from the TS-side styles['<token>']", 'index.ts', 1],
['from the token definition', 'a.module.css', 0],
] as const;
for (const [name, file, index] of cases) {
const result = await client.sendPrepareRename(getFileLocation(file, 'a_1', index));
expect.soft(result, name).toStrictEqual({ ...getFileSpan(file, 'a_1', { index }), placeholder: 'a_1' });
}
});
66 changes: 54 additions & 12 deletions packages/content-mapper/e2e-test/test-util/language-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export type FileLocation = { file: string; position: Position };

export type FileSpan = { file: string; range: Range };

export type RenameLocation = FileSpan & { newText?: string };

export type PrepareRenameResult = FileSpan & { placeholder?: string };

export type Diagnostic = {
range: Range;
severity?: number;
Expand All @@ -33,22 +37,23 @@ interface LSPLocation {
range: Range;
}

/**
* A thin client for the TypeScript language server.
*
* Methods returning language feature results (definitions, references, diagnostics) return
* normalized values: only the fields relevant to tests are kept, URIs are converted to paths
* normalized with `formatPath`, and the results are sorted so that they can be compared with
* `toStrictEqual`. Spans are sorted by file path, then by position. Duplicates are not removed.
*/
interface LSPTextEdit {
range: Range;
newText: string;
}

interface LSPWorkspaceEdit {
changes?: Record<string, LSPTextEdit[]>;
documentChanges?: { textDocument: { uri: string }; edits: LSPTextEdit[] }[];
}

/** A thin client for the TypeScript language server. */
export interface LanguageClient {
sendDidOpen(files: readonly string[]): Promise<void>;
sendDefinition(args: FileLocation): Promise<FileSpan[]>;
sendReferences(args: FileLocation): Promise<FileSpan[]>;
/**
* Returns the diagnostics except the ones with the hint severity (e.g. unused locals), which
* tsserver reports as suggestion diagnostics instead of semantic or syntactic diagnostics.
*/
sendPrepareRename(args: FileLocation): Promise<PrepareRenameResult | null>;
sendRename(args: FileLocation, newName?: string): Promise<RenameLocation[]>;
sendDocumentDiagnostic(file: string): Promise<Diagnostic[]>;
}

Expand Down Expand Up @@ -84,6 +89,25 @@ function normalizeLocations(locations: readonly LSPLocation[] | null): FileSpan[
.toSorted(compareFileSpans);
}

function normalizeRenameLocations(edit: LSPWorkspaceEdit | null, newName: string): RenameLocation[] {
const editsPerUri: [string, LSPTextEdit[]][] = [
...Object.entries(edit?.changes ?? {}),
...(edit?.documentChanges ?? []).map(({ textDocument, edits }): [string, LSPTextEdit[]] => [
textDocument.uri,
edits,
]),
];
return editsPerUri
.flatMap(([uri, edits]) =>
edits.map((textEdit) => ({
file: toFilePath(uri),
range: textEdit.range,
...(textEdit.newText === newName ? {} : { newText: textEdit.newText }),
})),
)
.toSorted(compareFileSpans);
}

function normalizeDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] {
return diagnostics
.filter((diagnostic) => diagnostic.severity !== DiagnosticSeverity.Hint)
Expand Down Expand Up @@ -163,6 +187,24 @@ export async function launchLanguageClient(rootDir: string): Promise<LanguageCli
});
return normalizeLocations(result);
},
async sendPrepareRename(args) {
const result: Range | { range: Range; placeholder: string } | null = await sendRequest(
'textDocument/prepareRename',
{ textDocument: { uri: toFileUri(args.file) }, position: args.position },
);
if (result === null) return null;
if ('range' in result)
return { file: formatPath(args.file), range: result.range, placeholder: result.placeholder };
return { file: formatPath(args.file), range: result };
},
async sendRename(args, newName = 'renamed') {
const result = await sendRequest('textDocument/rename', {
textDocument: { uri: toFileUri(args.file) },
position: args.position,
newName,
});
return normalizeRenameLocations(result, newName);
},
async sendDocumentDiagnostic(file) {
const result: { items: Diagnostic[] } = await sendRequest('textDocument/diagnostic', {
textDocument: { uri: toFileUri(file) },
Expand Down
28 changes: 28 additions & 0 deletions packages/content-mapper/src/test/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,31 @@ test('renders the mappings and the diagnostics as markers under the source and t
diag#0: Unclosed block"
`);
});

test('renders the features as the ones excluded from All when most features are included', () => {
const source = '.a_1 {}';
const text = "interface Styles { readonly 'a_1': string }";
const output: TransformOutput = {
text,
mappings: [
[
text.indexOf("'a_1'"),
5,
source.indexOf('a_1'),
3,
SpanMapKind.Atom,
SpanMapFeature.All & ~SpanMapFeature.Rename & ~SpanMapFeature.Hover,
],
],
diagnostics: [],
};
expect(renderTransformOutput(source, output)).toMatchInlineSnapshot(`
"=== source ===
.a_1 {}
^^^ #0

=== generated ===
interface Styles { readonly 'a_1': string }
^^^^^ #0 Atom(All&~Hover&~Rename)"
`);
});
7 changes: 6 additions & 1 deletion packages/content-mapper/src/test/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@ function formatFeatures(features: number | undefined): string {
if (features === undefined) return '';
// A numeric enum also has the entries of its reverse mapping.
const flags = Object.entries(SpanMapFeature).filter(
(entry): entry is [string, SpanMapFeature] => typeof entry[1] === 'number' && entry[1] !== SpanMapFeature.All,
(entry): entry is [string, SpanMapFeature] =>
typeof entry[1] === 'number' && entry[1] !== SpanMapFeature.All && entry[1] !== SpanMapFeature.None,
);
const included = flags.filter(([, bit]) => (features & bit) !== 0).map(([name]) => name);
const excluded = flags.filter(([, bit]) => (features & bit) === 0).map(([name]) => name);
if (included.length > excluded.length) {
return `(${['All', ...excluded].join('&~')})`;
}
return `(${included.join('|')})`;
}

Expand Down
30 changes: 24 additions & 6 deletions packages/content-mapper/src/transformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,30 @@ test('generates a declaration for each occurrence of a local token', () => {
"=== 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
^^^^^ #0 Atom(All&~Rename)
interface Styles { readonly 'a_2': string }
^^^^^ #1 Atom
^^^^^ #1 Atom(All&~Rename)
interface Styles { readonly 'a_2': string }
^^^^^ #2 Atom
^^^^^ #2 Atom(All&~Rename)
declare const styles: Styles;
^^^^^^ #3 Atom(Definition)
styles['a_1'];
^^^ #4 Verbatim(Rename)
styles['a_2'];
^^^ #5 Verbatim(Rename)
styles['a_2'];
^^^ #6 Verbatim(Rename)
export default styles;
"
`);
Expand All @@ -67,15 +76,18 @@ test('omits token importers and token references', () => {
@value b_1 from './b.module.css';
.a_1 {
^^^ #0
^^^ #2
composes: a_2;
composes: b_2 from './b.module.css';
}

=== generated ===
interface Styles { readonly 'a_1': string }
^^^^^ #0 Atom
^^^^^ #0 Atom(All&~Rename)
declare const styles: Styles;
^^^^^^ #1 Atom(Definition)
styles['a_1'];
^^^ #2 Verbatim(Rename)
export default styles;
"
`);
Expand Down Expand Up @@ -108,18 +120,24 @@ test('converts parse diagnostics into mapper diagnostics', () => {
"=== source ===
.a_1 { color: red; }
^^^ #0
^^^ #3
¦ #2
.a_2 {
^^^ #1
^^^ #4
^ diag#0

=== generated ===
interface Styles { readonly 'a_1': string }
^^^^^ #0 Atom
^^^^^ #0 Atom(All&~Rename)
interface Styles { readonly 'a_2': string }
^^^^^ #1 Atom
^^^^^ #1 Atom(All&~Rename)
declare const styles: Styles;
^^^^^^ #2 Atom(Definition)
styles['a_1'];
^^^ #3 Verbatim(Rename)
styles['a_2'];
^^^ #4 Verbatim(Rename)
export default styles;


Expand Down
23 changes: 22 additions & 1 deletion packages/content-mapper/src/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,35 @@ function generateText(tokens: Token[]): Pick<TransformOutput, 'text' | 'mappings
for (const { name, loc } of tokens) {
text += 'interface Styles { readonly ';
// The span includes the quotes because go-to-definition works only when the quotes are mapped as well.
mappings.push([text.length, name.length + 2, loc.start.offset, name.length, SpanMapKind.Atom]);
// Rename is excluded because TypeScript writes rename edits back only through a Verbatim span.
mappings.push([
text.length,
name.length + 2,
loc.start.offset,
name.length,
SpanMapKind.Atom,
SpanMapFeature.All & ~SpanMapFeature.Rename,
]);
text += `'${name}': string }\n`;
}
text += 'declare const ';
// Generates a mapping so that Go to Definition on `styles` in `import styles from '...'`
// jumps to the start of the CSS file.
mappings.push([text.length, 'styles'.length, 0, 0, SpanMapKind.Atom, SpanMapFeature.Definition]);
text += `styles: ${tokens.length > 0 ? 'Styles' : '{}'};\n`;
// A reference to each token, which exists only to provide a Verbatim mapping that writes rename edits back.
for (const { name, loc } of tokens) {
text += "styles['";
mappings.push([
text.length,
name.length,
loc.start.offset,
name.length,
SpanMapKind.Verbatim,
SpanMapFeature.Rename,
]);
text += `${name}'];\n`;
}
text += 'export default styles;\n';
return { text, mappings };
}
Expand Down
Loading