From 0e51b100ac7f0fa22010fea2e17cb08ab410c12b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 28 Mar 2025 19:10:13 -0400 Subject: [PATCH 1/6] Refactor --- packages/vscode-tailwindcss/src/analyze.ts | 93 +++++++++++ packages/vscode-tailwindcss/src/api.ts | 45 ++++++ packages/vscode-tailwindcss/src/exclusions.ts | 49 ++++++ packages/vscode-tailwindcss/src/extension.ts | 152 ++---------------- 4 files changed, 199 insertions(+), 140 deletions(-) create mode 100644 packages/vscode-tailwindcss/src/analyze.ts create mode 100644 packages/vscode-tailwindcss/src/api.ts create mode 100644 packages/vscode-tailwindcss/src/exclusions.ts diff --git a/packages/vscode-tailwindcss/src/analyze.ts b/packages/vscode-tailwindcss/src/analyze.ts new file mode 100644 index 000000000..ec9b2b9a3 --- /dev/null +++ b/packages/vscode-tailwindcss/src/analyze.ts @@ -0,0 +1,93 @@ +import { workspace, RelativePattern, CancellationToken, Uri, WorkspaceFolder } from 'vscode' +import braces from 'braces' +import { CONFIG_GLOB, CSS_GLOB } from '@tailwindcss/language-server/src/lib/constants' +import { getExcludePatterns } from './exclusions' + +export interface SearchOptions { + folders: readonly WorkspaceFolder[] + token: CancellationToken +} + +export async function anyWorkspaceFoldersNeedServer({ folders, token }: SearchOptions) { + // An explicit config file setting means we need the server + for (let folder of folders) { + let settings = workspace.getConfiguration('tailwindCSS', folder) + let configFilePath = settings.get('experimental.configFile') + + // No setting provided + if (!configFilePath) continue + + // Ths config file may be a string: + // A path pointing to a CSS or JS config file + if (typeof configFilePath === 'string') return true + + // Ths config file may be an object: + // A map of config files to one or more globs + // + // If we get an empty object the language server will do a search anyway so + // we'll act as if no option was passed to be consistent + if (typeof configFilePath === 'object' && Object.values(configFilePath).length > 0) return true + } + + let configs: Array<() => Thenable> = [] + let stylesheets: Array<() => Thenable> = [] + + for (let folder of folders) { + let exclusions = getExcludePatterns(folder).flatMap((pattern) => braces.expand(pattern)) + let exclude = `{${exclusions.join(',').replace(/{/g, '%7B').replace(/}/g, '%7D')}}` + + configs.push(() => + workspace.findFiles( + new RelativePattern(folder, `**/${CONFIG_GLOB}`), + exclude, + undefined, + token, + ), + ) + + stylesheets.push(() => + workspace.findFiles(new RelativePattern(folder, `**/${CSS_GLOB}`), exclude, undefined, token), + ) + } + + // If we find a config file then we need the server + let configUrls = await Promise.all(configs.map((fn) => fn())) + for (let group of configUrls) { + if (group.length > 0) { + return true + } + } + + // If we find a possibly-related stylesheet then we need the server + // The step is done last because it requires reading individual files + // to determine if the server should be started. + // + // This is also, unfortunately, prone to starting the server unncessarily + // in projects that don't use TailwindCSS so we do this one-by-one instead + // of all at once to keep disk I/O low. + let stylesheetUrls = await Promise.all(stylesheets.map((fn) => fn())) + for (let group of stylesheetUrls) { + for (let file of group) { + if (await fileMayBeTailwindRelated(file)) { + return true + } + } + } +} + +let HAS_CONFIG = /@config\s*['"]/ +let HAS_IMPORT = /@import\s*['"]/ +let HAS_TAILWIND = /@tailwind\s*[^;]+;/ +let HAS_THEME = /@theme\s*\{/ + +export async function fileMayBeTailwindRelated(uri: Uri) { + let buffer = await workspace.fs.readFile(uri) + let contents = buffer.toString() + + return ( + HAS_CONFIG.test(contents) || + HAS_IMPORT.test(contents) || + HAS_TAILWIND.test(contents) || + HAS_THEME.test(contents) + ) +} diff --git a/packages/vscode-tailwindcss/src/api.ts b/packages/vscode-tailwindcss/src/api.ts new file mode 100644 index 000000000..9bd0fa073 --- /dev/null +++ b/packages/vscode-tailwindcss/src/api.ts @@ -0,0 +1,45 @@ +import { workspace, CancellationTokenSource, OutputChannel, ExtensionContext, Uri } from 'vscode' +import { anyWorkspaceFoldersNeedServer, fileMayBeTailwindRelated } from './analyze' + +interface ApiOptions { + context: ExtensionContext + outputChannel: OutputChannel +} + +export async function createApi({ context, outputChannel }: ApiOptions) { + async function workspaceNeedsLanguageServer() { + let source: CancellationTokenSource | null = new CancellationTokenSource() + source.token.onCancellationRequested(() => { + source?.dispose() + source = null + + outputChannel.appendLine( + 'Server was not started. Search for Tailwind CSS-related files was taking too long.', + ) + }) + + // Cancel the search after roughly 15 seconds + setTimeout(() => source?.cancel(), 15_000) + context.subscriptions.push(source) + + folderAnalysis = anyWorkspaceFoldersNeedServer({ + token: source.token, + folders: workspace.workspaceFolders ?? [], + }) + + let result = await folderAnalysis + source?.dispose() + return result + } + + async function stylesheetNeedsLanguageServer(uri: Uri) { + outputChannel.appendLine(`Checking if ${uri.fsPath} may be Tailwind-related…`) + + return fileMayBeTailwindRelated(uri) + } + + return { + workspaceNeedsLanguageServer, + stylesheetNeedsLanguageServer, + } +} diff --git a/packages/vscode-tailwindcss/src/exclusions.ts b/packages/vscode-tailwindcss/src/exclusions.ts new file mode 100644 index 000000000..46ffd599a --- /dev/null +++ b/packages/vscode-tailwindcss/src/exclusions.ts @@ -0,0 +1,49 @@ +import { + workspace, + type WorkspaceConfiguration, + type ConfigurationScope, + type WorkspaceFolder, +} from 'vscode' +import picomatch from 'picomatch' +import * as path from 'node:path' + +function getGlobalExcludePatterns(scope: ConfigurationScope | null): string[] { + return Object.entries(workspace.getConfiguration('files', scope)?.get('exclude') ?? []) + .filter(([, value]) => value === true) + .map(([key]) => key) + .filter(Boolean) +} + +export function getExcludePatterns(scope: ConfigurationScope | null): string[] { + return [ + ...getGlobalExcludePatterns(scope), + ...(workspace.getConfiguration('tailwindCSS', scope).get('files.exclude')).filter( + Boolean, + ), + ] +} + +export function isExcluded(file: string, folder: WorkspaceFolder): boolean { + for (let pattern of getExcludePatterns(folder)) { + let matcher = picomatch(path.join(folder.uri.fsPath, pattern)) + + if (matcher(file)) { + return true + } + } + + return false +} + +export function mergeExcludes( + settings: WorkspaceConfiguration, + scope: ConfigurationScope | null, +): any { + return { + ...settings, + files: { + ...settings.files, + exclude: getExcludePatterns(scope), + }, + } +} diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index a37486160..8fdc2985e 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -4,9 +4,7 @@ import type { TextDocument, WorkspaceFolder, ConfigurationScope, - WorkspaceConfiguration, Selection, - CancellationToken, } from 'vscode' import { workspace as Workspace, @@ -16,8 +14,6 @@ import { SymbolInformation, Position, Range, - RelativePattern, - CancellationTokenSource, } from 'vscode' import type { DocumentFilter, @@ -34,11 +30,11 @@ import { languages as defaultLanguages } from '@tailwindcss/language-service/src import * as semver from '@tailwindcss/language-service/src/util/semver' import isObject from '@tailwindcss/language-service/src/util/isObject' import namedColors from 'color-name' -import picomatch from 'picomatch' import { CONFIG_GLOB, CSS_GLOB } from '@tailwindcss/language-server/src/lib/constants' -import braces from 'braces' import normalizePath from 'normalize-path' import * as servers from './servers/index' +import { isExcluded, mergeExcludes } from './exclusions' +import { createApi } from './api' const colorNames = Object.keys(namedColors) @@ -52,60 +48,6 @@ function getUserLanguages(folder?: WorkspaceFolder): Record { return isObject(langs) ? langs : {} } -function getGlobalExcludePatterns(scope: ConfigurationScope | null): string[] { - return Object.entries(Workspace.getConfiguration('files', scope)?.get('exclude') ?? []) - .filter(([, value]) => value === true) - .map(([key]) => key) - .filter(Boolean) -} - -function getExcludePatterns(scope: ConfigurationScope | null): string[] { - return [ - ...getGlobalExcludePatterns(scope), - ...(Workspace.getConfiguration('tailwindCSS', scope).get('files.exclude')).filter( - Boolean, - ), - ] -} - -function isExcluded(file: string, folder: WorkspaceFolder): boolean { - for (let pattern of getExcludePatterns(folder)) { - let matcher = picomatch(path.join(folder.uri.fsPath, pattern)) - - if (matcher(file)) { - return true - } - } - - return false -} - -function mergeExcludes(settings: WorkspaceConfiguration, scope: ConfigurationScope | null): any { - return { - ...settings, - files: { - ...settings.files, - exclude: getExcludePatterns(scope), - }, - } -} - -async function fileMayBeTailwindRelated(uri: Uri) { - let contents = (await Workspace.fs.readFile(uri)).toString() - - let HAS_CONFIG = /@config\s*['"]/ - let HAS_IMPORT = /@import\s*['"]/ - let HAS_TAILWIND = /@tailwind\s*[^;]+;/ - let HAS_THEME = /@theme\s*\{/ - - return ( - HAS_CONFIG.test(contents) || - HAS_IMPORT.test(contents) || - HAS_TAILWIND.test(contents) || - HAS_THEME.test(contents) - ) -} - function selectionsAreEqual( aSelections: readonly Selection[], bSelections: readonly Selection[], @@ -177,6 +119,12 @@ function resetActiveTextEditorContext(): void { export async function activate(context: ExtensionContext) { let outputChannel = Window.createOutputChannel(CLIENT_NAME) + + let api = await createApi({ + context, + outputChannel, + }) + context.subscriptions.push(outputChannel) context.subscriptions.push( commands.registerCommand('tailwindCSS.showOutput', () => { @@ -282,7 +230,7 @@ export async function activate(context: ExtensionContext) { if (!folder || isExcluded(uri.fsPath, folder)) { return } - if (await fileMayBeTailwindRelated(uri)) { + if (await api.stylesheetNeedsLanguageServer(uri)) { await bootWorkspaceClient() } } @@ -579,87 +527,11 @@ export async function activate(context: ExtensionContext) { } async function bootClientIfNeeded(): Promise { - if (currentClient) { - return - } - - let source: CancellationTokenSource | null = new CancellationTokenSource() - source.token.onCancellationRequested(() => { - source?.dispose() - source = null - outputChannel.appendLine( - 'Server was not started. Search for Tailwind CSS-related files was taking too long.', - ) - }) - - // Cancel the search after roughly 15 seconds - setTimeout(() => source?.cancel(), 15_000) + if (currentClient) return - if (!(await anyFolderNeedsLanguageServer(Workspace.workspaceFolders ?? [], source!.token))) { - source?.dispose() - return - } - - source?.dispose() - - await bootWorkspaceClient() - } - - async function anyFolderNeedsLanguageServer( - folders: readonly WorkspaceFolder[], - token: CancellationToken, - ): Promise { - for (let folder of folders) { - if (await folderNeedsLanguageServer(folder, token)) { - return true - } - } - - return false - } - - async function folderNeedsLanguageServer( - folder: WorkspaceFolder, - token: CancellationToken, - ): Promise { - let settings = Workspace.getConfiguration('tailwindCSS', folder) - if (settings.get('experimental.configFile') !== null) { - return true - } - - let exclude = `{${getExcludePatterns(folder) - .flatMap((pattern) => braces.expand(pattern)) - .join(',') - .replace(/{/g, '%7B') - .replace(/}/g, '%7D')}}` - - let configFiles = await Workspace.findFiles( - new RelativePattern(folder, `**/${CONFIG_GLOB}`), - exclude, - 1, - token, - ) - - for (let file of configFiles) { - return true - } - - let cssFiles = await Workspace.findFiles( - new RelativePattern(folder, `**/${CSS_GLOB}`), - exclude, - undefined, - token, - ) - - for (let file of cssFiles) { - outputChannel.appendLine(`Checking if ${file.fsPath} may be Tailwind-related…`) - - if (await fileMayBeTailwindRelated(file)) { - return true - } + if (await api.workspaceNeedsLanguageServer()) { + await bootWorkspaceClient() } - - return false } async function didOpenTextDocument(document: TextDocument): Promise { From 8d020061d381e88ab0ca8f2ca2ab560e15e182e9 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 28 Mar 2025 19:27:51 -0400 Subject: [PATCH 2/6] Only run workspace analysis once --- packages/vscode-tailwindcss/src/api.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vscode-tailwindcss/src/api.ts b/packages/vscode-tailwindcss/src/api.ts index 9bd0fa073..d7f67de64 100644 --- a/packages/vscode-tailwindcss/src/api.ts +++ b/packages/vscode-tailwindcss/src/api.ts @@ -7,7 +7,11 @@ interface ApiOptions { } export async function createApi({ context, outputChannel }: ApiOptions) { + let folderAnalysis: Promise | null = null + async function workspaceNeedsLanguageServer() { + if (folderAnalysis) return folderAnalysis + let source: CancellationTokenSource | null = new CancellationTokenSource() source.token.onCancellationRequested(() => { source?.dispose() @@ -22,7 +26,7 @@ export async function createApi({ context, outputChannel }: ApiOptions) { setTimeout(() => source?.cancel(), 15_000) context.subscriptions.push(source) - folderAnalysis = anyWorkspaceFoldersNeedServer({ + folderAnalysis ??= anyWorkspaceFoldersNeedServer({ token: source.token, folders: workspace.workspaceFolders ?? [], }) From ac906bbe95b1ce0e61994f8ff22936bc12ddea2c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 28 Mar 2025 19:58:24 -0400 Subject: [PATCH 3/6] Cleanup --- packages/vscode-tailwindcss/src/extension.ts | 44 ++++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index 8fdc2985e..f48155843 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -214,10 +214,10 @@ export async function activate(context: ExtensionContext) { let configWatcher = Workspace.createFileSystemWatcher(`**/${CONFIG_GLOB}`, false, true, true) configWatcher.onDidCreate(async (uri) => { + if (currentClient) return let folder = Workspace.getWorkspaceFolder(uri) - if (!folder || isExcluded(uri.fsPath, folder)) { - return - } + if (!folder || isExcluded(uri.fsPath, folder)) return + await bootWorkspaceClient() }) @@ -226,13 +226,12 @@ export async function activate(context: ExtensionContext) { let cssWatcher = Workspace.createFileSystemWatcher(`**/${CSS_GLOB}`, false, false, true) async function bootClientIfCssFileMayBeTailwindRelated(uri: Uri) { + if (currentClient) return let folder = Workspace.getWorkspaceFolder(uri) - if (!folder || isExcluded(uri.fsPath, folder)) { - return - } - if (await api.stylesheetNeedsLanguageServer(uri)) { - await bootWorkspaceClient() - } + if (!folder || isExcluded(uri.fsPath, folder)) return + if (!(await api.stylesheetNeedsLanguageServer(uri))) return + + await bootWorkspaceClient() } cssWatcher.onDidCreate(bootClientIfCssFileMayBeTailwindRelated) @@ -526,35 +525,34 @@ export async function activate(context: ExtensionContext) { return client } - async function bootClientIfNeeded(): Promise { - if (currentClient) return - - if (await api.workspaceNeedsLanguageServer()) { - await bootWorkspaceClient() - } - } - + /** + * Note that this method can fire *many* times even for documents that are + * not in a visible editor. It's critical that this doesn't start any + * expensive operations more than is necessary. + */ async function didOpenTextDocument(document: TextDocument): Promise { if (document.languageId === 'tailwindcss') { servers.css.boot(context, outputChannel) } + if (currentClient) return + // We are only interested in language mode text - if (document.uri.scheme !== 'file') { - return - } + if (document.uri.scheme !== 'file') return - let uri = document.uri - let folder = Workspace.getWorkspaceFolder(uri) + let folder = Workspace.getWorkspaceFolder(document.uri) // Files outside a folder can't be handled. This might depend on the language. // Single file languages like JSON might handle files outside the workspace folders. if (!folder) return - await bootClientIfNeeded() + if (!(await api.workspaceNeedsLanguageServer())) return + + await bootWorkspaceClient() } context.subscriptions.push(Workspace.onDidOpenTextDocument(didOpenTextDocument)) + Workspace.textDocuments.forEach(didOpenTextDocument) context.subscriptions.push( Workspace.onDidChangeWorkspaceFolders(async () => { From ddf9545c9ebfdce835127ac16ecf4d42b0af19b9 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 7 Apr 2025 09:52:05 -0400 Subject: [PATCH 4/6] =?UTF-8?q?Don=E2=80=99t=20boot=20the=20server=20when?= =?UTF-8?q?=20opening=20an=20ignored=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vscode-tailwindcss/src/extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index f48155843..467d4e085 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -544,7 +544,7 @@ export async function activate(context: ExtensionContext) { // Files outside a folder can't be handled. This might depend on the language. // Single file languages like JSON might handle files outside the workspace folders. - if (!folder) return + if (!folder || isExcluded(document.uri.fsPath, folder)) return if (!(await api.workspaceNeedsLanguageServer())) return From 027a18a840dea8d273cce077c158db733fe5c5c0 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 7 Apr 2025 11:33:02 -0400 Subject: [PATCH 5/6] 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 6a8c2dac5..c1ea9e99e 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -2,7 +2,7 @@ ## Prerelease -- Nothing yet! +- Don't scan the filesystem once for each opened document ([#1287](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1287)) # 0.14.13 From 09d1596b26787b068ab58f487522267a334b4d81 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 7 Apr 2025 11:33:43 -0400 Subject: [PATCH 6/6] 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 c1ea9e99e..661c8d174 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -2,7 +2,7 @@ ## Prerelease -- Don't scan the filesystem once for each opened document ([#1287](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1287)) +- Only scan the file system once when needed ([#1287](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1287)) # 0.14.13