Skip to content

Add support for initializing when using workspaceFolders #955

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 94 additions & 13 deletions packages/tailwindcss-language-server/src/tw.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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)
})
}
Loading