Repository: tailwindlabs/tailwindcss-intellisense
Pull request #1258: Add `tailwindCSS.classFunctions` setting
Author: login: LaurynasGr
Created: 2025-03-07T14:17:44Z | Merged: 2025-03-19T13:54:21Z

This PR adds `tailwindCSS.classFunctions` option to the settings to add simple and performant class completions, hover previews, linting etc. for such cases:

```ts
const classes = cn(
  '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)]',
  Date.now() > 15000 ? 'text-red-200' : 'text-red-700',
  'data-[swipe=move]:transition-none',
)
```
![image](https://github.com/user-attachments/assets/eb5728af-6412-4323-b14c-893472f2e897)

```ts
const variants = cva(
  cn(
    'pointer-events-auto relative flex bg-green-500',
    'md:min-w-[20rem] md:max-w-[37.5rem] md:py-sm pl-md py-xs pr-xs gap-sm w-full',
    '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
          bg-blue-700
        `,
      },
    },
    defaultVariants: {
      mobile: 'default',
    },
  },
)
```
![image](https://github.com/user-attachments/assets/47025c28-50bc-4aa5-874c-06434835141b)

```ts
const tagged = cn`
  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)] text-green-700
  data-[swipe=move]:transition-none
`
```
![image](https://github.com/user-attachments/assets/0d112788-dd4f-48dc-aa01-0407b6c6b119)


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


Reviews:
user: login: thecrypticace
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T16:53:21Z
user: login: LaurynasGr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T12:47:33Z
user: login: LaurynasGr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T12:56:42Z
user: login: thecrypticace
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T15:41:29Z
user: login: thecrypticace
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T15:43:45Z
user: login: thecrypticace
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T15:45:11Z
user: login: LaurynasGr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T17:06:45Z
user: login: RobinMalfait
body: We should add this to the readme so people know that this is available and how they can configure it.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T13:13:09Z

Review comments:
diff_hunk: @@ -64,7 +65,7 @@ export type TailwindCssSettings = {
     recommendedVariantOrder: DiagnosticSeveritySetting
   }
   experimental: {
-    classRegex: string[]
+    classRegex: string[] | [string, string][]
path: packages/tailwindcss-language-service/src/util/state.ts
user: login: thecrypticace
body: ngl seeing that this was wrong is a bit embarrassing 😂 
created_at: 2025-03-13T16:53:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,18 +164,49 @@ export function matchClassAttributes(text: string, attributes: string[]): RegExp
   return findAll(new RegExp(re.source.replace('ATTRS', attrs.join('|')), 'gi'), text)
 }
 
+export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatchArray[] {
+  // 1. Validate the list of function name patterns provided by the user
+  let names = fnNames.filter((x) => typeof x === 'string')
+  if (names.length === 0) return []
+
+  // 2. Extract function names in the document
+  // This is intentionally scoped to JS syntax for now but should be extended to
+  // other languages in the future
+  //
+  // This regex the JS pattern for an identifier + function call with some
+  // additional constraints:
+  //
+  // - It needs to be in an expression position — so it must be preceded by
+  // whitespace, parens, curlies, commas, whitespace, etc…
+  // - It must look like a fn call or a tagged template literal
+  let FN_NAMES = /(?<=^|[:=,;\s{()])([\p{ID_Start}$_][\p{ID_Continue}$_.]*)[(`]/dgiu
+  let foundFns = findAll(FN_NAMES, text)
+
+  // 3. Match against the function names in the document
+  let re = /^(NAMES)$/
+  let isClassFn = new RegExp(re.source.replace('NAMES', names.join('|')), 'dgi')
path: packages/tailwindcss-language-service/src/util/find.ts
user: login: LaurynasGr
body: @thecrypticace This is used to test against every single matched function name, so it should only have the `i` flag.

