From 39b4a94a3f6984044a3fd05ab7582c0baf372eaa Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Thu, 6 Mar 2025 20:07:06 +0200 Subject: [PATCH 01/32] Added "tailwindCSS.experimental.classFunctions" option --- .../tailwindcss-language-server/src/config.ts | 1 + .../src/completionProvider.ts | 6 +++++- .../src/util/find.ts | 16 ++++++++++++---- .../src/util/lexers.ts | 9 +++++++++ .../src/util/state.ts | 1 + packages/vscode-tailwindcss/package.json | 8 ++++++++ 6 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss-language-server/src/config.ts b/packages/tailwindcss-language-server/src/config.ts index d8364d06..59e2a90e 100644 --- a/packages/tailwindcss-language-server/src/config.ts +++ b/packages/tailwindcss-language-server/src/config.ts @@ -37,6 +37,7 @@ function getDefaultSettings(): Settings { experimental: { classRegex: [], configFile: null, + classFunctions: [] }, }, } diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index c67eb0f5..9f0550b0 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' @@ -747,6 +747,10 @@ async function provideClassAttributeCompletions( let matches = matchClassAttributes(str, settings.classAttributes) + if (settings.experimental.classFunctions.length > 0) { + matches.push(...matchClassFunctions(str, settings.experimental.classFunctions)) + } + if (matches.length === 0) { return null } diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index f38d7a16..1d3fac82 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -164,6 +164,12 @@ 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[] { + const names = fnNames.filter((x) => typeof x === 'string') + const re = /\b(F_NAMES)\(/ + return findAll(new RegExp(re.source.replace('F_NAMES', names.join('|')), 'gi'), text) +} + export async function findClassListsInHtmlRange( state: State, doc: TextDocument, @@ -172,10 +178,12 @@ export async function findClassListsInHtmlRange( ): Promise { 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) + + if (settings.experimental.classFunctions.length > 0) { + matches.push(...matchClassFunctions(text, settings.experimental.classFunctions)) + } const result: DocumentClassList[] = [] 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/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 95afe8ec..ffd741cc 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -66,6 +66,7 @@ export type TailwindCssSettings = { experimental: { classRegex: string[] configFile: string | Record | null + classFunctions: string[] } files: { exclude: string[] diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 08906fbd..859e13b1 100644 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -304,6 +304,14 @@ "default": null, "markdownDescription": "Manually specify the Tailwind config file or files that should be read to provide IntelliSense features. Can either be a single string value, or an object where each key is a config file path and each value is a glob or array of globs representing the set of files that the config file applies to." }, + "tailwindCSS.experimental.classFunctions": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "The function names for which to provide class completions, hover previews, linting etc." + }, "tailwindCSS.showPixelEquivalents": { "type": "boolean", "default": true, From baee7b5bf03da0d463a6361c9625e6e110082545 Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Fri, 7 Mar 2025 12:50:42 +0200 Subject: [PATCH 02/32] Use optional chaining to access classFunctions --- packages/tailwindcss-language-service/src/completionProvider.ts | 2 +- packages/tailwindcss-language-service/src/util/find.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index 9f0550b0..7050bf30 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -747,7 +747,7 @@ async function provideClassAttributeCompletions( let matches = matchClassAttributes(str, settings.classAttributes) - if (settings.experimental.classFunctions.length > 0) { + if (settings.experimental.classFunctions?.length) { matches.push(...matchClassFunctions(str, settings.experimental.classFunctions)) } diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index 1d3fac82..25afde92 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -181,7 +181,7 @@ export async function findClassListsInHtmlRange( const settings = (await state.editor.getConfiguration(doc.uri)).tailwindCSS const matches = matchClassAttributes(text, settings.classAttributes) - if (settings.experimental.classFunctions.length > 0) { + if (settings.experimental.classFunctions?.length) { matches.push(...matchClassFunctions(text, settings.experimental.classFunctions)) } From dc5a29830bfcbe46e44a28f010195859936743b6 Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Fri, 7 Mar 2025 12:53:18 +0200 Subject: [PATCH 03/32] Added tests for "tailwindCSS.experimental.classFunctions" --- .../src/util/find.test.ts | 235 +++++++++++++++++- 1 file changed, 233 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 90dbcfb3..c078afb2 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -1,9 +1,9 @@ -import type { State } from './state' +import type { DocumentClassList, State } from './state' import { test } from 'vitest' import { TextDocument } from 'vscode-languageserver-textdocument' import { findClassListsInHtmlRange } from './find' -test('test', async ({ expect }) => { +test('test astro', async ({ expect }) => { let content = [ // '', @@ -79,3 +79,234 @@ test('test', async ({ expect }) => { ] `) }) + +test('test simple classFunctions', async ({ expect }) => { + const state: State = { + blocklist: [], + editor: { + userLanguages: {}, + getConfiguration: async () => ({ + editor: { + tabSize: 1, + }, + tailwindCSS: { + classAttributes: ['class'], + experimental: { + classFunctions: ['cva', 'cn'], + }, + }, + }), + }, + } as any + + const classList = `'pointer-events-auto relative flex bg-red-500', + 'items-center justify-between overflow-hidden', + 'md:min-w-[20rem] md:max-w-[37.5rem] md:py-sm py-xs pl-md pr-xs gap-sm w-full', + 'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]', + Date.now() > 15000 ? 'text-red-200' : 'text-red-700', + 'data-[swipe=move]:transition-none', + ` + + const expectedResult: DocumentClassList[] = [ + { + classList: 'pointer-events-auto relative flex bg-red-500', + range: { + start: { character: 7, line: 2 }, + end: { character: 51, line: 2 }, + }, + }, + { + classList: 'items-center justify-between overflow-hidden', + range: { + start: { character: 7, line: 3 }, + end: { character: 51, line: 3 }, + }, + }, + { + classList: 'md:min-w-[20rem] md:max-w-[37.5rem] md:py-sm py-xs pl-md pr-xs gap-sm w-full', + range: { + start: { character: 7, line: 4 }, + end: { character: 83, line: 4 }, + }, + }, + { + classList: 'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]', + range: { + start: { line: 5, character: 7 }, + end: { line: 5, character: 68 }, + }, + }, + { + classList: 'text-red-200', + range: { + start: { line: 6, character: 28 }, + end: { line: 6, character: 40 }, + }, + }, + { + classList: 'text-red-700', + range: { + start: { line: 6, character: 45 }, + end: { line: 6, character: 57 }, + }, + }, + { + classList: 'data-[swipe=move]:transition-none', + range: { + start: { line: 7, character: 7 }, + end: { line: 7, character: 40 }, + }, + }, + ] + + const cnContent = ` + const classes = cn( + ${classList} + ) + ` + + const cnDoc = TextDocument.create('file://file.html', 'html', 1, cnContent) + const cnClassLists = await findClassListsInHtmlRange(state, cnDoc, 'html') + + expect(cnClassLists).toMatchObject(expectedResult) + + const cvaContent = ` + const classes = cva( + ${classList} + ) + ` + + const cvaDoc = TextDocument.create('file://file.html', 'html', 1, cvaContent) + const cvaClassLists = await findClassListsInHtmlRange(state, cvaDoc, 'html') + + expect(cvaClassLists).toMatchObject(expectedResult) + + // Ensure another function name with the same layout doesn't match + const cmaContent = ` + const classes = cma( + ${classList} + ) + ` + + const cmaDoc = TextDocument.create('file://file.html', 'html', 1, cmaContent) + const cmaClassLists = await findClassListsInHtmlRange(state, cmaDoc, 'html') + + expect(cmaClassLists).not.toMatchObject(expectedResult) +}) + +test('test nested classFunctions', async ({ expect }) => { + const state: State = { + blocklist: [], + editor: { + userLanguages: {}, + getConfiguration: async () => ({ + editor: { + tabSize: 1, + }, + tailwindCSS: { + classAttributes: ['class'], + experimental: { + classFunctions: ['cva', 'cn'], + }, + }, + }), + }, + } as any + + const expectedResult: DocumentClassList[] = [ + { + classList: 'fixed flex', + range: { + start: { line: 3, character: 9 }, + end: { line: 3, character: 19 }, + }, + }, + { + classList: 'md:h-[calc(100%-2rem)]', + range: { + start: { line: 4, character: 9 }, + end: { line: 4, character: 31 }, + }, + }, + { + classList: 'bg-red-700', + range: { + start: { line: 5, character: 9 }, + end: { line: 5, character: 19 }, + }, + }, + { + classList: 'bottom-0 left-0', + range: { + start: { line: 10, character: 22 }, + end: { line: 10, character: 37 }, + }, + }, + { + classList: + 'inset-0\n md:h-[calc(100%-2rem)]\n rounded-none\n ', + range: { + start: { line: 12, character: 14 }, + end: { line: 14, character: 26 }, + }, + }, + { + classList: 'default', + range: { + start: { line: 19, character: 19 }, + end: { line: 19, character: 26 }, + }, + }, + { + classList: 'fixed flex', + range: { + start: { line: 3, character: 9 }, + end: { line: 3, character: 19 }, + }, + }, + { + classList: 'md:h-[calc(100%-2rem)]', + range: { + start: { line: 4, character: 9 }, + end: { line: 4, character: 31 }, + }, + }, + { + classList: 'bg-red-700', + range: { + start: { line: 5, character: 9 }, + end: { line: 5, character: 19 }, + }, + }, + ] + + const content = ` + const variants = cva( + cn( + 'fixed flex', + 'md:h-[calc(100%-2rem)]', + 'bg-red-700', + ), + { + variants: { + mobile: { + default: 'bottom-0 left-0', + fullScreen: \` + inset-0 + md:h-[calc(100%-2rem)] + rounded-none + \`, + }, + }, + defaultVariants: { + mobile: 'default', + }, + }, + ) + ` + + const cnDoc = TextDocument.create('file://file.html', 'html', 1, content) + const classLists = await findClassListsInHtmlRange(state, cnDoc, 'html') + + expect(classLists).toMatchObject(expectedResult) +}) From 0e227e9a17ac6569f5370e18a92064eefd257b9f Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Fri, 7 Mar 2025 15:09:54 +0200 Subject: [PATCH 04/32] Removed settings duplication: Created getDefaultTailwindSettings in @tailwindcss/language-service --- .../tailwindcss-language-server/src/config.ts | 42 +++---------------- .../tests/utils/configuration.ts | 41 +++--------------- .../src/util/state.ts | 35 ++++++++++++++++ 3 files changed, 45 insertions(+), 73 deletions(-) diff --git a/packages/tailwindcss-language-server/src/config.ts b/packages/tailwindcss-language-server/src/config.ts index 59e2a90e..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,41 +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, - classFunctions: [] - }, - }, - } -} - export function createSettingsCache(connection: Connection): SettingsCache { const cache: Map = new Map() @@ -74,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/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-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index ffd741cc..5fd6696c 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -172,3 +172,38 @@ export type ClassNameMeta = { scope: string[] context: string[] } + +export function getDefaultTailwindSettings(): 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, + classFunctions: [], + }, + }, + } +} From 100cad726e06e4a856080ceb8b438f3101766c1a Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Fri, 7 Mar 2025 15:14:50 +0200 Subject: [PATCH 05/32] Use getDefaultTailwindSettings in find.test.ts --- .../src/util/find.test.ts | 81 +++++++++---------- .../src/util/state.ts | 2 +- 2 files changed, 38 insertions(+), 45 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index c078afb2..84b77812 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -1,4 +1,9 @@ -import type { DocumentClassList, State } from './state' +import { + getDefaultTailwindSettings, + type DocumentClassList, + type EditorState, + type State, +} from './state' import { test } from 'vitest' import { TextDocument } from 'vscode-languageserver-textdocument' import { findClassListsInHtmlRange } from './find' @@ -12,26 +17,27 @@ test('test astro', async ({ expect }) => { ].join('\n') let doc = TextDocument.create('file://file.astro', 'astro', 1, content) + let defaultSettings = getDefaultTailwindSettings() let state: State = { blocklist: [], + enabled: true, editor: { userLanguages: {}, getConfiguration: async () => ({ - editor: { - tabSize: 1, - }, + ...defaultSettings, tailwindCSS: { - classAttributes: ['class'], + ...defaultSettings.tailwindCSS, experimental: { + ...defaultSettings.tailwindCSS.experimental, classRegex: [ ['cva\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], ['cn\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], ], }, - } as any, + }, }), - } as any, - } as any + } as EditorState, + } let classLists = await findClassListsInHtmlRange(state, doc, 'html') @@ -81,24 +87,7 @@ test('test astro', async ({ expect }) => { }) test('test simple classFunctions', async ({ expect }) => { - const state: State = { - blocklist: [], - editor: { - userLanguages: {}, - getConfiguration: async () => ({ - editor: { - tabSize: 1, - }, - tailwindCSS: { - classAttributes: ['class'], - experimental: { - classFunctions: ['cva', 'cn'], - }, - }, - }), - }, - } as any - + const state = getTailwindSettingsForClassFunctions() const classList = `'pointer-events-auto relative flex bg-red-500', 'items-center justify-between overflow-hidden', 'md:min-w-[20rem] md:max-w-[37.5rem] md:py-sm py-xs pl-md pr-xs gap-sm w-full', @@ -195,24 +184,7 @@ test('test simple classFunctions', async ({ expect }) => { }) test('test nested classFunctions', async ({ expect }) => { - const state: State = { - blocklist: [], - editor: { - userLanguages: {}, - getConfiguration: async () => ({ - editor: { - tabSize: 1, - }, - tailwindCSS: { - classAttributes: ['class'], - experimental: { - classFunctions: ['cva', 'cn'], - }, - }, - }), - }, - } as any - + const state = getTailwindSettingsForClassFunctions() const expectedResult: DocumentClassList[] = [ { classList: 'fixed flex', @@ -310,3 +282,24 @@ test('test nested classFunctions', async ({ expect }) => { expect(classLists).toMatchObject(expectedResult) }) + +function getTailwindSettingsForClassFunctions(): State { + const defaultSettings = getDefaultTailwindSettings() + return { + blocklist: [], + enabled: true, + editor: { + userLanguages: {}, + getConfiguration: async () => ({ + ...defaultSettings, + tailwindCSS: { + ...defaultSettings.tailwindCSS, + experimental: { + ...defaultSettings.tailwindCSS.experimental, + classFunctions: ['cva', 'cn'], + }, + }, + }), + } as EditorState, + } +} diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 5fd6696c..29db4c88 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -64,7 +64,7 @@ export type TailwindCssSettings = { recommendedVariantOrder: DiagnosticSeveritySetting } experimental: { - classRegex: string[] + classRegex: string[] | [string, string][] configFile: string | Record | null classFunctions: string[] } From 41cc24ac335db896a2627616a022f92d998b81d0 Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Fri, 7 Mar 2025 16:15:09 +0200 Subject: [PATCH 06/32] Fixed findClassListsInHtmlRange state type & removed type casting in find.test.ts --- .../src/util/find.test.ts | 24 ++++++------------- .../src/util/find.ts | 4 +++- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 84b77812..9a103cd8 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -1,9 +1,4 @@ -import { - getDefaultTailwindSettings, - type DocumentClassList, - type EditorState, - type State, -} from './state' +import { getDefaultTailwindSettings, type DocumentClassList } from './state' import { test } from 'vitest' import { TextDocument } from 'vscode-languageserver-textdocument' import { findClassListsInHtmlRange } from './find' @@ -18,15 +13,13 @@ test('test astro', async ({ expect }) => { let doc = TextDocument.create('file://file.astro', 'astro', 1, content) let defaultSettings = getDefaultTailwindSettings() - let state: State = { - blocklist: [], - enabled: true, + let state: Parameters[0] = { editor: { - userLanguages: {}, getConfiguration: async () => ({ ...defaultSettings, tailwindCSS: { ...defaultSettings.tailwindCSS, + classAttributes: ['class'], experimental: { ...defaultSettings.tailwindCSS.experimental, classRegex: [ @@ -36,7 +29,7 @@ test('test astro', async ({ expect }) => { }, }, }), - } as EditorState, + }, } let classLists = await findClassListsInHtmlRange(state, doc, 'html') @@ -180,7 +173,7 @@ test('test simple classFunctions', async ({ expect }) => { const cmaDoc = TextDocument.create('file://file.html', 'html', 1, cmaContent) const cmaClassLists = await findClassListsInHtmlRange(state, cmaDoc, 'html') - expect(cmaClassLists).not.toMatchObject(expectedResult) + expect(cmaClassLists).toMatchObject([]) }) test('test nested classFunctions', async ({ expect }) => { @@ -283,13 +276,10 @@ test('test nested classFunctions', async ({ expect }) => { expect(classLists).toMatchObject(expectedResult) }) -function getTailwindSettingsForClassFunctions(): State { +function getTailwindSettingsForClassFunctions(): Parameters[0] { const defaultSettings = getDefaultTailwindSettings() return { - blocklist: [], - enabled: true, editor: { - userLanguages: {}, getConfiguration: async () => ({ ...defaultSettings, tailwindCSS: { @@ -300,6 +290,6 @@ function getTailwindSettingsForClassFunctions(): State { }, }, }), - } as EditorState, + }, } } diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index 25afde92..aa9ba3bc 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -171,7 +171,9 @@ export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatc } export async function findClassListsInHtmlRange( - state: State, + // PickDeep from 'type-fest' package cannot be used here due to a circular reference issue in the package + // Fixes are underway (see: https://github.com/sindresorhus/type-fest/pull/1079) + state: { editor?: Pick, 'getConfiguration'> }, doc: TextDocument, type: 'html' | 'js' | 'jsx', range?: Range, From 3b4e04c53ee6fb7d29f255dbb3e70c5e6839261f Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Fri, 7 Mar 2025 16:31:48 +0200 Subject: [PATCH 07/32] Changed getDefaultTailwindSettings to return const object that satisfies Settings --- packages/tailwindcss-language-service/src/util/state.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 29db4c88..0c808060 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -173,7 +173,7 @@ export type ClassNameMeta = { context: string[] } -export function getDefaultTailwindSettings(): Settings { +export function getDefaultTailwindSettings() { return { editor: { tabSize: 2 }, tailwindCSS: { @@ -205,5 +205,6 @@ export function getDefaultTailwindSettings(): Settings { classFunctions: [], }, }, - } + // Return this as const object that satisfies Settings to be able to see the exact default values we specify + } as const satisfies Settings } From 7d059da9cde2f39f195fa25d19a104d436fd4398 Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Fri, 7 Mar 2025 17:44:24 +0200 Subject: [PATCH 08/32] Moved classFunctions option out of experimental --- .../src/completionProvider.ts | 4 ++-- .../src/util/find.test.ts | 5 +---- .../src/util/find.ts | 4 ++-- .../src/util/state.ts | 4 ++-- packages/vscode-tailwindcss/package.json | 16 ++++++++-------- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index 7050bf30..7343c1e9 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -747,8 +747,8 @@ async function provideClassAttributeCompletions( let matches = matchClassAttributes(str, settings.classAttributes) - if (settings.experimental.classFunctions?.length) { - matches.push(...matchClassFunctions(str, settings.experimental.classFunctions)) + if (settings.classFunctions?.length) { + matches.push(...matchClassFunctions(str, settings.classFunctions)) } if (matches.length === 0) { diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 9a103cd8..977881f7 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -284,10 +284,7 @@ function getTailwindSettingsForClassFunctions(): Parameters classAttributes: string[] + classFunctions: string[] suggestions: boolean hovers: boolean codeActions: boolean @@ -66,7 +67,6 @@ export type TailwindCssSettings = { experimental: { classRegex: string[] | [string, string][] configFile: string | Record | null - classFunctions: string[] } files: { exclude: string[] @@ -180,6 +180,7 @@ export function getDefaultTailwindSettings() { inspectPort: null, emmetCompletions: false, classAttributes: ['class', 'className', 'ngClass', 'class:list'], + classFunctions: [], codeActions: true, hovers: true, suggestions: true, @@ -202,7 +203,6 @@ export function getDefaultTailwindSettings() { experimental: { classRegex: [], configFile: null, - classFunctions: [], }, }, // Return this as const object that satisfies Settings to be able to see the exact default values we specify diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 859e13b1..de73bfba 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 names for which to provide class completions, hover previews, linting etc." + }, "tailwindCSS.suggestions": { "type": "boolean", "default": true, @@ -304,14 +312,6 @@ "default": null, "markdownDescription": "Manually specify the Tailwind config file or files that should be read to provide IntelliSense features. Can either be a single string value, or an object where each key is a config file path and each value is a glob or array of globs representing the set of files that the config file applies to." }, - "tailwindCSS.experimental.classFunctions": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "markdownDescription": "The function names for which to provide class completions, hover previews, linting etc." - }, "tailwindCSS.showPixelEquivalents": { "type": "boolean", "default": true, From dc441bc55387bea5fd98e5371a64fb6812b49b85 Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Fri, 7 Mar 2025 18:12:43 +0200 Subject: [PATCH 09/32] Added support for tagged template literals --- .../src/util/find.test.ts | 51 +++++++++++++++++++ .../src/util/find.ts | 2 +- packages/vscode-tailwindcss/package.json | 2 +- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 977881f7..ff4e85d3 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -276,6 +276,57 @@ test('test nested classFunctions', async ({ expect }) => { expect(classLists).toMatchObject(expectedResult) }) +test('test classFunctions with tagged template literals', async ({ expect }) => { + const state = getTailwindSettingsForClassFunctions() + const classList = `pointer-events-auto relative flex bg-red-500 + items-center justify-between overflow-hidden + md:min-w-[20rem] md:max-w-[37.5rem] md:py-sm pl-md py-xs pr-xs gap-sm w-full + data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] + md:h-[calc(100%-2rem)] + data-[swipe=move]:transition-none` + + const expectedResult: DocumentClassList[] = [ + { + classList, + range: { + start: { line: 2, character: 6 }, + end: { line: 7, character: 37 }, + }, + }, + ] + + const cnContent = ` + const tagged = cn\` + ${classList}\` + ` + const cnDoc = TextDocument.create('file://file.html', 'html', 1, cnContent) + const cnClassLists = await findClassListsInHtmlRange(state, cnDoc, 'html') + + console.log('cnClassLists', JSON.stringify(cnClassLists, null, 2)) + + expect(cnClassLists).toMatchObject(expectedResult) + + const cvaContent = ` + const tagged = cva\` + ${classList}\` + ` + const cvaDoc = TextDocument.create('file://file.html', 'html', 1, cvaContent) + const cvaClassLists = await findClassListsInHtmlRange(state, cvaDoc, 'html') + + expect(cvaClassLists).toMatchObject(expectedResult) + + // Ensure another tag name with the same layout doesn't match + const cmaContent = ` + const tagged = cma\` + ${classList}\` + ` + + const cmaDoc = TextDocument.create('file://file.html', 'html', 1, cmaContent) + const cmaClassLists = await findClassListsInHtmlRange(state, cmaDoc, 'html') + + expect(cmaClassLists).toMatchObject([]) +}) + function getTailwindSettingsForClassFunctions(): Parameters[0] { const defaultSettings = getDefaultTailwindSettings() return { diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index 5fd93dd7..2adda7f7 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -166,7 +166,7 @@ export function matchClassAttributes(text: string, attributes: string[]): RegExp export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatchArray[] { const names = fnNames.filter((x) => typeof x === 'string') - const re = /\b(F_NAMES)\(/ + const re = /\b(F_NAMES)(\(|`)/ return findAll(new RegExp(re.source.replace('F_NAMES', names.join('|')), 'gi'), text) } diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index de73bfba..9f218da6 100644 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -190,7 +190,7 @@ "type": "string" }, "default": [], - "markdownDescription": "The function names for which to provide class completions, hover previews, linting etc." + "markdownDescription": "The function or tagged template literal names for which to provide class completions, hover previews, linting etc." }, "tailwindCSS.suggestions": { "type": "boolean", From 43fab13e0aa000d2a0b4e375d9dd3f10fe5a7471 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 12 Mar 2025 11:40:16 -0400 Subject: [PATCH 10/32] wip --- .../src/util/find.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index 2adda7f7..b9d1b999 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -165,9 +165,27 @@ export function matchClassAttributes(text: string, attributes: string[]): RegExp } export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatchArray[] { - const names = fnNames.filter((x) => typeof x === 'string') - const re = /\b(F_NAMES)(\(|`)/ - return findAll(new RegExp(re.source.replace('F_NAMES', names.join('|')), 'gi'), text) + // 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}$_.]*)[(`]/dgu + 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('|')), 'dgi') + return foundFns.filter((fn) => isClassFn.test(fn[1])) } export async function findClassListsInHtmlRange( From 9ee8b19bc99a1439fc88959298fb0570d50b2b6f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 12 Mar 2025 11:46:40 -0400 Subject: [PATCH 11/32] wip --- .../src/util/find.test.ts | 21 ------------------- .../src/util/find.ts | 7 +++++-- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index ff4e85d3..41ffa77e 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -222,27 +222,6 @@ test('test nested classFunctions', async ({ expect }) => { end: { line: 19, character: 26 }, }, }, - { - classList: 'fixed flex', - range: { - start: { line: 3, character: 9 }, - end: { line: 3, character: 19 }, - }, - }, - { - classList: 'md:h-[calc(100%-2rem)]', - range: { - start: { line: 4, character: 9 }, - end: { line: 4, character: 31 }, - }, - }, - { - classList: 'bg-red-700', - range: { - start: { line: 5, character: 9 }, - end: { line: 5, character: 19 }, - }, - }, ] const content = ` diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index b9d1b999..ba73e9da 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -179,13 +179,16 @@ export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatc // - 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}$_.]*)[(`]/dgu + 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('|')), 'dgi') - return foundFns.filter((fn) => isClassFn.test(fn[1])) + + let matches = foundFns.filter((fn) => isClassFn.test(fn[1])) + + return matches } export async function findClassListsInHtmlRange( From 94f130039252a04dd3f4f77dd07f27c513cbf210 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 12 Mar 2025 11:50:35 -0400 Subject: [PATCH 12/32] wip --- packages/tailwindcss-language-service/src/util/find.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index ba73e9da..b0ab3bf3 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -192,13 +192,13 @@ export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatc } export async function findClassListsInHtmlRange( - // PickDeep from 'type-fest' package cannot be used here due to a circular reference issue in the package - // Fixes are underway (see: https://github.com/sindresorhus/type-fest/pull/1079) - state: { editor?: Pick, 'getConfiguration'> }, + state: State, doc: TextDocument, type: 'html' | 'js' | 'jsx', range?: Range, ): Promise { + if (!state.editor) return [] + const text = getTextWithoutComments(doc, type, range) const settings = (await state.editor.getConfiguration(doc.uri)).tailwindCSS From e29481dc3687f6a1ba06e17ea5c79ff23412ec92 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 12 Mar 2025 12:02:04 -0400 Subject: [PATCH 13/32] wip --- .../src/util/find.test.ts | 6 ++-- .../src/util/state.ts | 34 +++++++++++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 41ffa77e..ab9bce59 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -1,4 +1,4 @@ -import { getDefaultTailwindSettings, type DocumentClassList } from './state' +import { createState, getDefaultTailwindSettings, type DocumentClassList } from './state' import { test } from 'vitest' import { TextDocument } from 'vscode-languageserver-textdocument' import { findClassListsInHtmlRange } from './find' @@ -13,7 +13,7 @@ test('test astro', async ({ expect }) => { let doc = TextDocument.create('file://file.astro', 'astro', 1, content) let defaultSettings = getDefaultTailwindSettings() - let state: Parameters[0] = { + let state = createState({ editor: { getConfiguration: async () => ({ ...defaultSettings, @@ -30,7 +30,7 @@ test('test astro', async ({ expect }) => { }, }), }, - } + }) let classLists = await findClassListsInHtmlRange(state, doc, 'html') diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 68a199c3..26e84308 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -173,7 +173,7 @@ export type ClassNameMeta = { context: string[] } -export function getDefaultTailwindSettings() { +export function getDefaultTailwindSettings(): Settings { return { editor: { tabSize: 2 }, tailwindCSS: { @@ -205,6 +205,34 @@ export function getDefaultTailwindSettings() { configFile: null, }, }, - // Return this as const object that satisfies Settings to be able to see the exact default values we specify - } as const satisfies Settings + } +} + +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, + }, + } } From 23ccf9c544d8a1011fd31583734577561ba0900b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 13 Mar 2025 12:50:06 -0400 Subject: [PATCH 14/32] Move types --- packages/tailwindcss-language-server/tests/utils/client.ts | 2 +- .../tests/utils => tailwindcss-language-service/src}/types.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/{tailwindcss-language-server/tests/utils => tailwindcss-language-service/src}/types.ts (100%) 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/types.ts b/packages/tailwindcss-language-service/src/types.ts similarity index 100% rename from packages/tailwindcss-language-server/tests/utils/types.ts rename to packages/tailwindcss-language-service/src/types.ts From 481d270a5a9898266e0c58d13563941056f79e97 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 13 Mar 2025 12:50:11 -0400 Subject: [PATCH 15/32] wip --- packages/tailwindcss-language-service/src/util/state.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 26e84308..d548b270 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -173,6 +173,9 @@ export type ClassNameMeta = { context: string[] } +/** + * @internal + */ export function getDefaultTailwindSettings(): Settings { return { editor: { tabSize: 2 }, From b2140924aaf7ee4fe44dd44b268819582dbc33ca Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 13 Mar 2025 12:50:23 -0400 Subject: [PATCH 16/32] Rewrite tests --- .../tailwindcss-language-service/package.json | 2 + .../src/util/find.test.ts | 620 +++++++++++------- 2 files changed, 401 insertions(+), 221 deletions(-) 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/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index ab9bce59..cc8ff013 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -1,322 +1,500 @@ -import { createState, getDefaultTailwindSettings, type DocumentClassList } 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 astro', async ({ expect }) => { - let content = [ - // - '', - ' ', - '', - ].join('\n') +const js = dedent - let doc = TextDocument.create('file://file.astro', 'astro', 1, content) - let defaultSettings = getDefaultTailwindSettings() - let state = createState({ - editor: { - getConfiguration: async () => ({ - ...defaultSettings, - tailwindCSS: { - ...defaultSettings.tailwindCSS, - classAttributes: ['class'], - experimental: { - ...defaultSettings.tailwindCSS.experimental, - classRegex: [ - ['cva\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], - ['cn\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], - ], - }, +test('class regex works in astro', async ({ expect }) => { + let { doc, state } = createDocument({ + name: 'file.astro', + lang: 'astro', + settings: { + tailwindCSS: { + classAttributes: ['class'], + experimental: { + classRegex: [ + ['cva\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], + ['cn\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'], + ], }, - }), + }, }, + content: [ + '', + ' ', + '', + ], }) let classLists = await findClassListsInHtmlRange(state, doc, 'html') - expect(classLists).toMatchInlineSnapshot(` - [ - { - "classList": "p-4 sm:p-2 $", - "range": { - "end": { - "character": 22, - "line": 0, - }, - "start": { - "character": 10, - "line": 0, - }, - }, + expect(classLists).toEqual([ + { + classList: 'p-4 sm:p-2 $', + range: { + start: { line: 0, character: 10 }, + end: { line: 0, character: 22 }, }, - { - "classList": "underline", - "range": { - "end": { - "character": 42, - "line": 0, - }, - "start": { - "character": 33, - "line": 0, - }, - }, + }, + { + classList: 'underline', + range: { + start: { line: 0, character: 33 }, + end: { line: 0, character: 42 }, }, - { - "classList": "line-through", - "range": { - "end": { - "character": 58, - "line": 0, - }, - "start": { - "character": 46, - "line": 0, - }, - }, + }, + { + classList: 'line-through', + range: { + start: { line: 0, character: 46 }, + end: { line: 0, character: 58 }, }, - ] - `) + }, + ]) }) -test('test simple classFunctions', async ({ expect }) => { - const state = getTailwindSettingsForClassFunctions() - const classList = `'pointer-events-auto relative flex bg-red-500', - 'items-center justify-between overflow-hidden', - 'md:min-w-[20rem] md:max-w-[37.5rem] md:py-sm py-xs pl-md pr-xs gap-sm w-full', - 'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]', - Date.now() > 15000 ? 'text-red-200' : 'text-red-700', - 'data-[swipe=move]:transition-none', - ` - - const expectedResult: DocumentClassList[] = [ +test('find class lists in functions', async ({ expect }) => { + let fileA = createDocument({ + name: 'file.jsx', + lang: 'javascript', + 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: 'javascript', + 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: 'pointer-events-auto relative flex bg-red-500', + classList: 'flex p-4', range: { - start: { character: 7, line: 2 }, - end: { character: 51, line: 2 }, + start: { line: 2, character: 3 }, + end: { line: 2, character: 11 }, }, }, { - classList: 'items-center justify-between overflow-hidden', + classList: 'block sm:p-0', range: { - start: { character: 7, line: 3 }, - end: { character: 51, line: 3 }, + start: { line: 3, character: 3 }, + end: { line: 3, character: 15 }, }, }, { - classList: 'md:min-w-[20rem] md:max-w-[37.5rem] md:py-sm py-xs pl-md pr-xs gap-sm w-full', + classList: 'text-white', range: { - start: { character: 7, line: 4 }, - end: { character: 83, line: 4 }, + start: { line: 4, character: 22 }, + end: { line: 4, character: 32 }, }, }, { - classList: 'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]', + classList: 'text-black', range: { - start: { line: 5, character: 7 }, - end: { line: 5, character: 68 }, + start: { line: 4, character: 37 }, + end: { line: 4, character: 47 }, }, }, + + // from cva(…) { - classList: 'text-red-200', + classList: 'flex p-4', range: { - start: { line: 6, character: 28 }, - end: { line: 6, character: 40 }, + start: { line: 9, character: 3 }, + end: { line: 9, character: 11 }, }, }, { - classList: 'text-red-700', + classList: 'block sm:p-0', range: { - start: { line: 6, character: 45 }, - end: { line: 6, character: 57 }, + start: { line: 10, character: 3 }, + end: { line: 10, character: 15 }, }, }, { - classList: 'data-[swipe=move]:transition-none', + classList: 'text-white', range: { - start: { line: 7, character: 7 }, - end: { line: 7, character: 40 }, + start: { line: 11, character: 22 }, + end: { line: 11, character: 32 }, }, }, - ] - - const cnContent = ` - const classes = cn( - ${classList} - ) - ` - - const cnDoc = TextDocument.create('file://file.html', 'html', 1, cnContent) - const cnClassLists = await findClassListsInHtmlRange(state, cnDoc, 'html') + { + classList: 'text-black', + range: { + start: { line: 11, character: 37 }, + end: { line: 11, character: 47 }, + }, + }, + ]) - expect(cnClassLists).toMatchObject(expectedResult) + // none from cn(…) since it's not in the list of class functions + expect(classListsB).toEqual([]) +}) - const cvaContent = ` - const classes = cva( - ${classList} - ) - ` +test('find class lists in nested fn calls', async ({ expect }) => { + let fileA = createDocument({ + name: 'file.jsx', + lang: 'javascript', + settings: { + tailwindCSS: { + classFunctions: ['clsx', 'cva'], + }, + }, - const cvaDoc = TextDocument.create('file://file.html', 'html', 1, cvaContent) - const cvaClassLists = await findClassListsInHtmlRange(state, cvaDoc, 'html') + 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 + \`, + }, + } + } + ) + ) + `, + }) - expect(cvaClassLists).toMatchObject(expectedResult) + let classLists = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'html') - // Ensure another function name with the same layout doesn't match - const cmaContent = ` - const classes = cma( - ${classList} - ) - ` + expect(classLists).toMatchObject([ + { + classList: 'flex', + range: { + start: { line: 3, character: 3 }, + end: { line: 3, character: 7 }, + }, + }, - const cmaDoc = TextDocument.create('file://file.html', 'html', 1, cmaContent) - const cmaClassLists = await findClassListsInHtmlRange(state, cmaDoc, 'html') + // 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 }, + }, + }, - expect(cmaClassLists).toMatchObject([]) -}) + // TODO: This should be ignored because they're inside cn(…) + { + classList: 'text-white', + range: { + start: { line: 6, character: 5 }, + end: { line: 6, character: 15 }, + }, + }, -test('test nested classFunctions', async ({ expect }) => { - const state = getTailwindSettingsForClassFunctions() - const expectedResult: DocumentClassList[] = [ { - classList: 'fixed flex', + classList: 'fixed', + range: { + start: { line: 9, character: 5 }, + end: { line: 9, character: 10 }, + }, + }, + { + classList: 'absolute inset-0', range: { - start: { line: 3, character: 9 }, - end: { line: 3, character: 19 }, + start: { line: 10, character: 5 }, + end: { line: 10, character: 21 }, }, }, { - classList: 'md:h-[calc(100%-2rem)]', + classList: 'bottom-0', range: { - start: { line: 4, character: 9 }, - end: { line: 4, character: 31 }, + start: { line: 13, character: 6 }, + end: { line: 13, character: 14 }, }, }, { - classList: 'bg-red-700', + classList: 'border', range: { - start: { line: 5, character: 9 }, - end: { line: 5, character: 19 }, + start: { line: 13, character: 18 }, + end: { line: 13, character: 24 }, }, }, { classList: 'bottom-0 left-0', range: { - start: { line: 10, character: 22 }, - end: { line: 10, character: 37 }, + start: { line: 17, character: 20 }, + end: { line: 17, character: 35 }, }, }, { - classList: - 'inset-0\n md:h-[calc(100%-2rem)]\n rounded-none\n ', + classList: `inset-0\n rounded-none\n `, range: { - start: { line: 12, character: 14 }, - end: { line: 14, character: 26 }, + start: { line: 19, character: 12 }, + // TODO: Fix the range calculation. Its wrong on this one + end: { line: 20, character: 24 }, }, }, + + // TODO: These duplicates are from matching nested clsx(…) and should be ignored { - classList: 'default', + classList: 'fixed', range: { - start: { line: 19, character: 19 }, - end: { line: 19, character: 26 }, - }, - }, - ] - - const content = ` - const variants = cva( - cn( - 'fixed flex', - 'md:h-[calc(100%-2rem)]', - 'bg-red-700', - ), - { - variants: { - mobile: { - default: 'bottom-0 left-0', - fullScreen: \` - inset-0 - md:h-[calc(100%-2rem)] - rounded-none - \`, - }, - }, - defaultVariants: { - mobile: 'default', - }, + 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 }, }, - ) - ` + }, + ]) +}) - const cnDoc = TextDocument.create('file://file.html', 'html', 1, content) - const classLists = await findClassListsInHtmlRange(state, cnDoc, 'html') +test('find class lists in nested fn calls (only nested matches)', async ({ expect }) => { + let fileA = createDocument({ + name: 'file.jsx', + lang: 'javascript', + settings: { + tailwindCSS: { + classFunctions: ['clsx', 'cva'], + }, + }, - expect(classLists).toMatchObject(expectedResult) -}) + 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' + ), + ) + `, + }) -test('test classFunctions with tagged template literals', async ({ expect }) => { - const state = getTailwindSettingsForClassFunctions() - const classList = `pointer-events-auto relative flex bg-red-500 - items-center justify-between overflow-hidden - md:min-w-[20rem] md:max-w-[37.5rem] md:py-sm pl-md py-xs pr-xs gap-sm w-full - data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] - md:h-[calc(100%-2rem)] - data-[swipe=move]:transition-none` + let classLists = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'html') - const expectedResult: DocumentClassList[] = [ + expect(classLists).toMatchObject([ { - classList, + classList: 'fixed', range: { - start: { line: 2, character: 6 }, - end: { line: 7, character: 37 }, + start: { line: 9, character: 5 }, + end: { line: 9, character: 10 }, }, }, - ] - - const cnContent = ` - const tagged = cn\` - ${classList}\` - ` - const cnDoc = TextDocument.create('file://file.html', 'html', 1, cnContent) - const cnClassLists = await findClassListsInHtmlRange(state, cnDoc, 'html') + { + classList: 'absolute inset-0', + range: { + start: { line: 10, character: 5 }, + end: { line: 10, character: 21 }, + }, + }, + ]) +}) - console.log('cnClassLists', JSON.stringify(cnClassLists, null, 2)) +test('find class lists in tagged template literals', async ({ expect }) => { + let fileA = createDocument({ + name: 'file.jsx', + lang: 'javascript', + 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'} + \` - expect(cnClassLists).toMatchObject(expectedResult) + // These should match + let classes = cva\` + flex p-4 + block sm:p-0 + \${Date.now() > 100 ? 'text-white' : 'text-black'} + \` + `, + }) - const cvaContent = ` - const tagged = cva\` - ${classList}\` - ` - const cvaDoc = TextDocument.create('file://file.html', 'html', 1, cvaContent) - const cvaClassLists = await findClassListsInHtmlRange(state, cvaDoc, 'html') + let fileB = createDocument({ + name: 'file.jsx', + lang: 'javascript', + settings: { + tailwindCSS: { + classFunctions: ['clsx', 'cva'], + }, + }, + content: js` + let classes = cn\` + flex p-4 + block sm:p-0 + \${Date.now() > 100 ? 'text-white' : 'text-black'} + \` + `, + }) - expect(cvaClassLists).toMatchObject(expectedResult) + let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js') + let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js') - // Ensure another tag name with the same layout doesn't match - const cmaContent = ` - const tagged = cma\` - ${classList}\` - ` + 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 }, + }, + }, - const cmaDoc = TextDocument.create('file://file.html', 'html', 1, cmaContent) - const cmaClassLists = await findClassListsInHtmlRange(state, cmaDoc, 'html') + // 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 }, + }, + }, + ]) - expect(cmaClassLists).toMatchObject([]) + // none from cn`…` since it's not in the list of class functions + expect(classListsB).toEqual([]) }) -function getTailwindSettingsForClassFunctions(): Parameters[0] { - const defaultSettings = getDefaultTailwindSettings() - return { +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: { + // @ts-ignore getConfiguration: async () => ({ - ...defaultSettings, + ...defaults, + ...settings, tailwindCSS: { - ...defaultSettings.tailwindCSS, - classFunctions: ['cva', 'cn'], + ...defaults.tailwindCSS, + ...settings.tailwindCSS, + lint: { + ...defaults.tailwindCSS.lint, + ...(settings.tailwindCSS?.lint ?? {}), + }, + experimental: { + ...defaults.tailwindCSS.experimental, + ...(settings.tailwindCSS?.experimental ?? {}), + }, + files: { + ...defaults.tailwindCSS.files, + ...(settings.tailwindCSS?.files ?? {}), + }, + }, + editor: { + ...defaults.editor, + ...settings.editor, }, }), }, + }) + + return { + doc, + state, } } From 878b18dcf3760167a13bdc75ff763e9c2f8b342a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 13 Mar 2025 12:50:29 -0400 Subject: [PATCH 17/32] wip --- pnpm-lock.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) 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': {} From a1375d8cafa55002e139a1bde9ffa95eee738e91 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 13 Mar 2025 12:52:39 -0400 Subject: [PATCH 18/32] wip --- packages/tailwindcss-language-service/src/util/state.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index d548b270..3bdb1bc4 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -211,6 +211,9 @@ export function getDefaultTailwindSettings(): Settings { } } +/** + * @internal + */ export function createState( partial: Omit, 'editor'> & { editor?: Partial From 15ece2dfbaeef60a1d971aaa949708c8f11621cb Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 13 Mar 2025 13:10:36 -0400 Subject: [PATCH 19/32] wip --- .../src/util/find.test.ts | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index cc8ff013..c28a4277 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -59,7 +59,7 @@ test('class regex works in astro', async ({ expect }) => { test('find class lists in functions', async ({ expect }) => { let fileA = createDocument({ name: 'file.jsx', - lang: 'javascript', + lang: 'javascriptreact', settings: { tailwindCSS: { classFunctions: ['clsx', 'cva'], @@ -84,7 +84,7 @@ test('find class lists in functions', async ({ expect }) => { let fileB = createDocument({ name: 'file.jsx', - lang: 'javascript', + lang: 'javascriptreact', settings: { tailwindCSS: { classFunctions: ['clsx', 'cva'], @@ -171,7 +171,7 @@ test('find class lists in functions', async ({ expect }) => { test('find class lists in nested fn calls', async ({ expect }) => { let fileA = createDocument({ name: 'file.jsx', - lang: 'javascript', + lang: 'javascriptreact', settings: { tailwindCSS: { classFunctions: ['clsx', 'cva'], @@ -303,7 +303,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { test('find class lists in nested fn calls (only nested matches)', async ({ expect }) => { let fileA = createDocument({ name: 'file.jsx', - lang: 'javascript', + lang: 'javascriptreact', settings: { tailwindCSS: { classFunctions: ['clsx', 'cva'], @@ -350,7 +350,7 @@ test('find class lists in nested fn calls (only nested matches)', async ({ expec test('find class lists in tagged template literals', async ({ expect }) => { let fileA = createDocument({ name: 'file.jsx', - lang: 'javascript', + lang: 'javascriptreact', settings: { tailwindCSS: { classFunctions: ['clsx', 'cva'], @@ -375,7 +375,7 @@ test('find class lists in tagged template literals', async ({ expect }) => { let fileB = createDocument({ name: 'file.jsx', - lang: 'javascript', + lang: 'javascriptreact', settings: { tailwindCSS: { classFunctions: ['clsx', 'cva'], @@ -445,6 +445,71 @@ test('find class lists in tagged template literals', async ({ expect }) => { 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 fileA = 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 classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js') + + expect(classListsA).toEqual([]) +}) + function createDocument({ name, lang, From 9c9b63a438046da0827f08f4f3a7704bdf755b3b Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Sun, 16 Mar 2025 16:28:26 +0200 Subject: [PATCH 20/32] fix: DeepPartial type issues --- packages/tailwindcss-language-service/src/types.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss-language-service/src/types.ts b/packages/tailwindcss-language-service/src/types.ts index 6be62396..c84f0c7a 100644 --- a/packages/tailwindcss-language-service/src/types.ts +++ b/packages/tailwindcss-language-service/src/types.ts @@ -1,8 +1,8 @@ export type DeepPartial = { - [P in keyof T]?: T[P] extends (infer U)[] - ? U[] - : T[P] extends (...args: any) => any - ? T[P] | undefined + [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] From 9f2f840c239f8ef749a62ed5955e2bc2c3eb62fd Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Sun, 16 Mar 2025 16:31:12 +0200 Subject: [PATCH 21/32] fix: ts config issues in @tailwindcss/language-service --- packages/tailwindcss-language-service/scripts/build.mjs | 8 +++++++- .../tailwindcss-language-service/src/util/find.test.ts | 1 - packages/tailwindcss-language-service/tsconfig.json | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss-language-service/scripts/build.mjs b/packages/tailwindcss-language-service/scripts/build.mjs index 913debc3..76f4bef9 100644 --- a/packages/tailwindcss-language-service/scripts/build.mjs +++ b/packages/tailwindcss-language-service/scripts/build.mjs @@ -27,14 +27,20 @@ let build = await esbuild.context({ name: 'generate-types', async setup(build) { build.onEnd(async (result) => { + const distPath = path.resolve(__dirname, '../dist') + // Call the tsc command to generate the types spawnSync( 'tsc', - ['--emitDeclarationOnly', '--outDir', path.resolve(__dirname, '../dist')], + ['--emitDeclarationOnly', '--outDir', distPath], { stdio: 'inherit', }, ) + // Remove all .test.d.ts file definitions + spawnSync('find', [distPath, '-name', '*.test.d.ts', '-delete'], { + stdio: 'inherit', + }) }) }, }, diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index c28a4277..d568f0b6 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -530,7 +530,6 @@ function createDocument({ let defaults = getDefaultTailwindSettings() let state = createState({ editor: { - // @ts-ignore getConfiguration: async () => ({ ...defaults, ...settings, 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 } From 3baeb5b69a24f5e74a5e4af3ddfbab7bfb6a8a5f Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Sun, 16 Mar 2025 16:35:36 +0200 Subject: [PATCH 22/32] fix: matchClassFunctions isClassFn RegExp flags --- packages/tailwindcss-language-service/src/util/find.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index b0ab3bf3..d994c623 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -184,7 +184,7 @@ export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatc // 3. Match against the function names in the document let re = /^(NAMES)$/ - let isClassFn = new RegExp(re.source.replace('NAMES', names.join('|')), 'dgi') + let isClassFn = new RegExp(re.source.replace('NAMES', names.join('|')), 'i') let matches = foundFns.filter((fn) => isClassFn.test(fn[1])) From 0977df10148dbb973f399f4a9b50d45ba0e112a4 Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Sun, 16 Mar 2025 16:36:44 +0200 Subject: [PATCH 23/32] feat: classFunctions & classProperties should not duplicate matches --- .../src/util/find.test.ts | 67 +++++++++++---- .../src/util/find.ts | 84 ++++++++++--------- 2 files changed, 97 insertions(+), 54 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index d568f0b6..662d8113 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -281,22 +281,6 @@ test('find class lists in nested fn calls', async ({ expect }) => { end: { line: 20, character: 24 }, }, }, - - // TODO: These duplicates are from matching nested clsx(…) and should be ignored - { - 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 }, - }, - }, ]) }) @@ -510,6 +494,57 @@ test('classFunctions regexes only match on function names', async ({ expect }) = expect(classListsA).toEqual([]) }) +test('classFunctions & classProperties should not duplicate matches', async ({ expect }) => { + let fileA = createDocument({ + name: 'file.jsx', + lang: 'javascriptreact', + settings: { + tailwindCSS: { + classFunctions: ['cva', 'clsx'], + }, + }, + content: js` + const Component = ({ className }) => ( +
+ CONTENT +
+ ) + `, + }) + + let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js') + + expect(classListsA).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 }, + }, + }, + ]) +}) + function createDocument({ name, lang, diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index d994c623..ec96a842 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -208,7 +208,8 @@ export async function findClassListsInHtmlRange( matches.push(...matchClassFunctions(text, settings.classFunctions)) } - const result: DocumentClassList[] = [] + const existingResultSet = new Set() + const results: DocumentClassList[] = [] matches.forEach((match) => { const subtext = text.substr(match.index + match[0].length - 1) @@ -253,46 +254,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( From 8706e3a5f24c6ca7c55f3bfaf526267a41d479b4 Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Sun, 16 Mar 2025 17:22:34 +0200 Subject: [PATCH 24/32] feat: ensure same matches in a different spot are pushed to results in find.test.ts --- .../src/util/find.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 662d8113..db6d7e34 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -500,6 +500,7 @@ test('classFunctions & classProperties should not duplicate matches', async ({ e lang: 'javascriptreact', settings: { tailwindCSS: { + classAttributes: ['className'], classFunctions: ['cva', 'clsx'], }, }, @@ -515,6 +516,17 @@ test('classFunctions & classProperties should not duplicate matches', async ({ e CONTENT ) + const OtherComponent = ({ className }) => ( +
+ CONTENT +
+ ) `, }) @@ -542,6 +554,27 @@ test('classFunctions & classProperties should not duplicate matches', async ({ e 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 }, + }, + }, ]) }) From 3514bb6fc5c159fb9db77822ac25f9938131a9b7 Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis Date: Sun, 16 Mar 2025 20:06:52 +0200 Subject: [PATCH 25/32] fix: use extended tsconfig instead of find *.test.d.ts -delete to remove definitions --- packages/tailwindcss-language-service/scripts/build.mjs | 8 +------- .../scripts/type-gen.tsconfig.json | 4 ++++ 2 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 packages/tailwindcss-language-service/scripts/type-gen.tsconfig.json diff --git a/packages/tailwindcss-language-service/scripts/build.mjs b/packages/tailwindcss-language-service/scripts/build.mjs index 76f4bef9..b0c462d8 100644 --- a/packages/tailwindcss-language-service/scripts/build.mjs +++ b/packages/tailwindcss-language-service/scripts/build.mjs @@ -27,20 +27,14 @@ let build = await esbuild.context({ name: 'generate-types', async setup(build) { build.onEnd(async (result) => { - const distPath = path.resolve(__dirname, '../dist') - // Call the tsc command to generate the types spawnSync( 'tsc', - ['--emitDeclarationOnly', '--outDir', distPath], + ['-p', path.resolve(__dirname, './type-gen.tsconfig.json'), '--emitDeclarationOnly', '--outDir', path.resolve(__dirname, '../dist')], { stdio: 'inherit', }, ) - // Remove all .test.d.ts file definitions - spawnSync('find', [distPath, '-name', '*.test.d.ts', '-delete'], { - stdio: 'inherit', - }) }) }, }, diff --git a/packages/tailwindcss-language-service/scripts/type-gen.tsconfig.json b/packages/tailwindcss-language-service/scripts/type-gen.tsconfig.json new file mode 100644 index 00000000..e80bb38f --- /dev/null +++ b/packages/tailwindcss-language-service/scripts/type-gen.tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "exclude": ["../src/**/*.test.ts"] +} \ No newline at end of file From 86dace379465a9caaf27df229b0001ac78dc7179 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 13 Mar 2025 17:53:48 -0400 Subject: [PATCH 26/32] Remove `resolveRange` fn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It’s _identical_ to `absoluteRange` which is more used --- .../src/util/find.ts | 6 +++--- .../src/util/resolveRange.ts | 16 ---------------- 2 files changed, 3 insertions(+), 19 deletions(-) delete mode 100644 packages/tailwindcss-language-service/src/util/resolveRange.ts diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index ec96a842..37a9c043 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' @@ -446,14 +446,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/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, - }, - } -} From 44ba4b9bf9f188c01c2f1507ca7099d534fdc1a7 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sun, 16 Mar 2025 15:23:47 -0400 Subject: [PATCH 27/32] Rename config file --- packages/tailwindcss-language-service/scripts/build.mjs | 2 +- .../scripts/{type-gen.tsconfig.json => tsconfig.build.json} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/tailwindcss-language-service/scripts/{type-gen.tsconfig.json => tsconfig.build.json} (100%) diff --git a/packages/tailwindcss-language-service/scripts/build.mjs b/packages/tailwindcss-language-service/scripts/build.mjs index b0c462d8..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', - ['-p', path.resolve(__dirname, './type-gen.tsconfig.json'), '--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/type-gen.tsconfig.json b/packages/tailwindcss-language-service/scripts/tsconfig.build.json similarity index 100% rename from packages/tailwindcss-language-service/scripts/type-gen.tsconfig.json rename to packages/tailwindcss-language-service/scripts/tsconfig.build.json From d2b0eed667caa04beaa866a76f79c0ee2ef71c71 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sun, 16 Mar 2025 15:32:24 -0400 Subject: [PATCH 28/32] Add test --- .../src/util/find.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index db6d7e34..d89c6952 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -494,6 +494,51 @@ test('classFunctions regexes only match on function names', async ({ expect }) = expect(classListsA).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 & classProperties should not duplicate matches', async ({ expect }) => { let fileA = createDocument({ name: 'file.jsx', From a859e4b9445209c2cad365f2a247da8891db070d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sun, 16 Mar 2025 15:34:57 -0400 Subject: [PATCH 29/32] Cleanup --- .../src/util/find.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index d89c6952..c720e62f 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -8,7 +8,7 @@ import dedent from 'dedent' const js = dedent test('class regex works in astro', async ({ expect }) => { - let { doc, state } = createDocument({ + let file = createDocument({ name: 'file.astro', lang: 'astro', settings: { @@ -29,7 +29,7 @@ test('class regex works in astro', async ({ expect }) => { ], }) - let classLists = await findClassListsInHtmlRange(state, doc, 'html') + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'html') expect(classLists).toEqual([ { @@ -169,7 +169,7 @@ test('find class lists in functions', async ({ expect }) => { }) test('find class lists in nested fn calls', async ({ expect }) => { - let fileA = createDocument({ + let file = createDocument({ name: 'file.jsx', lang: 'javascriptreact', settings: { @@ -209,7 +209,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { `, }) - let classLists = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'html') + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'html') expect(classLists).toMatchObject([ { @@ -285,7 +285,7 @@ test('find class lists in nested fn calls', async ({ expect }) => { }) test('find class lists in nested fn calls (only nested matches)', async ({ expect }) => { - let fileA = createDocument({ + let file = createDocument({ name: 'file.jsx', lang: 'javascriptreact', settings: { @@ -311,7 +311,7 @@ test('find class lists in nested fn calls (only nested matches)', async ({ expec `, }) - let classLists = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'html') + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'html') expect(classLists).toMatchObject([ { @@ -474,7 +474,7 @@ test('classFunctions can be a regex', async ({ expect }) => { }) test('classFunctions regexes only match on function names', async ({ expect }) => { - let fileA = createDocument({ + let file = createDocument({ name: 'file.jsx', lang: 'javascriptreact', settings: { @@ -489,9 +489,9 @@ test('classFunctions regexes only match on function names', async ({ expect }) = `, }) - let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js') + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'js') - expect(classListsA).toEqual([]) + expect(classLists).toEqual([]) }) test('Finds consecutive instances of a class function', async ({ expect }) => { @@ -540,7 +540,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => { }) test('classFunctions & classProperties should not duplicate matches', async ({ expect }) => { - let fileA = createDocument({ + let file = createDocument({ name: 'file.jsx', lang: 'javascriptreact', settings: { @@ -575,9 +575,9 @@ test('classFunctions & classProperties should not duplicate matches', async ({ e `, }) - let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js') + let classLists = await findClassListsInHtmlRange(file.state, file.doc, 'js') - expect(classListsA).toEqual([ + expect(classLists).toEqual([ { classList: 'relative flex', range: { From 0eb371261e8240983c4cad671668bbfe99fd3f52 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Mar 2025 09:52:31 -0400 Subject: [PATCH 30/32] Fix test name --- packages/tailwindcss-language-service/src/util/find.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index c720e62f..ca839a58 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -539,7 +539,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => { ]) }) -test('classFunctions & classProperties should not duplicate matches', async ({ expect }) => { +test('classFunctions & classAttributes should not duplicate matches', async ({ expect }) => { let file = createDocument({ name: 'file.jsx', lang: 'javascriptreact', From 58df6d903e422893ff498796d38eed740555e6d0 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Mar 2025 09:23:09 -0400 Subject: [PATCH 31/32] Update readme --- packages/vscode-tailwindcss/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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`** From 5902f5d0f96007cb7d2afa2213380de0a032ce98 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Mar 2025 09:52:22 -0400 Subject: [PATCH 32/32] Limit class function matching to JS language boundaries --- .../src/completionProvider.ts | 21 +++++- .../src/util/find.test.ts | 66 +++++++++++++++++++ .../src/util/find.ts | 18 ++++- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index 7343c1e9..fe43bb68 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -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,8 +749,23 @@ async function provideClassAttributeCompletions( let matches = matchClassAttributes(str, settings.classAttributes) - if (settings.classFunctions?.length) { - matches.push(...matchClassFunctions(str, settings.classFunctions)) + 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) { diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index ca839a58..f9f17a96 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -6,6 +6,7 @@ import type { DeepPartial } from '../types' import dedent from 'dedent' const js = dedent +const html = dedent test('class regex works in astro', async ({ expect }) => { let file = createDocument({ @@ -623,6 +624,71 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e ]) }) +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, diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index 37a9c043..03218798 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -204,8 +204,22 @@ export async function findClassListsInHtmlRange( const settings = (await state.editor.getConfiguration(doc.uri)).tailwindCSS const matches = matchClassAttributes(text, settings.classAttributes) - if (settings.classFunctions?.length) { - matches.push(...matchClassFunctions(text, settings.classFunctions)) + 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()