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,
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<void>
dispose: () => 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>
@ -186,6 +188,8 @@ interface ProjectService {
onCodeAction(params: CodeActionParams): Promise<CodeAction[]>
}
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<ProjectService> {
const folder = projectConfig.folder
const disposables: Disposable[] = []
const documentSettingsCache: Map<string, Settings> = new Map()
@ -259,8 +265,6 @@ async function createProjectService(
},
}
let registrations: Promise<BulkUnregistration>
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<Hover> {
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<CompletionItem> {
if (!state.enabled) return null
return resolveCompletionItem(state, item)
return resolveCompletionItem(state, { ...item, data: item.data?.originalData })
},
async onCodeAction(params: CodeActionParams): Promise<CodeAction[]> {
if (!state.enabled) return null
@ -1345,6 +1321,7 @@ class TW {
private projects: Map<string, ProjectService>
private documentService: DocumentService
public initializeParams: InitializeParams
private registrations: Promise<BulkUnregistration>
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<ProjectConfig> =
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<void> {
if (this.projects.has(folder)) {
await this.projects.get(folder).tryInit()
private async addProject(projectConfig: ProjectConfig, params: InitializeParams): Promise<void> {
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<ColorInformation[]> {
const project = Array.from(this.projects.values())[0]
return project?.onDocumentColor(params) ?? []
return this.getProject(params.textDocument)?.onDocumentColor(params) ?? []
}
async onColorPresentation(params: ColorPresentationParams): Promise<ColorPresentation[]> {
const project = Array.from(this.projects.values())[0]
return project?.onColorPresentation(params) ?? []
return this.getProject(params.textDocument)?.onColorPresentation(params) ?? []
}
async onHover(params: TextDocumentPositionParams): Promise<Hover> {
// 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<CompletionList> {
// 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<CompletionItem> {
// 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<CodeAction[]> {
// TODO
const project = Array.from(this.projects.values())[0]
return project?.onCodeAction(params) ?? null
return this.getProject(params.textDocument)?.onCodeAction(params) ?? null
}
listen() {

View File

@ -59,6 +59,7 @@ export type Settings = {
}
experimental: {
classRegex: string[]
configFile: string | Record<string, string | string[]>
}
files: {
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`**
## 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 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",
"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,

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"
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)
}
})
})
)