diff --git a/examples/1-basic/generated/src/a.module.css.d.ts b/examples/1-basic/generated/src/a.module.css.d.ts index cb4fa88e..2449b2e1 100644 --- a/examples/1-basic/generated/src/a.module.css.d.ts +++ b/examples/1-basic/generated/src/a.module.css.d.ts @@ -6,6 +6,7 @@ declare const styles = { a_2: '' as readonly string, a_3: '' as readonly string, a_4: '' as readonly string, + 'a-1': '' as readonly string, ...blockErrorType((await import('./b.module.css')).default), c_1: (await import('./c.module.css')).default.c_1, c_alias: (await import('./c.module.css')).default.c_2, diff --git a/examples/1-basic/src/a.module.css b/examples/1-basic/src/a.module.css index f530e445..75ac60f6 100644 --- a/examples/1-basic/src/a.module.css +++ b/examples/1-basic/src/a.module.css @@ -7,5 +7,7 @@ 100% { transform: translateY(-100%); } } +.a-1 { color: red; } + @import './b.module.css'; @value c_1, c_2 as c_alias from './c.module.css'; diff --git a/examples/1-basic/src/a.tsx b/examples/1-basic/src/a.tsx index 8f9f4d56..c1ae2be7 100644 --- a/examples/1-basic/src/a.tsx +++ b/examples/1-basic/src/a.tsx @@ -7,6 +7,7 @@ styles.a_4; styles.b_1; styles.b_2; styles.c_1; +styles['a-1']; styles.c_alias; styles.unknown; // Expected TS2339 error diff --git a/packages/codegen/e2e-test/index.test.ts b/packages/codegen/e2e-test/index.test.ts index bf3402cf..c19d3e71 100644 --- a/packages/codegen/e2e-test/index.test.ts +++ b/packages/codegen/e2e-test/index.test.ts @@ -48,7 +48,7 @@ test('generates .d.ts', async () => { "// @ts-nocheck function blockErrorType(val: T): [0] extends [(1 & T)] ? {} : T; declare const styles = { - a1: '' as readonly string, + 'a1': '' as readonly string, ...blockErrorType((await import('./b.module.css')).default), ...blockErrorType((await import('./unmatched.module.css')).default), }; @@ -58,7 +58,7 @@ test('generates .d.ts', async () => { expect(await iff.readFile('generated/src/b.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - b1: '' as readonly string, + 'b1': '' as readonly string, }; export default styles; " @@ -66,7 +66,7 @@ test('generates .d.ts', async () => { expect(await iff.readFile('generated/src/c.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - c1: '' as readonly string, + 'c1': '' as readonly string, }; export default styles; " @@ -158,7 +158,7 @@ test('generates .d.ts with circular import', async () => { "// @ts-nocheck function blockErrorType(val: T): [0] extends [(1 & T)] ? {} : T; declare const styles = { - a1: '' as readonly string, + 'a1': '' as readonly string, ...blockErrorType((await import('./b.module.css')).default), }; export default styles; @@ -168,7 +168,7 @@ test('generates .d.ts with circular import', async () => { "// @ts-nocheck function blockErrorType(val: T): [0] extends [(1 & T)] ? {} : T; declare const styles = { - b1: '' as readonly string, + 'b1': '' as readonly string, ...blockErrorType((await import('./a.module.css')).default), }; export default styles; @@ -178,7 +178,7 @@ test('generates .d.ts with circular import', async () => { "// @ts-nocheck function blockErrorType(val: T): [0] extends [(1 & T)] ? {} : T; declare const styles = { - c1: '' as readonly string, + 'c1': '' as readonly string, ...blockErrorType((await import('./c.module.css')).default), }; export default styles; diff --git a/packages/codegen/src/project.test.ts b/packages/codegen/src/project.test.ts index 5963b58d..bcf466ea 100644 --- a/packages/codegen/src/project.test.ts +++ b/packages/codegen/src/project.test.ts @@ -197,7 +197,7 @@ describe('updateFile', () => { expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - a_1: '' as readonly string, + 'a_1': '' as readonly string, }; export default styles; " @@ -537,7 +537,7 @@ describe('emitDtsFiles', () => { expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - a1: '' as readonly string, + 'a1': '' as readonly string, }; export default styles; " @@ -545,7 +545,7 @@ describe('emitDtsFiles', () => { expect(await iff.readFile('generated/src/b.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - b1: '' as readonly string, + 'b1': '' as readonly string, }; export default styles; " @@ -562,7 +562,7 @@ describe('emitDtsFiles', () => { expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - a1: '' as readonly string, + 'a1': '' as readonly string, }; export default styles; " diff --git a/packages/codegen/src/runner.test.ts b/packages/codegen/src/runner.test.ts index 7aedba0a..9d68063b 100644 --- a/packages/codegen/src/runner.test.ts +++ b/packages/codegen/src/runner.test.ts @@ -30,7 +30,7 @@ describe('runCMK', () => { expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - a_1: '' as readonly string, + 'a_1': '' as readonly string, }; export default styles; " @@ -38,7 +38,7 @@ describe('runCMK', () => { expect(await iff.readFile('generated/src/b.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - b_1: '' as readonly string, + 'b_1': '' as readonly string, }; export default styles; " @@ -133,7 +133,7 @@ describe('runCMKInWatchMode', () => { expect(await iff.readFile('generated/src/a.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - a_1: '' as readonly string, + 'a_1': '' as readonly string, }; export default styles; " @@ -141,7 +141,7 @@ describe('runCMKInWatchMode', () => { expect(await iff.readFile('generated/src/b.module.css.d.ts')).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - b_1: '' as readonly string, + 'b_1': '' as readonly string, }; export default styles; " diff --git a/packages/core/src/checker.test.ts b/packages/core/src/checker.test.ts index a21a4b7f..5c934f00 100644 --- a/packages/core/src/checker.test.ts +++ b/packages/core/src/checker.test.ts @@ -51,16 +51,6 @@ describe('checkCSSModule', () => { const diagnostics = check(readAndParseCSSModule(iff.paths['a.module.css'])!); expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(` [ - { - "category": "error", - "fileName": "/a.module.css", - "length": 3, - "start": { - "column": 2, - "line": 1, - }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", - }, { "category": "error", "fileName": "/a.module.css", @@ -69,7 +59,7 @@ describe('checkCSSModule', () => { "column": 8, "line": 2, }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", + "text": "css-modules-kit does not support invalid names as JavaScript identifiers when \`cmkOptions.namedExports\` is set to \`true\`.", }, { "category": "error", @@ -79,7 +69,7 @@ describe('checkCSSModule', () => { "column": 13, "line": 2, }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", + "text": "css-modules-kit does not support invalid names as JavaScript identifiers when \`cmkOptions.namedExports\` is set to \`true\`.", }, { "category": "error", @@ -89,7 +79,7 @@ describe('checkCSSModule', () => { "column": 20, "line": 2, }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", + "text": "css-modules-kit does not support invalid names as JavaScript identifiers when \`cmkOptions.namedExports\` is set to \`true\`.", }, ] `); diff --git a/packages/core/src/checker.ts b/packages/core/src/checker.ts index d9c2023a..bd8afe92 100644 --- a/packages/core/src/checker.ts +++ b/packages/core/src/checker.ts @@ -26,7 +26,7 @@ export function checkCSSModule(cssModule: CSSModule, args: CheckerArgs): Diagnos for (const token of cssModule.localTokens) { // Reject special names as they may break .d.ts files - if (!isValidAsJSIdentifier(token.name)) { + if (config.namedExports && !isValidAsJSIdentifier(token.name)) { diagnostics.push(createInvalidNameAsJSIdentifiersDiagnostic(cssModule, token.loc)); } if (token.name === '__proto__') { @@ -106,7 +106,7 @@ function createModuleHasNoExportedTokenDiagnostic( function createInvalidNameAsJSIdentifiersDiagnostic(cssModule: CSSModule, loc: Location): Diagnostic { return { - text: `css-modules-kit does not support invalid names as JavaScript identifiers.`, + text: `css-modules-kit does not support invalid names as JavaScript identifiers when \`cmkOptions.namedExports\` is set to \`true\`.`, category: 'error', file: { fileName: cssModule.fileName, text: cssModule.text }, start: { line: loc.start.line, column: loc.start.column }, diff --git a/packages/core/src/dts-generator.test.ts b/packages/core/src/dts-generator.test.ts index f2de2c39..b2954477 100644 --- a/packages/core/src/dts-generator.test.ts +++ b/packages/core/src/dts-generator.test.ts @@ -33,8 +33,8 @@ describe('generateDts', () => { expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - local1: '' as readonly string, - local2: '' as readonly string, + 'local1': '' as readonly string, + 'local2': '' as readonly string, }; export default styles; " @@ -85,6 +85,7 @@ describe('generateDts', () => { expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { + 'a-1': '' as readonly string, }; export default styles; " @@ -115,7 +116,7 @@ describe('generateDts', () => { .toMatchInlineSnapshot(` "// @ts-nocheck declare const styles = { - default: '' as readonly string, + 'default': '' as readonly string, }; export default styles; " diff --git a/packages/core/src/dts-generator.ts b/packages/core/src/dts-generator.ts index 02236055..11fd1d66 100644 --- a/packages/core/src/dts-generator.ts +++ b/packages/core/src/dts-generator.ts @@ -17,6 +17,8 @@ interface CodeMapping { lengths: number[]; /** The generated offsets of the tokens in the *.d.ts file. */ generatedOffsets: number[]; + /** The lengths of the tokens in the *.d.ts file. */ + generatedLengths?: number[]; } /** The map linking the two codes in *.d.ts */ @@ -36,6 +38,8 @@ interface GenerateDtsResult { text: string; mapping: CodeMapping; linkedCodeMapping: LinkedCodeMapping; + /** Additional mappings used by ts-plugin (e.g. quoted string literal keys). */ + extraMappings?: CodeMapping[]; } /** @@ -43,7 +47,7 @@ interface GenerateDtsResult { */ export function generateDts(cssModule: CSSModule, options: GenerateDtsOptions): GenerateDtsResult { // Exclude invalid tokens - const localTokens = cssModule.localTokens.filter((token) => isValidName(token.name, options)); + const localTokens = cssModule.localTokens.filter((token) => isValidName(token.name, options, false)); const tokenImporters = cssModule.tokenImporters // Exclude invalid imported tokens .map((tokenImporter) => { @@ -52,8 +56,8 @@ export function generateDts(cssModule: CSSModule, options: GenerateDtsOptions): ...tokenImporter, values: tokenImporter.values.filter( (value) => - isValidName(value.name, options) && - (value.localName === undefined || isValidName(value.localName, options)), + isValidName(value.name, options, true) && + (value.localName === undefined || isValidName(value.localName, options, true)), ), }; } else { @@ -111,7 +115,7 @@ function generateNamedExportsDts( localTokens: Token[], tokenImporters: TokenImporter[], options: GenerateDtsOptions, -): { text: string; mapping: CodeMapping; linkedCodeMapping: LinkedCodeMapping } { +): GenerateDtsResult { const mapping: CodeMapping = { sourceOffsets: [], lengths: [], generatedOffsets: [] }; const linkedCodeMapping: LinkedCodeMapping = { sourceOffsets: [], @@ -261,11 +265,9 @@ function generateNamedExportsDts( } /** Generate a d.ts file with a default export. */ -function generateDefaultExportDts( - localTokens: Token[], - tokenImporters: TokenImporter[], -): { text: string; mapping: CodeMapping; linkedCodeMapping: LinkedCodeMapping } { - const mapping: CodeMapping = { sourceOffsets: [], lengths: [], generatedOffsets: [] }; +function generateDefaultExportDts(localTokens: Token[], tokenImporters: TokenImporter[]): GenerateDtsResult { + const mapping: CodeMapping = { sourceOffsets: [], lengths: [], generatedOffsets: [], generatedLengths: [] }; + const quotedMapping: CodeMapping = { sourceOffsets: [], lengths: [], generatedOffsets: [], generatedLengths: [] }; const linkedCodeMapping: LinkedCodeMapping = { sourceOffsets: [], lengths: [], @@ -313,10 +315,25 @@ function generateDefaultExportDts( */ text += ` `; + const quoteStart = text.length; + text += `'`; + const keyStart = text.length; + // Map unquoted range in the primary mapping. + // This mapping is necessary when renaming. + // For rename, tsserver tends to return a span without quotes, + // while for go to definition, the span tends to include quotes. + // This is why we keep a dual mapping. mapping.sourceOffsets.push(token.loc.start.offset); - mapping.generatedOffsets.push(text.length); mapping.lengths.push(token.name.length); - text += `${token.name}: '' as readonly string,\n`; + mapping.generatedOffsets.push(keyStart); + mapping.generatedLengths!.push(token.name.length); + // Map quoted range separately to avoid overlapping ranges in a single mapping. + // This mapping is necessary for features like "go to definition". + quotedMapping.sourceOffsets.push(token.loc.start.offset); + quotedMapping.lengths.push(token.name.length); + quotedMapping.generatedOffsets.push(quoteStart); + quotedMapping.generatedLengths!.push(token.name.length + 2); + text += `${token.name}': '' as readonly string,\n`; } for (const tokenImporter of tokenImporters) { if (tokenImporter.type === 'import') { @@ -347,6 +364,7 @@ function generateDefaultExportDts( mapping.sourceOffsets.push(tokenImporter.fromLoc.start.offset - 1); mapping.lengths.push(tokenImporter.from.length + 2); mapping.generatedOffsets.push(text.length); + mapping.generatedLengths!.push(tokenImporter.from.length + 2); text += `'${tokenImporter.from}')).default),\n`; } else { /** @@ -393,6 +411,7 @@ function generateDefaultExportDts( mapping.sourceOffsets.push(localLoc.start.offset); mapping.lengths.push(localName.length); mapping.generatedOffsets.push(text.length); + mapping.generatedLengths!.push(localName.length); linkedCodeMapping.sourceOffsets.push(text.length); linkedCodeMapping.lengths.push(localName.length); text += `${localName}: (await import(`; @@ -400,12 +419,14 @@ function generateDefaultExportDts( mapping.sourceOffsets.push(tokenImporter.fromLoc.start.offset - 1); mapping.lengths.push(tokenImporter.from.length + 2); mapping.generatedOffsets.push(text.length); + mapping.generatedLengths!.push(tokenImporter.from.length + 2); } text += `'${tokenImporter.from}')).default.`; if ('localName' in value) { mapping.sourceOffsets.push(value.loc.start.offset); mapping.lengths.push(value.name.length); mapping.generatedOffsets.push(text.length); + mapping.generatedLengths!.push(value.name.length); } linkedCodeMapping.generatedOffsets.push(text.length); linkedCodeMapping.generatedLengths.push(value.name.length); @@ -414,11 +435,14 @@ function generateDefaultExportDts( } } text += `};\nexport default ${STYLES_EXPORT_NAME};\n`; + if (quotedMapping.sourceOffsets.length) { + return { text, mapping, linkedCodeMapping, extraMappings: [quotedMapping] }; + } return { text, mapping, linkedCodeMapping }; } -function isValidName(name: string, options: GenerateDtsOptions): boolean { - if (!isValidAsJSIdentifier(name)) return false; +function isValidName(name: string, options: GenerateDtsOptions, isTokenImport: boolean): boolean { + if ((options.namedExports || isTokenImport) && !isValidAsJSIdentifier(name)) return false; if (name === '__proto__') return false; if (options.namedExports && name === 'default') return false; return true; diff --git a/packages/ts-plugin/e2e-test/feature/go-to-definition.test.ts b/packages/ts-plugin/e2e-test/feature/go-to-definition.test.ts index 309308f4..a94b83b5 100644 --- a/packages/ts-plugin/e2e-test/feature/go-to-definition.test.ts +++ b/packages/ts-plugin/e2e-test/feature/go-to-definition.test.ts @@ -14,6 +14,7 @@ describe('Go to Definition', async () => { styles.b_1; styles.c_1; styles.c_alias; + styles['d-1']; `, 'a.module.css': dedent` @import './b.module.css'; @@ -23,6 +24,7 @@ describe('Go to Definition', async () => { .a_2 { color: red; } @value a_3: red; @import url(./b.module.css); + .d-1 { color: red; } `, 'b.module.css': dedent` .b_1 { color: red; } @@ -300,6 +302,27 @@ describe('Go to Definition', async () => { { file: formatPath(iff.paths['b.module.css']), start: { line: 1, offset: 1 }, end: { line: 1, offset: 1 } }, ], }, + { + name: 'd-1 in index.ts', + file: iff.paths['index.ts'], + line: 8, + offset: 8, + expected: [ + { + file: formatPath(iff.paths['a.module.css']), + contextStart: { + line: 8, + offset: 1, + }, + contextEnd: { + line: 8, + offset: 21, + }, + start: { line: 8, offset: 2 }, + end: { line: 8, offset: 5 }, + }, + ], + }, ])('Go to Definition for $name', async ({ file, line, offset, expected }) => { const res = await tsserver.sendDefinitionAndBoundSpan({ file, diff --git a/packages/ts-plugin/e2e-test/feature/rename-symbol.test.ts b/packages/ts-plugin/e2e-test/feature/rename-symbol.test.ts index ee6ce5a1..0d022634 100644 --- a/packages/ts-plugin/e2e-test/feature/rename-symbol.test.ts +++ b/packages/ts-plugin/e2e-test/feature/rename-symbol.test.ts @@ -15,6 +15,7 @@ describe('Rename Symbol', async () => { styles.c_1; styles.c_alias; styles.d_1; + styles['e-1']; `, 'a.module.css': dedent` @import './b.module.css'; @@ -22,6 +23,7 @@ describe('Rename Symbol', async () => { .a_1 { color: red; } .a_1 { color: red; } @value a_2: red; + .e-1 { color: red; } `, 'b.module.css': dedent` .b_1 { color: red; } @@ -385,6 +387,22 @@ describe('Rename Symbol', async () => { }, ], }, + { + name: 'e-1 in index.ts', + file: iff.paths['index.ts'], + line: 9, + offset: 9, + expected: [ + { + file: formatPath(iff.paths['index.ts']), + locs: [{ start: { line: 9, offset: 9 }, end: { line: 9, offset: 12 } }], + }, + { + file: formatPath(iff.paths['a.module.css']), + locs: [{ start: { line: 6, offset: 2 }, end: { line: 6, offset: 5 } }], + }, + ], + }, ])('Rename Symbol for $name', async ({ file, line, offset, expected }) => { const res = await tsserver.sendRename({ file, diff --git a/packages/ts-plugin/e2e-test/feature/semantic-diagnostics.test.ts b/packages/ts-plugin/e2e-test/feature/semantic-diagnostics.test.ts index a0df2008..d1283d04 100644 --- a/packages/ts-plugin/e2e-test/feature/semantic-diagnostics.test.ts +++ b/packages/ts-plugin/e2e-test/feature/semantic-diagnostics.test.ts @@ -8,7 +8,7 @@ test('Semantic Diagnostics', async () => { const iff = await createIFF({ 'index.ts': dedent` import styles from './a.module.css'; - type Expected = { a_1: string, a_2: string, b_1: string, c_1: string, c_alias: string, c_3: string }; + type Expected = { a_1: string, a_2: string, b_1: string, c_1: string, c_alias: string, c_3: string, 'a-3': string }; const t1: Expected = styles; const t2: typeof styles = t1; styles.unknown; @@ -55,7 +55,7 @@ test('Semantic Diagnostics', async () => { "line": 5, "offset": 8, }, - "text": "Property 'unknown' does not exist on type '{ c_1: string; c_alias: string; c_3: any; b_1: string; a_1: string; a_2: string; }'.", + "text": "Property 'unknown' does not exist on type '{ c_1: string; c_alias: string; c_3: any; b_1: string; a_1: string; a_2: string; 'a-3': string; }'.", }, ] `); @@ -65,20 +65,6 @@ test('Semantic Diagnostics', async () => { }); expect(res2.body).toMatchInlineSnapshot(` [ - { - "category": "error", - "code": 0, - "end": { - "line": 5, - "offset": 5, - }, - "source": "css-modules-kit", - "start": { - "line": 5, - "offset": 2, - }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", - }, { "category": "error", "code": 0, diff --git a/packages/ts-plugin/e2e-test/file-operation.test.ts b/packages/ts-plugin/e2e-test/file-operation.test.ts index 8af59b7e..29288546 100644 --- a/packages/ts-plugin/e2e-test/file-operation.test.ts +++ b/packages/ts-plugin/e2e-test/file-operation.test.ts @@ -137,24 +137,7 @@ test('updating file', async () => { const res2 = await tsserver.sendSemanticDiagnosticsSync({ file: iff.paths['a.module.css'], }); - expect(res2.body).toMatchInlineSnapshot(` - [ - { - "category": "error", - "code": 0, - "end": { - "line": 2, - "offset": 5, - }, - "source": "css-modules-kit", - "start": { - "line": 2, - "offset": 2, - }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", - }, - ] - `); + expect(res2.body).toMatchInlineSnapshot(`[]`); // The diagnostics of files importing a.module.css are updated. const res3 = await tsserver.sendSemanticDiagnosticsSync({ diff --git a/packages/ts-plugin/src/language-plugin.ts b/packages/ts-plugin/src/language-plugin.ts index adc8463d..97c7eba3 100644 --- a/packages/ts-plugin/src/language-plugin.ts +++ b/packages/ts-plugin/src/language-plugin.ts @@ -52,7 +52,10 @@ export function createCSSLanguagePlugin( keyframes: config.keyframes, }); // eslint-disable-next-line prefer-const - let { text, mapping, linkedCodeMapping } = generateDts(cssModule, { ...config, forTsPlugin: true }); + let { text, mapping, linkedCodeMapping, extraMappings } = generateDts(cssModule, { + ...config, + forTsPlugin: true, + }); return { id: 'main', languageId: 'typescript', @@ -62,7 +65,10 @@ export function createCSSLanguagePlugin( getChangeRange: () => undefined, }, // `mappings` are required to support "Go to Definition" and renaming - mappings: [{ ...mapping, data: { navigation: true } }], + mappings: [ + { ...mapping, data: { navigation: true } }, + ...(extraMappings?.map((m) => ({ ...m, data: { navigation: true } })) ?? []), + ], // `linkedCodeMappings` are required to support "Go to Definition" and renaming for the imported tokens linkedCodeMappings: [{ ...linkedCodeMapping, data: undefined }], [CMK_DATA_KEY]: cssModule, diff --git a/packages/ts-plugin/src/language-service/feature/definition-and-bound-span.ts b/packages/ts-plugin/src/language-service/feature/definition-and-bound-span.ts index 366be4d9..8500eb9f 100644 --- a/packages/ts-plugin/src/language-service/feature/definition-and-bound-span.ts +++ b/packages/ts-plugin/src/language-service/feature/definition-and-bound-span.ts @@ -16,9 +16,12 @@ export function getDefinitionAndBoundSpan( const cssModule = script.generated.root[CMK_DATA_KEY]; + // The type definition is a string literal (single quotes), so remove them to match the classname. + const normalizedName = normalizeQuotedName(def.name); + // Search tokens and set `contextSpan`. `contextSpan` is used for Definition Preview in editors. const localToken = cssModule.localTokens.find( - (t) => t.name === def.name && t.loc.start.offset === def.textSpan.start, + (t) => t.name === normalizedName && t.loc.start.offset === def.textSpan.start, ); if (localToken?.declarationLoc) { def.contextSpan = { @@ -32,7 +35,7 @@ export function getDefinitionAndBoundSpan( .find((v) => { const localName = v.localName ?? v.name; const localLoc = v.localLoc ?? v.loc; - return localName === def.name && localLoc.start.offset === def.textSpan.start; + return localName === normalizedName && localLoc.start.offset === def.textSpan.start; }); if (importedValue) { const loc = importedValue.localLoc ?? importedValue.loc; @@ -45,3 +48,10 @@ export function getDefinitionAndBoundSpan( return result; }; } + +function normalizeQuotedName(name: string) { + if (name.length >= 2 && name.startsWith("'") && name.endsWith("'")) { + return name.slice(1, -1); + } + return name; +} diff --git a/packages/ts-plugin/src/language-service/feature/references.ts b/packages/ts-plugin/src/language-service/feature/references.ts new file mode 100644 index 00000000..84b0c69c --- /dev/null +++ b/packages/ts-plugin/src/language-service/feature/references.ts @@ -0,0 +1,46 @@ +import { isCSSModuleFile } from '@css-modules-kit/core'; +import type ts from 'typescript'; + +export function findReferences(languageService: ts.LanguageService): ts.LanguageService['findReferences'] { + return (...args) => { + const [fileName] = args; + const result = languageService.findReferences(...args); + if (!result) return result; + const isCssRequest = isCSSModuleFile(fileName); + const sharedSeen = isCssRequest ? new Set() : undefined; + return result.map((symbol) => { + const references = dedupeReferences(symbol.references, sharedSeen); + return { + ...symbol, + references, + }; + }); + }; +} + +export function getReferencesAtPosition( + languageService: ts.LanguageService, +): ts.LanguageService['getReferencesAtPosition'] { + return (...args) => { + const [fileName] = args; + const result = languageService.getReferencesAtPosition(...args); + if (!result) return result; + if (isCSSModuleFile(fileName)) { + return dedupeReferences(result); + } + return result; + }; +} + +/** + * DTS and CSS mappings are duplicated, + * so CSS references may duplicate when fetching the list from a CSS file. + */ +function dedupeReferences(references: readonly ts.ReferenceEntry[], seen = new Set()) { + return references.filter((ref) => { + const key = `${ref.fileName}:${ref.textSpan.start}:${ref.textSpan.length}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/packages/ts-plugin/src/language-service/proxy.ts b/packages/ts-plugin/src/language-service/proxy.ts index 35a57367..ee653d8f 100644 --- a/packages/ts-plugin/src/language-service/proxy.ts +++ b/packages/ts-plugin/src/language-service/proxy.ts @@ -7,6 +7,7 @@ import { getCodeFixesAtPosition } from './feature/code-fix.js'; import { getCompletionEntryDetails, getCompletionsAtPosition } from './feature/completion.js'; import { getDefinitionAndBoundSpan } from './feature/definition-and-bound-span.js'; import { getApplicableRefactors, getEditsForRefactor } from './feature/refactor.js'; +import { findReferences, getReferencesAtPosition } from './feature/references.js'; import { getSemanticDiagnostics } from './feature/semantic-diagnostic.js'; import { getSyntacticDiagnostics } from './feature/syntactic-diagnostic.js'; @@ -52,6 +53,8 @@ export function proxyLanguageService( proxy.getCompletionEntryDetails = getCompletionEntryDetails(languageService, resolver, config); proxy.getCodeFixesAtPosition = getCodeFixesAtPosition(language, languageService, project, resolver, config); proxy.getDefinitionAndBoundSpan = getDefinitionAndBoundSpan(language, languageService); + proxy.findReferences = findReferences(languageService); + proxy.getReferencesAtPosition = getReferencesAtPosition(languageService); return proxy; }