diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 6a8c2dac..661c8d17 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -2,7 +2,7 @@ ## Prerelease -- Nothing yet! +- Only scan the file system once when needed ([#1287](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1287)) # 0.14.13 diff --git a/packages/vscode-tailwindcss/src/analyze.ts b/packages/vscode-tailwindcss/src/analyze.ts new file mode 100644 index 00000000..ec9b2b9a --- /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 00000000..d7f67de6 --- /dev/null +++ b/packages/vscode-tailwindcss/src/api.ts @@ -0,0 +1,49 @@ +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) { + let folderAnalysis: Promise | null = null + + async function workspaceNeedsLanguageServer() { + if (folderAnalysis) return folderAnalysis + + 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 00000000..46ffd599 --- /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 a3748616..467d4e08 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', () => { @@ -266,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() }) @@ -278,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 fileMayBeTailwindRelated(uri)) { - await bootWorkspaceClient() - } + if (!folder || isExcluded(uri.fsPath, folder)) return + if (!(await api.stylesheetNeedsLanguageServer(uri))) return + + await bootWorkspaceClient() } cssWatcher.onDidCreate(bootClientIfCssFileMayBeTailwindRelated) @@ -578,111 +525,34 @@ export async function activate(context: ExtensionContext) { return client } - 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 (!(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 - } - } - - return false - } - + /** + * 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 + if (!folder || isExcluded(document.uri.fsPath, folder)) return + + if (!(await api.workspaceNeedsLanguageServer())) return - await bootClientIfNeeded() + await bootWorkspaceClient() } context.subscriptions.push(Workspace.onDidOpenTextDocument(didOpenTextDocument)) + Workspace.textDocuments.forEach(didOpenTextDocument) context.subscriptions.push( Workspace.onDidChangeWorkspaceFolders(async () => {