From b4b3a2a0351a2135e1bd34df7a22aae53288e540 Mon Sep 17 00:00:00 2001 From: Laurynas Grigutis <34269850+LaurynasGr@users.noreply.github.com> Date: Wed, 19 Mar 2025 15:54:20 +0200 Subject: [PATCH 01/93] Add `tailwindCSS.classFunctions` setting (#1258) 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) --------- Co-authored-by: Laurynas Grigutis Co-authored-by: Jordan Pittman --- .../tailwindcss-language-server/src/config.ts | 41 +- .../tests/utils/client.ts | 2 +- .../tests/utils/configuration.ts | 41 +- .../tests/utils/types.ts | 9 - .../tailwindcss-language-service/package.json | 2 + .../scripts/build.mjs | 2 +- .../scripts/tsconfig.build.json | 4 + .../src/completionProvider.ts | 23 +- .../tailwindcss-language-service/src/types.ts | 9 + .../src/util/find.test.ts | 800 ++++++++++++++++-- .../src/util/find.ts | 143 +++- .../src/util/lexers.ts | 9 + .../src/util/resolveRange.ts | 16 - .../src/util/state.ts | 73 +- .../tsconfig.json | 2 +- packages/vscode-tailwindcss/README.md | 20 + packages/vscode-tailwindcss/package.json | 8 + pnpm-lock.yaml | 11 + 18 files changed, 999 insertions(+), 216 deletions(-) delete mode 100644 packages/tailwindcss-language-server/tests/utils/types.ts create mode 100644 packages/tailwindcss-language-service/scripts/tsconfig.build.json create mode 100644 packages/tailwindcss-language-service/src/types.ts delete mode 100644 packages/tailwindcss-language-service/src/util/resolveRange.ts 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 f1047879..d5fddaaa 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 3b5fd76a..036ca936 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': {} From b68a681ac748a4669ad3f4287a40b8c5f9a62c95 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Mar 2025 09:57:07 -0400 Subject: [PATCH 02/93] Update changelog --- packages/vscode-tailwindcss/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index ca4255d7..dd3aa562 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -2,7 +2,7 @@ ## Prerelease -- Nothing yet! +- Detect classes in JS/TS functions and tagged template literals with the `tailwindCSS.classFunctions` setting ([#1258](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1258)) # 0.14.9 From 0d9bd2e8486a5cd3276fa47bd9a2749537e6a566 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Mar 2025 10:01:32 -0400 Subject: [PATCH 03/93] v4: Show completions after any valid variant (#1263) Fixes #943 Fixes #1257 This doesn't ensure variants like `not-supports-*` are suggested because `not-supports:` isn't valid. Only something with a value is e.g. `not-supports-display`. Making that work (it really should) is a larger task and requires designing an interactive suggestion API for v4. --- .../tests/completions/completions.test.js | 70 +++++++++++++++++- .../src/completionProvider.ts | 27 ------- .../src/util/getVariantsFromClassName.ts | 73 +++++++++---------- .../src/util/v4/design-system.ts | 2 +- packages/vscode-tailwindcss/CHANGELOG.md | 1 + 5 files changed, 104 insertions(+), 69 deletions(-) diff --git a/packages/tailwindcss-language-server/tests/completions/completions.test.js b/packages/tailwindcss-language-server/tests/completions/completions.test.js index 2727fcb0..f5393a40 100644 --- a/packages/tailwindcss-language-server/tests/completions/completions.test.js +++ b/packages/tailwindcss-language-server/tests/completions/completions.test.js @@ -1,5 +1,7 @@ -import { test } from 'vitest' +import { test, expect, describe } from 'vitest' import { withFixture } from '../common' +import { css, defineTest } from '../../src/testing' +import { createClient } from '../utils/client' function buildCompletion(c) { return async function completion({ @@ -670,3 +672,69 @@ withFixture('v4/workspaces', (c) => { }) }) }) + +defineTest({ + name: 'v4: Completions show after a variant arbitrary value', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 23 }) + + expect(completion?.items.length).toBe(12289) + }, +}) + +defineTest({ + name: 'v4: Completions show after an arbitrary variant', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 22 }) + + expect(completion?.items.length).toBe(12289) + }, +}) + +defineTest({ + name: 'v4: Completions show after a variant with a bare value', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 31 }) + + expect(completion?.items.length).toBe(12289) + }, +}) diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index fe43bb68..1579fd93 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -189,16 +189,8 @@ export function completionsFromClassList( }), ) } else { - let shouldSortVariants = !semver.gte(state.version, '2.99.0') let resultingVariants = [...existingVariants, variant.name] - if (shouldSortVariants) { - let allVariants = state.variants.map(({ name }) => name) - resultingVariants = resultingVariants.sort( - (a, b) => allVariants.indexOf(b) - allVariants.indexOf(a), - ) - } - let selectors: string[] = [] try { @@ -223,25 +215,6 @@ export function completionsFromClassList( .map((selector) => addPixelEquivalentsToMediaQuery(selector)) .join(', '), textEditText: resultingVariants[resultingVariants.length - 1] + sep, - additionalTextEdits: - shouldSortVariants && resultingVariants.length > 1 - ? [ - { - newText: - resultingVariants.slice(0, resultingVariants.length - 1).join(sep) + sep, - range: { - start: { - ...classListRange.start, - character: classListRange.end.character - partialClassName.length, - }, - end: { - ...replacementRange.start, - character: replacementRange.start.character, - }, - }, - }, - ] - : [], }), ) } diff --git a/packages/tailwindcss-language-service/src/util/getVariantsFromClassName.ts b/packages/tailwindcss-language-service/src/util/getVariantsFromClassName.ts index b58bb29f..c30e729a 100644 --- a/packages/tailwindcss-language-service/src/util/getVariantsFromClassName.ts +++ b/packages/tailwindcss-language-service/src/util/getVariantsFromClassName.ts @@ -1,5 +1,6 @@ import type { State } from './state' import * as jit from './jit' +import { segment } from './segment' export function getVariantsFromClassName( state: State, @@ -13,60 +14,52 @@ export function getVariantsFromClassName( } return [variant.name] }) - let variants = new Set() - let offset = 0 - let parts = splitAtTopLevelOnly(className, state.separator) + + let parts = segment(className, state.separator) if (parts.length < 2) { - return { variants: Array.from(variants), offset } + return { variants: [], offset: 0 } } + parts = parts.filter(Boolean) - for (let part of parts) { - if ( - allVariants.includes(part) || - (state.jit && - ((part.includes('[') && part.endsWith(']')) || part.includes('/')) && - jit.generateRules(state, [`${part}${state.separator}[color:red]`]).rules.length > 0) - ) { - variants.add(part) - offset += part.length + state.separator.length - continue + function isValidVariant(part: string) { + if (allVariants.includes(part)) { + return true } - break - } + let className = `${part}${state.separator}[color:red]` - return { variants: Array.from(variants), offset } -} + if (state.v4) { + // NOTE: This should never happen + if (!state.designSystem) return false -// https://github.com/tailwindlabs/tailwindcss/blob/a8a2e2a7191fbd4bee044523aecbade5823a8664/src/util/splitAtTopLevelOnly.js -function splitAtTopLevelOnly(input: string, separator: string): string[] { - let stack: string[] = [] - let parts: string[] = [] - let lastPos = 0 + // We don't use `compile()` so there's no overhead from PostCSS + let compiled = state.designSystem.candidatesToCss([className]) - for (let idx = 0; idx < input.length; idx++) { - let char = input[idx] + // NOTE: This should never happen + if (compiled.length !== 1) return false - if (stack.length === 0 && char === separator[0]) { - if (separator.length === 1 || input.slice(idx, idx + separator.length) === separator) { - parts.push(input.slice(lastPos, idx)) - lastPos = idx + separator.length - } + return compiled[0] !== null } - if (char === '(' || char === '[' || char === '{') { - stack.push(char) - } else if ( - (char === ')' && stack[stack.length - 1] === '(') || - (char === ']' && stack[stack.length - 1] === '[') || - (char === '}' && stack[stack.length - 1] === '{') - ) { - stack.pop() + if (state.jit) { + if ((part.includes('[') && part.endsWith(']')) || part.includes('/')) { + return jit.generateRules(state, [className]).rules.length > 0 + } } + + return false } - parts.push(input.slice(lastPos)) + let offset = 0 + let variants = new Set() - return parts + for (let part of parts) { + if (!isValidVariant(part)) break + + variants.add(part) + offset += part.length + state.separator!.length + } + + return { variants: Array.from(variants), offset } } diff --git a/packages/tailwindcss-language-service/src/util/v4/design-system.ts b/packages/tailwindcss-language-service/src/util/v4/design-system.ts index cce64d4b..3fb3c401 100644 --- a/packages/tailwindcss-language-service/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-service/src/util/v4/design-system.ts @@ -44,6 +44,6 @@ export interface DesignSystem { export interface DesignSystem { dependencies(): Set - compile(classes: string[]): postcss.Root[] + compile(classes: string[]): (postcss.Root | null)[] toCss(nodes: postcss.Root | postcss.Node[]): string } diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index dd3aa562..7b9e68fa 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -3,6 +3,7 @@ ## Prerelease - Detect classes in JS/TS functions and tagged template literals with the `tailwindCSS.classFunctions` setting ([#1258](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1258)) +- v4: Make sure completions show after variants using arbitrary and bare values ([#1263](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1263)) # 0.14.9 From 9f9208af592347924461f8a2c8ac26a09ccb14e3 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Mar 2025 10:25:34 -0400 Subject: [PATCH 04/93] =?UTF-8?q?Add=20support=20for=20`@source=20not`=20a?= =?UTF-8?q?nd=20`@source=20inline(=E2=80=A6)`=20(#1262)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IntelliSense counterpart of https://github.com/tailwindlabs/tailwindcss/pull/17147 --- .../tailwindcss-language-server/package.json | 1 + .../src/projects.ts | 35 +++++- .../tailwindcss-language-server/src/tw.ts | 13 +++ .../tests/code-lens/source-inline.test.ts | 101 ++++++++++++++++++ .../tests/completions/at-config.test.js | 97 +++++++++++++++++ .../document-links/document-links.test.js | 38 +++++++ .../tests/hover/hover.test.js | 95 ++++++++++++++++ .../tests/utils/client.ts | 26 +++++ .../tailwindcss-language-service/package.json | 1 + .../src/codeLensProvider.ts | 78 ++++++++++++++ .../src/completionProvider.ts | 13 +++ .../src/completions/file-paths.test.ts | 16 +++ .../src/completions/file-paths.ts | 10 +- .../getInvalidSourceDiagnostics.ts | 7 +- .../src/documentLinksProvider.ts | 2 +- .../src/features.ts | 20 ++-- .../src/hoverProvider.ts | 27 +++-- .../src/util/estimated-class-size.ts | 35 ++++++ .../src/util/format-bytes.ts | 11 ++ .../src/util/state.ts | 5 + packages/vscode-tailwindcss/CHANGELOG.md | 2 + packages/vscode-tailwindcss/package.json | 6 ++ pnpm-lock.yaml | 6 ++ 23 files changed, 624 insertions(+), 21 deletions(-) create mode 100644 packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts create mode 100644 packages/tailwindcss-language-service/src/codeLensProvider.ts create mode 100644 packages/tailwindcss-language-service/src/util/estimated-class-size.ts create mode 100644 packages/tailwindcss-language-service/src/util/format-bytes.ts diff --git a/packages/tailwindcss-language-server/package.json b/packages/tailwindcss-language-server/package.json index 5ed431fb..fa0a7e91 100644 --- a/packages/tailwindcss-language-server/package.json +++ b/packages/tailwindcss-language-server/package.json @@ -42,6 +42,7 @@ "@tailwindcss/line-clamp": "0.4.2", "@tailwindcss/oxide": "^4.0.0-alpha.19", "@tailwindcss/typography": "0.5.7", + "@types/braces": "3.0.1", "@types/color-name": "^1.1.3", "@types/culori": "^2.1.0", "@types/debounce": "1.2.0", diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index b55ee078..afc4515f 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -15,6 +15,8 @@ import type { Disposable, DocumentLinkParams, DocumentLink, + CodeLensParams, + CodeLens, } from 'vscode-languageserver/node' import { FileChangeType } from 'vscode-languageserver/node' import type { TextDocument } from 'vscode-languageserver-textdocument' @@ -35,6 +37,7 @@ import stackTrace from 'stack-trace' import extractClassNames from './lib/extractClassNames' import { klona } from 'klona/full' import { doHover } from '@tailwindcss/language-service/src/hoverProvider' +import { getCodeLens } from '@tailwindcss/language-service/src/codeLensProvider' import { Resolver } from './resolver' import { doComplete, @@ -110,6 +113,7 @@ export interface ProjectService { onColorPresentation(params: ColorPresentationParams): Promise onCodeAction(params: CodeActionParams): Promise onDocumentLinks(params: DocumentLinkParams): Promise + onCodeLens(params: CodeLensParams): Promise sortClassLists(classLists: string[]): string[] dependencies(): Iterable @@ -212,6 +216,7 @@ export async function createProjectService( let state: State = { enabled: false, + features: [], completionItemData: { _projectKey: projectKey, }, @@ -462,6 +467,14 @@ export async function createProjectService( // and this should be determined there and passed in instead let features = supportedFeatures(tailwindcssVersion, tailwindcss) log(`supported features: ${JSON.stringify(features)}`) + state.features = features + + if (params.initializationOptions?.testMode) { + state.features = [ + ...state.features, + ...(params.initializationOptions.additionalFeatures ?? []), + ] + } if (!features.includes('css-at-theme')) { tailwindcss = tailwindcss.default ?? tailwindcss @@ -688,6 +701,15 @@ export async function createProjectService( state.v4 = true state.v4Fallback = true state.jit = true + state.features = features + + if (params.initializationOptions?.testMode) { + state.features = [ + ...state.features, + ...(params.initializationOptions.additionalFeatures ?? []), + ] + } + state.modules = { tailwindcss: { version: tailwindcssVersion, module: tailwindcss }, postcss: { version: null, module: null }, @@ -1150,7 +1172,7 @@ export async function createProjectService( }, tryInit, async dispose() { - state = { enabled: false } + state = { enabled: false, features: [] } for (let disposable of disposables) { ;(await disposable).dispose() } @@ -1177,6 +1199,17 @@ export async function createProjectService( return doHover(state, document, params.position) }, null) }, + async onCodeLens(params: CodeLensParams): Promise { + return withFallback(async () => { + if (!state.enabled) return null + let document = documentService.getDocument(params.textDocument.uri) + if (!document) return null + let settings = await state.editor.getConfiguration(document.uri) + if (!settings.tailwindCSS.codeLens) return null + if (await isExcluded(state, document)) return null + return getCodeLens(state, document) + }, null) + }, async onCompletion(params: CompletionParams): Promise { return withFallback(async () => { if (!state.enabled) return null diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index 35da9386..fc6a87a8 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -19,6 +19,8 @@ import type { DocumentLink, InitializeResult, WorkspaceFolder, + CodeLensParams, + CodeLens, } from 'vscode-languageserver/node' import { CompletionRequest, @@ -30,6 +32,7 @@ import { FileChangeType, DocumentLinkRequest, TextDocumentSyncKind, + CodeLensRequest, } from 'vscode-languageserver/node' import { URI } from 'vscode-uri' import normalizePath from 'normalize-path' @@ -757,6 +760,7 @@ export class TW { this.connection.onDocumentColor(this.onDocumentColor.bind(this)) this.connection.onColorPresentation(this.onColorPresentation.bind(this)) this.connection.onCodeAction(this.onCodeAction.bind(this)) + this.connection.onCodeLens(this.onCodeLens.bind(this)) this.connection.onDocumentLinks(this.onDocumentLinks.bind(this)) this.connection.onRequest(this.onRequest.bind(this)) } @@ -809,6 +813,7 @@ export class TW { capabilities.add(HoverRequest.type, { documentSelector: null }) capabilities.add(DocumentColorRequest.type, { documentSelector: null }) capabilities.add(CodeActionRequest.type, { documentSelector: null }) + capabilities.add(CodeLensRequest.type, { documentSelector: null }) capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) capabilities.add(CompletionRequest.type, { @@ -931,6 +936,11 @@ export class TW { return this.getProject(params.textDocument)?.onCodeAction(params) ?? null } + async onCodeLens(params: CodeLensParams): Promise { + await this.init() + return this.getProject(params.textDocument)?.onCodeLens(params) ?? null + } + async onDocumentLinks(params: DocumentLinkParams): Promise { await this.init() return this.getProject(params.textDocument)?.onDocumentLinks(params) ?? null @@ -961,6 +971,9 @@ export class TW { hoverProvider: true, colorProvider: true, codeActionProvider: true, + codeLensProvider: { + resolveProvider: false, + }, documentLinkProvider: {}, completionProvider: { resolveProvider: true, diff --git a/packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts b/packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts new file mode 100644 index 00000000..55f6f22e --- /dev/null +++ b/packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts @@ -0,0 +1,101 @@ +import { expect } from 'vitest' +import { css, defineTest } from '../../src/testing' +import { createClient } from '../utils/client' + +defineTest({ + name: 'Code lenses are displayed for @source inline(…)', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + features: ['source-inline'], + }), + }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'css', + text: css` + @import 'tailwindcss'; + @source inline("{,{hover,focus}:}{flex,underline,bg-red-{50,{100..900.100},950}}"); + `, + }) + + let lenses = await document.codeLenses() + + expect(lenses).toEqual([ + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 81 }, + }, + command: { + title: 'Generates 15 classes', + command: '', + }, + }, + ]) + }, +}) + +defineTest({ + name: 'The user is warned when @source inline(…) generates a lerge amount of CSS', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + features: ['source-inline'], + }), + }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'css', + text: css` + @import 'tailwindcss'; + @source inline("{,dark:}{,{sm,md,lg,xl,2xl}:}{,{hover,focus,active}:}{flex,underline,bg-red-{50,{100..900.100},950}{,/{0..100}}}"); + `, + }) + + let lenses = await document.codeLenses() + + expect(lenses).toEqual([ + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 129 }, + }, + command: { + title: 'Generates 14,784 classes', + command: '', + }, + }, + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 129 }, + }, + command: { + title: 'At least 3MB of CSS', + command: '', + }, + }, + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 129 }, + }, + command: { + title: 'This may slow down your bundler/browser', + command: '', + }, + }, + ]) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/completions/at-config.test.js b/packages/tailwindcss-language-server/tests/completions/at-config.test.js index 15d99ac6..60ee1495 100644 --- a/packages/tailwindcss-language-server/tests/completions/at-config.test.js +++ b/packages/tailwindcss-language-server/tests/completions/at-config.test.js @@ -271,6 +271,51 @@ withFixture('v4/dependencies', (c) => { }) }) + test.concurrent('@source not', async ({ expect }) => { + let result = await completion({ + text: '@source not "', + lang: 'css', + position: { + line: 0, + character: 13, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'index.html', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'index.html', + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, + }, + }, + { + label: 'sub-dir/', + kind: 19, + command: { command: 'editor.action.triggerSuggest', title: '' }, + data: expect.anything(), + textEdit: { + newText: 'sub-dir/', + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, + }, + }, + { + label: 'tailwind.config.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'tailwind.config.js', + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, + }, + }, + ], + }) + }) + test.concurrent('@source directory', async ({ expect }) => { let result = await completion({ text: '@source "./sub-dir/', @@ -297,6 +342,58 @@ withFixture('v4/dependencies', (c) => { }) }) + test.concurrent('@source not directory', async ({ expect }) => { + let result = await completion({ + text: '@source not "./sub-dir/', + lang: 'css', + position: { + line: 0, + character: 23, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'colors.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'colors.js', + range: { start: { line: 0, character: 23 }, end: { line: 0, character: 23 } }, + }, + }, + ], + }) + }) + + test.concurrent('@source inline(…)', async ({ expect }) => { + let result = await completion({ + text: '@source inline("', + lang: 'css', + position: { + line: 0, + character: 16, + }, + }) + + expect(result).toEqual(null) + }) + + test.concurrent('@source not inline(…)', async ({ expect }) => { + let result = await completion({ + text: '@source not inline("', + lang: 'css', + position: { + line: 0, + character: 20, + }, + }) + + expect(result).toEqual(null) + }) + test.concurrent('@import "…" source(…)', async ({ expect }) => { let result = await completion({ text: '@import "tailwindcss" source("', diff --git a/packages/tailwindcss-language-server/tests/document-links/document-links.test.js b/packages/tailwindcss-language-server/tests/document-links/document-links.test.js index 861f74c9..0fc76cb6 100644 --- a/packages/tailwindcss-language-server/tests/document-links/document-links.test.js +++ b/packages/tailwindcss-language-server/tests/document-links/document-links.test.js @@ -131,6 +131,44 @@ withFixture('v4/basic', (c) => { ], }) + testDocumentLinks('source not: file exists', { + text: '@source not "index.html";', + lang: 'css', + expected: [ + { + target: `file://${path + .resolve('./tests/fixtures/v4/basic/index.html') + .replace(/@/g, '%40')}`, + range: { start: { line: 0, character: 12 }, end: { line: 0, character: 24 } }, + }, + ], + }) + + testDocumentLinks('source not: file does not exist', { + text: '@source not "does-not-exist.html";', + lang: 'css', + expected: [ + { + target: `file://${path + .resolve('./tests/fixtures/v4/basic/does-not-exist.html') + .replace(/@/g, '%40')}`, + range: { start: { line: 0, character: 12 }, end: { line: 0, character: 33 } }, + }, + ], + }) + + testDocumentLinks('@source inline(…)', { + text: '@source inline("m-{1,2,3}");', + lang: 'css', + expected: [], + }) + + testDocumentLinks('@source not inline(…)', { + text: '@source not inline("m-{1,2,3}");', + lang: 'css', + expected: [], + }) + testDocumentLinks('Directories in source(…) show links', { text: ` @import "tailwindcss" source("../../"); diff --git a/packages/tailwindcss-language-server/tests/hover/hover.test.js b/packages/tailwindcss-language-server/tests/hover/hover.test.js index 5c8bcb7f..ac97e414 100644 --- a/packages/tailwindcss-language-server/tests/hover/hover.test.js +++ b/packages/tailwindcss-language-server/tests/hover/hover.test.js @@ -293,6 +293,101 @@ withFixture('v4/basic', (c) => { }, }) + testHover('css @source not glob expansion', { + exact: true, + lang: 'css', + text: `@source not "../{app,components}/**/*.jsx"`, + position: { line: 0, character: 23 }, + expected: { + contents: { + kind: 'markdown', + value: [ + '**Expansion**', + '```plaintext', + '- ../app/**/*.jsx', + '- ../components/**/*.jsx', + '```', + ].join('\n'), + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 42 }, + }, + }, + expectedRange: { + start: { line: 2, character: 9 }, + end: { line: 2, character: 18 }, + }, + }) + + testHover('css @source inline glob expansion', { + exact: true, + lang: 'css', + text: `@source inline("{hover:,active:,}m-{1,2,3}")`, + position: { line: 0, character: 23 }, + expected: { + contents: { + kind: 'markdown', + value: [ + '**Expansion**', + '```plaintext', + '- hover:m-1', + '- hover:m-2', + '- hover:m-3', + '- active:m-1', + '- active:m-2', + '- active:m-3', + '- m-1', + '- m-2', + '- m-3', + '```', + ].join('\n'), + }, + range: { + start: { line: 0, character: 15 }, + end: { line: 0, character: 43 }, + }, + }, + expectedRange: { + start: { line: 2, character: 9 }, + end: { line: 2, character: 15 }, + }, + }) + + testHover('css @source not inline glob expansion', { + exact: true, + lang: 'css', + text: `@source not inline("{hover:,active:,}m-{1,2,3}")`, + position: { line: 0, character: 23 }, + expected: { + contents: { + kind: 'markdown', + value: [ + '**Expansion**', + '```plaintext', + '- hover:m-1', + '- hover:m-2', + '- hover:m-3', + '- active:m-1', + '- active:m-2', + '- active:m-3', + '- m-1', + '- m-2', + '- m-3', + '```', + ].join('\n'), + }, + range: { + start: { line: 0, character: 19 }, + end: { line: 0, character: 47 }, + }, + }, + expectedRange: { + start: { line: 2, character: 9 }, + end: { line: 2, character: 18 }, + }, + }) + testHover('--theme() works inside @media queries', { lang: 'tailwindcss', text: `@media (width>=--theme(--breakpoint-xl)) { .foo { color: red; } }`, diff --git a/packages/tailwindcss-language-server/tests/utils/client.ts b/packages/tailwindcss-language-server/tests/utils/client.ts index d6d317bb..3a860230 100644 --- a/packages/tailwindcss-language-server/tests/utils/client.ts +++ b/packages/tailwindcss-language-server/tests/utils/client.ts @@ -1,6 +1,8 @@ import type { Settings } from '@tailwindcss/language-service/src/util/state' import { ClientCapabilities, + CodeLens, + CodeLensRequest, CompletionList, CompletionParams, Diagnostic, @@ -45,6 +47,7 @@ import { clearLanguageBoundariesCache } from '@tailwindcss/language-service/src/ import { DefaultMap } from '../../src/util/default-map' import { connect, ConnectOptions } from './connection' import type { DeepPartial } from '@tailwindcss/language-service/src/types' +import type { Feature } from '@tailwindcss/language-service/src/features' export interface DocumentDescriptor { /** @@ -94,6 +97,11 @@ export interface ClientDocument { */ reopen(): Promise + /** + * Code lenses in the document + */ + codeLenses(): Promise + /** * The diagnostics for the current version of this document */ @@ -163,6 +171,14 @@ export interface ClientOptions extends ConnectOptions { * Settings to provide the server immediately when it starts */ settings?: DeepPartial + + /** + * Additional features to force-enable + * + * These should normally be enabled by the server based on the project + * and the Tailwind CSS version it detects + */ + features?: Feature[] } export interface Client extends ClientWorkspace { @@ -387,6 +403,7 @@ export async function createClient(opts: ClientOptions): Promise { workspaceFolders, initializationOptions: { testMode: true, + additionalFeatures: opts.features, ...opts.options, }, }) @@ -677,6 +694,14 @@ export async function createClientWorkspace({ return results } + async function codeLenses() { + return await conn.sendRequest(CodeLensRequest.type, { + textDocument: { + uri: uri.toString(), + }, + }) + } + return { uri, reopen, @@ -687,6 +712,7 @@ export async function createClientWorkspace({ symbols, completions, diagnostics, + codeLenses, } } diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json index d5fddaaa..e73f6ca3 100644 --- a/packages/tailwindcss-language-service/package.json +++ b/packages/tailwindcss-language-service/package.json @@ -40,6 +40,7 @@ "vscode-languageserver-textdocument": "1.0.11" }, "devDependencies": { + "@types/braces": "3.0.1", "@types/css.escape": "^1.5.2", "@types/dedent": "^0.7.2", "@types/line-column": "^1.0.2", diff --git a/packages/tailwindcss-language-service/src/codeLensProvider.ts b/packages/tailwindcss-language-service/src/codeLensProvider.ts new file mode 100644 index 00000000..b00df983 --- /dev/null +++ b/packages/tailwindcss-language-service/src/codeLensProvider.ts @@ -0,0 +1,78 @@ +import type { Range, TextDocument } from 'vscode-languageserver-textdocument' +import type { State } from './util/state' +import type { CodeLens } from 'vscode-languageserver' +import braces from 'braces' +import { findAll, indexToPosition } from './util/find' +import { absoluteRange } from './util/absoluteRange' +import { formatBytes } from './util/format-bytes' +import { estimatedClassSize } from './util/estimated-class-size' + +export async function getCodeLens(state: State, doc: TextDocument): Promise { + if (!state.enabled) return [] + + let groups: CodeLens[][] = await Promise.all([ + // + sourceInlineCodeLens(state, doc), + ]) + + return groups.flat() +} + +const SOURCE_INLINE_PATTERN = /@source(?:\s+not)?\s*inline\((?'[^']+'|"[^"]+")/dg +async function sourceInlineCodeLens(state: State, doc: TextDocument): Promise { + if (!state.features.includes('source-inline')) return [] + + let text = doc.getText() + + let countFormatter = new Intl.NumberFormat('en', { + maximumFractionDigits: 2, + }) + + let lenses: CodeLens[] = [] + + for (let match of findAll(SOURCE_INLINE_PATTERN, text)) { + let glob = match.groups.glob.slice(1, -1) + + // Perform brace expansion + let expanded = new Set(braces.expand(glob)) + if (expanded.size < 2) continue + + let slice: Range = absoluteRange({ + start: indexToPosition(text, match.indices.groups.glob[0]), + end: indexToPosition(text, match.indices.groups.glob[1]), + }) + + let size = 0 + for (let className of expanded) { + size += estimatedClassSize(className) + } + + lenses.push({ + range: slice, + command: { + title: `Generates ${countFormatter.format(expanded.size)} classes`, + command: '', + }, + }) + + if (size >= 1_000_000) { + lenses.push({ + range: slice, + command: { + title: `At least ${formatBytes(size)} of CSS`, + command: '', + }, + }) + + lenses.push({ + range: slice, + command: { + title: `This may slow down your bundler/browser`, + command: '', + }, + }) + } + } + + return lenses +} diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index 1579fd93..3cdd511a 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -1866,6 +1866,19 @@ function provideCssDirectiveCompletions( }, }) + if (state.features.includes('source-not')) { + items.push({ + label: '@source not', + documentation: { + kind: 'markdown' as typeof MarkupKind.Markdown, + value: `Use the \`@source not\` directive to ignore files when scanning.\n\n[Tailwind CSS Documentation](${docsUrl( + state.version, + 'functions-and-directives/#source', + )})`, + }, + }) + } + items.push({ label: '@plugin', documentation: { diff --git a/packages/tailwindcss-language-service/src/completions/file-paths.test.ts b/packages/tailwindcss-language-service/src/completions/file-paths.test.ts index c79fcbdd..ed521392 100644 --- a/packages/tailwindcss-language-service/src/completions/file-paths.test.ts +++ b/packages/tailwindcss-language-service/src/completions/file-paths.test.ts @@ -15,6 +15,7 @@ test('Detecting v3 directives that point to files', async () => { // The following are not supported in v3 await expect(find('@plugin "./')).resolves.toEqual(null) await expect(find('@source "./')).resolves.toEqual(null) + await expect(find('@source not "./')).resolves.toEqual(null) await expect(find('@import "tailwindcss" source("./')).resolves.toEqual(null) await expect(find('@tailwind utilities source("./')).resolves.toEqual(null) }) @@ -42,6 +43,12 @@ test('Detecting v4 directives that point to files', async () => { suggest: 'source', }) + await expect(find('@source not "./')).resolves.toEqual({ + directive: 'source', + partial: './', + suggest: 'source', + }) + await expect(find('@import "tailwindcss" source("./')).resolves.toEqual({ directive: 'import', partial: './', @@ -54,3 +61,12 @@ test('Detecting v4 directives that point to files', async () => { suggest: 'directory', }) }) + +test('@source inline is ignored', async () => { + function find(text: string) { + return findFileDirective({ enabled: true, v4: true }, text) + } + + await expect(find('@source inline("')).resolves.toEqual(null) + await expect(find('@source not inline("')).resolves.toEqual(null) +}) diff --git a/packages/tailwindcss-language-service/src/completions/file-paths.ts b/packages/tailwindcss-language-service/src/completions/file-paths.ts index a99325be..acf4f9fa 100644 --- a/packages/tailwindcss-language-service/src/completions/file-paths.ts +++ b/packages/tailwindcss-language-service/src/completions/file-paths.ts @@ -1,7 +1,10 @@ import type { State } from '../util/state' // @config, @plugin, @source -const PATTERN_CUSTOM_V4 = /@(?config|plugin|source)\s*(?'[^']*|"[^"]*)$/ +// - @source inline("…") is *not* a file directive +// - @source not inline("…") is *not* a file directive +const PATTERN_CUSTOM_V4 = + /@(?config|plugin|source)(?\s+not)?\s*(?'[^']*|"[^"]*)$/ const PATTERN_CUSTOM_V3 = /@(?config)\s*(?'[^']*|"[^"]*)$/ // @import … source('…') @@ -26,6 +29,7 @@ export async function findFileDirective(state: State, text: string): Promise 0 let directive = match.groups.directive let partial = match.groups.partial?.slice(1) ?? '' // remove leading quote @@ -40,6 +44,7 @@ export async function findFileDirective(state: State, text: string): Promise 0 + if (isNot) return null + let directive = match.groups.directive let partial = match.groups.partial.slice(1) // remove leading quote diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts index 2ac52a08..24fdcd9c 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts @@ -14,7 +14,7 @@ const PATTERN_UTIL_SOURCE = // @source … const PATTERN_AT_SOURCE = - /(?:\s|^)@(?source)\s*(?'[^']*'?|"[^"]*"?|[a-z]*|\)|;)/dg + /(?:\s|^)@(?source)\s*(?not)?\s*(?'[^']*'?|"[^"]*"?|[a-z]*(?:\([^)]+\))?|\)|;)/dg const HAS_DRIVE_LETTER = /^[A-Z]:/ @@ -135,6 +135,11 @@ export function getInvalidSourceDiagnostics( }) } + // `@source inline(…)` is fine + else if (directive === 'source' && source.startsWith('inline(')) { + // + } + // - `@import "tailwindcss" source(no)` // - `@tailwind utilities source('')` else if (directive === 'source' && source !== 'none' && !isQuoted) { diff --git a/packages/tailwindcss-language-service/src/documentLinksProvider.ts b/packages/tailwindcss-language-service/src/documentLinksProvider.ts index 76e39ed7..ea2e7bd9 100644 --- a/packages/tailwindcss-language-service/src/documentLinksProvider.ts +++ b/packages/tailwindcss-language-service/src/documentLinksProvider.ts @@ -18,7 +18,7 @@ export function getDocumentLinks( if (state.v4) { patterns.push( /@plugin\s*(?'[^']+'|"[^"]+")/g, - /@source\s*(?'[^']+'|"[^"]+")/g, + /@source(?:\s+not)?\s*(?'[^']+'|"[^"]+")/g, /@import\s*('[^']*'|"[^"]*")\s*(layer\([^)]+\)\s*)?source\((?'[^']*'?|"[^"]*"?)/g, /@reference\s*('[^']*'|"[^"]*")\s*source\((?'[^']*'?|"[^"]*"?)/g, /@tailwind\s*utilities\s*source\((?'[^']*'?|"[^"]*"?)/g, diff --git a/packages/tailwindcss-language-service/src/features.ts b/packages/tailwindcss-language-service/src/features.ts index 60181bf7..6aa9236e 100644 --- a/packages/tailwindcss-language-service/src/features.ts +++ b/packages/tailwindcss-language-service/src/features.ts @@ -15,6 +15,8 @@ export type Feature = | 'jit' | 'separator:root' | 'separator:options' + | 'source-not' + | 'source-inline' /** * Determine a list of features that are supported by the given Tailwind CSS version @@ -39,15 +41,21 @@ export function supportedFeatures(version: string, mod?: unknown): Feature[] { } if (isInsidersV4) { - return ['css-at-theme', 'layer:base', 'content-list'] + return ['css-at-theme', 'layer:base', 'content-list', 'source-inline', 'source-not'] } - if (!isInsidersV3 && semver.gte(version, '4.0.0-alpha.1')) { - return ['css-at-theme', 'layer:base', 'content-list'] - } + if (!isInsidersV3) { + if (semver.gte(version, '4.1.0')) { + return ['css-at-theme', 'layer:base', 'content-list', 'source-inline', 'source-not'] + } - if (!isInsidersV3 && version.startsWith('0.0.0-oxide')) { - return ['css-at-theme', 'layer:base', 'content-list'] + if (semver.gte(version, '4.0.0-alpha.1')) { + return ['css-at-theme', 'layer:base', 'content-list'] + } + + if (version.startsWith('0.0.0-oxide')) { + return ['css-at-theme', 'layer:base', 'content-list'] + } } if (semver.gte(version, '0.99.0')) { diff --git a/packages/tailwindcss-language-service/src/hoverProvider.ts b/packages/tailwindcss-language-service/src/hoverProvider.ts index eb5a7d1c..1db68bed 100644 --- a/packages/tailwindcss-language-service/src/hoverProvider.ts +++ b/packages/tailwindcss-language-service/src/hoverProvider.ts @@ -168,19 +168,24 @@ async function provideSourceGlobHover( let text = getTextWithoutComments(document, 'css', range) - let pattern = /@source\s*(?'[^']+'|"[^"]+")/dg + let patterns = [ + /@source(?:\s+not)?\s*(?'[^']+'|"[^"]+")/dg, + /@source(?:\s+not)?\s*inline\((?'[^']+'|"[^"]+")/dg, + ] - for (let match of findAll(pattern, text)) { - let path = match.groups.path.slice(1, -1) + let matches = patterns.flatMap((pattern) => findAll(pattern, text)) - // Ignore paths that don't need brace expansion - if (!path.includes('{') || !path.includes('}')) continue + for (let match of matches) { + let glob = match.groups.glob.slice(1, -1) + + // Ignore globs that don't need brace expansion + if (!glob.includes('{') || !glob.includes('}')) continue - // Ignore paths that don't contain the current position + // Ignore glob that don't contain the current position let slice: Range = absoluteRange( { - start: indexToPosition(text, match.indices.groups.path[0]), - end: indexToPosition(text, match.indices.groups.path[1]), + start: indexToPosition(text, match.indices.groups.glob[0]), + end: indexToPosition(text, match.indices.groups.glob[1]), }, range, ) @@ -188,8 +193,8 @@ async function provideSourceGlobHover( if (!isWithinRange(position, slice)) continue // Perform brace expansion - let paths = new Set(braces.expand(path)) - if (paths.size < 2) continue + let expanded = new Set(braces.expand(glob)) + if (expanded.size < 2) continue return { range: slice, @@ -197,7 +202,7 @@ async function provideSourceGlobHover( // '**Expansion**', '```plaintext', - ...Array.from(paths, (path) => `- ${path}`), + ...Array.from(expanded, (entry) => `- ${entry}`), '```', ]), } diff --git a/packages/tailwindcss-language-service/src/util/estimated-class-size.ts b/packages/tailwindcss-language-service/src/util/estimated-class-size.ts new file mode 100644 index 00000000..57dc4235 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/estimated-class-size.ts @@ -0,0 +1,35 @@ +import { segment } from './segment' + +/** + * Calculates the approximate size of a generated class + * + * This is meant to be a lower bound, as the actual size of a class can vary + * depending on the actual CSS properties and values, configured theme, etc… + */ +export function estimatedClassSize(className: string) { + let size = 0 + + // We estimate the size using the following structure which gives a reasonable + // lower bound on the size of the generated CSS: + // + // .class-name { + // &:variant-1 { + // &:variant-2 { + // … + // } + // } + // } + + // Class name + size += 1 + className.length + 3 + size += 2 + + // Variants + nesting + for (let [depth, variantName] of segment(className, ':').entries()) { + size += (depth + 1) * 2 + 2 + variantName.length + 3 + size += (depth + 1) * 2 + 2 + } + + // ~1.95x is a rough growth factor due to the actual properties being present + return size * 1.95 +} diff --git a/packages/tailwindcss-language-service/src/util/format-bytes.ts b/packages/tailwindcss-language-service/src/util/format-bytes.ts new file mode 100644 index 00000000..a7b0b050 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/format-bytes.ts @@ -0,0 +1,11 @@ +const UNITS = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte'] + +export function formatBytes(n: number) { + let i = n == 0 ? 0 : Math.floor(Math.log(n) / Math.log(1000)) + return new Intl.NumberFormat('en', { + notation: 'compact', + style: 'unit', + unit: UNITS[i], + unitDisplay: 'narrow', + }).format(n / 1000 ** i) +} diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 3bdb1bc4..119e5f59 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -4,6 +4,7 @@ import type { Postcss } from 'postcss' import type { KeywordColor } from './color' import type * as culori from 'culori' import type { DesignSystem } from './v4' +import type { Feature } from '../features' export type ClassNamesTree = { [key: string]: ClassNamesTree @@ -49,6 +50,7 @@ export type TailwindCssSettings = { classFunctions: string[] suggestions: boolean hovers: boolean + codeLens: boolean codeActions: boolean validate: boolean showPixelEquivalents: boolean @@ -141,6 +143,7 @@ export interface State { classListContainsMetadata?: boolean pluginVersions?: string completionItemData?: Record + features: Feature[] // postcssPlugins?: { before: any[]; after: any[] } } @@ -185,6 +188,7 @@ export function getDefaultTailwindSettings(): Settings { classAttributes: ['class', 'className', 'ngClass', 'class:list'], classFunctions: [], codeActions: true, + codeLens: true, hovers: true, suggestions: true, validate: true, @@ -221,6 +225,7 @@ export function createState( ): State { return { enabled: true, + features: [], ...partial, editor: { get connection(): Connection { diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 7b9e68fa..faf20065 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -4,6 +4,8 @@ - Detect classes in JS/TS functions and tagged template literals with the `tailwindCSS.classFunctions` setting ([#1258](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1258)) - v4: Make sure completions show after variants using arbitrary and bare values ([#1263](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1263)) +- v4: Add support for upcoming `@source not` feature ([#1262](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1262)) +- v4: Add support for upcoming `@source inline(…)` feature ([#1262](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1262)) # 0.14.9 diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 036ca936..876a661b 100644 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -210,6 +210,12 @@ "markdownDescription": "Enable code actions.", "scope": "language-overridable" }, + "tailwindCSS.codeLens": { + "type": "boolean", + "default": true, + "markdownDescription": "Enable code lens.", + "scope": "language-overridable" + }, "tailwindCSS.colorDecorators": { "type": "boolean", "default": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2378a376..67d58759 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@tailwindcss/typography': specifier: 0.5.7 version: 0.5.7(tailwindcss@3.4.17) + '@types/braces': + specifier: 3.0.1 + version: 3.0.1 '@types/color-name': specifier: ^1.1.3 version: 1.1.4 @@ -297,6 +300,9 @@ importers: specifier: 1.0.11 version: 1.0.11 devDependencies: + '@types/braces': + specifier: 3.0.1 + version: 3.0.1 '@types/css.escape': specifier: ^1.5.2 version: 1.5.2 From 182600d55606e81ea600e57be15580cf32b7fe73 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Mar 2025 10:47:17 -0400 Subject: [PATCH 05/93] Ensure `classFunctions` completions work inside ` + `, + }) + + // let classes = clsx('') + // ^ + let completion = await document.completions({ line: 1, character: 22 }) + + expect(completion?.items.length).toBe(12289) + }, +}) diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index 3cdd511a..62c54594 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -22,7 +22,7 @@ import isObject from './util/isObject' import { braceLevel, parenLevel } from './util/braceLevel' import * as emmetHelper from 'vscode-emmet-helper-bundled' import { isValidLocationForEmmetAbbreviation } from './util/isValidLocationForEmmetAbbreviation' -import { isJsDoc, isJsxContext } from './util/js' +import { isJsContext, isJsDoc, isJsxContext } from './util/js' import { naturalExpand } from './util/naturalExpand' import * as semver from './util/semver' import { getTextWithoutComments } from './util/doc' @@ -986,7 +986,11 @@ async function provideClassNameCompletions( return provideAtApplyCompletions(state, document, position, context) } - if (isHtmlContext(state, document, position) || isJsxContext(state, document, position)) { + if ( + isHtmlContext(state, document, position) || + isJsContext(state, document, position) || + isJsxContext(state, document, position) + ) { return provideClassAttributeCompletions(state, document, position, context) } diff --git a/packages/tailwindcss-language-service/src/util/js.ts b/packages/tailwindcss-language-service/src/util/js.ts index 1197d1e6..0c405a8d 100644 --- a/packages/tailwindcss-language-service/src/util/js.ts +++ b/packages/tailwindcss-language-service/src/util/js.ts @@ -12,6 +12,19 @@ export function isJsDoc(state: State, doc: TextDocument): boolean { return [...jsLanguages, ...userJsLanguages].indexOf(doc.languageId) !== -1 } +export function isJsContext(state: State, doc: TextDocument, position: Position): boolean { + let str = doc.getText({ + start: { line: 0, character: 0 }, + end: position, + }) + + let boundaries = getLanguageBoundaries(state, doc, str) + + return boundaries + ? ['js', 'ts', 'jsx', 'tsx'].includes(boundaries[boundaries.length - 1].type) + : false +} + export function isJsxContext(state: State, doc: TextDocument, position: Position): boolean { let str = doc.getText({ start: { line: 0, character: 0 }, diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index faf20065..468cdd08 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -2,7 +2,7 @@ ## Prerelease -- Detect classes in JS/TS functions and tagged template literals with the `tailwindCSS.classFunctions` setting ([#1258](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1258)) +- Detect classes in JS/TS functions and tagged template literals with the `tailwindCSS.classFunctions` setting ([#1258](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1258), [#1272](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1272)) - v4: Make sure completions show after variants using arbitrary and bare values ([#1263](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1263)) - v4: Add support for upcoming `@source not` feature ([#1262](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1262)) - v4: Add support for upcoming `@source inline(…)` feature ([#1262](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1262)) From 2f41b839a3856efd1ebe10b656e7b4704cbd57d8 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 19 Mar 2025 11:48:21 -0400 Subject: [PATCH 06/93] LSP: Refresh internal caches when settings are updated (#1273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We use the pull model (`workspace/configuration`) to get settings for a document and we cache these internally so we don't repeat these calls multiple times for a given request. We're set up to listen for configuration refresh notifications and update the project settings when we get them. Unfortunately, we didn't actually _register_ for these notifications, so we never got them. This meant that if you changed the settings for an already opened file or workspace folder, the language server would not react to these changes. This PR fixes this by registering for configuration change notifications and now open files with color decorators, completions, etc… should react to changes in the settings as needed. If settings are updated and our langauge server doesn't react to or handle these changes, it is definitely a bug. Hopefully this will squash all of those particular ones but… we'll see. 😅 --- packages/tailwindcss-language-server/src/projects.ts | 8 +++----- packages/tailwindcss-language-server/src/tw.ts | 6 ++++++ packages/vscode-tailwindcss/CHANGELOG.md | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index afc4515f..a8012920 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -1181,11 +1181,9 @@ export async function createProjectService( if (state.enabled) { refreshDiagnostics() } - if (settings.editor?.colorDecorators) { - updateCapabilities() - } else { - connection.sendNotification('@/tailwindCSS/clearColors') - } + + updateCapabilities() + connection.sendNotification('@/tailwindCSS/clearColors') }, onFileEvents, async onHover(params: TextDocumentPositionParams): Promise { diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index fc6a87a8..48d7b5f7 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -33,6 +33,7 @@ import { DocumentLinkRequest, TextDocumentSyncKind, CodeLensRequest, + DidChangeConfigurationNotification, } from 'vscode-languageserver/node' import { URI } from 'vscode-uri' import normalizePath from 'normalize-path' @@ -799,6 +800,7 @@ export class TW { private updateCapabilities() { if (!supportsDynamicRegistration(this.initializeParams)) { + this.connection.client.register(DidChangeConfigurationNotification.type, undefined) return } @@ -810,12 +812,16 @@ export class TW { let capabilities = BulkRegistration.create() + // TODO: We should *not* be re-registering these capabilities + // IDEA: These should probably be static registrations up front capabilities.add(HoverRequest.type, { documentSelector: null }) capabilities.add(DocumentColorRequest.type, { documentSelector: null }) capabilities.add(CodeActionRequest.type, { documentSelector: null }) capabilities.add(CodeLensRequest.type, { documentSelector: null }) capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) + capabilities.add(DidChangeConfigurationNotification.type, undefined) + // TODO: Only re-register this if trigger characters change capabilities.add(CompletionRequest.type, { documentSelector: null, resolveProvider: true, diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 468cdd08..fa2d07c3 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -6,6 +6,7 @@ - v4: Make sure completions show after variants using arbitrary and bare values ([#1263](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1263)) - v4: Add support for upcoming `@source not` feature ([#1262](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1262)) - v4: Add support for upcoming `@source inline(…)` feature ([#1262](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1262)) +- LSP: Refresh internal caches when settings are updated ([#1273](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1273)) # 0.14.9 From 747884f34b31c797db0915d1719276eae35431f1 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 20 Mar 2025 12:37:21 -0400 Subject: [PATCH 07/93] Ignore regex literals when analyzing language boundaries (#1275) Fixes #929 --- .../tests/env/v4.test.js | 48 +++++ .../src/util/doc.ts | 150 ++++++++++++++ .../src/util/find.test.ts | 61 +----- .../src/util/language-boundaries.test.ts | 191 ++++++++++++++++++ .../src/util/test-utils.ts | 65 ++++++ 5 files changed, 455 insertions(+), 60 deletions(-) create mode 100644 packages/tailwindcss-language-service/src/util/language-boundaries.test.ts create mode 100644 packages/tailwindcss-language-service/src/util/test-utils.ts diff --git a/packages/tailwindcss-language-server/tests/env/v4.test.js b/packages/tailwindcss-language-server/tests/env/v4.test.js index 76de7f15..3a7780af 100644 --- a/packages/tailwindcss-language-server/tests/env/v4.test.js +++ b/packages/tailwindcss-language-server/tests/env/v4.test.js @@ -791,3 +791,51 @@ defineTest({ }) }, }) + +defineTest({ + options: { only: true }, + name: 'regex literals do not break language boundaries', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'javascriptreact', + text: js` + export default function Page() { + let styles = "str".match(/ +
+
+ `, + }) + + let boundaries = getLanguageBoundaries(file.state, file.doc) + + expect(boundaries).toEqual([ + { + type: 'html', + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 2 }, + }, + }, + { + type: 'css', + range: { + start: { line: 1, character: 2 }, + end: { line: 5, character: 2 }, + }, + }, + { + type: 'html', + range: { + start: { line: 5, character: 2 }, + end: { line: 7, character: 6 }, + }, + }, + ]) +}) + +test('script tags in HTML are treated as a separate boundary', ({ expect }) => { + let file = createDocument({ + name: 'file.html', + lang: 'html', + content: html` +
+ +
+
+ `, + }) + + let boundaries = getLanguageBoundaries(file.state, file.doc) + + expect(boundaries).toEqual([ + { + type: 'html', + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 2 }, + }, + }, + { + type: 'js', + range: { + start: { line: 1, character: 2 }, + end: { line: 5, character: 2 }, + }, + }, + { + type: 'html', + range: { + start: { line: 5, character: 2 }, + end: { line: 7, character: 6 }, + }, + }, + ]) +}) + +test('Vue files detect