Skip to content

Commit 830dc0a

Browse files
Add support for initializing when using workspaceFolders (#955)
* Rename notification * Wait for document initialization in tests * Refactor * Look at all paths in `workspaceFolders` when initializing * Move event listeners * Only listen for workspace folder changes when the client supports it
1 parent 7f503d5 commit 830dc0a

File tree

3 files changed

+266
-22
lines changed

3 files changed

+266
-22
lines changed

packages/tailwindcss-language-server/src/tw.ts

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
DocumentLinkParams,
1919
DocumentLink,
2020
InitializeResult,
21+
WorkspaceFolder,
2122
} from 'vscode-languageserver/node'
2223
import {
2324
CompletionRequest,
@@ -37,7 +38,6 @@ import type * as chokidar from 'chokidar'
3738
import picomatch from 'picomatch'
3839
import resolveFrom from './util/resolveFrom'
3940
import * as parcel from './watcher/index.js'
40-
import { normalizeFileNameToFsPath } from './util/uri'
4141
import { equal } from '@tailwindcss/language-service/src/util/array'
4242
import { CONFIG_GLOB, CSS_GLOB, PACKAGE_LOCK_GLOB } from './lib/constants'
4343
import { clearRequireCache, isObject, changeAffectsFile } from './utils'
@@ -107,23 +107,57 @@ export class TW {
107107
await this.initPromise
108108
}
109109

110-
private async _init(): Promise<void> {
111-
clearRequireCache()
110+
private getWorkspaceFolders(): WorkspaceFolder[] {
111+
if (this.initializeParams.workspaceFolders?.length) {
112+
return this.initializeParams.workspaceFolders.map((folder) => ({
113+
uri: URI.parse(folder.uri).fsPath,
114+
name: folder.name,
115+
}))
116+
}
112117

113-
let base: string
114118
if (this.initializeParams.rootUri) {
115-
base = URI.parse(this.initializeParams.rootUri).fsPath
116-
} else if (this.initializeParams.rootPath) {
117-
base = normalizeFileNameToFsPath(this.initializeParams.rootPath)
119+
return [
120+
{
121+
uri: URI.parse(this.initializeParams.rootUri).fsPath,
122+
name: 'Root',
123+
},
124+
]
118125
}
119126

120-
if (!base) {
127+
if (this.initializeParams.rootPath) {
128+
return [
129+
{
130+
uri: URI.file(this.initializeParams.rootPath).fsPath,
131+
name: 'Root',
132+
},
133+
]
134+
}
135+
136+
return []
137+
}
138+
139+
private async _init(): Promise<void> {
140+
clearRequireCache()
141+
142+
let folders = this.getWorkspaceFolders().map((folder) => normalizePath(folder.uri))
143+
144+
if (folders.length === 0) {
121145
console.error('No workspace folders found, not initializing.')
122146
return
123147
}
124148

125-
base = normalizePath(base)
149+
// Initialize each workspace separately
150+
// We use `allSettled` here because failures in one folder should not prevent initialization of others
151+
//
152+
// NOTE: We should eventually be smart about avoiding duplicate work. We do
153+
// not necessarily need to set up file watchers, search for projects, read
154+
// configs, etc… per folder. Some of this work should be sharable.
155+
await Promise.allSettled(folders.map((basePath) => this._initFolder(basePath)))
126156

157+
await this.listenForEvents()
158+
}
159+
160+
private async _initFolder(base: string): Promise<void> {
127161
let workspaceFolders: Array<ProjectConfig> = []
128162
let globalSettings = await this.settingsCache.get()
129163
let ignore = globalSettings.tailwindCSS.files.exclude
@@ -459,12 +493,15 @@ export class TW {
459493
)
460494

461495
// init projects for documents that are _already_ open
496+
let readyDocuments: string[] = []
462497
for (let document of this.documentService.getAllDocuments()) {
463498
let project = this.getProject(document)
464499
if (project && !project.enabled()) {
465500
project.enable()
466501
await project.tryInit()
467502
}
503+
504+
readyDocuments.push(document.uri)
468505
}
469506

470507
this.setupLSPHandlers()
@@ -488,6 +525,22 @@ export class TW {
488525
}),
489526
)
490527

528+
const isTestMode = this.initializeParams.initializationOptions?.testMode ?? false
529+
530+
if (!isTestMode) return
531+
532+
await Promise.all(
533+
readyDocuments.map((uri) =>
534+
this.connection.sendNotification('@/tailwindCSS/documentReady', {
535+
uri,
536+
}),
537+
),
538+
)
539+
}
540+
541+
private async listenForEvents() {
542+
const isTestMode = this.initializeParams.initializationOptions?.testMode ?? false
543+
491544
this.disposables.push(
492545
this.connection.onShutdown(() => {
493546
this.dispose()
@@ -501,14 +554,42 @@ export class TW {
501554
)
502555

503556
this.disposables.push(
504-
this.documentService.onDidOpen((event) => {
557+
this.documentService.onDidOpen(async (event) => {
505558
let project = this.getProject(event.document)
506-
if (project && !project.enabled()) {
559+
if (!project) return
560+
561+
if (!project.enabled()) {
507562
project.enable()
508-
project.tryInit()
563+
await project.tryInit()
509564
}
565+
566+
if (!isTestMode) return
567+
568+
// TODO: This is a hack and shouldn't be necessary
569+
// await new Promise((resolve) => setTimeout(resolve, 100))
570+
await this.connection.sendNotification('@/tailwindCSS/documentReady', {
571+
uri: event.document.uri,
572+
})
510573
}),
511574
)
575+
576+
if (this.initializeParams.capabilities.workspace.workspaceFolders) {
577+
this.disposables.push(
578+
this.connection.workspace.onDidChangeWorkspaceFolders(async (evt) => {
579+
// Initialize any new folders that have appeared
580+
let added = evt.added
581+
.map((folder) => ({
582+
uri: URI.parse(folder.uri).fsPath,
583+
name: folder.name,
584+
}))
585+
.map((folder) => normalizePath(folder.uri))
586+
587+
await Promise.allSettled(added.map((basePath) => this._initFolder(basePath)))
588+
589+
// TODO: If folders get removed we should cleanup any associated state and resources
590+
}),
591+
)
592+
}
512593
}
513594

514595
private filterNewWatchPatterns(patterns: string[]) {
@@ -552,7 +633,7 @@ export class TW {
552633
return
553634
}
554635

555-
this.connection.sendNotification('tailwind/projectDetails', {
636+
this.connection.sendNotification('@/tailwindCSS/projectDetails', {
556637
config: projectConfig.configPath,
557638
tailwind: projectConfig.tailwind,
558639
})

packages/tailwindcss-language-server/tests/common.ts

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import {
1515
} from 'vscode-languageserver-protocol'
1616
import type { ClientCapabilities, ProtocolConnection } from 'vscode-languageclient'
1717
import type { Feature } from '@tailwindcss/language-service/src/features'
18+
import { CacheMap } from '../src/cache-map'
1819

1920
type Settings = any
2021

21-
interface FixtureContext extends Pick<ProtocolConnection, 'sendRequest' | 'onNotification'> {
22+
interface FixtureContext
23+
extends Pick<ProtocolConnection, 'sendRequest' | 'sendNotification' | 'onNotification'> {
2224
client: ProtocolConnection
2325
openDocument: (params: {
2426
text: string
@@ -28,6 +30,7 @@ interface FixtureContext extends Pick<ProtocolConnection, 'sendRequest' | 'onNot
2830
}) => Promise<{ uri: string; updateSettings: (settings: Settings) => Promise<void> }>
2931
updateSettings: (settings: Settings) => Promise<void>
3032
updateFile: (file: string, text: string) => Promise<void>
33+
fixtureUri(fixture: string): string
3134

3235
readonly project: {
3336
config: string
@@ -39,7 +42,7 @@ interface FixtureContext extends Pick<ProtocolConnection, 'sendRequest' | 'onNot
3942
}
4043
}
4144

42-
async function init(fixture: string): Promise<FixtureContext> {
45+
async function init(fixture: string | string[]): Promise<FixtureContext> {
4346
let settings = {}
4447
let docSettings = new Map<string, Settings>()
4548

@@ -125,13 +128,34 @@ async function init(fixture: string): Promise<FixtureContext> {
125128
},
126129
}
127130

131+
const fixtures = Array.isArray(fixture) ? fixture : [fixture]
132+
133+
function fixtureUri(fixture: string) {
134+
return `file://${path.resolve('./tests/fixtures', fixture)}`
135+
}
136+
137+
function resolveUri(...parts: string[]) {
138+
const filepath =
139+
fixtures.length > 1
140+
? path.resolve('./tests/fixtures', ...parts)
141+
: path.resolve('./tests/fixtures', fixtures[0], ...parts)
142+
143+
return `file://${filepath}`
144+
}
145+
146+
const workspaceFolders = fixtures.map((fixture) => ({
147+
name: `Fixture ${fixture}`,
148+
uri: fixtureUri(fixture),
149+
}))
150+
151+
const rootUri = fixtures.length > 1 ? null : workspaceFolders[0].uri
152+
128153
await client.sendRequest(InitializeRequest.type, {
129154
processId: -1,
130-
// rootPath: '.',
131-
rootUri: `file://${path.resolve('./tests/fixtures/', fixture)}`,
155+
rootUri,
132156
capabilities,
133157
trace: 'off',
134-
workspaceFolders: [],
158+
workspaceFolders,
135159
initializationOptions: {
136160
testMode: true,
137161
},
@@ -158,23 +182,38 @@ async function init(fixture: string): Promise<FixtureContext> {
158182
})
159183
})
160184

185+
interface PromiseWithResolvers<T> extends Promise<T> {
186+
resolve: (value?: T | PromiseLike<T>) => void
187+
reject: (reason?: any) => void
188+
}
189+
190+
let openingDocuments = new CacheMap<string, PromiseWithResolvers<void>>()
161191
let projectDetails: any = null
162192

163-
client.onNotification('tailwind/projectDetails', (params) => {
193+
client.onNotification('@/tailwindCSS/projectDetails', (params) => {
164194
console.log('[TEST] Project detailed changed')
165195
projectDetails = params
166196
})
167197

198+
client.onNotification('@/tailwindCSS/documentReady', (params) => {
199+
console.log('[TEST] Document ready', params.uri)
200+
openingDocuments.get(params.uri)?.resolve()
201+
})
202+
168203
let counter = 0
169204

170205
return {
171206
client,
207+
fixtureUri,
172208
get project() {
173209
return projectDetails
174210
},
175211
sendRequest(type: any, params: any) {
176212
return client.sendRequest(type, params)
177213
},
214+
sendNotification(type: any, params?: any) {
215+
return client.sendNotification(type, params)
216+
},
178217
onNotification(type: any, callback: any) {
179218
return client.onNotification(type, callback)
180219
},
@@ -189,9 +228,24 @@ async function init(fixture: string): Promise<FixtureContext> {
189228
dir?: string
190229
settings?: Settings
191230
}) {
192-
let uri = `file://${path.resolve('./tests/fixtures', fixture, dir, `file-${counter++}`)}`
231+
let uri = resolveUri(dir, `file-${counter++}`)
193232
docSettings.set(uri, settings)
194233

234+
let openPromise = openingDocuments.remember(uri, () => {
235+
let resolve = () => {}
236+
let reject = () => {}
237+
238+
let p = new Promise<void>((_resolve, _reject) => {
239+
resolve = _resolve
240+
reject = _reject
241+
})
242+
243+
return Object.assign(p, {
244+
resolve,
245+
reject,
246+
})
247+
})
248+
195249
await client.sendNotification(DidOpenTextDocumentNotification.type, {
196250
textDocument: {
197251
uri,
@@ -204,6 +258,7 @@ async function init(fixture: string): Promise<FixtureContext> {
204258
// If opening a document stalls then it's probably because this promise is not being resolved
205259
// This can happen if a document is not covered by one of the selectors because of it's URI
206260
await initPromise
261+
await openPromise
207262

208263
return {
209264
uri,
@@ -220,7 +275,7 @@ async function init(fixture: string): Promise<FixtureContext> {
220275
},
221276

222277
async updateFile(file: string, text: string) {
223-
let uri = `file://${path.resolve('./tests/fixtures', fixture, file)}`
278+
let uri = resolveUri(file)
224279

225280
await client.sendNotification(DidChangeTextDocumentNotification.type, {
226281
textDocument: { uri, version: counter++ },
@@ -230,7 +285,7 @@ async function init(fixture: string): Promise<FixtureContext> {
230285
}
231286
}
232287

233-
export function withFixture(fixture, callback: (c: FixtureContext) => void) {
288+
export function withFixture(fixture: string, callback: (c: FixtureContext) => void) {
234289
describe(fixture, () => {
235290
let c: FixtureContext = {} as any
236291

@@ -246,3 +301,26 @@ export function withFixture(fixture, callback: (c: FixtureContext) => void) {
246301
callback(c)
247302
})
248303
}
304+
305+
export function withWorkspace({
306+
fixtures,
307+
run,
308+
}: {
309+
fixtures: string[]
310+
run: (c: FixtureContext) => void
311+
}) {
312+
describe(`workspace: ${fixtures.join(', ')}`, () => {
313+
let c: FixtureContext = {} as any
314+
315+
beforeAll(async () => {
316+
// Using the connection object as the prototype lets us access the connection
317+
// without defining getters for all the methods and also lets us add helpers
318+
// to the connection object without having to resort to using a Proxy
319+
Object.setPrototypeOf(c, await init(fixtures))
320+
321+
return () => c.client.dispose()
322+
})
323+
324+
run(c)
325+
})
326+
}

0 commit comments

Comments
 (0)