From 065e618d9b1ec17cc683e1070e2206240ac8a9bd Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Fri, 22 Apr 2022 17:59:58 +0100 Subject: [PATCH 1/8] Add experimental `configFile` setting --- .../tailwindcss-language-server/src/server.ts | 253 +++++++++++------- .../src/util/state.ts | 1 + packages/vscode-tailwindcss/package.json | 6 + packages/vscode-tailwindcss/src/extension.ts | 22 +- 4 files changed, 181 insertions(+), 101 deletions(-) diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index f0828133..1d4f5dd5 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -26,6 +26,7 @@ import { DidChangeWatchedFilesNotification, FileChangeType, Disposable, + TextDocumentIdentifier, } from 'vscode-languageserver/node' import { TextDocument } from 'vscode-languageserver-textdocument' import { URI } from 'vscode-uri' @@ -177,6 +178,7 @@ interface ProjectService { tryInit: () => Promise dispose: () => void onUpdateSettings: (settings: any) => void + onFileEvents: (changes: Array<{ file: string; type: FileChangeType }>) => void onHover(params: TextDocumentPositionParams): Promise onCompletion(params: CompletionParams): Promise onCompletionResolve(item: CompletionItem): Promise @@ -186,6 +188,8 @@ interface ProjectService { onCodeAction(params: CodeActionParams): Promise } +type ProjectConfig = { folder: string; configPath?: string; documentSelector?: string[] } + function getMode(config: any): unknown { if (typeof config.mode !== 'undefined') { return config.mode @@ -210,11 +214,13 @@ function deleteMode(config: any): void { } async function createProjectService( - folder: string, + projectConfig: ProjectConfig, connection: Connection, params: InitializeParams, - documentService: DocumentService + documentService: DocumentService, + updateCapabilities: () => void ): Promise { + const folder = projectConfig.folder const disposables: Disposable[] = [] const documentSettingsCache: Map = new Map() @@ -311,15 +317,6 @@ async function createProjectService( } if (params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration) { - connection.onDidChangeWatchedFiles(({ changes }) => { - onFileEvents( - changes.map(({ uri, type }) => ({ - file: URI.parse(uri).fsPath, - type, - })) - ) - }) - connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [{ globPattern: `**/${CONFIG_FILE_GLOB}` }, { globPattern: `**/${PACKAGE_GLOB}` }], }) @@ -376,38 +373,6 @@ async function createProjectService( }) } - function registerCapabilities(watchFiles: string[] = []): void { - if (supportsDynamicRegistration(connection, params)) { - if (registrations) { - registrations.then((r) => r.dispose()) - } - - let capabilities = BulkRegistration.create() - - capabilities.add(HoverRequest.type, { - documentSelector: null, - }) - capabilities.add(DocumentColorRequest.type, { - documentSelector: null, - }) - capabilities.add(CodeActionRequest.type, { - documentSelector: null, - }) - capabilities.add(CompletionRequest.type, { - documentSelector: null, - resolveProvider: true, - triggerCharacters: [...TRIGGER_CHARACTERS, state.separator].filter(Boolean), - }) - if (watchFiles.length > 0) { - capabilities.add(DidChangeWatchedFilesNotification.type, { - watchers: watchFiles.map((file) => ({ globPattern: file })), - }) - } - - registrations = connection.client.register(capabilities) - } - } - function resetState(): void { clearAllDiagnostics(state) Object.keys(state).forEach((key) => { @@ -417,7 +382,7 @@ async function createProjectService( } }) state.enabled = false - registerCapabilities(state.dependencies) + updateCapabilities() } async function tryInit() { @@ -452,19 +417,23 @@ async function createProjectService( async function init() { clearRequireCache() - let [configPath] = ( - await glob([`**/${CONFIG_FILE_GLOB}`], { - cwd: folder, - ignore: state.editor.globalSettings.tailwindCSS.files?.exclude ?? DEFAULT_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) + let configPath = projectConfig.configPath + + if (!configPath) { + configPath = ( + await glob([`**/${CONFIG_FILE_GLOB}`], { + cwd: folder, + ignore: state.editor.globalSettings.tailwindCSS.files?.exclude ?? DEFAULT_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.') @@ -957,7 +926,7 @@ async function createProjectService( updateAllDiagnostics(state) - registerCapabilities(state.dependencies) + updateCapabilities() } return { @@ -980,12 +949,13 @@ async function createProjectService( updateAllDiagnostics(state) } if (settings.editor.colorDecorators) { - registerCapabilities(state.dependencies) + updateCapabilities() } else { connection.sendNotification('@/tailwindCSS/clearColors') } } }, + onFileEvents, async onHover(params: TextDocumentPositionParams): Promise { if (!state.enabled) return null let document = documentService.getDocument(params.textDocument.uri) @@ -1002,11 +972,19 @@ async function createProjectService( let settings = await state.editor.getConfiguration(document.uri) if (!settings.tailwindCSS.suggestions) return null if (await isExcluded(state, document)) return null - return doComplete(state, document, params.position, params.context) + 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 }, + })), + } }, onCompletionResolve(item: CompletionItem): Promise { if (!state.enabled) return null - return resolveCompletionItem(state, item) + return resolveCompletionItem(state, { ...item, data: item.data?.originalData }) }, async onCodeAction(params: CodeActionParams): Promise { if (!state.enabled) return null @@ -1345,6 +1323,7 @@ class TW { private projects: Map private documentService: DocumentService public initializeParams: InitializeParams + private registrations: Promise constructor(private connection: Connection) { this.documentService = new DocumentService(this.connection) @@ -1358,16 +1337,15 @@ class TW { this.initialized = true // TODO - const workspaceFolders = + let workspaceFolders: Array = false && Array.isArray(this.initializeParams.workspaceFolders) && this.initializeParams.capabilities.workspace?.workspaceFolders ? this.initializeParams.workspaceFolders.map((el) => ({ - name: el.name, - fsPath: getFileFsPath(el.uri), + folder: getFileFsPath(el.uri), })) : this.initializeParams.rootPath - ? [{ name: '', fsPath: normalizeFileNameToFsPath(this.initializeParams.rootPath) }] + ? [{ folder: normalizeFileNameToFsPath(this.initializeParams.rootPath) }] : [] if (workspaceFolders.length === 0) { @@ -1375,14 +1353,63 @@ class TW { return } + let { + experimental: { configFile: configFileOrFiles }, + } = (await connection.workspace.getConfiguration('tailwindCSS')) as Settings['tailwindCSS'] + + if (configFileOrFiles) { + let base = workspaceFolders[0].folder + + if ( + typeof configFileOrFiles !== 'string' && + (!isObject(configFileOrFiles) || + !Object.entries(configFileOrFiles).every(([key, value]) => { + if (typeof key !== 'string') return false + if (Array.isArray(value)) { + return value.every((item) => typeof item === 'string') + } + return typeof value === 'string' + })) + ) { + console.error('Invalid `experimental.configFile` configuration, not initializing.') + return + } + + let configFiles = + typeof configFileOrFiles === 'string' ? { [configFileOrFiles]: '**' } : configFileOrFiles + + workspaceFolders = Object.entries(configFiles).map( + ([relativeConfigPath, relativeDocumentSelectorOrSelectors]) => { + return { + folder: base, + configPath: path.join(base, relativeConfigPath), + documentSelector: [] + .concat(relativeDocumentSelectorOrSelectors) + .map((selector) => path.join(base, selector)), + } + } + ) + } + await Promise.all( - workspaceFolders.map(async (folder) => { - return this.addProject(folder.fsPath, this.initializeParams) - }) + workspaceFolders.map((projectConfig) => this.addProject(projectConfig, this.initializeParams)) ) this.setupLSPHandlers() + if (this.initializeParams.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration) { + this.connection.onDidChangeWatchedFiles(({ changes }) => { + for (let [, project] of this.projects) { + project.onFileEvents( + changes.map(({ uri, type }) => ({ + file: URI.parse(uri).fsPath, + type, + })) + ) + } + }) + } + this.connection.onDidChangeConfiguration(async ({ settings }) => { for (let [, project] of this.projects) { project.onUpdateSettings(settings) @@ -1394,24 +1421,24 @@ class TW { }) this.documentService.onDidChangeContent((change) => { - // TODO - const project = Array.from(this.projects.values())[0] - project?.provideDiagnostics(change.document) + this.getProject(change.document)?.provideDiagnostics(change.document) }) } - private async addProject(folder: string, params: InitializeParams): Promise { - if (this.projects.has(folder)) { - await this.projects.get(folder).tryInit() + private async addProject(projectConfig: ProjectConfig, params: InitializeParams): Promise { + let key = JSON.stringify(projectConfig) + if (this.projects.has(key)) { + await this.projects.get(key).tryInit() } else { const project = await createProjectService( - folder, + projectConfig, this.connection, params, - this.documentService + this.documentService, + () => this.updateCapabilities() ) await project.tryInit() - this.projects.set(folder, project) + this.projects.set(key, project) } } @@ -1424,38 +1451,78 @@ class TW { this.connection.onCodeAction(this.onCodeAction.bind(this)) } + private updateCapabilities() { + if (this.registrations) { + this.registrations.then((r) => r.dispose()) + } + + let projects = Array.from(this.projects.values()) + + let capabilities = BulkRegistration.create() + + capabilities.add(HoverRequest.type, { documentSelector: null }) + capabilities.add(DocumentColorRequest.type, { documentSelector: null }) + capabilities.add(CodeActionRequest.type, { documentSelector: null }) + + capabilities.add(CompletionRequest.type, { + documentSelector: null, + resolveProvider: true, + triggerCharacters: [ + ...TRIGGER_CHARACTERS, + ...projects.map((project) => project.state.separator).filter(Boolean), + ].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 + 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 + } + } + } else { + if (!fallbackProject) { + fallbackProject = project + } + } + } + return fallbackProject + } + async onDocumentColor(params: DocumentColorParams): Promise { - const project = Array.from(this.projects.values())[0] - return project?.onDocumentColor(params) ?? [] + return this.getProject(params.textDocument)?.onDocumentColor(params) ?? [] } async onColorPresentation(params: ColorPresentationParams): Promise { - const project = Array.from(this.projects.values())[0] - return project?.onColorPresentation(params) ?? [] + return this.getProject(params.textDocument)?.onColorPresentation(params) ?? [] } async onHover(params: TextDocumentPositionParams): Promise { - // TODO - const project = Array.from(this.projects.values())[0] - return project?.onHover(params) ?? null + return this.getProject(params.textDocument)?.onHover(params) ?? null } async onCompletion(params: CompletionParams): Promise { - // TODO - const project = Array.from(this.projects.values())[0] - return project?.onCompletion(params) ?? null + return this.getProject(params.textDocument)?.onCompletion(params) ?? null } async onCompletionResolve(item: CompletionItem): Promise { - // TODO - const project = Array.from(this.projects.values())[0] - return project?.onCompletionResolve(item) ?? null + return this.projects.get(item.data.projectKey)?.onCompletionResolve(item) ?? null } onCodeAction(params: CodeActionParams): Promise { - // TODO - const project = Array.from(this.projects.values())[0] - return project?.onCodeAction(params) ?? null + return this.getProject(params.textDocument)?.onCodeAction(params) ?? null } listen() { diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 061dd111..22573a11 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -59,6 +59,7 @@ export type Settings = { } experimental: { classRegex: string[] + configFile: string | Record } files: { exclude: string[] diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index eb8ced8b..a9278ece 100755 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -271,6 +271,12 @@ "type": "array", "scope": "language-overridable" }, + "tailwindCSS.experimental.configFile": { + "type": [ + "string", + "object" + ] + }, "tailwindCSS.showPixelEquivalents": { "type": "boolean", "default": true, diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index fc19acbc..35b4f759 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -169,25 +169,31 @@ export async function activate(context: ExtensionContext) { // e.g. "plaintext" already exists but you change it from "html" to "css" context.subscriptions.push( Workspace.onDidChangeConfiguration((event) => { - clients.forEach((client, key) => { + ;[...clients].forEach(([key, client]) => { const folder = Workspace.getWorkspaceFolder(Uri.parse(key)) + let reboot = false - if (event.affectsConfiguration('tailwindCSS', folder)) { + if (event.affectsConfiguration('tailwindCSS.includeLanguages', folder)) { const userLanguages = getUserLanguages(folder) if (userLanguages) { const userLanguageIds = Object.keys(userLanguages) const newLanguages = dedupe([...defaultLanguages, ...userLanguageIds]) if (!equal(newLanguages, languages.get(folder.uri.toString()))) { languages.set(folder.uri.toString(), newLanguages) - - if (client) { - clients.delete(folder.uri.toString()) - client.stop() - bootWorkspaceClient(folder) - } + reboot = true } } } + + if (event.affectsConfiguration('tailwindCSS.experimental.configFile', folder)) { + reboot = true + } + + if (reboot && client) { + clients.delete(folder.uri.toString()) + client.stop() + bootWorkspaceClient(folder) + } }) }) ) From 9e7e5b6b321b743460c0771d9dc1b12978f0c377 Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Mon, 25 Apr 2022 13:38:38 +0100 Subject: [PATCH 2/8] Fix initial capability registration --- packages/tailwindcss-language-server/src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index 1d4f5dd5..a25ee044 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -1437,8 +1437,8 @@ class TW { this.documentService, () => this.updateCapabilities() ) - await project.tryInit() this.projects.set(key, project) + await project.tryInit() } } From f7e0d93167dba9a0ad9a79a70112439a8d604f94 Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Mon, 25 Apr 2022 13:45:20 +0100 Subject: [PATCH 3/8] Update readme --- packages/vscode-tailwindcss/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/vscode-tailwindcss/README.md b/packages/vscode-tailwindcss/README.md index ce5068fc..3a2eb4c5 100644 --- a/packages/vscode-tailwindcss/README.md +++ b/packages/vscode-tailwindcss/README.md @@ -146,6 +146,31 @@ Class variants not in the recommended order (applies in [JIT mode](https://tailw Enable the Node.js inspector agent for the language server and listen on the specified port. **Default: `null`** +## Experimental Extension Settings + +**_Experimental settings may be changed or removed at any time._** + +### `tailwindCSS.experimental.configFile` + +**Default: `null`** + +By default the extension will automatically use the first `tailwind.config.js` or `tailwind.config.cjs` file that it can find to provide Tailwind CSS IntelliSense. Use this setting to manually specify the config file(s) yourself instead. + +If your project contains a single Tailwind config file you can specify a string value: + +``` +"tailwindCSS.experimental.configFile": ".config/tailwind.config.js" +``` + +For projects with multiple config files use an object where each key is a config file path and each value is a glob pattern (or array of glob patterns) representing the set of files that the config file applies to: + +``` +"tailwindCSS.experimental.configFile": { + "themes/simple/tailwind.config.js": "themes/simple/**", + "themes/neon/tailwind.config.js": "themes/neon/**" +} +``` + ## Troubleshooting If you’re having issues getting the IntelliSense features to activate, there are a few things you can check: From 781f24c78b2047c67b3f661fed2e4b973d60e123 Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Mon, 25 Apr 2022 13:45:43 +0100 Subject: [PATCH 4/8] Add setting default and description --- packages/vscode-tailwindcss/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index a9278ece..edcbba1d 100755 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -273,9 +273,12 @@ }, "tailwindCSS.experimental.configFile": { "type": [ + "null", "string", "object" - ] + ], + "default": null, + "markdownDescription": "Manually specify the Tailwind config file or files that should be read to provide IntelliSense features. Can either be a single string value, or an object where each key is a config file path and each value is a glob or array of globs representing the set of files that the config file applies to." }, "tailwindCSS.showPixelEquivalents": { "type": "boolean", From 4f14776d57742fea83790a62c9e0dcd35e6cb9e8 Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Mon, 25 Apr 2022 13:51:37 +0100 Subject: [PATCH 5/8] Remove unused variable --- packages/tailwindcss-language-server/src/server.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index a25ee044..e62bbe3a 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -265,8 +265,6 @@ async function createProjectService( }, } - let registrations: Promise - let chokidarWatcher: chokidar.FSWatcher let ignore = state.editor.globalSettings.tailwindCSS.files?.exclude ?? DEFAULT_FILES_EXCLUDE From 46ff6c32002d86e5676ac0f5fec058c7e12774df Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Mon, 25 Apr 2022 13:57:10 +0100 Subject: [PATCH 6/8] Be more defensive when reading setting --- packages/tailwindcss-language-server/src/server.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index e62bbe3a..69d508ab 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -1351,9 +1351,11 @@ class TW { return } - let { - experimental: { configFile: configFileOrFiles }, - } = (await connection.workspace.getConfiguration('tailwindCSS')) as Settings['tailwindCSS'] + let configFileOrFiles = dlv( + await connection.workspace.getConfiguration('tailwindCSS'), + 'experimental.configFile', + null + ) as Settings['tailwindCSS'] if (configFileOrFiles) { let base = workspaceFolders[0].folder From b24169ef0ed7a9575ac91c62edc07c0807b8d3d5 Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Mon, 25 Apr 2022 14:10:00 +0100 Subject: [PATCH 7/8] Fix type --- packages/tailwindcss-language-service/src/util/state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 22573a11..ff34d5bd 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -59,7 +59,7 @@ export type Settings = { } experimental: { classRegex: string[] - configFile: string | Record + configFile: string | Record } files: { exclude: string[] From 51af7b4ebf77c89520eded754d124244aee9a527 Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Mon, 25 Apr 2022 15:04:25 +0100 Subject: [PATCH 8/8] Fix type --- packages/tailwindcss-language-server/src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index 69d508ab..615fd950 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -1355,7 +1355,7 @@ class TW { await connection.workspace.getConfiguration('tailwindCSS'), 'experimental.configFile', null - ) as Settings['tailwindCSS'] + ) as Settings['tailwindCSS']['experimental']['configFile'] if (configFileOrFiles) { let base = workspaceFolders[0].folder