Skip to content

Commit

Permalink
Add support for initializing when using workspaceFolders (#955)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
thecrypticace committed Apr 16, 2024
1 parent 7f503d5 commit 830dc0a
Show file tree
Hide file tree
Showing 3 changed files with 266 additions and 22 deletions.
107 changes: 94 additions & 13 deletions packages/tailwindcss-language-server/src/tw.ts
Expand Up @@ -18,6 +18,7 @@ import type {
DocumentLinkParams,
DocumentLink,
InitializeResult,
WorkspaceFolder,
} from 'vscode-languageserver/node'
import {
CompletionRequest,
Expand All @@ -37,7 +38,6 @@ import type * as chokidar from 'chokidar'
import picomatch from 'picomatch'
import resolveFrom from './util/resolveFrom'
import * as parcel from './watcher/index.js'
import { normalizeFileNameToFsPath } from './util/uri'
import { equal } from '@tailwindcss/language-service/src/util/array'
import { CONFIG_GLOB, CSS_GLOB, PACKAGE_LOCK_GLOB } from './lib/constants'
import { clearRequireCache, isObject, changeAffectsFile } from './utils'
Expand Down Expand Up @@ -107,23 +107,57 @@ export class TW {
await this.initPromise
}

private async _init(): Promise<void> {
clearRequireCache()
private getWorkspaceFolders(): WorkspaceFolder[] {
if (this.initializeParams.workspaceFolders?.length) {
return this.initializeParams.workspaceFolders.map((folder) => ({
uri: URI.parse(folder.uri).fsPath,
name: folder.name,
}))
}

let base: string
if (this.initializeParams.rootUri) {
base = URI.parse(this.initializeParams.rootUri).fsPath
} else if (this.initializeParams.rootPath) {
base = normalizeFileNameToFsPath(this.initializeParams.rootPath)
return [
{
uri: URI.parse(this.initializeParams.rootUri).fsPath,
name: 'Root',
},
]
}

if (!base) {
if (this.initializeParams.rootPath) {
return [
{
uri: URI.file(this.initializeParams.rootPath).fsPath,
name: 'Root',
},
]
}

return []
}

private async _init(): Promise<void> {
clearRequireCache()

let folders = this.getWorkspaceFolders().map((folder) => normalizePath(folder.uri))

if (folders.length === 0) {
console.error('No workspace folders found, not initializing.')
return
}

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

await this.listenForEvents()
}

private async _initFolder(base: string): Promise<void> {
let workspaceFolders: Array<ProjectConfig> = []
let globalSettings = await this.settingsCache.get()
let ignore = globalSettings.tailwindCSS.files.exclude
Expand Down Expand Up @@ -459,12 +493,15 @@ export class TW {
)

// init projects for documents that are _already_ open
let readyDocuments: string[] = []
for (let document of this.documentService.getAllDocuments()) {
let project = this.getProject(document)
if (project && !project.enabled()) {
project.enable()
await project.tryInit()
}

readyDocuments.push(document.uri)
}

this.setupLSPHandlers()
Expand All @@ -488,6 +525,22 @@ export class TW {
}),
)

const isTestMode = this.initializeParams.initializationOptions?.testMode ?? false

if (!isTestMode) return

await Promise.all(
readyDocuments.map((uri) =>
this.connection.sendNotification('@/tailwindCSS/documentReady', {
uri,
}),
),
)
}

private async listenForEvents() {
const isTestMode = this.initializeParams.initializationOptions?.testMode ?? false

this.disposables.push(
this.connection.onShutdown(() => {
this.dispose()
Expand All @@ -501,14 +554,42 @@ export class TW {
)

this.disposables.push(
this.documentService.onDidOpen((event) => {
this.documentService.onDidOpen(async (event) => {
let project = this.getProject(event.document)
if (project && !project.enabled()) {
if (!project) return

if (!project.enabled()) {
project.enable()
project.tryInit()
await project.tryInit()
}

if (!isTestMode) return

// TODO: This is a hack and shouldn't be necessary
// await new Promise((resolve) => setTimeout(resolve, 100))
await this.connection.sendNotification('@/tailwindCSS/documentReady', {
uri: event.document.uri,
})
}),
)

if (this.initializeParams.capabilities.workspace.workspaceFolders) {
this.disposables.push(
this.connection.workspace.onDidChangeWorkspaceFolders(async (evt) => {
// Initialize any new folders that have appeared
let added = evt.added
.map((folder) => ({
uri: URI.parse(folder.uri).fsPath,
name: folder.name,
}))
.map((folder) => normalizePath(folder.uri))

await Promise.allSettled(added.map((basePath) => this._initFolder(basePath)))

// TODO: If folders get removed we should cleanup any associated state and resources
}),
)
}
}

private filterNewWatchPatterns(patterns: string[]) {
Expand Down Expand Up @@ -552,7 +633,7 @@ export class TW {
return
}

this.connection.sendNotification('tailwind/projectDetails', {
this.connection.sendNotification('@/tailwindCSS/projectDetails', {
config: projectConfig.configPath,
tailwind: projectConfig.tailwind,
})
Expand Down
96 changes: 87 additions & 9 deletions packages/tailwindcss-language-server/tests/common.ts
Expand Up @@ -15,10 +15,12 @@ import {
} from 'vscode-languageserver-protocol'
import type { ClientCapabilities, ProtocolConnection } from 'vscode-languageclient'
import type { Feature } from '@tailwindcss/language-service/src/features'
import { CacheMap } from '../src/cache-map'

type Settings = any

interface FixtureContext extends Pick<ProtocolConnection, 'sendRequest' | 'onNotification'> {
interface FixtureContext
extends Pick<ProtocolConnection, 'sendRequest' | 'sendNotification' | 'onNotification'> {
client: ProtocolConnection
openDocument: (params: {
text: string
Expand All @@ -28,6 +30,7 @@ interface FixtureContext extends Pick<ProtocolConnection, 'sendRequest' | 'onNot
}) => Promise<{ uri: string; updateSettings: (settings: Settings) => Promise<void> }>
updateSettings: (settings: Settings) => Promise<void>
updateFile: (file: string, text: string) => Promise<void>
fixtureUri(fixture: string): string

readonly project: {
config: string
Expand All @@ -39,7 +42,7 @@ interface FixtureContext extends Pick<ProtocolConnection, 'sendRequest' | 'onNot
}
}

async function init(fixture: string): Promise<FixtureContext> {
async function init(fixture: string | string[]): Promise<FixtureContext> {
let settings = {}
let docSettings = new Map<string, Settings>()

Expand Down Expand Up @@ -125,13 +128,34 @@ async function init(fixture: string): Promise<FixtureContext> {
},
}

const fixtures = Array.isArray(fixture) ? fixture : [fixture]

function fixtureUri(fixture: string) {
return `file://${path.resolve('./tests/fixtures', fixture)}`
}

function resolveUri(...parts: string[]) {
const filepath =
fixtures.length > 1
? path.resolve('./tests/fixtures', ...parts)
: path.resolve('./tests/fixtures', fixtures[0], ...parts)

return `file://${filepath}`
}

const workspaceFolders = fixtures.map((fixture) => ({
name: `Fixture ${fixture}`,
uri: fixtureUri(fixture),
}))

const rootUri = fixtures.length > 1 ? null : workspaceFolders[0].uri

await client.sendRequest(InitializeRequest.type, {
processId: -1,
// rootPath: '.',
rootUri: `file://${path.resolve('./tests/fixtures/', fixture)}`,
rootUri,
capabilities,
trace: 'off',
workspaceFolders: [],
workspaceFolders,
initializationOptions: {
testMode: true,
},
Expand All @@ -158,23 +182,38 @@ async function init(fixture: string): Promise<FixtureContext> {
})
})

interface PromiseWithResolvers<T> extends Promise<T> {
resolve: (value?: T | PromiseLike<T>) => void
reject: (reason?: any) => void
}

let openingDocuments = new CacheMap<string, PromiseWithResolvers<void>>()
let projectDetails: any = null

client.onNotification('tailwind/projectDetails', (params) => {
client.onNotification('@/tailwindCSS/projectDetails', (params) => {
console.log('[TEST] Project detailed changed')
projectDetails = params
})

client.onNotification('@/tailwindCSS/documentReady', (params) => {
console.log('[TEST] Document ready', params.uri)
openingDocuments.get(params.uri)?.resolve()
})

let counter = 0

return {
client,
fixtureUri,
get project() {
return projectDetails
},
sendRequest(type: any, params: any) {
return client.sendRequest(type, params)
},
sendNotification(type: any, params?: any) {
return client.sendNotification(type, params)
},
onNotification(type: any, callback: any) {
return client.onNotification(type, callback)
},
Expand All @@ -189,9 +228,24 @@ async function init(fixture: string): Promise<FixtureContext> {
dir?: string
settings?: Settings
}) {
let uri = `file://${path.resolve('./tests/fixtures', fixture, dir, `file-${counter++}`)}`
let uri = resolveUri(dir, `file-${counter++}`)
docSettings.set(uri, settings)

let openPromise = openingDocuments.remember(uri, () => {
let resolve = () => {}
let reject = () => {}

let p = new Promise<void>((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})

return Object.assign(p, {
resolve,
reject,
})
})

await client.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: {
uri,
Expand All @@ -204,6 +258,7 @@ async function init(fixture: string): Promise<FixtureContext> {
// If opening a document stalls then it's probably because this promise is not being resolved
// This can happen if a document is not covered by one of the selectors because of it's URI
await initPromise
await openPromise

return {
uri,
Expand All @@ -220,7 +275,7 @@ async function init(fixture: string): Promise<FixtureContext> {
},

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

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

export function withFixture(fixture, callback: (c: FixtureContext) => void) {
export function withFixture(fixture: string, callback: (c: FixtureContext) => void) {
describe(fixture, () => {
let c: FixtureContext = {} as any

Expand All @@ -246,3 +301,26 @@ export function withFixture(fixture, callback: (c: FixtureContext) => void) {
callback(c)
})
}

export function withWorkspace({
fixtures,
run,
}: {
fixtures: string[]
run: (c: FixtureContext) => void
}) {
describe(`workspace: ${fixtures.join(', ')}`, () => {
let c: FixtureContext = {} as any

beforeAll(async () => {
// Using the connection object as the prototype lets us access the connection
// without defining getters for all the methods and also lets us add helpers
// to the connection object without having to resort to using a Proxy
Object.setPrototypeOf(c, await init(fixtures))

return () => c.client.dispose()
})

run(c)
})
}

0 comments on commit 830dc0a

Please sign in to comment.