diff --git a/packages/tailwindcss-language-server/src/config.ts b/packages/tailwindcss-language-server/src/config.ts index d8364d06..a5e68f7d 100644 --- a/packages/tailwindcss-language-server/src/config.ts +++ b/packages/tailwindcss-language-server/src/config.ts @@ -1,6 +1,9 @@ import merge from 'deepmerge' import { isObject } from './utils' -import type { Settings } from '@tailwindcss/language-service/src/util/state' +import { + getDefaultTailwindSettings, + type Settings, +} from '@tailwindcss/language-service/src/util/state' import type { Connection } from 'vscode-languageserver' export interface SettingsCache { @@ -8,40 +11,6 @@ export interface SettingsCache { clear(): void } -function getDefaultSettings(): Settings { - return { - editor: { tabSize: 2 }, - tailwindCSS: { - inspectPort: null, - emmetCompletions: false, - classAttributes: ['class', 'className', 'ngClass', 'class:list'], - codeActions: true, - hovers: true, - suggestions: true, - validate: true, - colorDecorators: true, - rootFontSize: 16, - lint: { - cssConflict: 'warning', - invalidApply: 'error', - invalidScreen: 'error', - invalidVariant: 'error', - invalidConfigPath: 'error', - invalidTailwindDirective: 'error', - invalidSourceDirective: 'error', - recommendedVariantOrder: 'warning', - }, - showPixelEquivalents: true, - includeLanguages: {}, - files: { exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'] }, - experimental: { - classRegex: [], - configFile: null, - }, - }, - } -} - export function createSettingsCache(connection: Connection): SettingsCache { const cache: Map = new Map() @@ -73,7 +42,7 @@ export function createSettingsCache(connection: Connection): SettingsCache { tailwindCSS = isObject(tailwindCSS) ? tailwindCSS : {} return merge( - getDefaultSettings(), + getDefaultTailwindSettings(), { editor, tailwindCSS }, { arrayMerge: (_destinationArray, sourceArray, _options) => sourceArray }, ) diff --git a/packages/tailwindcss-language-server/tests/utils/client.ts b/packages/tailwindcss-language-server/tests/utils/client.ts index f7ee6e94..d6d317bb 100644 --- a/packages/tailwindcss-language-server/tests/utils/client.ts +++ b/packages/tailwindcss-language-server/tests/utils/client.ts @@ -44,7 +44,7 @@ import { createConfiguration, Configuration } from './configuration' import { clearLanguageBoundariesCache } from '@tailwindcss/language-service/src/util/getLanguageBoundaries' import { DefaultMap } from '../../src/util/default-map' import { connect, ConnectOptions } from './connection' -import type { DeepPartial } from './types' +import type { DeepPartial } from '@tailwindcss/language-service/src/types' export interface DocumentDescriptor { /** diff --git a/packages/tailwindcss-language-server/tests/utils/configuration.ts b/packages/tailwindcss-language-server/tests/utils/configuration.ts index 2b4d6fbc..8c08a518 100644 --- a/packages/tailwindcss-language-server/tests/utils/configuration.ts +++ b/packages/tailwindcss-language-server/tests/utils/configuration.ts @@ -1,4 +1,7 @@ -import type { Settings } from '@tailwindcss/language-service/src/util/state' +import { + getDefaultTailwindSettings, + type Settings, +} from '@tailwindcss/language-service/src/util/state' import { URI } from 'vscode-uri' import type { DeepPartial } from './types' import { CacheMap } from '../../src/cache-map' @@ -10,41 +13,7 @@ export interface Configuration { } export function createConfiguration(): Configuration { - let defaults: Settings = { - editor: { - tabSize: 2, - }, - tailwindCSS: { - inspectPort: null, - emmetCompletions: false, - includeLanguages: {}, - classAttributes: ['class', 'className', 'ngClass', 'class:list'], - suggestions: true, - hovers: true, - codeActions: true, - validate: true, - showPixelEquivalents: true, - rootFontSize: 16, - colorDecorators: true, - lint: { - cssConflict: 'warning', - invalidApply: 'error', - invalidScreen: 'error', - invalidVariant: 'error', - invalidConfigPath: 'error', - invalidTailwindDirective: 'error', - invalidSourceDirective: 'error', - recommendedVariantOrder: 'warning', - }, - experimental: { - classRegex: [], - configFile: {}, - }, - files: { - exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'], - }, - }, - } + let defaults = getDefaultTailwindSettings() /** * Settings per file or directory URI diff --git a/packages/tailwindcss-language-server/tests/utils/types.ts b/packages/tailwindcss-language-server/tests/utils/types.ts deleted file mode 100644 index 6be62396..00000000 --- a/packages/tailwindcss-language-server/tests/utils/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type DeepPartial = { - [P in keyof T]?: T[P] extends (infer U)[] - ? U[] - : T[P] extends (...args: any) => any - ? T[P] | undefined - : T[P] extends object - ? DeepPartial - : T[P] -} diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json index 0cbdde25..39feb660 100644 --- a/packages/tailwindcss-language-service/package.json +++ b/packages/tailwindcss-language-service/package.json @@ -41,9 +41,11 @@ }, "devDependencies": { "@types/css.escape": "^1.5.2", + "@types/dedent": "^0.7.2", "@types/line-column": "^1.0.2", "@types/node": "^18.19.33", "@types/stringify-object": "^4.0.5", + "dedent": "^1.5.3", "esbuild": "^0.25.0", "esbuild-node-externals": "^1.9.0", "minimist": "^1.2.8", diff --git a/packages/tailwindcss-language-service/scripts/build.mjs b/packages/tailwindcss-language-service/scripts/build.mjs index 913debc3..128426be 100644 --- a/packages/tailwindcss-language-service/scripts/build.mjs +++ b/packages/tailwindcss-language-service/scripts/build.mjs @@ -30,7 +30,7 @@ let build = await esbuild.context({ // Call the tsc command to generate the types spawnSync( 'tsc', - ['--emitDeclarationOnly', '--outDir', path.resolve(__dirname, '../dist')], + ['-p', path.resolve(__dirname, './tsconfig.build.json'), '--emitDeclarationOnly', '--outDir', path.resolve(__dirname, '../dist')], { stdio: 'inherit', }, diff --git a/packages/tailwindcss-language-service/scripts/tsconfig.build.json b/packages/tailwindcss-language-service/scripts/tsconfig.build.json new file mode 100644 index 00000000..e80bb38f --- /dev/null +++ b/packages/tailwindcss-language-service/scripts/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "exclude": ["../src/**/*.test.ts"] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index c67eb0f5..fe43bb68 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -15,7 +15,7 @@ import removeMeta from './util/removeMeta' import { formatColor, getColor, getColorFromValue } from './util/color' import { isHtmlContext, isHtmlDoc, isVueDoc } from './util/html' import { isCssContext } from './util/css' -import { findLast, matchClassAttributes } from './util/find' +import { findLast, matchClassAttributes, matchClassFunctions } from './util/find' import { stringifyConfigValue, stringifyCss } from './util/stringify' import { stringifyScreen, Screen } from './util/screens' import isObject from './util/isObject' @@ -45,6 +45,8 @@ import type { ThemeEntry } from './util/v4' import { segment } from './util/segment' import { resolveKnownThemeKeys, resolveKnownThemeNamespaces } from './util/v4/theme-keys' import { SEARCH_RANGE } from './util/constants' +import { getLanguageBoundaries } from './util/getLanguageBoundaries' +import { isWithinRange } from './util/isWithinRange' let isUtil = (className) => Array.isArray(className.__info) @@ -747,6 +749,25 @@ async function provideClassAttributeCompletions( let matches = matchClassAttributes(str, settings.classAttributes) + let boundaries = getLanguageBoundaries(state, document) + + for (let boundary of boundaries ?? []) { + let isJsContext = boundary.type === 'js' || boundary.type === 'jsx' + if (!isJsContext) continue + if (!settings.classFunctions?.length) continue + if (!isWithinRange(position, boundary.range)) continue + + let str = document.getText(boundary.range) + let offset = document.offsetAt(boundary.range.start) + let fnMatches = matchClassFunctions(str, settings.classFunctions) + + fnMatches.forEach((match) => { + if (match.index) match.index += offset + }) + + matches.push(...fnMatches) + } + if (matches.length === 0) { return null } diff --git a/packages/tailwindcss-language-service/src/types.ts b/packages/tailwindcss-language-service/src/types.ts new file mode 100644 index 00000000..c84f0c7a --- /dev/null +++ b/packages/tailwindcss-language-service/src/types.ts @@ -0,0 +1,9 @@ +export type DeepPartial = { + [P in keyof T]?: T[P] extends ((...args: any) => any) | ReadonlyArray | Date + ? T[P] + : T[P] extends (infer U)[] + ? U[] + : T[P] extends object + ? DeepPartial + : T[P] +} diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 90dbcfb3..f9f17a96 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -1,81 +1,743 @@ -import type { State } from './state' +import { createState, getDefaultTailwindSettings, Settings, type DocumentClassList } from './state' import { test } from 'vitest' import { TextDocument } from 'vscode-languageserver-textdocument' import { findClassListsInHtmlRange } from './find' +import type { DeepPartial } from '../types' +import dedent from 'dedent' -test('test', async ({ expect }) => { - let content = [ - // - '', - ' ', - '', - ].join('\n') - - let doc = TextDocument.create('file://file.astro', 'astro', 1, content) - let state: State = { - blocklist: [], +const js = dedent +const html = dedent + +test('class regex works in astro', async ({ expect }) => { + let file = createDocument({ + name: 'file.astro', + lang: 'astro', + settings: { + tailwindCSS: { + classAttributes: ['class'], + experimental: { + classRegex: [ + ['cva\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], + ['cn\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], + ], + }, + }, + }, + content: [ + '', + ' ', + '', + ], + }) + + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'html') + + expect(classLists).toEqual([ + { + classList: 'p-4 sm:p-2 $', + range: { + start: { line: 0, character: 10 }, + end: { line: 0, character: 22 }, + }, + }, + { + classList: 'underline', + range: { + start: { line: 0, character: 33 }, + end: { line: 0, character: 42 }, + }, + }, + { + classList: 'line-through', + range: { + start: { line: 0, character: 46 }, + end: { line: 0, character: 58 }, + }, + }, + ]) +}) + +test('find class lists in functions', async ({ expect }) => { + let fileA = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classFunctions: ['clsx', 'cva'], + }, + }, + content: js` + // These should match + let classes = clsx( + 'flex p-4', + 'block sm:p-0', + Date.now() > 100 ? 'text-white' : 'text-black', + ) + + // These should match + let classes = cva( + 'flex p-4', + 'block sm:p-0', + Date.now() > 100 ? 'text-white' : 'text-black', + ) + `, + }) + + let fileB = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classFunctions: ['clsx', 'cva'], + }, + }, + content: js` + let classes = cn( + 'flex p-4', + 'block sm:p-0', + Date.now() > 100 ? 'text-white' : 'text-black', + ) + `, + }) + + let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js') + let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js') + + expect(classListsA).toEqual([ + // from clsx(…) + { + classList: 'flex p-4', + range: { + start: { line: 2, character: 3 }, + end: { line: 2, character: 11 }, + }, + }, + { + classList: 'block sm:p-0', + range: { + start: { line: 3, character: 3 }, + end: { line: 3, character: 15 }, + }, + }, + { + classList: 'text-white', + range: { + start: { line: 4, character: 22 }, + end: { line: 4, character: 32 }, + }, + }, + { + classList: 'text-black', + range: { + start: { line: 4, character: 37 }, + end: { line: 4, character: 47 }, + }, + }, + + // from cva(…) + { + classList: 'flex p-4', + range: { + start: { line: 9, character: 3 }, + end: { line: 9, character: 11 }, + }, + }, + { + classList: 'block sm:p-0', + range: { + start: { line: 10, character: 3 }, + end: { line: 10, character: 15 }, + }, + }, + { + classList: 'text-white', + range: { + start: { line: 11, character: 22 }, + end: { line: 11, character: 32 }, + }, + }, + { + classList: 'text-black', + range: { + start: { line: 11, character: 37 }, + end: { line: 11, character: 47 }, + }, + }, + ]) + + // none from cn(…) since it's not in the list of class functions + expect(classListsB).toEqual([]) +}) + +test('find class lists in nested fn calls', async ({ expect }) => { + let file = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classFunctions: ['clsx', 'cva'], + }, + }, + + content: js` + // NOTE: All strings inside a matched class function will be treated as class lists + // TODO: Nested calls tha are *not* class functions should have their content ignored + let classes = clsx( + 'flex', + cn({ + 'bg-red-500': true, + 'text-white': Date.now() > 100, + }), + clsx( + 'fixed', + 'absolute inset-0' + ), + cva( + ['bottom-0', 'border'], + { + variants: { + mobile: { + default: 'bottom-0 left-0', + large: \` + inset-0 + rounded-none + \`, + }, + } + } + ) + ) + `, + }) + + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'html') + + expect(classLists).toMatchObject([ + { + classList: 'flex', + range: { + start: { line: 3, character: 3 }, + end: { line: 3, character: 7 }, + }, + }, + + // TODO: This should be ignored because they're inside cn(…) + { + classList: 'bg-red-500', + range: { + start: { line: 5, character: 5 }, + end: { line: 5, character: 15 }, + }, + }, + + // TODO: This should be ignored because they're inside cn(…) + { + classList: 'text-white', + range: { + start: { line: 6, character: 5 }, + end: { line: 6, character: 15 }, + }, + }, + + { + classList: 'fixed', + range: { + start: { line: 9, character: 5 }, + end: { line: 9, character: 10 }, + }, + }, + { + classList: 'absolute inset-0', + range: { + start: { line: 10, character: 5 }, + end: { line: 10, character: 21 }, + }, + }, + { + classList: 'bottom-0', + range: { + start: { line: 13, character: 6 }, + end: { line: 13, character: 14 }, + }, + }, + { + classList: 'border', + range: { + start: { line: 13, character: 18 }, + end: { line: 13, character: 24 }, + }, + }, + { + classList: 'bottom-0 left-0', + range: { + start: { line: 17, character: 20 }, + end: { line: 17, character: 35 }, + }, + }, + { + classList: `inset-0\n rounded-none\n `, + range: { + start: { line: 19, character: 12 }, + // TODO: Fix the range calculation. Its wrong on this one + end: { line: 20, character: 24 }, + }, + }, + ]) +}) + +test('find class lists in nested fn calls (only nested matches)', async ({ expect }) => { + let file = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classFunctions: ['clsx', 'cva'], + }, + }, + + content: js` + let classes = cn( + 'flex', + cn({ + 'bg-red-500': true, + 'text-white': Date.now() > 100, + }), + // NOTE: The only class lists appear inside this function because cn is + // not in the list of class functions + clsx( + 'fixed', + 'absolute inset-0' + ), + ) + `, + }) + + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'html') + + expect(classLists).toMatchObject([ + { + classList: 'fixed', + range: { + start: { line: 9, character: 5 }, + end: { line: 9, character: 10 }, + }, + }, + { + classList: 'absolute inset-0', + range: { + start: { line: 10, character: 5 }, + end: { line: 10, character: 21 }, + }, + }, + ]) +}) + +test('find class lists in tagged template literals', async ({ expect }) => { + let fileA = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classFunctions: ['clsx', 'cva'], + }, + }, + content: js` + // These should match + let classes = clsx\` + flex p-4 + block sm:p-0 + \${Date.now() > 100 ? 'text-white' : 'text-black'} + \` + + // These should match + let classes = cva\` + flex p-4 + block sm:p-0 + \${Date.now() > 100 ? 'text-white' : 'text-black'} + \` + `, + }) + + let fileB = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classFunctions: ['clsx', 'cva'], + }, + }, + content: js` + let classes = cn\` + flex p-4 + block sm:p-0 + \${Date.now() > 100 ? 'text-white' : 'text-black'} + \` + `, + }) + + let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js') + let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js') + + expect(classListsA).toEqual([ + // from clsx`…` + { + classList: 'flex p-4\n block sm:p-0\n $', + range: { + start: { line: 2, character: 2 }, + end: { line: 4, character: 3 }, + }, + }, + { + classList: 'text-white', + range: { + start: { line: 4, character: 24 }, + end: { line: 4, character: 34 }, + }, + }, + { + classList: 'text-black', + range: { + start: { line: 4, character: 39 }, + end: { line: 4, character: 49 }, + }, + }, + + // from cva`…` + { + classList: 'flex p-4\n block sm:p-0\n $', + range: { + start: { line: 9, character: 2 }, + end: { line: 11, character: 3 }, + }, + }, + { + classList: 'text-white', + range: { + start: { line: 11, character: 24 }, + end: { line: 11, character: 34 }, + }, + }, + { + classList: 'text-black', + range: { + start: { line: 11, character: 39 }, + end: { line: 11, character: 49 }, + }, + }, + ]) + + // none from cn`…` since it's not in the list of class functions + expect(classListsB).toEqual([]) +}) + +test('classFunctions can be a regex', async ({ expect }) => { + let fileA = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classFunctions: ['tw\\.[a-z]+'], + }, + }, + content: js` + let classes = tw.div('flex p-4') + `, + }) + + let fileB = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classFunctions: ['tw\\.[a-z]+'], + }, + }, + content: js` + let classes = tw.div.foo('flex p-4') + `, + }) + + let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js') + let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js') + + expect(classListsA).toEqual([ + { + classList: 'flex p-4', + range: { + start: { line: 0, character: 22 }, + end: { line: 0, character: 30 }, + }, + }, + ]) + + // none from tw.div.foo(`…`) since it does not match a class function + expect(classListsB).toEqual([]) +}) + +test('classFunctions regexes only match on function names', async ({ expect }) => { + let file = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + // A function name itself cannot contain a `:` + classFunctions: [':\\s*tw\\.[a-z]+'], + }, + }, + content: js` + let classes = tw.div('flex p-4') + let classes = { foo: tw.div('flex p-4') } + `, + }) + + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'js') + + expect(classLists).toEqual([]) +}) + +test('Finds consecutive instances of a class function', async ({ expect }) => { + let file = createDocument({ + name: 'file.js', + lang: 'javascript', + settings: { + tailwindCSS: { + classFunctions: ['cn'], + }, + }, + content: js` + export const list = [ + cn('relative flex bg-red-500'), + cn('relative flex bg-red-500'), + cn('relative flex bg-red-500'), + ] + `, + }) + + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'js') + + expect(classLists).toEqual([ + { + classList: 'relative flex bg-red-500', + range: { + start: { line: 1, character: 6 }, + end: { line: 1, character: 30 }, + }, + }, + { + classList: 'relative flex bg-red-500', + range: { + start: { line: 2, character: 6 }, + end: { line: 2, character: 30 }, + }, + }, + { + classList: 'relative flex bg-red-500', + range: { + start: { line: 3, character: 6 }, + end: { line: 3, character: 30 }, + }, + }, + ]) +}) + +test('classFunctions & classAttributes should not duplicate matches', async ({ expect }) => { + let file = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classAttributes: ['className'], + classFunctions: ['cva', 'clsx'], + }, + }, + content: js` + const Component = ({ className }) => ( +
+ CONTENT +
+ ) + const OtherComponent = ({ className }) => ( +
+ CONTENT +
+ ) + `, + }) + + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'js') + + expect(classLists).toEqual([ + { + classList: 'relative flex', + range: { + start: { line: 3, character: 7 }, + end: { line: 3, character: 20 }, + }, + }, + { + classList: 'inset-0 md:h-[calc(100%-2rem)]', + range: { + start: { line: 4, character: 7 }, + end: { line: 4, character: 37 }, + }, + }, + { + classList: 'rounded-none bg-blue-700', + range: { + start: { line: 5, character: 12 }, + end: { line: 5, character: 36 }, + }, + }, + { + classList: 'relative flex', + range: { + start: { line: 14, character: 7 }, + end: { line: 14, character: 20 }, + }, + }, + { + classList: 'inset-0 md:h-[calc(100%-2rem)]', + range: { + start: { line: 15, character: 7 }, + end: { line: 15, character: 37 }, + }, + }, + { + classList: 'rounded-none bg-blue-700', + range: { + start: { line: 16, character: 12 }, + end: { line: 16, character: 36 }, + }, + }, + ]) +}) + +test('classFunctions should only match in JS-like contexts', async ({ expect }) => { + let file = createDocument({ + name: 'file.html', + lang: 'html', + settings: { + tailwindCSS: { + classAttributes: ['className'], + classFunctions: ['clsx'], + }, + }, + content: html` + + clsx('relative flex') clsx('relative flex') + + + + + + clsx('relative flex') clsx('relative flex') + + + + `, + }) + + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'js') + + expect(classLists).toEqual([ + { + classList: 'relative flex', + range: { + start: { line: 5, character: 16 }, + end: { line: 5, character: 29 }, + }, + }, + { + classList: 'relative flex', + range: { + start: { line: 6, character: 16 }, + end: { line: 6, character: 29 }, + }, + }, + { + classList: 'relative flex', + range: { + start: { line: 14, character: 16 }, + end: { line: 14, character: 29 }, + }, + }, + { + classList: 'relative flex', + range: { + start: { line: 15, character: 16 }, + end: { line: 15, character: 29 }, + }, + }, + ]) +}) + +function createDocument({ + name, + lang, + content, + settings, +}: { + name: string + lang: string + content: string | string[] + settings: DeepPartial +}) { + let doc = TextDocument.create( + `file://${name}`, + lang, + 1, + typeof content === 'string' ? content : content.join('\n'), + ) + let defaults = getDefaultTailwindSettings() + let state = createState({ editor: { - userLanguages: {}, getConfiguration: async () => ({ - editor: { - tabSize: 1, - }, + ...defaults, + ...settings, tailwindCSS: { - classAttributes: ['class'], - experimental: { - classRegex: [ - ['cva\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], - ['cn\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], - ], - }, - } as any, - }), - } as any, - } as any - - let classLists = await findClassListsInHtmlRange(state, doc, 'html') - - expect(classLists).toMatchInlineSnapshot(` - [ - { - "classList": "p-4 sm:p-2 $", - "range": { - "end": { - "character": 22, - "line": 0, + ...defaults.tailwindCSS, + ...settings.tailwindCSS, + lint: { + ...defaults.tailwindCSS.lint, + ...(settings.tailwindCSS?.lint ?? {}), }, - "start": { - "character": 10, - "line": 0, - }, - }, - }, - { - "classList": "underline", - "range": { - "end": { - "character": 42, - "line": 0, + experimental: { + ...defaults.tailwindCSS.experimental, + ...(settings.tailwindCSS?.experimental ?? {}), }, - "start": { - "character": 33, - "line": 0, + files: { + ...defaults.tailwindCSS.files, + ...(settings.tailwindCSS?.files ?? {}), }, }, - }, - { - "classList": "line-through", - "range": { - "end": { - "character": 58, - "line": 0, - }, - "start": { - "character": 46, - "line": 0, - }, + editor: { + ...defaults.editor, + ...settings.editor, }, - }, - ] - `) -}) + }), + }, + }) + + return { + doc, + state, + } +} diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index f38d7a16..03218798 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -9,7 +9,7 @@ import { isJsxContext } from './js' import { dedupeByRange, flatten } from './array' import { getClassAttributeLexer, getComputedClassAttributeLexer } from './lexers' import { getLanguageBoundaries } from './getLanguageBoundaries' -import { resolveRange } from './resolveRange' +import { absoluteRange } from './absoluteRange' import { getTextWithoutComments } from './doc' import { isSemicolonlessCssLanguage } from './languages' import { customClassesIn } from './classes' @@ -164,20 +164,66 @@ export function matchClassAttributes(text: string, attributes: string[]): RegExp return findAll(new RegExp(re.source.replace('ATTRS', attrs.join('|')), 'gi'), text) } +export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatchArray[] { + // 1. Validate the list of function name patterns provided by the user + let names = fnNames.filter((x) => typeof x === 'string') + if (names.length === 0) return [] + + // 2. Extract function names in the document + // This is intentionally scoped to JS syntax for now but should be extended to + // other languages in the future + // + // This regex the JS pattern for an identifier + function call with some + // additional constraints: + // + // - It needs to be in an expression position — so it must be preceded by + // whitespace, parens, curlies, commas, whitespace, etc… + // - It must look like a fn call or a tagged template literal + let FN_NAMES = /(?<=^|[:=,;\s{()])([\p{ID_Start}$_][\p{ID_Continue}$_.]*)[(`]/dgiu + let foundFns = findAll(FN_NAMES, text) + + // 3. Match against the function names in the document + let re = /^(NAMES)$/ + let isClassFn = new RegExp(re.source.replace('NAMES', names.join('|')), 'i') + + let matches = foundFns.filter((fn) => isClassFn.test(fn[1])) + + return matches +} + export async function findClassListsInHtmlRange( state: State, doc: TextDocument, type: 'html' | 'js' | 'jsx', range?: Range, ): Promise { + if (!state.editor) return [] + const text = getTextWithoutComments(doc, type, range) - const matches = matchClassAttributes( - text, - (await state.editor.getConfiguration(doc.uri)).tailwindCSS.classAttributes, - ) + const settings = (await state.editor.getConfiguration(doc.uri)).tailwindCSS + const matches = matchClassAttributes(text, settings.classAttributes) - const result: DocumentClassList[] = [] + let boundaries = getLanguageBoundaries(state, doc) + + for (let boundary of boundaries ?? []) { + let isJsContext = boundary.type === 'js' || boundary.type === 'jsx' + if (!isJsContext) continue + if (!settings.classFunctions?.length) continue + + let str = doc.getText(boundary.range) + let offset = doc.offsetAt(boundary.range.start) + let fnMatches = matchClassFunctions(str, settings.classFunctions) + + fnMatches.forEach((match) => { + if (match.index) match.index += offset + }) + + matches.push(...fnMatches) + } + + const existingResultSet = new Set() + const results: DocumentClassList[] = [] matches.forEach((match) => { const subtext = text.substr(match.index + match[0].length - 1) @@ -222,46 +268,53 @@ export async function findClassListsInHtmlRange( }) } - result.push( - ...classLists - .map(({ value, offset }) => { - if (value.trim() === '') { - return null - } + classLists.forEach(({ value, offset }) => { + if (value.trim() === '') { + return null + } - const before = value.match(/^\s*/) - const beforeOffset = before === null ? 0 : before[0].length - const after = value.match(/\s*$/) - const afterOffset = after === null ? 0 : -after[0].length - - const start = indexToPosition( - text, - match.index + match[0].length - 1 + offset + beforeOffset, - ) - const end = indexToPosition( - text, - match.index + match[0].length - 1 + offset + value.length + afterOffset, - ) - - return { - classList: value.substr(beforeOffset, value.length + afterOffset), - range: { - start: { - line: (range?.start.line || 0) + start.line, - character: (end.line === 0 ? range?.start.character || 0 : 0) + start.character, - }, - end: { - line: (range?.start.line || 0) + end.line, - character: (end.line === 0 ? range?.start.character || 0 : 0) + end.character, - }, - }, - } - }) - .filter((x) => x !== null), - ) + const before = value.match(/^\s*/) + const beforeOffset = before === null ? 0 : before[0].length + const after = value.match(/\s*$/) + const afterOffset = after === null ? 0 : -after[0].length + + const start = indexToPosition(text, match.index + match[0].length - 1 + offset + beforeOffset) + const end = indexToPosition( + text, + match.index + match[0].length - 1 + offset + value.length + afterOffset, + ) + + const result: DocumentClassList = { + classList: value.substr(beforeOffset, value.length + afterOffset), + range: { + start: { + line: (range?.start.line || 0) + start.line, + character: (end.line === 0 ? range?.start.character || 0 : 0) + start.character, + }, + end: { + line: (range?.start.line || 0) + end.line, + character: (end.line === 0 ? range?.start.character || 0 : 0) + end.character, + }, + }, + } + + const resultKey = [ + result.classList, + result.range.start.line, + result.range.start.character, + result.range.end.line, + result.range.end.character, + ].join(':') + + // No need to add the result if it was already matched + if (!existingResultSet.has(resultKey)) { + existingResultSet.add(resultKey) + results.push(result) + } + }) }) - return result + return results } export async function findClassListsInRange( @@ -407,14 +460,14 @@ export function findHelperFunctionsInRange( helper, path, ranges: { - full: resolveRange( + full: absoluteRange( { start: indexToPosition(text, startIndex), end: indexToPosition(text, startIndex + match.groups.path.length), }, range, ), - path: resolveRange( + path: absoluteRange( { start: indexToPosition(text, startIndex + quotesBefore.length), end: indexToPosition(text, startIndex + quotesBefore.length + path.length), diff --git a/packages/tailwindcss-language-service/src/util/lexers.ts b/packages/tailwindcss-language-service/src/util/lexers.ts index 38ecc2d7..a8315f51 100644 --- a/packages/tailwindcss-language-service/src/util/lexers.ts +++ b/packages/tailwindcss-language-service/src/util/lexers.ts @@ -29,6 +29,14 @@ const classAttributeStates: () => { [x: string]: moo.Rules } = () => ({ rbrace: { match: new RegExp('(? { start2: { match: "'", push: 'singleClassList' }, start3: { match: '{', push: 'interpBrace' }, start4: { match: '`', push: 'tickClassList' }, + start5: { match: '(', push: 'interpParen' }, }, ...classAttributeStates(), }) diff --git a/packages/tailwindcss-language-service/src/util/resolveRange.ts b/packages/tailwindcss-language-service/src/util/resolveRange.ts deleted file mode 100644 index d90fa5b9..00000000 --- a/packages/tailwindcss-language-service/src/util/resolveRange.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Range } from 'vscode-languageserver' - -export function resolveRange(range: Range, relativeTo?: Range) { - return { - start: { - line: (relativeTo?.start.line || 0) + range.start.line, - character: - (range.end.line === 0 ? relativeTo?.start.character || 0 : 0) + range.start.character, - }, - end: { - line: (relativeTo?.start.line || 0) + range.end.line, - character: - (range.end.line === 0 ? relativeTo?.start.character || 0 : 0) + range.end.character, - }, - } -} diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 95afe8ec..3bdb1bc4 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -46,6 +46,7 @@ export type TailwindCssSettings = { emmetCompletions: boolean includeLanguages: Record classAttributes: string[] + classFunctions: string[] suggestions: boolean hovers: boolean codeActions: boolean @@ -64,7 +65,7 @@ export type TailwindCssSettings = { recommendedVariantOrder: DiagnosticSeveritySetting } experimental: { - classRegex: string[] + classRegex: string[] | [string, string][] configFile: string | Record | null } files: { @@ -171,3 +172,73 @@ export type ClassNameMeta = { scope: string[] context: string[] } + +/** + * @internal + */ +export function getDefaultTailwindSettings(): Settings { + return { + editor: { tabSize: 2 }, + tailwindCSS: { + inspectPort: null, + emmetCompletions: false, + classAttributes: ['class', 'className', 'ngClass', 'class:list'], + classFunctions: [], + codeActions: true, + hovers: true, + suggestions: true, + validate: true, + colorDecorators: true, + rootFontSize: 16, + lint: { + cssConflict: 'warning', + invalidApply: 'error', + invalidScreen: 'error', + invalidVariant: 'error', + invalidConfigPath: 'error', + invalidTailwindDirective: 'error', + invalidSourceDirective: 'error', + recommendedVariantOrder: 'warning', + }, + showPixelEquivalents: true, + includeLanguages: {}, + files: { exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'] }, + experimental: { + classRegex: [], + configFile: null, + }, + }, + } +} + +/** + * @internal + */ +export function createState( + partial: Omit, 'editor'> & { + editor?: Partial + }, +): State { + return { + enabled: true, + ...partial, + editor: { + get connection(): Connection { + throw new Error('Not implemented') + }, + folder: '/', + userLanguages: {}, + capabilities: { + configuration: true, + diagnosticRelatedInformation: true, + itemDefaults: [], + }, + getConfiguration: () => { + throw new Error('Not implemented') + }, + getDocumentSymbols: async () => [], + readDirectory: async () => [], + ...partial.editor, + }, + } +} diff --git a/packages/tailwindcss-language-service/tsconfig.json b/packages/tailwindcss-language-service/tsconfig.json index 605ece3c..883356e7 100644 --- a/packages/tailwindcss-language-service/tsconfig.json +++ b/packages/tailwindcss-language-service/tsconfig.json @@ -1,6 +1,5 @@ { "include": ["src", "../../types"], - "exclude": ["src/**/*.test.ts"], "compilerOptions": { "module": "NodeNext", "lib": ["ES2022"], @@ -15,6 +14,7 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "moduleResolution": "NodeNext", + "skipLibCheck": true, "jsx": "react", "esModuleInterop": true } diff --git a/packages/vscode-tailwindcss/README.md b/packages/vscode-tailwindcss/README.md index 62165308..efc937cc 100644 --- a/packages/vscode-tailwindcss/README.md +++ b/packages/vscode-tailwindcss/README.md @@ -90,6 +90,26 @@ Enable completions when using [Emmet](https://emmet.io/)-style syntax, for examp The HTML attributes for which to provide class completions, hover previews, linting etc. **Default: `class`, `className`, `ngClass`, `class:list`** +### `tailwindCSS.classFunctions` + +Functions in which to provide completions, hover previews, linting etc. Currently, this works for both function calls and tagged template literals in JavaScript / TypeScript. + +Example: + +```json +{ + "tailwindCSS.classFunctions": ["tw", "clsx"] +} +``` + +```javascript +let classes = tw`flex bg-red-500` +let classes2 = clsx([ + "flex bg-red-500", + { "text-red-500": true } +]) +``` + ### `tailwindCSS.colorDecorators` Controls whether the editor should render inline color decorators for Tailwind CSS classes and helper functions. **Default: `true`** diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 08906fbd..9f218da6 100644 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -184,6 +184,14 @@ ], "markdownDescription": "The HTML attributes for which to provide class completions, hover previews, linting etc." }, + "tailwindCSS.classFunctions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "The function or tagged template literal names for which to provide class completions, hover previews, linting etc." + }, "tailwindCSS.suggestions": { "type": "boolean", "default": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1048aaa..2378a376 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,9 @@ importers: '@types/css.escape': specifier: ^1.5.2 version: 1.5.2 + '@types/dedent': + specifier: ^0.7.2 + version: 0.7.2 '@types/line-column': specifier: ^1.0.2 version: 1.0.2 @@ -309,6 +312,9 @@ importers: '@types/stringify-object': specifier: ^4.0.5 version: 4.0.5 + dedent: + specifier: ^1.5.3 + version: 1.5.3 esbuild: specifier: ^0.25.0 version: 0.25.0 @@ -969,6 +975,9 @@ packages: '@types/debounce@1.2.0': resolution: {integrity: sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw==} + '@types/dedent@0.7.2': + resolution: {integrity: sha512-kRiitIeUg1mPV9yH4VUJ/1uk2XjyANfeL8/7rH1tsjvHeO9PJLBHJIYsFWmAvmGj5u8rj+1TZx7PZzW2qLw3Lw==} + '@types/dlv@1.1.4': resolution: {integrity: sha512-m8KmImw4Jt+4rIgupwfivrWEOnj1LzkmKkqbh075uG13eTQ1ZxHWT6T0vIdSQhLIjQCiR0n0lZdtyDOPO1x2Mw==} @@ -3227,6 +3236,8 @@ snapshots: '@types/debounce@1.2.0': {} + '@types/dedent@0.7.2': {} + '@types/dlv@1.1.4': {} '@types/estree@1.0.6': {}