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
diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts
index 008c221a7744fb579ca9a8887c75c87a9916755c..0b9b4f576833ca4784c611e280d7422d6253f4e8 100644
--- a/packages/tailwindcss-language-server/src/server.ts
+++ b/packages/tailwindcss-language-server/src/server.ts
@@ -65,11 +65,7 @@ Settings,
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 @@ }
`
)(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 @@ }
}
interface ProjectService {
+ enabled: () => boolean
+ enable: () => void
+ documentSelector: () => Array<DocumentSelector>
state: State
tryInit: () => Promise<void>
- dispose: () => void
+ dispose: () => Promise<void>
onUpdateSettings: (settings: any) => void
onFileEvents: (changes: Array<{ file: string; type: FileChangeType }>) => void
onHover(params: TextDocumentPositionParams): Promise<Hover>
onCompletion(params: CompletionParams): Promise<CompletionList>
onCompletionResolve(item: CompletionItem): Promise<CompletionItem>
provideDiagnostics(document: TextDocument): void
+ provideDiagnosticsForce(document: TextDocument): void
onDocumentColor(params: DocumentColorParams): Promise<ColorInformation[]>
onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]>
onCodeAction(params: CodeActionParams): Promise<CodeAction[]>
onDocumentLinks(params: DocumentLinkParams): DocumentLink[]
}
-type ProjectConfig = { folder: string; configPath?: string; documentSelector?: string[] }
+type ProjectConfig = {
+ folder: string
+ configPath?: string
+ documentSelector?: Array<DocumentSelector>
+ 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 @@ }
}
}
+const documentSettingsCache: Map<string, Settings> = 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<Settings>(
+ {
+ 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<T>(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<T>(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<ProjectService> {
+ let enabled = false
const folder = projectConfig.folder
- const disposables: Disposable[] = []
- const documentSettingsCache: Map<string, Settings> = 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<Settings>(
- {
- 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 disposables: Array<Disposable | Promise<Disposable>> = []
+ let documentSelector = projectConfig.documentSelector
- 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 @@ },
},
}
- 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 @@
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 })
- 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 && !isDependency && !isPackageFile) continue
- if (!isConfigFile && !isPackageFile && !isDependency) 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
+ }
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 @@ } else {
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 @@ tryRebuild()
}
}
- 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<void>((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 @@ delete state[key]
}
})
state.enabled = false
+ refreshDiagnostics()
updateCapabilities()
}
async function tryInit() {
+ if (!enabled) {
+ return
+ }
try {
await init()
} catch (error) {
@@ -457,6 +526,9 @@ }
}
async function tryRebuild() {
+ if (!enabled) {
+ return
+ }
try {
await rebuild()
} catch (error) {
@@ -465,41 +537,18 @@ showError(connection, error)
}
}
- 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...')
- async function init() {
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]
+ throw new SilentError('No config file found.')
}
- if (!configPath) {
- throw new SilentError('No config file found.')
- }
+ watchPatterns(getWatchPatternsForFile(configPath))
const pnpPath = findUp.sync(
(dir) => {
@@ -573,14 +622,14 @@ ) {
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 @@ expandApplyAtRules: {
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 @@ await tryRebuild()
}
async function rebuild() {
+ log('Building...')
+
clearRequireCache()
const { tailwindcss, postcss, resolveConfig } = state.modules
@@ -912,6 +963,22 @@ if (!originalConfig) {
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 @@ hook.unhook()
}
}
- 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 @@ state.screens = isObject(screens) ? Object.keys(screens) : []
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<void> {
- 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<Hover> {
- 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<CompletionList> {
- 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<CompletionItem> {
- 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<CodeAction[]> {
- 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 @@ provideDiagnostics: debounce((document: TextDocument) => {
if (!state.enabled) return
provideDiagnostics(state, document)
}, 500),
+ provideDiagnosticsForce: (document: TextDocument) => {
+ if (!state.enabled) return
+ provideDiagnostics(state, document)
+ },
async onDocumentColor(params: DocumentColorParams): Promise<ColorInformation[]> {
- 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<ColorPresentation[]> {
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 @@
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 @@ // return []
// }
}
+async function getConfigFileFromCssFile(cssFile: string): Promise<string | null> {
+ let css = getTextWithoutComments(await fs.promises.readFile(cssFile, 'utf8'), 'css')
+ let match = css.match(/@config\s*(?<config>'[^']+'|"[^"]+")/)
+ 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<string, { name: string; workspaceFsPath: string }>
private projects: Map<string, ProjectService>
private documentService: DocumentService
public initializeParams: InitializeParams
private registrations: Promise<BulkUnregistration>
+ 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 @@
async init(): Promise<void> {
if (this.initialized) return
- this.initialized = true
+ clearRequireCache()
- // TODO
- let workspaceFolders: Array<ProjectConfig> =
- 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) }]
- : []
+ this.initialized = true
- 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<ProjectConfig> = []
+ 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<string, string> = new Map()
+ let configTailwindVersionMap: Map<string, string> = new Map()
if (configFileOrFiles) {
- let base = workspaceFolders[0].folder
-
if (
typeof configFileOrFiles !== 'string' &&
(!isObject(configFileOrFiles) ||
@@ -1472,66 +1616,441 @@ ([relativeConfigPath, relativeDocumentSelectorOrSelectors]) => {
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<string, Array<DocumentSelector>> = {}
+
+ 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<DocumentSelector> = []
+ 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<void> => {
+ 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<void>((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<void> {
+ 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<void> {
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 @@ .map((sep) => sep.slice(-1)),
].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 @@ fallbackProject = project
}
}
}
+
+ if (matchedProject) {
+ return matchedProject
+ }
+
return fallbackProject
}
@@ -1631,6 +2170,26 @@ dispose(): void {
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()
}
}
@@ -1655,6 +2214,9 @@ return this.documents.onDidChangeContent
}
get onDidClose() {
return this.documents.onDidClose
+ }
+ get onDidOpen() {
+ return this.documents.onDidOpen
}
}