diff --git a/.changeset/content-mapper-rename-symbol.md b/.changeset/content-mapper-rename-symbol.md new file mode 100644 index 00000000..89601074 --- /dev/null +++ b/.changeset/content-mapper-rename-symbol.md @@ -0,0 +1,5 @@ +--- +'@css-modules-kit/content-mapper': minor +--- + +feat: support Rename Symbol for tokens diff --git a/packages/content-mapper/README.md b/packages/content-mapper/README.md index 4a158836..9f6f1ca8 100644 --- a/packages/content-mapper/README.md +++ b/packages/content-mapper/README.md @@ -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 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..d62472fc --- /dev/null +++ b/packages/content-mapper/e2e-test/rename-symbol.test.ts @@ -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., and styles['']", 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.', getFileLocation('index.ts', 'a_1', 0)], + ["from the TS-side styles['']", 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.', 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.', 'index.ts', 0], + ["from the TS-side styles['']", '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' }); + } +}); diff --git a/packages/content-mapper/e2e-test/test-util/language-client.ts b/packages/content-mapper/e2e-test/test-util/language-client.ts index 8167c3c5..63341cf4 100644 --- a/packages/content-mapper/e2e-test/test-util/language-client.ts +++ b/packages/content-mapper/e2e-test/test-util/language-client.ts @@ -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; @@ -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; + documentChanges?: { textDocument: { uri: string }; edits: LSPTextEdit[] }[]; +} + +/** A thin client for the TypeScript language server. */ export interface LanguageClient { sendDidOpen(files: readonly string[]): Promise; sendDefinition(args: FileLocation): Promise; sendReferences(args: FileLocation): Promise; - /** - * 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; + sendRename(args: FileLocation, newName?: string): Promise; sendDocumentDiagnostic(file: string): Promise; } @@ -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) @@ -163,6 +187,24 @@ export async function launchLanguageClient(rootDir: string): Promise { + 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)" + `); +}); diff --git a/packages/content-mapper/src/test/render.ts b/packages/content-mapper/src/test/render.ts index c3b3af7d..b5bdc9a8 100644 --- a/packages/content-mapper/src/test/render.ts +++ b/packages/content-mapper/src/test/render.ts @@ -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('|')})`; } diff --git a/packages/content-mapper/src/transformer.test.ts b/packages/content-mapper/src/transformer.test.ts index 97cefc3c..86f68381 100644 --- a/packages/content-mapper/src/transformer.test.ts +++ b/packages/content-mapper/src/transformer.test.ts @@ -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; " `); @@ -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; " `); @@ -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; diff --git a/packages/content-mapper/src/transformer.ts b/packages/content-mapper/src/transformer.ts index 7ca08c7c..08303e2c 100644 --- a/packages/content-mapper/src/transformer.ts +++ b/packages/content-mapper/src/transformer.ts @@ -35,7 +35,15 @@ function generateText(tokens: Token[]): Pick 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 }; }