Add `experimental.configFile` setting (#541)

* Add experimental `configFile` setting

* Fix initial capability registration

* Update readme

* Add setting default and description

* Remove unused variable

* Be more defensive when reading setting

* Fix type

* Fix type
master
Brad Cornes 2022-04-25 15:06:31 +01:00 committed by GitHub
parent 68682298b9
commit 78a20f4daf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 211 additions and 103 deletions

View File

@ -26,6 +26,7 @@ import {
DidChangeWatchedFilesNotification, DidChangeWatchedFilesNotification,
FileChangeType, FileChangeType,
Disposable, Disposable,
TextDocumentIdentifier,
} from 'vscode-languageserver/node' } from 'vscode-languageserver/node'
import { TextDocument } from 'vscode-languageserver-textdocument' import { TextDocument } from 'vscode-languageserver-textdocument'
import { URI } from 'vscode-uri' import { URI } from 'vscode-uri'
@ -177,6 +178,7 @@ interface ProjectService {
tryInit: () => Promise<void> tryInit: () => Promise<void>
dispose: () => void dispose: () => void
onUpdateSettings: (settings: any) => void onUpdateSettings: (settings: any) => void
onFileEvents: (changes: Array<{ file: string; type: FileChangeType }>) => void
onHover(params: TextDocumentPositionParams): Promise<Hover> onHover(params: TextDocumentPositionParams): Promise<Hover>
onCompletion(params: CompletionParams): Promise<CompletionList> onCompletion(params: CompletionParams): Promise<CompletionList>
onCompletionResolve(item: CompletionItem): Promise<CompletionItem> onCompletionResolve(item: CompletionItem): Promise<CompletionItem>
@ -186,6 +188,8 @@ interface ProjectService {
onCodeAction(params: CodeActionParams): Promise<CodeAction[]> onCodeAction(params: CodeActionParams): Promise<CodeAction[]>
} }
type ProjectConfig = { folder: string; configPath?: string; documentSelector?: string[] }
function getMode(config: any): unknown { function getMode(config: any): unknown {
if (typeof config.mode !== 'undefined') { if (typeof config.mode !== 'undefined') {
return config.mode return config.mode
@ -210,11 +214,13 @@ function deleteMode(config: any): void {
} }
async function createProjectService( async function createProjectService(
folder: string, projectConfig: ProjectConfig,
connection: Connection, connection: Connection,
params: InitializeParams, params: InitializeParams,
documentService: DocumentService documentService: DocumentService,
updateCapabilities: () => void
): Promise<ProjectService> { ): Promise<ProjectService> {
const folder = projectConfig.folder
const disposables: Disposable[] = [] const disposables: Disposable[] = []
const documentSettingsCache: Map<string, Settings> = new Map() const documentSettingsCache: Map<string, Settings> = new Map()
@ -259,8 +265,6 @@ async function createProjectService(
}, },
} }
let registrations: Promise<BulkUnregistration>
let chokidarWatcher: chokidar.FSWatcher let chokidarWatcher: chokidar.FSWatcher
let ignore = state.editor.globalSettings.tailwindCSS.files?.exclude ?? DEFAULT_FILES_EXCLUDE let ignore = state.editor.globalSettings.tailwindCSS.files?.exclude ?? DEFAULT_FILES_EXCLUDE
@ -311,15 +315,6 @@ async function createProjectService(
} }
if (params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration) { 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, { connection.client.register(DidChangeWatchedFilesNotification.type, {
watchers: [{ globPattern: `**/${CONFIG_FILE_GLOB}` }, { globPattern: `**/${PACKAGE_GLOB}` }], 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 { function resetState(): void {
clearAllDiagnostics(state) clearAllDiagnostics(state)
Object.keys(state).forEach((key) => { Object.keys(state).forEach((key) => {
@ -417,7 +380,7 @@ async function createProjectService(
} }
}) })
state.enabled = false state.enabled = false
registerCapabilities(state.dependencies) updateCapabilities()
} }
async function tryInit() { async function tryInit() {
@ -452,7 +415,10 @@ async function createProjectService(
async function init() { async function init() {
clearRequireCache() clearRequireCache()
let [configPath] = ( let configPath = projectConfig.configPath
if (!configPath) {
configPath = (
await glob([`**/${CONFIG_FILE_GLOB}`], { await glob([`**/${CONFIG_FILE_GLOB}`], {
cwd: folder, cwd: folder,
ignore: state.editor.globalSettings.tailwindCSS.files?.exclude ?? DEFAULT_FILES_EXCLUDE, ignore: state.editor.globalSettings.tailwindCSS.files?.exclude ?? DEFAULT_FILES_EXCLUDE,
@ -464,7 +430,8 @@ async function createProjectService(
}) })
) )
.sort((a: string, b: string) => a.split('/').length - b.split('/').length) .sort((a: string, b: string) => a.split('/').length - b.split('/').length)
.map(path.normalize) .map(path.normalize)[0]
}
if (!configPath) { if (!configPath) {
throw new SilentError('No config file found.') throw new SilentError('No config file found.')
@ -957,7 +924,7 @@ async function createProjectService(
updateAllDiagnostics(state) updateAllDiagnostics(state)
registerCapabilities(state.dependencies) updateCapabilities()
} }
return { return {
@ -980,12 +947,13 @@ async function createProjectService(
updateAllDiagnostics(state) updateAllDiagnostics(state)
} }
if (settings.editor.colorDecorators) { if (settings.editor.colorDecorators) {
registerCapabilities(state.dependencies) updateCapabilities()
} else { } else {
connection.sendNotification('@/tailwindCSS/clearColors') connection.sendNotification('@/tailwindCSS/clearColors')
} }
} }
}, },
onFileEvents,
async onHover(params: TextDocumentPositionParams): Promise<Hover> { async onHover(params: TextDocumentPositionParams): Promise<Hover> {
if (!state.enabled) return null if (!state.enabled) return null
let document = documentService.getDocument(params.textDocument.uri) let document = documentService.getDocument(params.textDocument.uri)
@ -1002,11 +970,19 @@ async function createProjectService(
let settings = await state.editor.getConfiguration(document.uri) let settings = await state.editor.getConfiguration(document.uri)
if (!settings.tailwindCSS.suggestions) return null if (!settings.tailwindCSS.suggestions) return null
if (await isExcluded(state, document)) 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<CompletionItem> { onCompletionResolve(item: CompletionItem): Promise<CompletionItem> {
if (!state.enabled) return null if (!state.enabled) return null
return resolveCompletionItem(state, item) return resolveCompletionItem(state, { ...item, data: item.data?.originalData })
}, },
async onCodeAction(params: CodeActionParams): Promise<CodeAction[]> { async onCodeAction(params: CodeActionParams): Promise<CodeAction[]> {
if (!state.enabled) return null if (!state.enabled) return null
@ -1345,6 +1321,7 @@ class TW {
private projects: Map<string, ProjectService> private projects: Map<string, ProjectService>
private documentService: DocumentService private documentService: DocumentService
public initializeParams: InitializeParams public initializeParams: InitializeParams
private registrations: Promise<BulkUnregistration>
constructor(private connection: Connection) { constructor(private connection: Connection) {
this.documentService = new DocumentService(this.connection) this.documentService = new DocumentService(this.connection)
@ -1358,16 +1335,15 @@ class TW {
this.initialized = true this.initialized = true
// TODO // TODO
const workspaceFolders = let workspaceFolders: Array<ProjectConfig> =
false && false &&
Array.isArray(this.initializeParams.workspaceFolders) && Array.isArray(this.initializeParams.workspaceFolders) &&
this.initializeParams.capabilities.workspace?.workspaceFolders this.initializeParams.capabilities.workspace?.workspaceFolders
? this.initializeParams.workspaceFolders.map((el) => ({ ? this.initializeParams.workspaceFolders.map((el) => ({
name: el.name, folder: getFileFsPath(el.uri),
fsPath: getFileFsPath(el.uri),
})) }))
: this.initializeParams.rootPath : this.initializeParams.rootPath
? [{ name: '', fsPath: normalizeFileNameToFsPath(this.initializeParams.rootPath) }] ? [{ folder: normalizeFileNameToFsPath(this.initializeParams.rootPath) }]
: [] : []
if (workspaceFolders.length === 0) { if (workspaceFolders.length === 0) {
@ -1375,14 +1351,65 @@ class TW {
return 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( await Promise.all(
workspaceFolders.map(async (folder) => { workspaceFolders.map((projectConfig) => this.addProject(projectConfig, this.initializeParams))
return this.addProject(folder.fsPath, this.initializeParams)
})
) )
this.setupLSPHandlers() 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 }) => { this.connection.onDidChangeConfiguration(async ({ settings }) => {
for (let [, project] of this.projects) { for (let [, project] of this.projects) {
project.onUpdateSettings(settings) project.onUpdateSettings(settings)
@ -1394,24 +1421,24 @@ class TW {
}) })
this.documentService.onDidChangeContent((change) => { this.documentService.onDidChangeContent((change) => {
// TODO this.getProject(change.document)?.provideDiagnostics(change.document)
const project = Array.from(this.projects.values())[0]
project?.provideDiagnostics(change.document)
}) })
} }
private async addProject(folder: string, params: InitializeParams): Promise<void> { private async addProject(projectConfig: ProjectConfig, params: InitializeParams): Promise<void> {
if (this.projects.has(folder)) { let key = JSON.stringify(projectConfig)
await this.projects.get(folder).tryInit() if (this.projects.has(key)) {
await this.projects.get(key).tryInit()
} else { } else {
const project = await createProjectService( const project = await createProjectService(
folder, projectConfig,
this.connection, this.connection,
params, params,
this.documentService this.documentService,
() => this.updateCapabilities()
) )
this.projects.set(key, project)
await project.tryInit() await project.tryInit()
this.projects.set(folder, project)
} }
} }
@ -1424,38 +1451,78 @@ class TW {
this.connection.onCodeAction(this.onCodeAction.bind(this)) 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<ColorInformation[]> { async onDocumentColor(params: DocumentColorParams): Promise<ColorInformation[]> {
const project = Array.from(this.projects.values())[0] return this.getProject(params.textDocument)?.onDocumentColor(params) ?? []
return project?.onDocumentColor(params) ?? []
} }
async onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]> { async onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]> {
const project = Array.from(this.projects.values())[0] return this.getProject(params.textDocument)?.onColorPresentation(params) ?? []
return project?.onColorPresentation(params) ?? []
} }
async onHover(params: TextDocumentPositionParams): Promise<Hover> { async onHover(params: TextDocumentPositionParams): Promise<Hover> {
// TODO return this.getProject(params.textDocument)?.onHover(params) ?? null
const project = Array.from(this.projects.values())[0]
return project?.onHover(params) ?? null
} }
async onCompletion(params: CompletionParams): Promise<CompletionList> { async onCompletion(params: CompletionParams): Promise<CompletionList> {
// TODO return this.getProject(params.textDocument)?.onCompletion(params) ?? null
const project = Array.from(this.projects.values())[0]
return project?.onCompletion(params) ?? null
} }
async onCompletionResolve(item: CompletionItem): Promise<CompletionItem> { async onCompletionResolve(item: CompletionItem): Promise<CompletionItem> {
// TODO return this.projects.get(item.data.projectKey)?.onCompletionResolve(item) ?? null
const project = Array.from(this.projects.values())[0]
return project?.onCompletionResolve(item) ?? null
} }
onCodeAction(params: CodeActionParams): Promise<CodeAction[]> { onCodeAction(params: CodeActionParams): Promise<CodeAction[]> {
// TODO return this.getProject(params.textDocument)?.onCodeAction(params) ?? null
const project = Array.from(this.projects.values())[0]
return project?.onCodeAction(params) ?? null
} }
listen() { listen() {

View File

@ -59,6 +59,7 @@ export type Settings = {
} }
experimental: { experimental: {
classRegex: string[] classRegex: string[]
configFile: string | Record<string, string | string[]>
} }
files: { files: {
exclude: string[] exclude: string[]

View File

@ -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`** 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 ## Troubleshooting
If youre having issues getting the IntelliSense features to activate, there are a few things you can check: If youre having issues getting the IntelliSense features to activate, there are a few things you can check:

View File

@ -277,6 +277,15 @@
"type": "array", "type": "array",
"scope": "language-overridable" "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": { "tailwindCSS.showPixelEquivalents": {
"type": "boolean", "type": "boolean",
"default": true, "default": true,

View File

@ -169,25 +169,31 @@ export async function activate(context: ExtensionContext) {
// e.g. "plaintext" already exists but you change it from "html" to "css" // e.g. "plaintext" already exists but you change it from "html" to "css"
context.subscriptions.push( context.subscriptions.push(
Workspace.onDidChangeConfiguration((event) => { Workspace.onDidChangeConfiguration((event) => {
clients.forEach((client, key) => { ;[...clients].forEach(([key, client]) => {
const folder = Workspace.getWorkspaceFolder(Uri.parse(key)) 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) const userLanguages = getUserLanguages(folder)
if (userLanguages) { if (userLanguages) {
const userLanguageIds = Object.keys(userLanguages) const userLanguageIds = Object.keys(userLanguages)
const newLanguages = dedupe([...defaultLanguages, ...userLanguageIds]) const newLanguages = dedupe([...defaultLanguages, ...userLanguageIds])
if (!equal(newLanguages, languages.get(folder.uri.toString()))) { if (!equal(newLanguages, languages.get(folder.uri.toString()))) {
languages.set(folder.uri.toString(), newLanguages) languages.set(folder.uri.toString(), newLanguages)
reboot = true
}
}
}
if (client) { if (event.affectsConfiguration('tailwindCSS.experimental.configFile', folder)) {
reboot = true
}
if (reboot && client) {
clients.delete(folder.uri.toString()) clients.delete(folder.uri.toString())
client.stop() client.stop()
bootWorkspaceClient(folder) bootWorkspaceClient(folder)
} }
}
}
}
}) })
}) })
) )