diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index f082813..615fd95 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() @@ -259,8 +265,6 @@ async function createProjectService( }, } - let registrations: Promise - let chokidarWatcher: chokidar.FSWatcher let ignore = state.editor.globalSettings.tailwindCSS.files?.exclude ?? DEFAULT_FILES_EXCLUDE @@ -311,15 +315,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 +371,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 +380,7 @@ async function createProjectService( } }) state.enabled = false - registerCapabilities(state.dependencies) + updateCapabilities() } async function tryInit() { @@ -452,19 +415,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 +924,7 @@ async function createProjectService( updateAllDiagnostics(state) - registerCapabilities(state.dependencies) + updateCapabilities() } return { @@ -980,12 +947,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 +970,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 +1321,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 +1335,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 +1351,65 @@ class TW { return } + let configFileOrFiles = dlv( + await connection.workspace.getConfiguration('tailwindCSS'), + 'experimental.configFile', + null + ) as Settings['tailwindCSS']['experimental']['configFile'] + + 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() ) + this.projects.set(key, project) await project.tryInit() - this.projects.set(folder, 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 061dd11..ff34d5b 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/README.md b/packages/vscode-tailwindcss/README.md index ce5068f..3a2eb4c 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: diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 67ef3f6..7838b93 100755 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -277,6 +277,15 @@ "type": "array", "scope": "language-overridable" }, + "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", "default": true, diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index fc19acb..35b4f75 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) + } }) }) )