Specifically the main issue here is that the `g` flag makes it miss every other function call
![image](https://github.com/user-attachments/assets/997ece60-8ca4-45b0-af36-5873c26a095e)

The `d` flag is useless here as we do not need `indices` when we just use it to do `isClassFn.test`

When we just use `i` we get what we expect
![image](https://github.com/user-attachments/assets/ae57fac9-b2ad-4461-8ce9-7c59811b4b85)
created_at: 2025-03-16T11:55:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,18 +164,49 @@ export function matchClassAttributes(text: string, attributes: string[]): RegExp
   return findAll(new RegExp(re.source.replace('ATTRS', attrs.join('|')), 'gi'), text)
 }
 
+export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatchArray[] {
+  // 1. Validate the list of function name patterns provided by the user
+  let names = fnNames.filter((x) => typeof x === 'string')
+  if (names.length === 0) return []
+
+  // 2. Extract function names in the document
+  // This is intentionally scoped to JS syntax for now but should be extended to
+  // other languages in the future
+  //
+  // This regex the JS pattern for an identifier + function call with some
+  // additional constraints:
+  //
+  // - It needs to be in an expression position — so it must be preceded by
+  // whitespace, parens, curlies, commas, whitespace, etc…
+  // - It must look like a fn call or a tagged template literal
+  let FN_NAMES = /(?<=^|[:=,;\s{()])([\p{ID_Start}$_][\p{ID_Continue}$_.]*)[(`]/dgiu
+  let foundFns = findAll(FN_NAMES, text)
+
+  // 3. Match against the function names in the document
+  let re = /^(NAMES)$/
+  let isClassFn = new RegExp(re.source.replace('NAMES', names.join('|')), 'dgi')
path: packages/tailwindcss-language-service/src/util/find.ts
user: login: LaurynasGr
body: Will add some simple logic to remove duplicate matches today, so I can fix this as well
created_at: 2025-03-16T12:28:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,81 +1,565 @@
-import type { State } from './state'
+import { createState, getDefaultTailwindSettings, Settings, type DocumentClassList } from './state'
 import { test } from 'vitest'
 import { TextDocument } from 'vscode-languageserver-textdocument'
 import { findClassListsInHtmlRange } from './find'
+import type { DeepPartial } from '../types'
+import dedent from 'dedent'
 
-test('test', async ({ expect }) => {
-  let content = [
-    //
-    '<a class=`p-4 sm:p-2 ${active ? "underline": "line-through"}`>',
-    '  <slot />',
-    '</a>',
-  ].join('\n')
-
-  let doc = TextDocument.create('file://file.astro', 'astro', 1, content)
-  let state: State = {
-    blocklist: [],
-    editor: {
-      userLanguages: {},
-      getConfiguration: async () => ({
-        editor: {
-          tabSize: 1,
+const js = dedent
+
+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\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'],
+          ],
         },
-        tailwindCSS: {
-          classAttributes: ['class'],
-          experimental: {
-            classRegex: [
-              ['cva\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'],
-              ['cn\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'],
-            ],
-          },
-        } as any,
-      }),
-    } as any,
-  } as any
+      },
+    },
+    content: [
+      '<a class=`p-4 sm:p-2 ${active ? "underline": "line-through"}`>',
+      '  <slot />',
+      '</a>',
+    ],
+  })
 
   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: {
+        start: { line: 0, character: 46 },
+        end: { line: 0, character: 58 },
+      },
+    },
+  ])
+})
+
+test('find class lists in functions', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+    content: js`
+      // These should match
+      let classes = clsx(
+        'flex p-4',
+        'block sm:p-0',
+        Date.now() > 100 ? 'text-white' : 'text-black',
+      )
+
+      // These should match
+      let classes = cva(
+        'flex p-4',
+        'block sm:p-0',
+        Date.now() > 100 ? 'text-white' : 'text-black',
+      )
+    `,
+  })
+
+  let fileB = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+    content: js`
+      let classes = cn(
+        'flex p-4',
+        'block sm:p-0',
+        Date.now() > 100 ? 'text-white' : 'text-black',
+      )
+    `,
+  })
+
+  let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js')
+  let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js')
+
+  expect(classListsA).toEqual([
+    // from clsx(…)
+    {
+      classList: 'flex p-4',
+      range: {
+        start: { line: 2, character: 3 },
+        end: { line: 2, character: 11 },
+      },
+    },
+    {
+      classList: 'block sm:p-0',
+      range: {
+        start: { line: 3, character: 3 },
+        end: { line: 3, character: 15 },
+      },
+    },
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 4, character: 22 },
+        end: { line: 4, character: 32 },
+      },
+    },
+    {
+      classList: 'text-black',
+      range: {
+        start: { line: 4, character: 37 },
+        end: { line: 4, character: 47 },
+      },
+    },
+
+    // from cva(…)
+    {
+      classList: 'flex p-4',
+      range: {
+        start: { line: 9, character: 3 },
+        end: { line: 9, character: 11 },
+      },
+    },
+    {
+      classList: 'block sm:p-0',
+      range: {
+        start: { line: 10, character: 3 },
+        end: { line: 10, character: 15 },
+      },
+    },
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 11, character: 22 },
+        end: { line: 11, character: 32 },
+      },
+    },
+    {
+      classList: 'text-black',
+      range: {
+        start: { line: 11, character: 37 },
+        end: { line: 11, character: 47 },
+      },
+    },
+  ])
+
+  // none from cn(…) since it's not in the list of class functions
+  expect(classListsB).toEqual([])
+})
+
+test('find class lists in nested fn calls', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+
+    content: js`
+      // NOTE: All strings inside a matched class function will be treated as class lists
+      // TODO: Nested calls tha are *not* class functions should have their content ignored
+      let classes = clsx(
+        'flex',
+        cn({
+          'bg-red-500': true,
+          'text-white': Date.now() > 100,
+        }),
+        clsx(
+          'fixed',
+          'absolute inset-0'
+        ),
+        cva(
+          ['bottom-0', 'border'],
+          {
+            variants: {
+              mobile: {
+                default: 'bottom-0 left-0',
+                large: \`
+                  inset-0
+                  rounded-none
+                \`,
+              },
+            }
+          }
+        )
+      )
+    `,
+  })
+
+  let classLists = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'html')
+
+  expect(classLists).toMatchObject([
+    {
+      classList: 'flex',
+      range: {
+        start: { line: 3, character: 3 },
+        end: { line: 3, character: 7 },
+      },
+    },
+
+    // TODO: This should be ignored because they're inside cn(…)
+    {
+      classList: 'bg-red-500',
+      range: {
+        start: { line: 5, character: 5 },
+        end: { line: 5, character: 15 },
+      },
+    },
+
+    // TODO: This should be ignored because they're inside cn(…)
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 6, character: 5 },
+        end: { line: 6, character: 15 },
+      },
+    },
+
+    {
+      classList: 'fixed',
+      range: {
+        start: { line: 9, character: 5 },
+        end: { line: 9, character: 10 },
+      },
+    },
+    {
+      classList: 'absolute inset-0',
+      range: {
+        start: { line: 10, character: 5 },
+        end: { line: 10, character: 21 },
+      },
+    },
+    {
+      classList: 'bottom-0',
+      range: {
+        start: { line: 13, character: 6 },
+        end: { line: 13, character: 14 },
+      },
+    },
+    {
+      classList: 'border',
+      range: {
+        start: { line: 13, character: 18 },
+        end: { line: 13, character: 24 },
+      },
+    },
+    {
+      classList: 'bottom-0 left-0',
+      range: {
+        start: { line: 17, character: 20 },
+        end: { line: 17, character: 35 },
+      },
+    },
+    {
+      classList: `inset-0\n            rounded-none\n          `,
+      range: {
+        start: { line: 19, character: 12 },
+        // TODO: Fix the range calculation. Its wrong on this one
+        end: { line: 20, character: 24 },
+      },
+    },
+
+    // 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 },
+      },
+    },
+  ])
+})
+
+test('find class lists in nested fn calls (only nested matches)', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+
+    content: js`
+      let classes = cn(
+        'flex',
+        cn({
+          'bg-red-500': true,
+          'text-white': Date.now() > 100,
+        }),
+        // NOTE: The only class lists appear inside this function because cn is
+        // not in the list of class functions
+        clsx(
+          'fixed',
+          'absolute inset-0'
+        ),
+      )
+    `,
+  })
+
+  let classLists = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'html')
+
+  expect(classLists).toMatchObject([
+    {
+      classList: 'fixed',
+      range: {
+        start: { line: 9, character: 5 },
+        end: { line: 9, character: 10 },
       },
-      {
-        "classList": "line-through",
-        "range": {
-          "end": {
-            "character": 58,
-            "line": 0,
+    },
+    {
+      classList: 'absolute inset-0',
+      range: {
+        start: { line: 10, character: 5 },
+        end: { line: 10, character: 21 },
+      },
+    },
+  ])
+})
+
+test('find class lists in tagged template literals', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+    content: js`
+      // These should match
+      let classes = clsx\`
+        flex p-4
+        block sm:p-0
+        \${Date.now() > 100 ? 'text-white' : 'text-black'}
+      \`
+
+      // These should match
+      let classes = cva\`
+        flex p-4
+        block sm:p-0
+        \${Date.now() > 100 ? 'text-white' : 'text-black'}
+      \`
+    `,
+  })
+
+  let fileB = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+    content: js`
+      let classes = cn\`
+        flex p-4
+        block sm:p-0
+        \${Date.now() > 100 ? 'text-white' : 'text-black'}
+      \`
+    `,
+  })
+
+  let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js')
+  let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js')
+
+  expect(classListsA).toEqual([
+    // from clsx`…`
+    {
+      classList: 'flex p-4\n  block sm:p-0\n  $',
+      range: {
+        start: { line: 2, character: 2 },
+        end: { line: 4, character: 3 },
+      },
+    },
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 4, character: 24 },
+        end: { line: 4, character: 34 },
+      },
+    },
+    {
+      classList: 'text-black',
+      range: {
+        start: { line: 4, character: 39 },
+        end: { line: 4, character: 49 },
+      },
+    },
+
+    // from cva`…`
+    {
+      classList: 'flex p-4\n  block sm:p-0\n  $',
+      range: {
+        start: { line: 9, character: 2 },
+        end: { line: 11, character: 3 },
+      },
+    },
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 11, character: 24 },
+        end: { line: 11, character: 34 },
+      },
+    },
+    {
+      classList: 'text-black',
+      range: {
+        start: { line: 11, character: 39 },
+        end: { line: 11, character: 49 },
+      },
+    },
+  ])
+
+  // none from cn`…` since it's not in the list of class functions
+  expect(classListsB).toEqual([])
+})
+
+test('classFunctions can be a regex', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['tw\\.[a-z]+'],
+      },
+    },
+    content: js`
+      let classes = tw.div('flex p-4')
+    `,
+  })
+
+  let fileB = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['tw\\.[a-z]+'],
+      },
+    },
+    content: js`
+      let classes = tw.div.foo('flex p-4')
+    `,
+  })
+
+  let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js')
+  let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js')
+
+  expect(classListsA).toEqual([
+    {
+      classList: 'flex p-4',
+      range: {
+        start: { line: 0, character: 22 },
+        end: { line: 0, character: 30 },
+      },
+    },
+  ])
+
+  // none from tw.div.foo(`…`) since it does not match a class function
+  expect(classListsB).toEqual([])
+})
+
+test('classFunctions regexes only match on function names', async ({ expect }) => {
+  let 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,
+  content,
+  settings,
+}: {
+  name: string
+  lang: string
+  content: string | string[]
+  settings: DeepPartial<Settings>
+}) {
+  let doc = TextDocument.create(
+    `file://${name}`,
+    lang,
+    1,
+    typeof content === 'string' ? content : content.join('\n'),
+  )
+  let defaults = getDefaultTailwindSettings()
+  let state = createState({
+    editor: {
+      // @ts-ignore
path: packages/tailwindcss-language-service/src/util/find.test.ts
user: login: LaurynasGr
body: Should probably not use ignore statements. Same issue as casting types - makes us miss things later on (e.g. https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1258#discussion_r1993947918)
created_at: 2025-03-16T12:55:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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',
+          })
path: packages/tailwindcss-language-service/scripts/build.mjs
user: login: thecrypticace
body: Should probably use a separate project tsconfig file that extends from the normal one. I'd prefer that tsc not generate these in the first place.

Also at some point I need to get the test suite running on Windows which this will fail on (at lot of things do right now though so 🤷‍♂️)
created_at: 2025-03-16T15:41:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,81 +1,565 @@
-import type { State } from './state'
+import { createState, getDefaultTailwindSettings, Settings, type DocumentClassList } from './state'
 import { test } from 'vitest'
 import { TextDocument } from 'vscode-languageserver-textdocument'
 import { findClassListsInHtmlRange } from './find'
+import type { DeepPartial } from '../types'
+import dedent from 'dedent'
 
-test('test', async ({ expect }) => {
-  let content = [
-    //
-    '<a class=`p-4 sm:p-2 ${active ? "underline": "line-through"}`>',
-    '  <slot />',
-    '</a>',
-  ].join('\n')
-
-  let doc = TextDocument.create('file://file.astro', 'astro', 1, content)
-  let state: State = {
-    blocklist: [],
-    editor: {
-      userLanguages: {},
-      getConfiguration: async () => ({
-        editor: {
-          tabSize: 1,
+const js = dedent
+
+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\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'],
+          ],
         },
-        tailwindCSS: {
-          classAttributes: ['class'],
-          experimental: {
-            classRegex: [
-              ['cva\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'],
-              ['cn\\(([^)]*)\\)', '["\'`]([^"\'`]*).*?["\'`]'],
-            ],
-          },
-        } as any,
-      }),
-    } as any,
-  } as any
+      },
+    },
+    content: [
+      '<a class=`p-4 sm:p-2 ${active ? "underline": "line-through"}`>',
+      '  <slot />',
+      '</a>',
+    ],
+  })
 
   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: {
+        start: { line: 0, character: 46 },
+        end: { line: 0, character: 58 },
+      },
+    },
+  ])
+})
+
+test('find class lists in functions', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+    content: js`
+      // These should match
+      let classes = clsx(
+        'flex p-4',
+        'block sm:p-0',
+        Date.now() > 100 ? 'text-white' : 'text-black',
+      )
+
+      // These should match
+      let classes = cva(
+        'flex p-4',
+        'block sm:p-0',
+        Date.now() > 100 ? 'text-white' : 'text-black',
+      )
+    `,
+  })
+
+  let fileB = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+    content: js`
+      let classes = cn(
+        'flex p-4',
+        'block sm:p-0',
+        Date.now() > 100 ? 'text-white' : 'text-black',
+      )
+    `,
+  })
+
+  let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js')
+  let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js')
+
+  expect(classListsA).toEqual([
+    // from clsx(…)
+    {
+      classList: 'flex p-4',
+      range: {
+        start: { line: 2, character: 3 },
+        end: { line: 2, character: 11 },
+      },
+    },
+    {
+      classList: 'block sm:p-0',
+      range: {
+        start: { line: 3, character: 3 },
+        end: { line: 3, character: 15 },
+      },
+    },
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 4, character: 22 },
+        end: { line: 4, character: 32 },
+      },
+    },
+    {
+      classList: 'text-black',
+      range: {
+        start: { line: 4, character: 37 },
+        end: { line: 4, character: 47 },
+      },
+    },
+
+    // from cva(…)
+    {
+      classList: 'flex p-4',
+      range: {
+        start: { line: 9, character: 3 },
+        end: { line: 9, character: 11 },
+      },
+    },
+    {
+      classList: 'block sm:p-0',
+      range: {
+        start: { line: 10, character: 3 },
+        end: { line: 10, character: 15 },
+      },
+    },
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 11, character: 22 },
+        end: { line: 11, character: 32 },
+      },
+    },
+    {
+      classList: 'text-black',
+      range: {
+        start: { line: 11, character: 37 },
+        end: { line: 11, character: 47 },
+      },
+    },
+  ])
+
+  // none from cn(…) since it's not in the list of class functions
+  expect(classListsB).toEqual([])
+})
+
+test('find class lists in nested fn calls', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+
+    content: js`
+      // NOTE: All strings inside a matched class function will be treated as class lists
+      // TODO: Nested calls tha are *not* class functions should have their content ignored
+      let classes = clsx(
+        'flex',
+        cn({
+          'bg-red-500': true,
+          'text-white': Date.now() > 100,
+        }),
+        clsx(
+          'fixed',
+          'absolute inset-0'
+        ),
+        cva(
+          ['bottom-0', 'border'],
+          {
+            variants: {
+              mobile: {
+                default: 'bottom-0 left-0',
+                large: \`
+                  inset-0
+                  rounded-none
+                \`,
+              },
+            }
+          }
+        )
+      )
+    `,
+  })
+
+  let classLists = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'html')
+
+  expect(classLists).toMatchObject([
+    {
+      classList: 'flex',
+      range: {
+        start: { line: 3, character: 3 },
+        end: { line: 3, character: 7 },
+      },
+    },
+
+    // TODO: This should be ignored because they're inside cn(…)
+    {
+      classList: 'bg-red-500',
+      range: {
+        start: { line: 5, character: 5 },
+        end: { line: 5, character: 15 },
+      },
+    },
+
+    // TODO: This should be ignored because they're inside cn(…)
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 6, character: 5 },
+        end: { line: 6, character: 15 },
+      },
+    },
+
+    {
+      classList: 'fixed',
+      range: {
+        start: { line: 9, character: 5 },
+        end: { line: 9, character: 10 },
+      },
+    },
+    {
+      classList: 'absolute inset-0',
+      range: {
+        start: { line: 10, character: 5 },
+        end: { line: 10, character: 21 },
+      },
+    },
+    {
+      classList: 'bottom-0',
+      range: {
+        start: { line: 13, character: 6 },
+        end: { line: 13, character: 14 },
+      },
+    },
+    {
+      classList: 'border',
+      range: {
+        start: { line: 13, character: 18 },
+        end: { line: 13, character: 24 },
+      },
+    },
+    {
+      classList: 'bottom-0 left-0',
+      range: {
+        start: { line: 17, character: 20 },
+        end: { line: 17, character: 35 },
+      },
+    },
+    {
+      classList: `inset-0\n            rounded-none\n          `,
+      range: {
+        start: { line: 19, character: 12 },
+        // TODO: Fix the range calculation. Its wrong on this one
+        end: { line: 20, character: 24 },
+      },
+    },
+
+    // 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 },
+      },
+    },
+  ])
+})
+
+test('find class lists in nested fn calls (only nested matches)', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+
+    content: js`
+      let classes = cn(
+        'flex',
+        cn({
+          'bg-red-500': true,
+          'text-white': Date.now() > 100,
+        }),
+        // NOTE: The only class lists appear inside this function because cn is
+        // not in the list of class functions
+        clsx(
+          'fixed',
+          'absolute inset-0'
+        ),
+      )
+    `,
+  })
+
+  let classLists = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'html')
+
+  expect(classLists).toMatchObject([
+    {
+      classList: 'fixed',
+      range: {
+        start: { line: 9, character: 5 },
+        end: { line: 9, character: 10 },
       },
-      {
-        "classList": "line-through",
-        "range": {
-          "end": {
-            "character": 58,
-            "line": 0,
+    },
+    {
+      classList: 'absolute inset-0',
+      range: {
+        start: { line: 10, character: 5 },
+        end: { line: 10, character: 21 },
+      },
+    },
+  ])
+})
+
+test('find class lists in tagged template literals', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+    content: js`
+      // These should match
+      let classes = clsx\`
+        flex p-4
+        block sm:p-0
+        \${Date.now() > 100 ? 'text-white' : 'text-black'}
+      \`
+
+      // These should match
+      let classes = cva\`
+        flex p-4
+        block sm:p-0
+        \${Date.now() > 100 ? 'text-white' : 'text-black'}
+      \`
+    `,
+  })
+
+  let fileB = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['clsx', 'cva'],
+      },
+    },
+    content: js`
+      let classes = cn\`
+        flex p-4
+        block sm:p-0
+        \${Date.now() > 100 ? 'text-white' : 'text-black'}
+      \`
+    `,
+  })
+
+  let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js')
+  let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js')
+
+  expect(classListsA).toEqual([
+    // from clsx`…`
+    {
+      classList: 'flex p-4\n  block sm:p-0\n  $',
+      range: {
+        start: { line: 2, character: 2 },
+        end: { line: 4, character: 3 },
+      },
+    },
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 4, character: 24 },
+        end: { line: 4, character: 34 },
+      },
+    },
+    {
+      classList: 'text-black',
+      range: {
+        start: { line: 4, character: 39 },
+        end: { line: 4, character: 49 },
+      },
+    },
+
+    // from cva`…`
+    {
+      classList: 'flex p-4\n  block sm:p-0\n  $',
+      range: {
+        start: { line: 9, character: 2 },
+        end: { line: 11, character: 3 },
+      },
+    },
+    {
+      classList: 'text-white',
+      range: {
+        start: { line: 11, character: 24 },
+        end: { line: 11, character: 34 },
+      },
+    },
+    {
+      classList: 'text-black',
+      range: {
+        start: { line: 11, character: 39 },
+        end: { line: 11, character: 49 },
+      },
+    },
+  ])
+
+  // none from cn`…` since it's not in the list of class functions
+  expect(classListsB).toEqual([])
+})
+
+test('classFunctions can be a regex', async ({ expect }) => {
+  let fileA = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['tw\\.[a-z]+'],
+      },
+    },
+    content: js`
+      let classes = tw.div('flex p-4')
+    `,
+  })
+
+  let fileB = createDocument({
+    name: 'file.jsx',
+    lang: 'javascriptreact',
+    settings: {
+      tailwindCSS: {
+        classFunctions: ['tw\\.[a-z]+'],
+      },
+    },
+    content: js`
+      let classes = tw.div.foo('flex p-4')
+    `,
+  })
+
+  let classListsA = await findClassListsInHtmlRange(fileA.state, fileA.doc, 'js')
+  let classListsB = await findClassListsInHtmlRange(fileB.state, fileB.doc, 'js')
+
+  expect(classListsA).toEqual([
+    {
+      classList: 'flex p-4',
+      range: {
+        start: { line: 0, character: 22 },
+        end: { line: 0, character: 30 },
+      },
+    },
+  ])
+
+  // none from tw.div.foo(`…`) since it does not match a class function
+  expect(classListsB).toEqual([])
+})
+
+test('classFunctions regexes only match on function names', async ({ expect }) => {
+  let 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,
+  content,
+  settings,
+}: {
+  name: string
+  lang: string
+  content: string | string[]
+  settings: DeepPartial<Settings>
+}) {
+  let doc = TextDocument.create(
+    `file://${name}`,
+    lang,
+    1,
+    typeof content === 'string' ? content : content.join('\n'),
+  )
+  let defaults = getDefaultTailwindSettings()
+  let state = createState({
+    editor: {
+      // @ts-ignore
path: packages/tailwindcss-language-service/src/util/find.test.ts
user: login: thecrypticace
body: Yeah the types in this code base need a lot of work so I was just like w/e — thanks for fixing things up to get rid of this tho
created_at: 2025-03-16T15:43:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,18 +164,49 @@ export function matchClassAttributes(text: string, attributes: string[]): RegExp
   return findAll(new RegExp(re.source.replace('ATTRS', attrs.join('|')), 'gi'), text)
 }
 
+export function matchClassFunctions(text: string, fnNames: string[]): RegExpMatchArray[] {
+  // 1. Validate the list of function name patterns provided by the user
+  let names = fnNames.filter((x) => typeof x === 'string')
+  if (names.length === 0) return []
+
+  // 2. Extract function names in the document
+  // This is intentionally scoped to JS syntax for now but should be extended to
+  // other languages in the future
+  //
+  // This regex the JS pattern for an identifier + function call with some
+  // additional constraints:
+  //
+  // - It needs to be in an expression position — so it must be preceded by
+  // whitespace, parens, curlies, commas, whitespace, etc…
+  // - It must look like a fn call or a tagged template literal
+  let FN_NAMES = /(?<=^|[:=,;\s{()])([\p{ID_Start}$_][\p{ID_Continue}$_.]*)[(`]/dgiu
+  let foundFns = findAll(FN_NAMES, text)
+
+  // 3. Match against the function names in the document
+  let re = /^(NAMES)$/
+  let isClassFn = new RegExp(re.source.replace('NAMES', names.join('|')), 'dgi')
path: packages/tailwindcss-language-service/src/util/find.ts
user: login: thecrypticace
body: Ah yeah oops, forgot about lastIndex needing to be reset (or just not making it global). I need to add a test for this.

As for the `d` flag —  was testing something with indices and then realized I didn't need them afterwards but forgot to drop it. Good catch.
created_at: 2025-03-16T15:45:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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',
+          })
path: packages/tailwindcss-language-service/scripts/build.mjs
user: login: LaurynasGr
body: Good point on this not working on Windows - no clue how it didn't occur to me. Will update this to use a separate tsconfig file for generating type definitions (`type-gen.tsconfig.json` or something like that)
created_at: 2025-03-16T17:06:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: thecrypticace
created_at: 2025-03-07T15:25:31Z
body: Hah. This is fantastic! I was thinking about adding *exactly* this feature last week while working on a larger refactor of stuff which is intended to make features like this simpler to implement and hopefully cleanup code a ton too: [Document Scopes](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/feat/document-scopes). It's a massive WIP and **nowhere** near done though.

Some notes:
- I absolutely want to add this feature
- I'd prefer to implement this in a way that makes it feels pretty solid internally so I'd name the setting `tailwindCSS.classFunctions`
- I don't want to introduce more regex-ish features which match against the document as a whole. Users inevitably write regexes that have catastrophic backtracking problems (… its happened a lot 😭).
- Regex matching of fn names themselves are fine but I'd rather match against a set of extracted function names rather than something that could technically match against the entire document.
- The rexes works for a lot of languages but not *all* of them. This is fine but I'd love to make it easier to add support for new languages.
- I also want to support tagged template literals with this feature so there is parity with the prettier plugin.

The Document Scopes feature I've been working on is pretty much designed to make every since one of the above goals easier to achieve but it's a long way off from being done.

I'm going to think on this some to see if there's an interim solution that hits most of those points (the primary one being the whole document regex one tho).
author_association: CONTRIBUTOR
user: login: LaurynasGr
created_at: 2025-03-07T16:21:12Z
body: @thecrypticace thanks for the notes!

> * I'd prefer to implement this in a way that makes it feels pretty solid internally so I'd name the setting `tailwindCSS.classFunctions`

Moved it outside `experimental` so it's simply `tailwindCSS.classFunctions`

> * I don't want to introduce more regex-ish features which match against the document as a whole. Users inevitably write regexes that have catastrophic backtracking problems (… its happened a lot 😭).

Yea, I know.. Prior to taking this on, I tried to write logic to achieve this with `tailwindCSS.experimental.classRegex`, but some regex combinations were to aggressive, consuming every single string in the document after it sees a `cn` or `cva`, others were too weak and broke as soon as it encountered a `(` in the class list (e.g. `translate-x-[var(--radix-toast-swipe-end-x)]`) and yet others were just to unstable, breaking the extension in VS Code as soon as you have an unclosed `(` 😅

> * I also want to support tagged template literals with this feature so there is parity with the prettier plugin.

Added support for tagged template literals with the same option (See https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1258/commits/dc441bc55387bea5fd98e5371a64fb6812b49b85)

![image](https://github.com/user-attachments/assets/f4fe3399-7915-4e4c-b289-a4647ddf3d1a)

author_association: CONTRIBUTOR
user: login: LaurynasGr
created_at: 2025-03-07T17:26:17Z
body: > * I don't want to introduce more regex-ish features which match against the document as a whole. Users inevitably write regexes that have catastrophic backtracking problems (… its happened a lot 😭).
> * Regex matching of fn names themselves are fine but I'd rather match against a set of extracted function names rather than something that could technically match against the entire document.

We could possibly look into an AST approach (e.g. for JS/JSX `@babel/template`) - Parse the document into an AST and then use the AST to generate `DocumentClassList[]`

Edit:
Or even better to use something like `acorn` (and `acorn-jsx` for JSX files) for the AST generation as that exposes code locations nicely and we need these to generate `DocumentClassList[]`. Then using the AST in combination with `tailwindCSS` options, `DocumentClassList[]` could be generated
author_association: CONTRIBUTOR
user: login: thecrypticace
created_at: 2025-03-07T21:41:29Z
body: I can probably write a specialized parser that's "good enough" for this case. We definitely do not need the full power of acorn or babel here.
author_association: CONTRIBUTOR
user: login: thecrypticace
created_at: 2025-03-13T19:38:29Z
body: Alright, I've made a handful of changes to the implementation and tests. Still some typescript things that I want to cleanup but I'm not gonna do that in this PR (tbh there's a LOT of low hanging fruit there — I'm very, very slowly working towards migrating everything to strict mode). 

Gonna have some other folks take a look at this and hopefully get it in a release maybe next week.
author_association: CONTRIBUTOR
user: login: LaurynasGr
created_at: 2025-03-13T22:55:47Z
body: Perfect! I'll look over your changes during the weekend as well - thanks for taking this on!
author_association: CONTRIBUTOR
user: login: LaurynasGr
created_at: 2025-03-16T15:27:15Z
body: @thecrypticace added a few fixes https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1258/files/15ece2dfbaeef60a1d971aaa949708c8f11621cb..8706e3a5f24c6ca7c55f3bfaf526267a41d479b4
author_association: CONTRIBUTOR
user: login: RobinMalfait
created_at: 2025-03-19T13:19:44Z
body: Only small thing we noticed is that this also triggers it:
<img width="454" alt="image" src="https://github.com/user-attachments/assets/1dac2e9c-2ab3-4881-a05d-59f2ac8066fb" />

But this can be addressed in a follow up PR by @thecrypticace!
author_association: MEMBER
user: login: thecrypticace
created_at: 2025-03-19T13:53:45Z
body: I went ahead and addressed it in this one
author_association: CONTRIBUTOR
user: login: thecrypticace
created_at: 2025-03-19T13:54:14Z
body: Also realized I need completion tests for this but I'm gonna add those after merging a couple other PRs b/c conflicts
author_association: CONTRIBUTOR
user: login: thecrypticace
created_at: 2025-03-19T13:55:08Z
body: Thanks @LaurynasGr! Such a nice feature to have 💯 

I'm gonna try getting a release out with this today or tomorrow
author_association: CONTRIBUTOR
user: login: thecrypticace
created_at: 2025-03-20T19:37:05Z
body: Just published v0.14.10 with this 🔥
author_association: CONTRIBUTOR
user: login: LaurynasGr
created_at: 2025-03-20T21:25:48Z
body: Awesome! Thanks @thecrypticace for accepting this and helping to get it through. I'm certain this will help lots of people struggling to achieve this behaviour with `classRegex` 
author_association: CONTRIBUTOR
user: login: thecrypticace
created_at: 2025-03-20T22:05:49Z
body: just found a bug — if you've got `cva` & `cn` in your list, the following breaks:

```
let x = cva("  no completions here   ");

export function Button() {
  return <Comp className={cn(" or here   ")} />;
}

export function Button2() {
  return <Comp className={cn(" or here   ")} />;
}

let x = cva("  but I do get them here   ");
```

The hovers are fine. Looking at the class attribute completion code in general it seems a bit weird so I think I'll need to rework it.

😱 
author_association: CONTRIBUTOR
user: login: thecrypticace
created_at: 2025-03-20T22:52:33Z
body: Figured it out #1278
author_association: CONTRIBUTOR