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