From b26e122fac4da99238e38dd5ca837a76e87c54cc Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Tue, 18 Oct 2022 20:35:02 +0100 Subject: [PATCH] Add automatic support for multi-config workspaces, including `@config` resolution (#633) * wip * wip * Boot client if a CSS file contains `@config` * wip * Check document exists * wip * Fix duplicate document selector * wip * Use enum for document selector priorities * Delete unused functions * Remove unused state type * Share glob patterns * Update config file glob * fix logs * Fix filename checks on Windows * Don't show error popups * wip * handle negated content paths * Handle non-tailwind dependency installs * add package root to document selectors * tidy * wip * dedupe document selectors * Fix `@config` regex * Fix document selectors when using `experimental.configFile` * Remove log --- .../src/lib/constants.ts | 3 + .../src/lsp/diagnosticsProvider.ts | 12 - .../tailwindcss-language-server/src/server.ts | 1156 ++++++++++++----- .../src/util/error.ts | 10 +- .../src/codeActions/codeActionProvider.ts | 5 + .../provideInvalidApplyCodeActions.ts | 1 + .../src/diagnostics/diagnosticsProvider.ts | 26 - .../src/util/state.ts | 1 - packages/vscode-tailwindcss/src/extension.ts | 53 +- 9 files changed, 915 insertions(+), 352 deletions(-) create mode 100644 packages/tailwindcss-language-server/src/lib/constants.ts diff --git a/packages/tailwindcss-language-server/src/lib/constants.ts b/packages/tailwindcss-language-server/src/lib/constants.ts new file mode 100644 index 0000000..7324327 --- /dev/null +++ b/packages/tailwindcss-language-server/src/lib/constants.ts @@ -0,0 +1,3 @@ +export const CONFIG_GLOB = '{tailwind,tailwind.config,tailwind.*.config,tailwind.config.*}.{js,cjs}' +export const PACKAGE_LOCK_GLOB = '{package-lock.json,yarn.lock,pnpm-lock.yaml}' +export const CSS_GLOB = '*.{css,scss,sass,less,pcss}' diff --git a/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts b/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts index e5f01a8..fe08b24 100644 --- a/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts +++ b/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts @@ -20,15 +20,3 @@ export function clearDiagnostics(state: State, document: TextDocument): void { diagnostics: [], }) } - -export function clearAllDiagnostics(state: State): void { - state.editor?.documents.all().forEach((document) => { - clearDiagnostics(state, document) - }) -} - -export function updateAllDiagnostics(state: State): void { - state.editor?.documents.all().forEach((document) => { - provideDiagnostics(state, document) - }) -} diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index 008c221..0b9b4f5 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -65,11 +65,7 @@ import { ClassNames, Variant, } from 'tailwindcss-language-service/src/util/state' -import { - provideDiagnostics, - updateAllDiagnostics, - clearAllDiagnostics, -} from './lsp/diagnosticsProvider' +import { provideDiagnostics } from './lsp/diagnosticsProvider' import { doCodeActions } from 'tailwindcss-language-service/src/codeActions/codeActionProvider' import { getDocumentColors } from 'tailwindcss-language-service/src/documentColorProvider' import { getDocumentLinks } from 'tailwindcss-language-service/src/documentLinksProvider' @@ -88,6 +84,8 @@ import { getFileFsPath, normalizeFileNameToFsPath } from './util/uri' import { equal } from 'tailwindcss-language-service/src/util/array' import preflight from 'tailwindcss/lib/css/preflight.css' import merge from 'deepmerge' +import { getTextWithoutComments } from 'tailwindcss-language-service/src/util/doc' +import { CONFIG_GLOB, CSS_GLOB, PACKAGE_LOCK_GLOB } from './lib/constants' // @ts-ignore global.__preflight = preflight @@ -105,8 +103,6 @@ new Function( ` )(require, __dirname) -const CONFIG_FILE_GLOB = '{tailwind,tailwind.config}.{js,cjs}' -const PACKAGE_GLOB = '{package-lock.json,yarn.lock,pnpm-lock.yaml}' const TRIGGER_CHARACTERS = [ // class attributes '"', @@ -181,22 +177,42 @@ function firstOptional(...options: Array<() => T>): T | undefined { } interface ProjectService { + enabled: () => boolean + enable: () => void + documentSelector: () => Array state: State tryInit: () => Promise - dispose: () => void + dispose: () => Promise onUpdateSettings: (settings: any) => void onFileEvents: (changes: Array<{ file: string; type: FileChangeType }>) => void onHover(params: TextDocumentPositionParams): Promise onCompletion(params: CompletionParams): Promise onCompletionResolve(item: CompletionItem): Promise provideDiagnostics(document: TextDocument): void + provideDiagnosticsForce(document: TextDocument): void onDocumentColor(params: DocumentColorParams): Promise onColorPresentation(params: ColorPresentationParams): Promise onCodeAction(params: CodeActionParams): Promise onDocumentLinks(params: DocumentLinkParams): DocumentLink[] } -type ProjectConfig = { folder: string; configPath?: string; documentSelector?: string[] } +type ProjectConfig = { + folder: string + configPath?: string + documentSelector?: Array + isUserConfigured: boolean +} + +enum DocumentSelectorPriority { + USER_CONFIGURED = 0, + CONFIG_FILE = 0, + CSS_FILE = 0, + CONTENT_FILE = 1, + CSS_DIRECTORY = 2, + CONFIG_DIRECTORY = 3, + ROOT_DIRECTORY = 4, +} +type DocumentSelector = { pattern: string; priority: DocumentSelectorPriority } function getMode(config: any): unknown { if (typeof config.mode !== 'undefined') { @@ -221,77 +237,150 @@ function deleteMode(config: any): void { } } +const documentSettingsCache: Map = new Map() +async function getConfiguration(uri?: string) { + if (documentSettingsCache.has(uri)) { + return documentSettingsCache.get(uri) + } + let [editor, tailwindCSS] = await Promise.all([ + connection.workspace.getConfiguration({ + section: 'editor', + scopeUri: uri, + }), + connection.workspace.getConfiguration({ + section: 'tailwindCSS', + scopeUri: uri, + }), + ]) + editor = isObject(editor) ? editor : {} + tailwindCSS = isObject(tailwindCSS) ? tailwindCSS : {} + + let config: Settings = merge( + { + editor: { tabSize: 2 }, + tailwindCSS: { + emmetCompletions: false, + classAttributes: ['class', 'className', 'ngClass'], + 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', + recommendedVariantOrder: 'warning', + }, + showPixelEquivalents: true, + includeLanguages: {}, + files: { exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'] }, + experimental: { + classRegex: [], + configFile: null, + }, + }, + }, + { editor, tailwindCSS }, + { arrayMerge: (_destinationArray, sourceArray, _options) => sourceArray } + ) + documentSettingsCache.set(uri, config) + return config +} + +function clearRequireCache(): void { + Object.keys(require.cache).forEach((key) => { + if (!key.endsWith('.node')) { + delete require.cache[key] + } + }) + Object.keys((Module as any)._pathCache).forEach((key) => { + delete (Module as any)._pathCache[key] + }) +} + +function withoutLogs(getter: () => T): T { + let fns = { + log: console.log, + warn: console.warn, + error: console.error, + } + for (let key in fns) { + console[key] = () => {} + } + try { + return getter() + } finally { + for (let key in fns) { + console[key] = fns[key] + } + } +} + +function withFallback(getter: () => T, fallback: T): T { + try { + return getter() + } catch (e) { + return fallback + } +} + +function dirContains(dir: string, file: string): boolean { + let relative = path.relative(dir, file) + return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +function changeAffectsFile(change: string, files: string[]): boolean { + for (let file of files) { + if (change === file || dirContains(change, file)) { + return true + } + } + return false +} + +// We need to add parent directories to the watcher: +// https://github.com/microsoft/vscode/issues/60813 +function getWatchPatternsForFile(file: string): string[] { + let tmp: string + let dir = path.dirname(file) + let patterns: string[] = [file, dir] + while (true) { + dir = path.dirname((tmp = dir)) + if (tmp === dir) { + break + } else { + patterns.push(dir) + } + } + return patterns +} + async function createProjectService( projectConfig: ProjectConfig, connection: Connection, params: InitializeParams, documentService: DocumentService, - updateCapabilities: () => void + updateCapabilities: () => void, + checkOpenDocuments: () => void, + refreshDiagnostics: () => void, + watchPatterns: (patterns: string[]) => void, + initialTailwindVersion: string ): Promise { + let enabled = false const folder = projectConfig.folder - const disposables: Disposable[] = [] - const documentSettingsCache: Map = new Map() + const disposables: Array> = [] + let documentSelector = projectConfig.documentSelector - async function getConfiguration(uri?: string) { - if (documentSettingsCache.has(uri)) { - return documentSettingsCache.get(uri) - } - let [editor, tailwindCSS] = await Promise.all([ - connection.workspace.getConfiguration({ - section: 'editor', - scopeUri: uri, - }), - connection.workspace.getConfiguration({ - section: 'tailwindCSS', - scopeUri: uri, - }), - ]) - editor = isObject(editor) ? editor : {} - tailwindCSS = isObject(tailwindCSS) ? tailwindCSS : {} - - let config: Settings = merge( - { - editor: { tabSize: 2 }, - tailwindCSS: { - emmetCompletions: false, - classAttributes: ['class', 'className', 'ngClass'], - 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', - recommendedVariantOrder: 'warning', - }, - showPixelEquivalents: true, - includeLanguages: {}, - files: { exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'] }, - experimental: { - classRegex: [], - configFile: null, - }, - }, - }, - { editor, tailwindCSS }, - { arrayMerge: (_destinationArray, sourceArray, _options) => sourceArray } - ) - documentSettingsCache.set(uri, config) - return config - } - - const state: State = { + let state: State = { enabled: false, editor: { connection, folder, - globalSettings: await getConfiguration(), userLanguages: params.initializationOptions.userLanguages ? params.initializationOptions.userLanguages : {}, @@ -329,8 +418,22 @@ async function createProjectService( }, } - let chokidarWatcher: chokidar.FSWatcher - let ignore = state.editor.globalSettings.tailwindCSS.files.exclude + if (projectConfig.configPath) { + let deps = [] + try { + deps = getModuleDependencies(projectConfig.configPath) + } catch {} + watchPatterns([ + ...getWatchPatternsForFile(projectConfig.configPath), + ...deps.flatMap((dep) => getWatchPatternsForFile(dep)), + ]) + } + + function log(...args: string[]): void { + console.log( + `[${path.relative(projectConfig.folder, projectConfig.configPath)}] ${args.join(' ')}` + ) + } function onFileEvents(changes: Array<{ file: string; type: FileChangeType }>): void { let needsInit = false @@ -339,22 +442,40 @@ async function createProjectService( for (let change of changes) { let file = normalizePath(change.file) - for (let ignorePattern of ignore) { - if (minimatch(file, ignorePattern, { dot: true })) { - continue + let isConfigFile = changeAffectsFile(file, [projectConfig.configPath]) + let isDependency = changeAffectsFile(change.file, state.dependencies ?? []) + let isPackageFile = minimatch(file, `**/${PACKAGE_LOCK_GLOB}`, { dot: true }) + + if (!isConfigFile && !isDependency && !isPackageFile) continue + + if (!enabled) { + if ( + !projectConfig.isUserConfigured && + projectConfig.configPath && + (isConfigFile || isDependency) + ) { + documentSelector = [ + ...documentSelector.filter( + ({ priority }) => priority !== DocumentSelectorPriority.CONTENT_FILE + ), + ...getContentDocumentSelectorFromConfigFile( + projectConfig.configPath, + initialTailwindVersion, + projectConfig.folder + ), + ] + + checkOpenDocuments() } + continue } - let isConfigFile = minimatch(file, `**/${CONFIG_FILE_GLOB}`, { dot: true }) - let isPackageFile = minimatch(file, `**/${PACKAGE_GLOB}`, { dot: true }) - let isDependency = state.dependencies && state.dependencies.includes(change.file) - - if (!isConfigFile && !isPackageFile && !isDependency) continue - if (change.type === FileChangeType.Created) { + log('File created:', change.file) needsInit = true break } else if (change.type === FileChangeType.Changed) { + log('File changed:', change.file) if (!state.enabled || isPackageFile) { needsInit = true break @@ -362,7 +483,8 @@ async function createProjectService( needsRebuild = true } } else if (change.type === FileChangeType.Deleted) { - if (!state.enabled || isPackageFile || isConfigFile) { + log('File deleted:', change.file) + if (!state.enabled || isConfigFile || isPackageFile) { needsInit = true break } else { @@ -378,65 +500,8 @@ async function createProjectService( } } - if (params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration) { - connection.client.register(DidChangeWatchedFilesNotification.type, { - watchers: [{ globPattern: `**/${CONFIG_FILE_GLOB}` }, { globPattern: `**/${PACKAGE_GLOB}` }], - }) - } else if (parcel.getBinding()) { - let typeMap = { - create: FileChangeType.Created, - update: FileChangeType.Changed, - delete: FileChangeType.Deleted, - } - - let subscription = await parcel.subscribe( - folder, - (err, events) => { - onFileEvents(events.map((event) => ({ file: event.path, type: typeMap[event.type] }))) - }, - { - ignore: ignore.map((ignorePattern) => - path.resolve(folder, ignorePattern.replace(/^[*/]+/, '').replace(/[*/]+$/, '')) - ), - } - ) - - disposables.push({ - dispose() { - subscription.unsubscribe() - }, - }) - } else { - let watch: typeof chokidar.watch = require('chokidar').watch - chokidarWatcher = watch([`**/${CONFIG_FILE_GLOB}`, `**/${PACKAGE_GLOB}`], { - cwd: folder, - ignorePermissionErrors: true, - ignoreInitial: true, - ignored: ignore, - awaitWriteFinish: { - stabilityThreshold: 100, - pollInterval: 20, - }, - }) - - await new Promise((resolve) => { - chokidarWatcher.on('ready', () => resolve()) - }) - - chokidarWatcher - .on('add', (file) => onFileEvents([{ file, type: FileChangeType.Created }])) - .on('change', (file) => onFileEvents([{ file, type: FileChangeType.Changed }])) - .on('unlink', (file) => onFileEvents([{ file, type: FileChangeType.Deleted }])) - - disposables.push({ - dispose() { - chokidarWatcher.close() - }, - }) - } - function resetState(): void { - clearAllDiagnostics(state) + // clearAllDiagnostics(state) Object.keys(state).forEach((key) => { // Keep `dependencies` to ensure that they are still watched if (key !== 'editor' && key !== 'dependencies') { @@ -444,10 +509,14 @@ async function createProjectService( } }) state.enabled = false + refreshDiagnostics() updateCapabilities() } async function tryInit() { + if (!enabled) { + return + } try { await init() } catch (error) { @@ -457,6 +526,9 @@ async function createProjectService( } async function tryRebuild() { + if (!enabled) { + return + } try { await rebuild() } catch (error) { @@ -465,42 +537,19 @@ async function createProjectService( } } - function clearRequireCache(): void { - Object.keys(require.cache).forEach((key) => { - if (!key.endsWith('.node')) { - delete require.cache[key] - } - }) - Object.keys((Module as any)._pathCache).forEach((key) => { - delete (Module as any)._pathCache[key] - }) - } - async function init() { + log('Initializing...') + clearRequireCache() let configPath = projectConfig.configPath - if (!configPath) { - configPath = ( - await glob([`**/${CONFIG_FILE_GLOB}`], { - cwd: folder, - ignore: state.editor.globalSettings.tailwindCSS.files.exclude, - onlyFiles: true, - absolute: true, - suppressErrors: true, - dot: true, - concurrency: Math.max(os.cpus().length, 1), - }) - ) - .sort((a: string, b: string) => a.split('/').length - b.split('/').length) - .map(path.normalize)[0] - } - if (!configPath) { throw new SilentError('No config file found.') } + watchPatterns(getWatchPatternsForFile(configPath)) + const pnpPath = findUp.sync( (dir) => { let pnpFile = path.join(dir, '.pnp.js') @@ -573,14 +622,14 @@ async function createProjectService( return } - console.log(`Found Tailwind CSS config file: ${configPath}`) + log(`Loaded Tailwind CSS config file: ${configPath}`) postcss = require(postcssPath) postcssSelectorParser = require(postcssSelectorParserPath) - console.log(`Loaded postcss v${postcssVersion}: ${postcssDir}`) + log(`Loaded postcss v${postcssVersion}: ${postcssDir}`) tailwindcss = require(tailwindcssPath) - console.log(`Loaded tailwindcss v${tailwindcssVersion}: ${tailwindDir}`) + log(`Loaded tailwindcss v${tailwindcssVersion}: ${tailwindDir}`) try { resolveConfigFn = require(resolveFrom(tailwindDir, './resolveConfig.js')) @@ -727,9 +776,9 @@ async function createProjectService( module: require('tailwindcss/lib/lib/expandApplyAtRules').default, }, } - console.log('Failed to load workspace modules.') - console.log(`Using bundled version of \`tailwindcss\`: v${tailwindcssVersion}`) - console.log(`Using bundled version of \`postcss\`: v${postcssVersion}`) + log('Failed to load workspace modules.') + log(`Using bundled version of \`tailwindcss\`: v${tailwindcssVersion}`) + log(`Using bundled version of \`postcss\`: v${postcssVersion}`) } state.configPath = configPath @@ -817,6 +866,8 @@ async function createProjectService( } async function rebuild() { + log('Building...') + clearRequireCache() const { tailwindcss, postcss, resolveConfig } = state.modules @@ -912,6 +963,22 @@ async function createProjectService( throw new SilentError(`Failed to load config file: ${state.configPath}`) } + ///////////////////// + if (!projectConfig.isUserConfigured) { + documentSelector = [ + ...documentSelector.filter( + ({ priority }) => priority !== DocumentSelectorPriority.CONTENT_FILE + ), + ...getContentDocumentSelectorFromConfigFile( + state.configPath, + tailwindcss.version, + projectConfig.folder, + originalConfig + ), + ] + } + ////////////////////// + try { state.config = resolveConfig.module(originalConfig) state.separator = state.config.separator @@ -967,11 +1034,12 @@ async function createProjectService( } } - if (state.dependencies) { - chokidarWatcher?.unwatch(state.dependencies) - } + // if (state.dependencies) { + // chokidarWatcher?.unwatch(state.dependencies) + // } state.dependencies = getModuleDependencies(state.configPath) - chokidarWatcher?.add(state.dependencies) + // chokidarWatcher?.add(state.dependencies) + watchPatterns((state.dependencies ?? []).flatMap((dep) => getWatchPatternsForFile(dep))) state.configId = getConfigId(state.configPath, state.dependencies) @@ -986,74 +1054,86 @@ async function createProjectService( state.enabled = true - updateAllDiagnostics(state) + // updateAllDiagnostics(state) + refreshDiagnostics() updateCapabilities() } return { + enabled() { + return enabled + }, + enable() { + enabled = true + }, state, + documentSelector() { + return documentSelector + }, tryInit, - dispose() { - for (let { dispose } of disposables) { - dispose() + async dispose() { + state = { enabled: false } + for (let disposable of disposables) { + ;(await disposable).dispose() } }, async onUpdateSettings(settings: any): Promise { - documentSettingsCache.clear() - let previousExclude = state.editor.globalSettings.tailwindCSS.files.exclude - state.editor.globalSettings = await state.editor.getConfiguration() - if (!equal(previousExclude, settings.tailwindCSS.files.exclude)) { - tryInit() + if (state.enabled) { + refreshDiagnostics() + } + if (settings.editor.colorDecorators) { + updateCapabilities() } else { - if (state.enabled) { - updateAllDiagnostics(state) - } - if (settings.editor.colorDecorators) { - updateCapabilities() - } else { - connection.sendNotification('@/tailwindCSS/clearColors') - } + connection.sendNotification('@/tailwindCSS/clearColors') } }, onFileEvents, async onHover(params: TextDocumentPositionParams): Promise { - 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.hovers) return null - if (await isExcluded(state, document)) return null - return doHover(state, document, params.position) + 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.hovers) return null + if (await isExcluded(state, document)) return null + return doHover(state, document, params.position) + }, null) }, async onCompletion(params: CompletionParams): Promise { - 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.suggestions) return null - if (await isExcluded(state, document)) return null - let result = await doComplete(state, document, params.position, params.context) - if (!result) return result - return { - isIncomplete: result.isIncomplete, - items: result.items.map((item) => ({ - ...item, - data: { projectKey: JSON.stringify(projectConfig), originalData: item.data }, - })), - } + 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.suggestions) return null + if (await isExcluded(state, document)) return null + let result = await doComplete(state, document, params.position, params.context) + if (!result) return result + return { + isIncomplete: result.isIncomplete, + items: result.items.map((item) => ({ + ...item, + data: { projectKey: JSON.stringify(projectConfig), originalData: item.data }, + })), + } + }, null) }, onCompletionResolve(item: CompletionItem): Promise { - if (!state.enabled) return null - return resolveCompletionItem(state, { ...item, data: item.data?.originalData }) + return withFallback(() => { + if (!state.enabled) return null + return resolveCompletionItem(state, { ...item, data: item.data?.originalData }) + }, null) }, async onCodeAction(params: CodeActionParams): Promise { - 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.codeActions) return null - return doCodeActions(state, params) + 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.codeActions) return null + return doCodeActions(state, params) + }, null) }, onDocumentLinks(params: DocumentLinkParams): DocumentLink[] { if (!state.enabled) return null @@ -1067,15 +1147,22 @@ async function createProjectService( if (!state.enabled) return provideDiagnostics(state, document) }, 500), + provideDiagnosticsForce: (document: TextDocument) => { + if (!state.enabled) return + provideDiagnostics(state, document) + }, async onDocumentColor(params: DocumentColorParams): Promise { - if (!state.enabled) return [] - let document = documentService.getDocument(params.textDocument.uri) - if (!document) return [] - if (await isExcluded(state, document)) return null - return getDocumentColors(state, document) + return withFallback(async () => { + if (!state.enabled) return [] + let document = documentService.getDocument(params.textDocument.uri) + if (!document) return [] + if (await isExcluded(state, document)) return null + return getDocumentColors(state, document) + }, null) }, async onColorPresentation(params: ColorPresentationParams): Promise { let document = documentService.getDocument(params.textDocument.uri) + if (!document) return [] let className = document.getText(params.range) let match = className.match( new RegExp(`-\\[(${colorNames.join('|')}|(?:(?:#|rgba?\\(|hsla?\\())[^\\]]+)\\]$`, 'i') @@ -1251,19 +1338,21 @@ function getVariants(state: State): Array { for (let fn of fns) { let definition: string let container = root.clone() - let returnValue = fn({ - container, - separator: state.separator, - modifySelectors, - format: (def: string) => { - definition = def.replace(/:merge\(([^)]+)\)/g, '$1') - }, - wrap: (rule: Container) => { - if (isAtRule(rule)) { - definition = `@${rule.name} ${rule.params}` - } - }, - }) + let returnValue = withoutLogs(() => + fn({ + container, + separator: state.separator, + modifySelectors, + format: (def: string) => { + definition = def.replace(/:merge\(([^)]+)\)/g, '$1') + }, + wrap: (rule: Container) => { + if (isAtRule(rule)) { + definition = `@${rule.name} ${rule.params}` + } + }, + }) + ) if (!definition) { definition = returnValue @@ -1404,13 +1493,77 @@ async function getPlugins(config: any) { // } } +async function getConfigFileFromCssFile(cssFile: string): Promise { + let css = getTextWithoutComments(await fs.promises.readFile(cssFile, 'utf8'), 'css') + let match = css.match(/@config\s*(?'[^']+'|"[^"]+")/) + if (!match) { + return null + } + return path.resolve(path.dirname(cssFile), match.groups.config.slice(1, -1)) +} + +function getPackageRoot(cwd: string, rootDir: string) { + try { + let pkgJsonPath = findUp.sync( + (dir) => { + let pkgJson = path.join(dir, 'package.json') + if (findUp.sync.exists(pkgJson)) { + return pkgJson + } + if (dir === rootDir) { + return findUp.stop + } + }, + { cwd } + ) + return pkgJsonPath ? path.dirname(pkgJsonPath) : rootDir + } catch { + return rootDir + } +} + +function getContentDocumentSelectorFromConfigFile( + configPath: string, + tailwindVersion: string, + rootDir: string, + actualConfig?: any +): DocumentSelector[] { + let config = actualConfig ?? require(configPath) + let contentConfig: unknown = config.content?.files ?? config.content + let content = Array.isArray(contentConfig) ? contentConfig : [] + let relativeEnabled = semver.gte(tailwindVersion, '3.2.0') + ? config.future?.relativeContentPathsByDefault || config.content?.relative + : false + let contentBase: string + if (relativeEnabled) { + contentBase = path.dirname(configPath) + } else { + contentBase = getPackageRoot(path.dirname(configPath), rootDir) + } + return content + .filter((item): item is string => typeof item === 'string') + .map((item) => + item.startsWith('!') + ? `!${path.resolve(contentBase, item.slice(1))}` + : path.resolve(contentBase, item) + ) + .map((item) => ({ + pattern: normalizePath(item), + priority: DocumentSelectorPriority.CONTENT_FILE, + })) +} + class TW { private initialized = false + private lspHandlersAdded = false private workspaces: Map private projects: Map private documentService: DocumentService public initializeParams: InitializeParams private registrations: Promise + private disposables: Disposable[] = [] + private watchPatterns: (patterns: string[]) => void + private watched: string[] = [] constructor(private connection: Connection) { this.documentService = new DocumentService(this.connection) @@ -1421,34 +1574,25 @@ class TW { async init(): Promise { if (this.initialized) return + clearRequireCache() + this.initialized = true - // TODO - let workspaceFolders: Array = - false && - Array.isArray(this.initializeParams.workspaceFolders) && - this.initializeParams.capabilities.workspace?.workspaceFolders - ? this.initializeParams.workspaceFolders.map((el) => ({ - folder: getFileFsPath(el.uri), - })) - : this.initializeParams.rootPath - ? [{ folder: normalizeFileNameToFsPath(this.initializeParams.rootPath) }] - : [] - - if (workspaceFolders.length === 0) { + if (!this.initializeParams.rootPath) { console.error('No workspace folders found, not initializing.') return } - let configFileOrFiles = dlv( - await connection.workspace.getConfiguration('tailwindCSS'), - 'experimental.configFile', - null - ) as Settings['tailwindCSS']['experimental']['configFile'] + let workspaceFolders: Array = [] + let globalSettings = await getConfiguration() + let ignore = globalSettings.tailwindCSS.files.exclude + let configFileOrFiles = globalSettings.tailwindCSS.experimental.configFile + + let base = normalizeFileNameToFsPath(this.initializeParams.rootPath) + let cssFileConfigMap: Map = new Map() + let configTailwindVersionMap: Map = new Map() if (configFileOrFiles) { - let base = workspaceFolders[0].folder - if ( typeof configFileOrFiles !== 'string' && (!isObject(configFileOrFiles) || @@ -1472,66 +1616,441 @@ class TW { return { folder: base, configPath: path.resolve(base, relativeConfigPath), - documentSelector: [] - .concat(relativeDocumentSelectorOrSelectors) - .map((selector) => path.resolve(base, selector)), + documentSelector: [].concat(relativeDocumentSelectorOrSelectors).map((selector) => ({ + priority: DocumentSelectorPriority.USER_CONFIGURED, + pattern: path.resolve(base, selector), + })), + isUserConfigured: true, } } ) + } else { + let projects: Record> = {} + + let files = await glob([`**/${CONFIG_GLOB}`, `**/${CSS_GLOB}`], { + cwd: base, + ignore: (await getConfiguration()).tailwindCSS.files.exclude, + onlyFiles: true, + absolute: true, + suppressErrors: true, + dot: true, + concurrency: Math.max(os.cpus().length, 1), + }) + + for (let filename of files) { + let normalizedFilename = normalizePath(filename) + let isCssFile = minimatch(normalizedFilename, `**/${CSS_GLOB}`, { dot: true }) + let configPath = isCssFile ? await getConfigFileFromCssFile(filename) : filename + if (!configPath) { + continue + } + + let twVersion = require('tailwindcss/package.json').version + let isDefaultVersion = true + try { + let v = require(resolveFrom(path.dirname(configPath), 'tailwindcss/package.json')).version + if (typeof v === 'string') { + twVersion = v + isDefaultVersion = false + } + } catch {} + + if (isCssFile && (!semver.gte(twVersion, '3.2.0') || isDefaultVersion)) { + continue + } + + configTailwindVersionMap.set(configPath, twVersion) + + let contentSelector: Array = [] + try { + contentSelector = getContentDocumentSelectorFromConfigFile(configPath, twVersion, base) + } catch {} + + let documentSelector: DocumentSelector[] = [ + { + pattern: normalizePath(filename), + priority: isCssFile + ? DocumentSelectorPriority.CSS_FILE + : DocumentSelectorPriority.CONFIG_FILE, + }, + ...(isCssFile + ? [ + { + pattern: normalizePath(configPath), + priority: DocumentSelectorPriority.CONFIG_FILE, + }, + ] + : []), + ...contentSelector, + { + pattern: normalizePath(path.join(path.dirname(filename), '**')), + priority: isCssFile + ? DocumentSelectorPriority.CSS_DIRECTORY + : DocumentSelectorPriority.CONFIG_DIRECTORY, + }, + ...(isCssFile + ? [ + { + pattern: normalizePath(path.join(path.dirname(configPath), '**')), + priority: DocumentSelectorPriority.CONFIG_DIRECTORY, + }, + ] + : []), + { + pattern: normalizePath(path.join(getPackageRoot(path.dirname(configPath), base), '**')), + priority: DocumentSelectorPriority.ROOT_DIRECTORY, + }, + ] + + projects[configPath] = [...(projects[configPath] ?? []), ...documentSelector] + + if (isCssFile) { + cssFileConfigMap.set(normalizedFilename, configPath) + } + } + + if (Object.keys(projects).length > 0) { + workspaceFolders = Object.entries(projects).map(([configPath, documentSelector]) => { + return { + folder: base, + configPath, + isUserConfigured: false, + documentSelector: documentSelector + .sort((a, z) => a.priority - z.priority) + .filter( + ({ pattern }, index, documentSelectors) => + documentSelectors.findIndex(({ pattern: p }) => p === pattern) === index + ), + } + }) + } } - await Promise.all( - workspaceFolders.map((projectConfig) => this.addProject(projectConfig, this.initializeParams)) - ) + console.log(`[Global] Creating projects: ${JSON.stringify(workspaceFolders)}`) - this.setupLSPHandlers() + const onDidChangeWatchedFiles = async ( + changes: Array<{ file: string; type: FileChangeType }> + ): Promise => { + let needsRestart = false + + changeLoop: for (let change of changes) { + let normalizedFilename = normalizePath(change.file) + + for (let ignorePattern of ignore) { + if (minimatch(normalizedFilename, ignorePattern, { dot: true })) { + continue changeLoop + } + } + + let isPackageFile = minimatch(normalizedFilename, `**/${PACKAGE_LOCK_GLOB}`, { dot: true }) + if (isPackageFile) { + for (let [key] of this.projects) { + let projectConfig = JSON.parse(key) as ProjectConfig + let twVersion = require('tailwindcss/package.json').version + try { + let v = require(resolveFrom( + path.dirname(projectConfig.configPath), + 'tailwindcss/package.json' + )).version + if (typeof v === 'string') { + twVersion = v + } + } catch {} + if (configTailwindVersionMap.get(projectConfig.configPath) !== twVersion) { + needsRestart = true + break changeLoop + } + } + } + + let isCssFile = minimatch(normalizedFilename, `**/${CSS_GLOB}`, { + dot: true, + }) + if (isCssFile) { + let configPath = await getConfigFileFromCssFile(change.file) + if ( + cssFileConfigMap.has(normalizedFilename) && + cssFileConfigMap.get(normalizedFilename) !== configPath + ) { + needsRestart = true + break + } else if (!cssFileConfigMap.has(normalizedFilename) && configPath) { + needsRestart = true + break + } + } + + let isConfigFile = minimatch(normalizedFilename, `**/${CONFIG_GLOB}`, { + dot: true, + }) + if (isConfigFile && change.type === FileChangeType.Created) { + needsRestart = true + break + } + + for (let [key] of this.projects) { + let projectConfig = JSON.parse(key) as ProjectConfig + if ( + change.type === FileChangeType.Deleted && + changeAffectsFile(normalizedFilename, [projectConfig.configPath]) + ) { + needsRestart = true + break changeLoop + } + } + } + + if (needsRestart) { + this.restart() + return + } + + for (let [, project] of this.projects) { + project.onFileEvents(changes) + } + } if (this.initializeParams.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration) { - this.connection.onDidChangeWatchedFiles(({ changes }) => { - for (let [, project] of this.projects) { - project.onFileEvents( - changes.map(({ uri, type }) => ({ + this.disposables.push( + this.connection.onDidChangeWatchedFiles(async ({ changes }) => { + let normalizedChanges = changes + .map(({ uri, type }) => ({ file: URI.parse(uri).fsPath, type, })) - ) + .filter( + (change, changeIndex, changes) => + changes.findIndex((c) => c.file === change.file && c.type === change.type) === + changeIndex + ) + + await onDidChangeWatchedFiles(normalizedChanges) + }) + ) + + let disposable = await this.connection.client.register( + DidChangeWatchedFilesNotification.type, + { + watchers: [ + { globPattern: `**/${CONFIG_GLOB}` }, + { globPattern: `**/${PACKAGE_LOCK_GLOB}` }, + { globPattern: `**/${CSS_GLOB}` }, + ], } + ) + + this.disposables.push(disposable) + + this.watchPatterns = (patterns) => { + let newPatterns = this.filterNewWatchPatterns(patterns) + if (newPatterns.length) { + console.log(`[Global] Adding watch patterns: ${newPatterns.join(', ')}`) + this.connection.client + .register(DidChangeWatchedFilesNotification.type, { + watchers: newPatterns.map((pattern) => ({ globPattern: pattern })), + }) + .then((disposable) => { + this.disposables.push(disposable) + }) + } + } + } else if (parcel.getBinding()) { + let typeMap = { + create: FileChangeType.Created, + update: FileChangeType.Changed, + delete: FileChangeType.Deleted, + } + + let subscription = await parcel.subscribe( + base, + (err, events) => { + onDidChangeWatchedFiles( + events.map((event) => ({ file: event.path, type: typeMap[event.type] })) + ) + }, + { + ignore: ignore.map((ignorePattern) => + path.resolve(base, ignorePattern.replace(/^[*/]+/, '').replace(/[*/]+$/, '')) + ), + } + ) + + this.disposables.push({ + dispose() { + subscription.unsubscribe() + }, }) + } else { + let watch: typeof chokidar.watch = require('chokidar').watch + let chokidarWatcher = watch( + [`**/${CONFIG_GLOB}`, `**/${PACKAGE_LOCK_GLOB}`, `**/${CSS_GLOB}`], + { + cwd: base, + ignorePermissionErrors: true, + ignoreInitial: true, + ignored: ignore, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 20, + }, + } + ) + + await new Promise((resolve) => { + chokidarWatcher.on('ready', () => resolve()) + }) + + chokidarWatcher + .on('add', (file) => + onDidChangeWatchedFiles([ + { file: path.resolve(base, file), type: FileChangeType.Created }, + ]) + ) + .on('change', (file) => + onDidChangeWatchedFiles([ + { file: path.resolve(base, file), type: FileChangeType.Changed }, + ]) + ) + .on('unlink', (file) => + onDidChangeWatchedFiles([ + { file: path.resolve(base, file), type: FileChangeType.Deleted }, + ]) + ) + + this.disposables.push({ + dispose() { + chokidarWatcher.close() + }, + }) + + this.watchPatterns = (patterns) => { + let newPatterns = this.filterNewWatchPatterns(patterns) + if (newPatterns.length) { + console.log(`[Global] Adding watch patterns: ${newPatterns.join(', ')}`) + chokidarWatcher.add(newPatterns) + } + } } - this.connection.onDidChangeConfiguration(async ({ settings }) => { - for (let [, project] of this.projects) { - project.onUpdateSettings(settings) + await Promise.all( + workspaceFolders.map((projectConfig) => + this.addProject( + projectConfig, + this.initializeParams, + this.watchPatterns, + configTailwindVersionMap.get(projectConfig.configPath) + ) + ) + ) + + // init projects for documents that are _already_ open + for (let document of this.documentService.getAllDocuments()) { + let project = this.getProject(document) + if (project && !project.enabled()) { + project.enable() + await project.tryInit() } - }) + } - this.connection.onShutdown(() => { - this.dispose() - }) + this.setupLSPHandlers() - this.documentService.onDidChangeContent((change) => { - this.getProject(change.document)?.provideDiagnostics(change.document) - }) + this.disposables.push( + this.connection.onDidChangeConfiguration(async ({ settings }) => { + let previousExclude = globalSettings.tailwindCSS.files.exclude + + documentSettingsCache.clear() + globalSettings = await getConfiguration() + + if (!equal(previousExclude, globalSettings.tailwindCSS.files.exclude)) { + this.restart() + return + } + + for (let [, project] of this.projects) { + project.onUpdateSettings(settings) + } + }) + ) + + this.disposables.push( + this.connection.onShutdown(() => { + this.dispose() + }) + ) + + this.disposables.push( + this.documentService.onDidChangeContent((change) => { + this.getProject(change.document)?.provideDiagnostics(change.document) + }) + ) + + this.disposables.push( + this.documentService.onDidOpen((event) => { + let project = this.getProject(event.document) + if (project && !project.enabled()) { + project.enable() + project.tryInit() + } + }) + ) } - private async addProject(projectConfig: ProjectConfig, params: InitializeParams): Promise { + private filterNewWatchPatterns(patterns: string[]) { + let newWatchPatterns = patterns.filter((pattern) => !this.watched.includes(pattern)) + this.watched.push(...newWatchPatterns) + return newWatchPatterns + } + + private async addProject( + projectConfig: ProjectConfig, + params: InitializeParams, + watchPatterns: (patterns: string[]) => void, + tailwindVersion: string + ): Promise { let key = JSON.stringify(projectConfig) - if (this.projects.has(key)) { - await this.projects.get(key).tryInit() - } else { + + if (!this.projects.has(key)) { const project = await createProjectService( projectConfig, this.connection, params, this.documentService, - () => this.updateCapabilities() + () => this.updateCapabilities(), + () => { + for (let document of this.documentService.getAllDocuments()) { + let project = this.getProject(document) + if (project && !project.enabled()) { + project.enable() + project.tryInit() + break + } + } + }, + () => this.refreshDiagnostics(), + (patterns: string[]) => watchPatterns(patterns), + tailwindVersion ) this.projects.set(key, project) - await project.tryInit() + } + } + + private refreshDiagnostics() { + for (let doc of this.documentService.getAllDocuments()) { + let project = this.getProject(doc) + if (project) { + project.provideDiagnosticsForce(doc) + } else { + this.connection.sendDiagnostics({ uri: doc.uri, diagnostics: [] }) + } } } private setupLSPHandlers() { + if (this.lspHandlersAdded) { + return + } + this.lspHandlersAdded = true + this.connection.onHover(this.onHover.bind(this)) this.connection.onCompletion(this.onCompletion.bind(this)) this.connection.onCompletionResolve(this.onCompletionResolve.bind(this)) @@ -1567,23 +2086,38 @@ class TW { ].filter(Boolean), }) - capabilities.add(DidChangeWatchedFilesNotification.type, { - watchers: projects.flatMap( - (project) => project.state.dependencies?.map((file) => ({ globPattern: file })) ?? [] - ), - }) - this.registrations = this.connection.client.register(capabilities) } private getProject(document: TextDocumentIdentifier): ProjectService { let fallbackProject: ProjectService + let matchedProject: ProjectService + let matchedPriority: number = Infinity + for (let [key, project] of this.projects) { let projectConfig = JSON.parse(key) as ProjectConfig if (projectConfig.configPath) { - for (let selector of projectConfig.documentSelector) { - if (minimatch(URI.parse(document.uri).fsPath, selector)) { - return project + let documentSelector = project + .documentSelector() + .concat() + // move all the negated patterns to the front + .sort((a, z) => { + if (a.pattern.startsWith('!') && !z.pattern.startsWith('!')) { + return -1 + } + if (!a.pattern.startsWith('!') && z.pattern.startsWith('!')) { + return 1 + } + return 0 + }) + for (let { pattern, priority } of documentSelector) { + let fsPath = URI.parse(document.uri).fsPath + if (pattern.startsWith('!') && minimatch(fsPath, pattern.slice(1), { dot: true })) { + break + } + if (minimatch(fsPath, pattern, { dot: true }) && priority < matchedPriority) { + matchedProject = project + matchedPriority = priority } } } else { @@ -1592,6 +2126,11 @@ class TW { } } } + + if (matchedProject) { + return matchedProject + } + return fallbackProject } @@ -1631,6 +2170,26 @@ class TW { for (let [, project] of this.projects) { project.dispose() } + this.projects = new Map() + + this.refreshDiagnostics() + + if (this.registrations) { + this.registrations.then((r) => r.dispose()) + this.registrations = undefined + } + + this.disposables.forEach((d) => d.dispose()) + this.disposables.length = 0 + + this.watched.length = 0 + } + + restart(): void { + console.log('----------\nRESTARTING\n----------') + this.dispose() + this.initialized = false + this.init() } } @@ -1656,6 +2215,9 @@ class DocumentService { get onDidClose() { return this.documents.onDidClose } + get onDidOpen() { + return this.documents.onDidOpen + } } function supportsDynamicRegistration(connection: Connection, params: InitializeParams): boolean { diff --git a/packages/tailwindcss-language-server/src/util/error.ts b/packages/tailwindcss-language-server/src/util/error.ts index 620412e..00fdac2 100644 --- a/packages/tailwindcss-language-server/src/util/error.ts +++ b/packages/tailwindcss-language-server/src/util/error.ts @@ -25,11 +25,11 @@ export function showError( message: string = 'Tailwind CSS' ): void { console.error(formatError(message, err)) - if (!(err instanceof SilentError)) { - connection.sendNotification('@/tailwindCSS/error', { - message: formatError(message, err, false), - }) - } + // if (!(err instanceof SilentError)) { + // connection.sendNotification('@/tailwindCSS/error', { + // message: formatError(message, err, false), + // }) + // } } export function SilentError(message: string) { diff --git a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts index cc3fbc6..df30e66 100644 --- a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts +++ b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts @@ -24,6 +24,7 @@ async function getDiagnosticsFromCodeActionParams( only?: DiagnosticKind[] ): Promise { let document = state.editor.documents.get(params.textDocument.uri) + if (!document) return [] let diagnostics = await doValidate(state, document, only) return params.context.diagnostics @@ -40,6 +41,10 @@ async function getDiagnosticsFromCodeActionParams( } export async function doCodeActions(state: State, params: CodeActionParams): Promise { + if (!state.enabled) { + return [] + } + let diagnostics = await getDiagnosticsFromCodeActionParams( state, params, diff --git a/packages/tailwindcss-language-service/src/codeActions/provideInvalidApplyCodeActions.ts b/packages/tailwindcss-language-service/src/codeActions/provideInvalidApplyCodeActions.ts index 3730492..c212eea 100644 --- a/packages/tailwindcss-language-service/src/codeActions/provideInvalidApplyCodeActions.ts +++ b/packages/tailwindcss-language-service/src/codeActions/provideInvalidApplyCodeActions.ts @@ -25,6 +25,7 @@ export async function provideInvalidApplyCodeActions( diagnostic: InvalidApplyDiagnostic ): Promise { let document = state.editor.documents.get(params.textDocument.uri) + if (!document) return [] let documentText = getTextWithoutComments(document, 'css') let cssRange: Range let cssText = documentText diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts index 5ef686a..a8d7f7d 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts @@ -50,29 +50,3 @@ export async function doValidate( ] : [] } - -export async function provideDiagnostics(state: State, document: TextDocument) { - // state.editor.connection.sendDiagnostics({ - // uri: document.uri, - // diagnostics: await doValidate(state, document), - // }) -} - -export function clearDiagnostics(state: State, document: TextDocument): void { - // state.editor.connection.sendDiagnostics({ - // uri: document.uri, - // diagnostics: [], - // }) -} - -export function clearAllDiagnostics(state: State): void { - state.editor.documents.all().forEach((document) => { - clearDiagnostics(state, document) - }) -} - -export function updateAllDiagnostics(state: State): void { - state.editor.documents.all().forEach((document) => { - provideDiagnostics(state, document) - }) -} diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index c958d2b..5d5c832 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -21,7 +21,6 @@ export type EditorState = { connection: Connection folder: string documents: TextDocuments - globalSettings: Settings userLanguages: Record capabilities: { configuration: boolean diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index d1cb232..e618e06 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -42,14 +42,13 @@ import isObject from 'tailwindcss-language-service/src/util/isObject' import { dedupe, equal } from 'tailwindcss-language-service/src/util/array' import namedColors from 'color-name' import minimatch from 'minimatch' +import { CONFIG_GLOB, CSS_GLOB } from 'tailwindcss-language-server/src/lib/constants' const colorNames = Object.keys(namedColors) const CLIENT_ID = 'tailwindcss-intellisense' const CLIENT_NAME = 'Tailwind CSS IntelliSense' -const CONFIG_FILE_GLOB = '{tailwind,tailwind.config}.{js,cjs}' - let clients: Map = new Map() let languages: Map = new Map() let searchedFolders: Set = new Set() @@ -131,6 +130,11 @@ function mergeExcludes(settings: WorkspaceConfiguration, scope: ConfigurationSco } } +async function fileContainsAtConfig(uri: Uri) { + let contents = (await Workspace.fs.readFile(uri)).toString() + return /@config\s*['"]/.test(contents) +} + export async function activate(context: ExtensionContext) { let module = context.asAbsolutePath(path.join('dist', 'server.js')) let prod = path.join('dist', 'tailwindServer.js') @@ -206,20 +210,36 @@ export async function activate(context: ExtensionContext) { // ) // ) - let watcher = Workspace.createFileSystemWatcher(`**/${CONFIG_FILE_GLOB}`, false, true, true) + let configWatcher = Workspace.createFileSystemWatcher(`**/${CONFIG_GLOB}`, false, true, true) - watcher.onDidCreate((uri) => { + configWatcher.onDidCreate((uri) => { let folder = Workspace.getWorkspaceFolder(uri) - if (!folder) { + if (!folder || isExcluded(uri.fsPath, folder)) { return } - if (!isExcluded(uri.fsPath, folder)) { + folder = getOuterMostWorkspaceFolder(folder) + bootWorkspaceClient(folder) + }) + + context.subscriptions.push(configWatcher) + + let cssWatcher = Workspace.createFileSystemWatcher(`**/${CSS_GLOB}`, false, false, true) + + async function bootClientIfCssFileContainsAtConfig(uri: Uri) { + let folder = Workspace.getWorkspaceFolder(uri) + if (!folder || isExcluded(uri.fsPath, folder)) { + return + } + if (await fileContainsAtConfig(uri)) { folder = getOuterMostWorkspaceFolder(folder) bootWorkspaceClient(folder) } - }) + } - context.subscriptions.push(watcher) + cssWatcher.onDidCreate(bootClientIfCssFileContainsAtConfig) + cssWatcher.onDidChange(bootClientIfCssFileContainsAtConfig) + + context.subscriptions.push(cssWatcher) // TODO: check if the actual language MAPPING changed // not just the language IDs @@ -607,16 +627,27 @@ export async function activate(context: ExtensionContext) { searchedFolders.add(folder.uri.toString()) let [configFile] = await Workspace.findFiles( - new RelativePattern(folder, `**/${CONFIG_FILE_GLOB}`), + new RelativePattern(folder, `**/${CONFIG_GLOB}`), `{${getExcludePatterns(folder).join(',')}}`, 1 ) - if (!configFile) { + if (configFile) { + bootWorkspaceClient(folder) return } - bootWorkspaceClient(folder) + let cssFiles = await Workspace.findFiles( + new RelativePattern(folder, `**/${CSS_GLOB}`), + `{${getExcludePatterns(folder).join(',')}}` + ) + + for (let cssFile of cssFiles) { + if (await fileContainsAtConfig(cssFile)) { + bootWorkspaceClient(folder) + return + } + } } context.subscriptions.push(Workspace.onDidOpenTextDocument(didOpenTextDocument))