diff --git a/.changeset/easy-breads-chew.md b/.changeset/easy-breads-chew.md new file mode 100644 index 00000000..25623ba2 --- /dev/null +++ b/.changeset/easy-breads-chew.md @@ -0,0 +1,6 @@ +--- +'@css-modules-kit/ts-plugin': minor +'@css-modules-kit/core': minor +--- + +feat(core, ts-plugin): support non-JavaScript identifier token in default export diff --git a/README.md b/README.md index 6f9453e4..8aaf29ed 100644 --- a/README.md +++ b/README.md @@ -231,9 +231,11 @@ Due to implementation constraints and technical reasons, css-modules-kit has var - Sass and Less are not supported. - If you want to use Sass and Less, please use [happy-css-modules](https://github.com/mizdra/happy-css-modules). Although it does not offer as rich language features as css-modules-kit, it provides basic features such as code completion and Go to Definition. -- The name of classes, `@value`, and `@keyframes` must be valid JavaScript identifiers. +- Case conversion for [token](docs/glossary.md#token) names is not supported. + - For example, if you have a CSS class `.foo-bar`, it will be exported as `styles['foo-bar']`, not `styles.fooBar` or `styles.foo_bar`. +- The [token](docs/glossary.md#token) names must be valid JavaScript identifiers when `cmkOptions.namedExports` is `true`. - For example, `.fooBar` and `.foo_bar` are supported, but `.foo-bar` is not supported. - - See [#176](https://github.com/mizdra/css-modules-kit/issues/176) for more details. + - This restriction may be lifted in the future. - The specifiers in `@import ''` and `@value ... from ''` are resolved according to TypeScript's module resolution method. - This may differ from the resolution methods of bundlers like Turbopack or Vite. - If you want to use import aliases, use [`compilerOptions.paths`](https://www.typescriptlang.org/tsconfig/#paths) or [`imports`](https://nodejs.org/api/packages.html#imports) in `package.json`. 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..48284261 100644 --- a/examples/1-basic/generated/src/a.module.css.d.ts +++ b/examples/1-basic/generated/src/a.module.css.d.ts @@ -1,13 +1,13 @@ // @ts-nocheck function blockErrorType(val: T): [0] extends [(1 & T)] ? {} : T; declare const styles = { - a_1: '' as readonly string, - a_2: '' as readonly string, - a_2: '' as readonly string, - a_3: '' as readonly string, - a_4: '' as readonly string, + 'a_1': '' as readonly string, + 'a_2': '' as readonly string, + 'a_2': '' as readonly string, + 'a_3': '' as readonly string, + 'a_4': '' 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, + 'c_1': (await import('./c.module.css')).default['c_1'], + 'c_alias': (await import('./c.module.css')).default['c_2'], }; export default styles; diff --git a/examples/1-basic/generated/src/b.module.css.d.ts b/examples/1-basic/generated/src/b.module.css.d.ts index 63416ea8..4d067d88 100644 --- a/examples/1-basic/generated/src/b.module.css.d.ts +++ b/examples/1-basic/generated/src/b.module.css.d.ts @@ -1,6 +1,6 @@ // @ts-nocheck declare const styles = { - b_1: '' as readonly string, - b_2: '' as readonly string, + 'b_1': '' as readonly string, + 'b_2': '' as readonly string, }; export default styles; diff --git a/examples/1-basic/generated/src/c.module.css.d.ts b/examples/1-basic/generated/src/c.module.css.d.ts index 83129be6..4825968c 100644 --- a/examples/1-basic/generated/src/c.module.css.d.ts +++ b/examples/1-basic/generated/src/c.module.css.d.ts @@ -1,6 +1,6 @@ // @ts-nocheck declare const styles = { - c_1: '' as readonly string, - c_2: '' as readonly string, + 'c_1': '' as readonly string, + 'c_2': '' as readonly string, }; export default styles; diff --git a/examples/3-import-alias/generated/src/b.module.css.d.ts b/examples/3-import-alias/generated/src/b.module.css.d.ts index 59ae3188..8d713870 100644 --- a/examples/3-import-alias/generated/src/b.module.css.d.ts +++ b/examples/3-import-alias/generated/src/b.module.css.d.ts @@ -1,5 +1,5 @@ // @ts-nocheck declare const styles = { - b_1: '' as readonly string, + 'b_1': '' as readonly string, }; export default styles; 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..888ea99b 100644 --- a/packages/core/src/checker.test.ts +++ b/packages/core/src/checker.test.ts @@ -36,7 +36,7 @@ function prepareChecker(args?: Partial): Checker { } describe('checkCSSModule', () => { - test('report diagnostics for invalid name as js identifier', async () => { + test('do not report diagnostics for invalid name as js identifier when namedExports is false', async () => { const iff = await createIFF({ 'a.module.css': dedent` .a-1 { color: red; } @@ -49,6 +49,21 @@ describe('checkCSSModule', () => { }); const check = prepareChecker(); const diagnostics = check(readAndParseCSSModule(iff.paths['a.module.css'])!); + expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(`[]`); + }); + test('report diagnostics for invalid name as js identifier when namedExports is true', async () => { + const iff = await createIFF({ + 'a.module.css': dedent` + .a-1 { color: red; } + @value b-1, b-2 as a-2 from './b.module.css'; + `, + 'b.module.css': dedent` + @value b-1: red; + @value b-2: red; + `, + }); + const check = prepareChecker({ config: fakeConfig({ namedExports: true }) }); + const diagnostics = check(readAndParseCSSModule(iff.paths['a.module.css'])!); expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(` [ { @@ -59,7 +74,7 @@ describe('checkCSSModule', () => { "column": 2, "line": 1, }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", + "text": "Token names must be valid JavaScript identifiers when \`cmkOptions.namedExports\` is set to \`true\`.", }, { "category": "error", @@ -69,7 +84,7 @@ describe('checkCSSModule', () => { "column": 8, "line": 2, }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", + "text": "Token names must be valid JavaScript identifiers when \`cmkOptions.namedExports\` is set to \`true\`.", }, { "category": "error", @@ -79,7 +94,7 @@ describe('checkCSSModule', () => { "column": 13, "line": 2, }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", + "text": "Token names must be valid JavaScript identifiers when \`cmkOptions.namedExports\` is set to \`true\`.", }, { "category": "error", @@ -89,7 +104,7 @@ describe('checkCSSModule', () => { "column": 20, "line": 2, }, - "text": "css-modules-kit does not support invalid names as JavaScript identifiers.", + "text": "Token names must be valid JavaScript identifiers when \`cmkOptions.namedExports\` is set to \`true\`.", }, ] `); @@ -190,6 +205,31 @@ describe('checkCSSModule', () => { ] `); }); + test('report diagnostics for backslash in name when namedExports is false', async () => { + // NOTE: The backslash is valid syntax in class selectors, but it is invalid syntax in `@value`. + // Therefore, it is sufficient for diagnostics to be reported only for class selectors. + const iff = await createIFF({ + 'a.module.css': dedent` + .a\\1 { color: red; } + `, + }); + const check = prepareChecker(); + const diagnostics = check(readAndParseCSSModule(iff.paths['a.module.css'])!); + expect(formatDiagnostics(diagnostics, iff.rootDir)).toMatchInlineSnapshot(` + [ + { + "category": "error", + "fileName": "/a.module.css", + "length": 4, + "start": { + "column": 2, + "line": 1, + }, + "text": "Backslash (\\) is not allowed in names when \`cmkOptions.namedExports\` is set to \`false\`.", + }, + ] + `); + }); test('report diagnostics for non-exported token', async () => { const iff = await createIFF({ 'a.module.css': `@value b_1, b_2 from './b.module.css';`, diff --git a/packages/core/src/checker.ts b/packages/core/src/checker.ts index 74994cde..c81d9f47 100644 --- a/packages/core/src/checker.ts +++ b/packages/core/src/checker.ts @@ -69,7 +69,7 @@ function createTokenNameDiagnostic(cssModule: CSSModule, loc: Location, violatio let text: string; switch (violation) { case 'invalid-js-identifier': - text = `css-modules-kit does not support invalid names as JavaScript identifiers.`; + text = `Token names must be valid JavaScript identifiers when \`cmkOptions.namedExports\` is set to \`true\`.`; break; case 'proto-not-allowed': text = `\`__proto__\` is not allowed as names.`; @@ -77,6 +77,9 @@ function createTokenNameDiagnostic(cssModule: CSSModule, loc: Location, violatio case 'default-not-allowed': text = `\`default\` is not allowed as names when \`cmkOptions.namedExports\` is set to \`true\`.`; break; + case 'backslash-not-allowed': + text = `Backslash (\\) is not allowed in names when \`cmkOptions.namedExports\` is set to \`false\`.`; + break; default: throw new Error('unreachable: unknown TokenNameViolation'); } diff --git a/packages/core/src/dts-generator.test.ts b/packages/core/src/dts-generator.test.ts index c91217d4..45d3af11 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; " @@ -52,8 +52,8 @@ describe('generateDts', () => { function blockErrorType(val: T): [0] extends [(1 & T)] ? {} : T; declare const styles = { ...blockErrorType((await import('./a.module.css')).default), - imported1: (await import('./b.module.css')).default.imported1, - aliasedImported2: (await import('./b.module.css')).default.imported2, + 'imported1': (await import('./b.module.css')).default['imported1'], + 'aliasedImported2': (await import('./b.module.css')).default['imported2'], }; export default styles; " @@ -77,9 +77,9 @@ describe('generateDts', () => { test('does not generate types for invalid name', async () => { const iff = await createIFF({ 'test.module.css': dedent` - .a-1 { color: red; } - @value b-1 from './b.module.css'; - @value b_2 as a-2 from './b.module.css'; + .__proto__ { color: red; } + @value __proto__ from './b.module.css'; + @value b_1 as __proto__ from './b.module.css'; `, }); expect(generateDts(readAndParseCSSModule(iff.paths['test.module.css'])!, options).text).toMatchInlineSnapshot(` diff --git a/packages/core/src/dts-generator.ts b/packages/core/src/dts-generator.ts index 106a1e38..6e65d809 100644 --- a/packages/core/src/dts-generator.ts +++ b/packages/core/src/dts-generator.ts @@ -18,6 +18,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. If not provided, it is assumed to be the same as `lengths`. */ + generatedLengths?: number[]; } /** The map linking the two codes in *.d.ts */ @@ -36,6 +38,7 @@ interface LinkedCodeMapping extends CodeMapping { interface GenerateDtsResult { text: string; mapping: CodeMapping; + secondaryMapping?: CodeMapping; linkedCodeMapping: LinkedCodeMapping; } @@ -265,8 +268,24 @@ function generateNamedExportsDts( function generateDefaultExportDts( localTokens: Token[], tokenImporters: TokenImporter[], -): { text: string; mapping: CodeMapping; linkedCodeMapping: LinkedCodeMapping } { +): { + text: string; + mapping: CodeMapping; + secondaryMapping: CodeMapping; + linkedCodeMapping: LinkedCodeMapping; +} { const mapping: CodeMapping = { sourceOffsets: [], lengths: [], generatedOffsets: [] }; + /** + * In "Go to Definition", mapping only the inner part of the quotes does not work. + * Therefore, we also generate a mapping that includes the quotes. + * For more details, see https://github.com/mizdra/volar-single-quote-span-problem. + */ + const secondaryMapping: CodeMapping & { generatedLengths: number[] } = { + sourceOffsets: [], + lengths: [], + generatedOffsets: [], + generatedLengths: [], + }; const linkedCodeMapping: LinkedCodeMapping = { sourceOffsets: [], lengths: [], @@ -296,28 +315,34 @@ function generateDefaultExportDts( * The mapping is created as follows: * a.module.css: * 1 | .a_1 { color: red; } - * | ^ mapping.sourceOffsets[0] + * | ^ mapping.sourceOffsets[0], secondaryMapping.sourceOffsets[0] * | * 2 | .a_2 { color: blue; } - * | ^ mapping.sourceOffsets[1] + * | ^ mapping.sourceOffsets[1], secondaryMapping.sourceOffsets[1] * | * * a.module.css.d.ts: * 1 | declare const styles = { - * 2 | a_1: '' as readonly string, - * | ^ mapping.generatedOffsets[0] + * 2 | 'a_1': '' as readonly string, + * | ^^ mapping.generatedOffsets[0] + * | ^ secondaryMapping.generatedOffsets[0] * | - * 3 | a_2: '' as readonly string, - * | ^ mapping.generatedOffsets[1] + * 3 | 'a_2': '' as readonly string, + * | ^^ mapping.generatedOffsets[1] + * | ^ secondaryMapping.generatedOffsets[1] * | * 4 | }; */ - text += ` `; + text += ` '`; 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(text.length); + secondaryMapping.sourceOffsets.push(token.loc.start.offset); + secondaryMapping.lengths.push(token.name.length); + secondaryMapping.generatedOffsets.push(text.length - 1); + secondaryMapping.generatedLengths.push(token.name.length + 2); + text += `${token.name}': '' as readonly string,\n`; } for (const tokenImporter of tokenImporters) { if (tokenImporter.type === 'import') { @@ -354,31 +379,35 @@ function generateDefaultExportDts( * The mapping is created as follows: * a.module.css: * 1 | @value b_1, b_2 from './b.module.css'; - * | ^ ^ ^ mapping.sourceOffsets[0] - * | ^ ^ mapping.sourceOffsets[2] - * | ^ mapping.sourceOffsets[1] + * | ^ ^ ^ mapping.sourceOffsets[1] + * | ^ ^ mapping.sourceOffsets[2], secondaryMapping.sourceOffsets[1] + * | ^ mapping.sourceOffsets[0], secondaryMapping.sourceOffsets[0] * | * 2 | @value c_1 as aliased_c_1 from './c.module.css'; * | ^ ^ ^ mapping.sourceOffsets[4] - * | ^ ^ mapping.sourceOffsets[3] - * | ^ mapping.sourceOffsets[5] + * | ^ ^ mapping.sourceOffsets[3], secondaryMapping.sourceOffsets[2] + * | ^ mapping.sourceOffsets[5], secondaryMapping.sourceOffsets[3] * | * * a.module.css.d.ts: * 1 | declare const styles = { - * 2 | b_1: (await import('./b.module.css')).default.b_1, - * | ^ ^ ^ linkedCodeMapping.generatedOffsets[0] - * | ^ ^ mapping.generatedOffsets[1] - * | ^ mapping.generatedOffsets[0], linkedCodeMapping.sourceOffsets[0] + * 2 | 'b_1': (await import('./b.module.css')).default['b_1'], + * | ^^ ^ ^ linkedCodeMapping.generatedOffsets[0] + * | ^^ ^ mapping.generatedOffsets[1] + * | ^^ mapping.generatedOffsets[0] + * | ^ secondaryMapping.generatedOffsets[0], linkedCodeMapping.sourceOffsets[0] * | - * 3 | b_2: (await import('./b.module.css')).default.b_2, - * | ^ ^ linkedCodeMapping.generatedOffsets[1] - * | ^ mapping.generatedOffsets[2], linkedCodeMapping.sourceOffsets[1] + * 3 | 'b_2': (await import('./b.module.css')).default['b_2'], + * | ^^ ^ linkedCodeMapping.generatedOffsets[1] + * | ^^ mapping.generatedOffsets[2] + * | ^ secondaryMapping.generatedOffsets[1], linkedCodeMapping.sourceOffsets[1] * | - * 4 | aliased_c_1: (await import('./c.module.css')).default.c_1, - * | ^ ^ ^ mapping.generatedOffsets[5], linkedCodeMapping.generatedOffsets[2] - * | ^ ^ mapping.generatedOffsets[4] - * | ^ mapping.generatedOffsets[3], linkedCodeMapping.sourceOffsets[2] + * 4 | 'aliased_c_1': (await import('./c.module.css')).default['c_1'], + * | ^^ ^ ^^ mapping.generatedOffsets[5] + * | ^^ ^ ^ secondaryMapping.generatedOffsets[3], linkedCodeMapping.generatedOffsets[2] + * | ^^ ^ mapping.generatedOffsets[4] + * | ^^ mapping.generatedOffsets[3] + * | ^ secondaryMapping.generatedOffsets[2], linkedCodeMapping.sourceOffsets[2] * | * 5 | }; * @@ -390,32 +419,40 @@ function generateDefaultExportDts( const localName = value.localName ?? value.name; const localLoc = value.localLoc ?? value.loc; - text += ` `; + text += ` '`; mapping.sourceOffsets.push(localLoc.start.offset); mapping.lengths.push(localName.length); mapping.generatedOffsets.push(text.length); - linkedCodeMapping.sourceOffsets.push(text.length); - linkedCodeMapping.lengths.push(localName.length); - text += `${localName}: (await import(`; + secondaryMapping.sourceOffsets.push(localLoc.start.offset); + secondaryMapping.lengths.push(localName.length); + secondaryMapping.generatedOffsets.push(text.length - 1); + secondaryMapping.generatedLengths.push(localName.length + 2); + linkedCodeMapping.sourceOffsets.push(text.length - 1); + linkedCodeMapping.lengths.push(localName.length + 2); + text += `${localName}': (await import(`; if (i === 0) { mapping.sourceOffsets.push(tokenImporter.fromLoc.start.offset - 1); mapping.lengths.push(tokenImporter.from.length + 2); mapping.generatedOffsets.push(text.length); } - text += `'${tokenImporter.from}')).default.`; + 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); + secondaryMapping.sourceOffsets.push(value.loc.start.offset); + secondaryMapping.lengths.push(value.name.length); + secondaryMapping.generatedOffsets.push(text.length - 1); + secondaryMapping.generatedLengths.push(value.name.length + 2); } - linkedCodeMapping.generatedOffsets.push(text.length); - linkedCodeMapping.generatedLengths.push(value.name.length); - text += `${value.name},\n`; + linkedCodeMapping.generatedOffsets.push(text.length - 1); + linkedCodeMapping.generatedLengths.push(value.name.length + 2); + text += `${value.name}'],\n`; }); } } text += `};\nexport default ${STYLES_EXPORT_NAME};\n`; - return { text, mapping, linkedCodeMapping }; + return { text, mapping, linkedCodeMapping, secondaryMapping }; } function isValidTokenName(name: string, options: ValidateTokenNameOptions): boolean { diff --git a/packages/core/src/util.test.ts b/packages/core/src/util.test.ts index 8167f310..c5b308cd 100644 --- a/packages/core/src/util.test.ts +++ b/packages/core/src/util.test.ts @@ -17,9 +17,17 @@ describe('validateTokenName', () => { test('returns undefined for default when namedExports is false', () => { expect(validateTokenName('default', { namedExports: false })).toBe(undefined); }); - test('returns "invalid-js-identifier" for invalid JS identifier', () => { - expect(validateTokenName('a-1', { namedExports: false })).toBe('invalid-js-identifier'); - expect(validateTokenName('123', { namedExports: false })).toBe('invalid-js-identifier'); + test('returns "invalid-js-identifier" for invalid JS identifier when namedExports is true', () => { + expect(validateTokenName('a-1', { namedExports: true })).toBe('invalid-js-identifier'); + }); + test('returns undefined for invalid JS identifier when namedExports is false', () => { + expect(validateTokenName('a-1', { namedExports: false })).toBe(undefined); + }); + test('returns "backslash-not-allowed" for backslash when namedExports is false', () => { + expect(validateTokenName('a\\b', { namedExports: false })).toBe('backslash-not-allowed'); + }); + test('returns "invalid-js-identifier" for backslash when namedExports is true', () => { + expect(validateTokenName('a\\b', { namedExports: true })).toBe('invalid-js-identifier'); }); }); diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index c53ef129..3083616e 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -8,7 +8,8 @@ const JS_IDENTIFIER_PATTERN = /^[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$ export type TokenNameViolation = | 'invalid-js-identifier' // Invalid as a JavaScript identifier | 'proto-not-allowed' // `__proto__` is not allowed - | 'default-not-allowed'; // `default` is not allowed when namedExports is true + | 'default-not-allowed' // `default` is not allowed when namedExports is true + | 'backslash-not-allowed'; // Backslash (`\`) is not allowed export interface ValidateTokenNameOptions { namedExports: boolean; @@ -21,14 +22,12 @@ export interface ValidateTokenNameOptions { * @returns The violation, or `undefined` if the name is valid. */ export function validateTokenName(name: string, options: ValidateTokenNameOptions): TokenNameViolation | undefined { - if (name === '__proto__') { - return 'proto-not-allowed'; - } - if (options.namedExports && name === 'default') { - return 'default-not-allowed'; - } - if (!JS_IDENTIFIER_PATTERN.test(name)) { - return 'invalid-js-identifier'; + if (name === '__proto__') return 'proto-not-allowed'; + if (options.namedExports) { + if (name === 'default') return 'default-not-allowed'; + if (!JS_IDENTIFIER_PATTERN.test(name)) return 'invalid-js-identifier'; + } else { + if (name.includes('\\')) return 'backslash-not-allowed'; } return undefined; } diff --git a/packages/ts-plugin/e2e-test/feature/find-all-references.test.ts b/packages/ts-plugin/e2e-test/feature/find-all-references.test.ts index 3bf18472..2d551217 100644 --- a/packages/ts-plugin/e2e-test/feature/find-all-references.test.ts +++ b/packages/ts-plugin/e2e-test/feature/find-all-references.test.ts @@ -10,6 +10,7 @@ describe('Find All References', async () => { 'index.ts': dedent` import styles from './a.module.css'; styles.a_1; + styles['a-2']; styles.b_1; styles.c_1; styles.c_alias; @@ -19,6 +20,7 @@ describe('Find All References', async () => { @value c_1, c_2 as c_alias from './c.module.css'; .a_1 { color: red; } .a_1 { color: red; } + .a-2 { color: red; } `, 'b.module.css': dedent` .b_1 { color: red; } @@ -51,10 +53,20 @@ describe('Find All References', async () => { start: { line: 4, offset: 2 }, end: { line: 4, offset: 5 }, }; + const a_2_in_index_ts = { + file: formatPath(iff.paths['index.ts']), + start: { line: 3, offset: 9 }, + end: { line: 3, offset: 12 }, + }; + const a_2_in_a_module_css = { + file: formatPath(iff.paths['a.module.css']), + start: { line: 5, offset: 2 }, + end: { line: 5, offset: 5 }, + }; const b_1_in_index_ts = { file: formatPath(iff.paths['index.ts']), - start: { line: 3, offset: 8 }, - end: { line: 3, offset: 11 }, + start: { line: 4, offset: 8 }, + end: { line: 4, offset: 11 }, }; const b_1_in_b_module_css = { file: formatPath(iff.paths['b.module.css']), @@ -63,8 +75,8 @@ describe('Find All References', async () => { }; const c_1_in_index_ts = { file: formatPath(iff.paths['index.ts']), - start: { line: 4, offset: 8 }, - end: { line: 4, offset: 11 }, + start: { line: 5, offset: 8 }, + end: { line: 5, offset: 11 }, }; const c_1_in_a_module_css = { file: formatPath(iff.paths['a.module.css']), @@ -78,8 +90,8 @@ describe('Find All References', async () => { }; const c_alias_in_index_ts = { file: formatPath(iff.paths['index.ts']), - start: { line: 5, offset: 8 }, - end: { line: 5, offset: 15 }, + start: { line: 6, offset: 8 }, + end: { line: 6, offset: 15 }, }; const c_alias_in_a_module_css = { file: formatPath(iff.paths['a.module.css']), @@ -111,6 +123,7 @@ describe('Find All References', async () => { { file: formatPath(iff.paths['index.ts']), start: { line: 3, offset: 1 }, end: { line: 3, offset: 7 } }, { file: formatPath(iff.paths['index.ts']), start: { line: 4, offset: 1 }, end: { line: 4, offset: 7 } }, { file: formatPath(iff.paths['index.ts']), start: { line: 5, offset: 1 }, end: { line: 5, offset: 7 } }, + { file: formatPath(iff.paths['index.ts']), start: { line: 6, offset: 1 }, end: { line: 6, offset: 7 } }, ], }, { @@ -152,6 +165,18 @@ describe('Find All References', async () => { ...a_1_1_in_a_module_css.start, expected: [a_1_in_index_ts, a_1_1_in_a_module_css, a_1_2_in_a_module_css], }, + { + name: 'a-2 in index.ts', + file: a_2_in_index_ts.file, + ...a_2_in_index_ts.start, + expected: [a_2_in_index_ts, a_2_in_a_module_css], + }, + { + name: 'a-2 in a.module.css', + file: a_2_in_a_module_css.file, + ...a_2_in_a_module_css.start, + expected: [a_2_in_index_ts, a_2_in_a_module_css], + }, { name: 'b_1 in index.ts', file: b_1_in_index_ts.file, 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..b73f0440 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 @@ -11,6 +11,7 @@ describe('Go to Definition', async () => { styles.a_1; styles.a_2; styles.a_3; + styles['a-4']; styles.b_1; styles.c_1; styles.c_alias; @@ -22,6 +23,7 @@ describe('Go to Definition', async () => { .a_2 { color: red; } .a_2 { color: red; } @value a_3: red; + .a-4 { color: red; } @import url(./b.module.css); `, 'b.module.css': dedent` @@ -157,10 +159,40 @@ describe('Go to Definition', async () => { ], }, { - name: 'b_1 in index.ts', + name: 'a-4 in index.ts', file: iff.paths['index.ts'], line: 5, offset: 8, + expected: [ + { + file: formatPath(iff.paths['a.module.css']), + start: { line: 7, offset: 2 }, + end: { line: 7, offset: 5 }, + contextStart: { line: 7, offset: 1 }, + contextEnd: { line: 7, offset: 21 }, + }, + ], + }, + { + name: 'a-4 in a.module.css', + file: iff.paths['a.module.css'], + line: 7, + offset: 2, + expected: [ + { + file: formatPath(iff.paths['a.module.css']), + start: { line: 7, offset: 2 }, + end: { line: 7, offset: 5 }, + contextStart: { line: 7, offset: 1 }, + contextEnd: { line: 7, offset: 21 }, + }, + ], + }, + { + name: 'b_1 in index.ts', + file: iff.paths['index.ts'], + line: 6, + offset: 8, expected: [ { file: formatPath(iff.paths['b.module.css']), @@ -174,7 +206,7 @@ describe('Go to Definition', async () => { { name: 'c_1 in index.ts', file: iff.paths['index.ts'], - line: 6, + line: 7, offset: 8, // NOTE: For simplicity of implementation, this is not the ideal behavior. The ideal behavior is as follows: // expected: [ @@ -226,7 +258,7 @@ describe('Go to Definition', async () => { { name: 'c_alias in index.ts', file: iff.paths['index.ts'], - line: 7, + line: 8, offset: 8, // NOTE: For simplicity of implementation, this is not the ideal behavior. The ideal behavior is as follows: // expected: [ @@ -294,7 +326,7 @@ describe('Go to Definition', async () => { // NOTE: It is strange that `(` has a definition, but we allow it to keep the implementation simple. name: '(./b.module.css) in a.module.css', file: iff.paths['a.module.css'], - line: 7, + line: 8, offset: 12, expected: [ { file: formatPath(iff.paths['b.module.css']), start: { line: 1, offset: 1 }, end: { line: 1, offset: 1 } }, 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..f362c302 100644 --- a/packages/ts-plugin/e2e-test/feature/rename-symbol.test.ts +++ b/packages/ts-plugin/e2e-test/feature/rename-symbol.test.ts @@ -11,6 +11,7 @@ describe('Rename Symbol', async () => { styles.a_1; styles.a_1; styles.a_2; + styles['a-3']; styles.b_1; styles.c_1; styles.c_alias; @@ -22,6 +23,7 @@ describe('Rename Symbol', async () => { .a_1 { color: red; } .a_1 { color: red; } @value a_2: red; + .a-3 { color: red; } `, 'b.module.css': dedent` .b_1 { color: red; } @@ -124,14 +126,46 @@ describe('Rename Symbol', async () => { ], }, { - name: 'b_1 in index.ts', + name: 'a-2 in index.ts', file: iff.paths['index.ts'], line: 5, + offset: 9, + expected: [ + { + file: formatPath(iff.paths['index.ts']), + locs: [{ start: { line: 5, offset: 9 }, end: { line: 5, offset: 12 } }], + }, + { + file: formatPath(iff.paths['a.module.css']), + locs: [{ start: { line: 6, offset: 2 }, end: { line: 6, offset: 5 } }], + }, + ], + }, + { + name: 'a-2 in a.module.css', + file: iff.paths['a.module.css'], + line: 6, + offset: 2, + expected: [ + { + file: formatPath(iff.paths['index.ts']), + locs: [{ start: { line: 5, offset: 9 }, end: { line: 5, offset: 12 } }], + }, + { + file: formatPath(iff.paths['a.module.css']), + locs: [{ start: { line: 6, offset: 2 }, end: { line: 6, offset: 5 } }], + }, + ], + }, + { + name: 'b_1 in index.ts', + file: iff.paths['index.ts'], + line: 6, offset: 8, expected: [ { file: formatPath(iff.paths['index.ts']), - locs: [{ start: { line: 5, offset: 8 }, end: { line: 5, offset: 11 } }], + locs: [{ start: { line: 6, offset: 8 }, end: { line: 6, offset: 11 } }], }, { file: formatPath(iff.paths['b.module.css']), @@ -147,7 +181,7 @@ describe('Rename Symbol', async () => { expected: [ { file: formatPath(iff.paths['index.ts']), - locs: [{ start: { line: 5, offset: 8 }, end: { line: 5, offset: 11 } }], + locs: [{ start: { line: 6, offset: 8 }, end: { line: 6, offset: 11 } }], }, { file: formatPath(iff.paths['b.module.css']), @@ -158,13 +192,13 @@ describe('Rename Symbol', async () => { { name: 'c_1 in index.ts', file: iff.paths['index.ts'], - line: 6, + line: 7, offset: 8, // NOTE: For simplicity of implementation, this is not the ideal behavior. The ideal behavior is as follows: // expected: [ // { // file: formatPath(iff.paths['index.ts']), - // locs: [{ start: { line: 6, offset: 8 }, end: { line: 6, offset: 11 } }], + // locs: [{ start: { line: 7, offset: 8 }, end: { line: 7, offset: 11 } }], // }, // { // file: formatPath(iff.paths['a.module.css']), @@ -174,7 +208,7 @@ describe('Rename Symbol', async () => { expected: [ { file: formatPath(iff.paths['index.ts']), - locs: [{ start: { line: 6, offset: 8 }, end: { line: 6, offset: 11 } }], + locs: [{ start: { line: 7, offset: 8 }, end: { line: 7, offset: 11 } }], }, { file: formatPath(iff.paths['a.module.css']), @@ -195,7 +229,7 @@ describe('Rename Symbol', async () => { // expected: [ // { // file: formatPath(iff.paths['index.ts']), - // locs: [{ start: { line: 6, offset: 8 }, end: { line: 6, offset: 11 } }], + // locs: [{ start: { line: 7, offset: 8 }, end: { line: 7, offset: 11 } }], // }, // { // file: formatPath(iff.paths['a.module.css']), @@ -205,7 +239,7 @@ describe('Rename Symbol', async () => { expected: [ { file: formatPath(iff.paths['index.ts']), - locs: [{ start: { line: 6, offset: 8 }, end: { line: 6, offset: 11 } }], + locs: [{ start: { line: 7, offset: 8 }, end: { line: 7, offset: 11 } }], }, { file: formatPath(iff.paths['a.module.css']), @@ -236,7 +270,7 @@ describe('Rename Symbol', async () => { expected: [ { file: formatPath(iff.paths['index.ts']), - locs: [{ start: { line: 6, offset: 8 }, end: { line: 6, offset: 11 } }], + locs: [{ start: { line: 7, offset: 8 }, end: { line: 7, offset: 11 } }], }, { file: formatPath(iff.paths['a.module.css']), @@ -251,13 +285,13 @@ describe('Rename Symbol', async () => { { name: 'c_alias in index.ts', file: iff.paths['index.ts'], - line: 7, + line: 8, offset: 8, // NOTE: For simplicity of implementation, this is not the ideal behavior. The ideal behavior is as follows: // expected: [ // { // file: formatPath(iff.paths['index.ts']), - // locs: [{ start: { line: 7, offset: 8 }, end: { line: 7, offset: 15 } }], + // locs: [{ start: { line: 8, offset: 8 }, end: { line: 8, offset: 15 } }], // }, // { // file: formatPath(iff.paths['a.module.css']), @@ -267,7 +301,7 @@ describe('Rename Symbol', async () => { expected: [ { file: formatPath(iff.paths['index.ts']), - locs: [{ start: { line: 7, offset: 8 }, end: { line: 7, offset: 15 } }], + locs: [{ start: { line: 8, offset: 8 }, end: { line: 8, offset: 15 } }], }, { file: formatPath(iff.paths['a.module.css']), @@ -301,7 +335,7 @@ describe('Rename Symbol', async () => { expected: [ { file: formatPath(iff.paths['index.ts']), - locs: [{ start: { line: 7, offset: 8 }, end: { line: 7, offset: 15 } }], + locs: [{ start: { line: 8, offset: 8 }, end: { line: 8, offset: 15 } }], }, { file: formatPath(iff.paths['a.module.css']), @@ -335,7 +369,7 @@ describe('Rename Symbol', async () => { expected: [ { file: formatPath(iff.paths['index.ts']), - locs: [{ start: { line: 7, offset: 8 }, end: { line: 7, offset: 15 } }], + locs: [{ start: { line: 8, offset: 8 }, end: { line: 8, offset: 15 } }], }, { file: formatPath(iff.paths['a.module.css']), @@ -369,7 +403,7 @@ describe('Rename Symbol', async () => { expected: [ { file: formatPath(iff.paths['index.ts']), - locs: [{ start: { line: 8, offset: 8 }, end: { line: 8, offset: 11 } }], + locs: [{ start: { line: 9, offset: 8 }, end: { line: 9, offset: 11 } }], }, { file: formatPath(iff.paths['a.module.css']), diff --git a/packages/ts-plugin/e2e-test/named-exports.test.ts b/packages/ts-plugin/e2e-test/named-exports.test.ts index 9bed804f..77a2aeda 100644 --- a/packages/ts-plugin/e2e-test/named-exports.test.ts +++ b/packages/ts-plugin/e2e-test/named-exports.test.ts @@ -183,16 +183,7 @@ describe('supports basic language features', async () => { name: 'c_alias in index.ts', file: c_alias_in_index_ts.file, ...c_alias_in_index_ts.start, - expected: [ - // For some reason, `c_alias_in_a_module_css` and `c_alias_in_index_ts` appear to be duplicated. - // This is likely a bug in Volar.js. - c_alias_in_index_ts, - c_alias_in_index_ts, - c_2_in_a_module_css, - c_alias_in_a_module_css, - c_alias_in_a_module_css, - c_2_in_c_module_css, - ], + expected: [c_alias_in_index_ts, c_2_in_a_module_css, c_alias_in_a_module_css, c_2_in_c_module_css], }, ])('Find All References for $name', async ({ file, line, offset, expected }) => { const res = await tsserver.sendReferences({ diff --git a/packages/ts-plugin/src/language-plugin.ts b/packages/ts-plugin/src/language-plugin.ts index adc8463d..3aade2d3 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, secondaryMapping } = generateDts(cssModule, { + ...config, + forTsPlugin: true, + }); return { id: 'main', languageId: 'typescript', @@ -61,9 +64,11 @@ export function createCSSLanguagePlugin( getLength: () => text.length, getChangeRange: () => undefined, }, - // `mappings` are required to support "Go to Definition" and renaming - mappings: [{ ...mapping, data: { navigation: true } }], - // `linkedCodeMappings` are required to support "Go to Definition" and renaming for the imported tokens + // `mappings` are required to support navigation features such as "Go to Definition" and "Find all References". + mappings: [mapping, secondaryMapping] + .filter((mapping) => mapping !== undefined) + .map((mapping) => ({ ...mapping, data: { navigation: true } })), + // `linkedCodeMappings` are required to support navigation features 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..11e380f1 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 @@ -17,8 +17,9 @@ export function getDefinitionAndBoundSpan( const cssModule = script.generated.root[CMK_DATA_KEY]; // Search tokens and set `contextSpan`. `contextSpan` is used for Definition Preview in editors. + const defName = unquote(def.name); const localToken = cssModule.localTokens.find( - (t) => t.name === def.name && t.loc.start.offset === def.textSpan.start, + (t) => t.name === defName && t.loc.start.offset === def.textSpan.start, ); if (localToken?.declarationLoc) { def.contextSpan = { @@ -32,7 +33,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 === defName && localLoc.start.offset === def.textSpan.start; }); if (importedValue) { const loc = importedValue.localLoc ?? importedValue.loc; @@ -45,3 +46,15 @@ export function getDefinitionAndBoundSpan( return result; }; } + +/** + * Removes surrounding single quotes from a string if present. + * When `namedExport` is false, `def.name` is `"'tokenName'"` (with quotes), + * but `token.name` is `"tokenName"` (without quotes). + */ +function unquote(name: string): 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/find-references.ts b/packages/ts-plugin/src/language-service/feature/find-references.ts new file mode 100644 index 00000000..01ea1a79 --- /dev/null +++ b/packages/ts-plugin/src/language-service/feature/find-references.ts @@ -0,0 +1,46 @@ +import type ts from 'typescript'; + +export function findReferences(languageService: ts.LanguageService): ts.LanguageService['findReferences'] { + return (...args) => { + const symbols = languageService.findReferences(...args); + if (!symbols) return symbols; + return mergeSameDefinitionSymbols(symbols); + }; +} + +/** + * Merges ReferencedSymbols that have the same definition into one, + * combining their references. + */ +function mergeSameDefinitionSymbols(symbols: ts.ReferencedSymbol[]): ts.ReferencedSymbol[] { + // Volar.js may return multiple ReferencedSymbols with the same definition + // when a VirtualCode contains multiple CodeMapping objects. + // Editors usually merge symbols with the same definition before displaying them, + // so this typically isn't an issue in practice. + // However, we merge them here to make comparisons in tests easier. + const map = new Map(); + for (const symbol of symbols) { + const def = symbol.definition; + const key = `${def.fileName}:${def.textSpan.start}:${def.textSpan.length}`; + const existing = map.get(key); + if (existing) { + existing.references = uniqueReferences([...existing.references, ...symbol.references]); + } else { + map.set(key, symbol); + } + } + return Array.from(map.values()); +} + +function uniqueReferences(references: ts.ReferencedSymbolEntry[]): ts.ReferencedSymbolEntry[] { + const seen = new Set(); + const result: ts.ReferencedSymbolEntry[] = []; + for (const ref of references) { + const key = `${ref.fileName}:${ref.textSpan.start}:${ref.textSpan.length}`; + if (!seen.has(key)) { + seen.add(key); + result.push(ref); + } + } + return result; +} diff --git a/packages/ts-plugin/src/language-service/proxy.ts b/packages/ts-plugin/src/language-service/proxy.ts index 35a57367..31e5bbfd 100644 --- a/packages/ts-plugin/src/language-service/proxy.ts +++ b/packages/ts-plugin/src/language-service/proxy.ts @@ -6,6 +6,7 @@ import { CMK_DATA_KEY, isCSSModuleScript } from '../language-plugin.js'; import { getCodeFixesAtPosition } from './feature/code-fix.js'; import { getCompletionEntryDetails, getCompletionsAtPosition } from './feature/completion.js'; import { getDefinitionAndBoundSpan } from './feature/definition-and-bound-span.js'; +import { findReferences } from './feature/find-references.js'; import { getApplicableRefactors, getEditsForRefactor } from './feature/refactor.js'; import { getSemanticDiagnostics } from './feature/semantic-diagnostic.js'; import { getSyntacticDiagnostics } from './feature/syntactic-diagnostic.js'; @@ -52,6 +53,7 @@ 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); return proxy; }