From 9edbd6a8d43287cfa51b1f61b5a8db965f91c9ff Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 9 Oct 2025 16:52:00 -0400 Subject: [PATCH 1/4] Work around testing race conditions --- .../tailwindcss-language-server/tests/common.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/tailwindcss-language-server/tests/common.ts b/packages/tailwindcss-language-server/tests/common.ts index 9b40b3d3..3a343ce7 100644 --- a/packages/tailwindcss-language-server/tests/common.ts +++ b/packages/tailwindcss-language-server/tests/common.ts @@ -91,6 +91,18 @@ export async function init( projectDetails = project }) + // TODO: This shouldn't be needed + // The server should either delay requests *or* + // openDocument shouldn't return until the project its a part of has been + // built otherwise all requests will return nothing and it's not something + // we can await directly right now + // + // Like maybe documentReady should be delayed by project build state? + // because otherwise the document isn't really ready anyway + let projectBuilt = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/projectReloaded', () => resolve()) + }) + return { client, fixtureUri(fixture: string) { @@ -130,6 +142,8 @@ export async function init( settings, }) + await projectBuilt + return { get uri() { return doc.uri.toString() From b86b146d1e57f26309f3934401ba909ee10d1175 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 3 Oct 2025 11:55:19 -0400 Subject: [PATCH 2/4] Offload Oxide scanning to separate process --- .../tailwindcss-language-server/package.json | 3 +- .../src/oxide-helper.ts | 14 +++ .../src/oxide-session.ts | 93 +++++++++++++++++++ .../tailwindcss-language-server/src/oxide.ts | 4 +- .../src/project-locator.ts | 53 +++++++++-- .../src/projects.ts | 1 + packages/vscode-tailwindcss/package.json | 2 +- .../vscode-tailwindcss/src/oxide-helper.ts | 1 + 8 files changed, 159 insertions(+), 12 deletions(-) create mode 100644 packages/tailwindcss-language-server/src/oxide-helper.ts create mode 100644 packages/tailwindcss-language-server/src/oxide-session.ts create mode 100644 packages/vscode-tailwindcss/src/oxide-helper.ts diff --git a/packages/tailwindcss-language-server/package.json b/packages/tailwindcss-language-server/package.json index a7e4ea2c..142df17c 100644 --- a/packages/tailwindcss-language-server/package.json +++ b/packages/tailwindcss-language-server/package.json @@ -13,8 +13,9 @@ }, "homepage": "https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme", "scripts": { - "build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:css", + "build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:oxide && pnpm run _esbuild:css", "_esbuild": "node ../../esbuild.mjs src/server.ts --outfile=bin/tailwindcss-language-server --minify", + "_esbuild:oxide": "node ../../esbuild.mjs src/oxide-helper.ts --outfile=bin/oxide-helper.js --minify", "_esbuild:css": "node ../../esbuild.mjs src/language/css.ts --outfile=bin/css-language-server --minify", "clean": "rimraf bin", "prepublishOnly": "pnpm run build", diff --git a/packages/tailwindcss-language-server/src/oxide-helper.ts b/packages/tailwindcss-language-server/src/oxide-helper.ts new file mode 100644 index 00000000..ec001562 --- /dev/null +++ b/packages/tailwindcss-language-server/src/oxide-helper.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import * as rpc from 'vscode-jsonrpc/node' +import { scan, type ScanOptions, type ScanResult } from './oxide' + +let connection = rpc.createMessageConnection( + new rpc.IPCMessageReader(process), + new rpc.IPCMessageWriter(process), +) + +let scanRequest = new rpc.RequestType('scan') +connection.onRequest(scanRequest, (options) => scan(options)) + +connection.listen() diff --git a/packages/tailwindcss-language-server/src/oxide-session.ts b/packages/tailwindcss-language-server/src/oxide-session.ts new file mode 100644 index 00000000..a99ae49a --- /dev/null +++ b/packages/tailwindcss-language-server/src/oxide-session.ts @@ -0,0 +1,93 @@ +import * as rpc from 'vscode-jsonrpc/node' +import * as proc from 'node:child_process' +import * as path from 'node:path' +import * as fs from 'node:fs/promises' +import { type ScanOptions, type ScanResult } from './oxide' + +/** + * This helper starts a session in which we can use Oxide in *another process* + * to communicate content scanning results. + * + * Thie exists for two reasons: + * - The Oxide API has changed over time so this function presents a unified + * interface that works with all versions of the Oxide API. The results may + * vary but the structure of the results will always be identical. + * + * - Requiring a native node module on Windows permanently keeps an open handle + * to the binary for the duration of the process. This prevents unlinking the + * file like happens when running `npm ci`. Running an ephemeral process lets + * us sidestep the problem as the process will only be running as needed. + */ +export class OxideSession { + helper: proc.ChildProcess | null = null + connection: rpc.MessageConnection | null = null + + public async scan(options: ScanOptions): Promise { + await this.startIfNeeded() + + return await this.connection.sendRequest('scan', options) + } + + async startIfNeeded(): Promise { + if (this.connection) return + + // TODO: Can we find a way to not require a build first? + // let module = path.resolve(path.dirname(__filename), './oxide-helper.ts') + + let modulePaths = [ + // Separate Language Server package + '../bin/oxide-helper.js', + + // Bundled with the VSCode extension + '../dist/oxide-helper.js', + ] + + let module: string | null = null + + for (let relativePath of modulePaths) { + let filepath = path.resolve(path.dirname(__filename), relativePath) + + if ( + await fs.access(filepath).then( + () => true, + () => false, + ) + ) { + module = filepath + break + } + } + + if (!module) throw new Error('unable to load') + + let helper = proc.fork(module) + let connection = rpc.createMessageConnection( + new rpc.IPCMessageReader(helper), + new rpc.IPCMessageWriter(helper), + ) + + helper.on('disconnect', () => { + connection.dispose() + this.connection = null + this.helper = null + }) + + helper.on('exit', () => { + connection.dispose() + this.connection = null + this.helper = null + }) + + connection.listen() + + this.helper = helper + this.connection = connection + } + + async stop() { + if (!this.helper) return + + this.helper.disconnect() + this.helper.kill() + } +} diff --git a/packages/tailwindcss-language-server/src/oxide.ts b/packages/tailwindcss-language-server/src/oxide.ts index 4dd529df..5c5e59e6 100644 --- a/packages/tailwindcss-language-server/src/oxide.ts +++ b/packages/tailwindcss-language-server/src/oxide.ts @@ -111,14 +111,14 @@ interface SourceEntry { negated: boolean } -interface ScanOptions { +export interface ScanOptions { oxidePath: string oxideVersion: string basePath: string sources: Array } -interface ScanResult { +export interface ScanResult { files: Array globs: Array } diff --git a/packages/tailwindcss-language-server/src/project-locator.ts b/packages/tailwindcss-language-server/src/project-locator.ts index bec10290..c9079c4e 100644 --- a/packages/tailwindcss-language-server/src/project-locator.ts +++ b/packages/tailwindcss-language-server/src/project-locator.ts @@ -16,6 +16,7 @@ import { normalizeDriveLetter, normalizePath, pathToFileURL } from './utils' import postcss from 'postcss' import * as oxide from './oxide' import { analyzeStylesheet, TailwindStylesheet } from './version-guesser' +import { OxideSession } from './oxide-session' export interface ProjectConfig { /** The folder that contains the project */ @@ -60,7 +61,10 @@ export class ProjectLocator { let configs = await this.findConfigs() // Create a project for each of the config files - let results = await Promise.allSettled(configs.map((config) => this.createProject(config))) + let session = new OxideSession() + let results = await Promise.allSettled( + configs.map((config) => this.createProject(config, session)), + ) let projects: ProjectConfig[] = [] for (let result of results) { @@ -71,6 +75,8 @@ export class ProjectLocator { } } + console.log(projects[0]) + if (projects.length === 1) { projects[0].additionalSelectors.push({ pattern: normalizePath(path.join(this.base, '**')), @@ -98,6 +104,8 @@ export class ProjectLocator { } } + await session.stop() + return projects } @@ -148,7 +156,10 @@ export class ProjectLocator { } } - private async createProject(config: ConfigEntry): Promise { + private async createProject( + config: ConfigEntry, + session: OxideSession, + ): Promise { let tailwind = await this.detectTailwindVersion(config) let possibleVersions = config.entries.flatMap((entry) => entry.meta?.versions ?? []) @@ -218,7 +229,12 @@ export class ProjectLocator { // Look for the package root for the config config.packageRoot = await getPackageRoot(path.dirname(config.path), this.base) - let selectors = await calculateDocumentSelectors(config, tailwind.features, this.resolver) + let selectors = await calculateDocumentSelectors( + config, + tailwind.features, + this.resolver, + session, + ) return { config, @@ -520,10 +536,11 @@ function contentSelectorsFromConfig( entry: ConfigEntry, features: Feature[], resolver: Resolver, + session: OxideSession, actualConfig?: any, ): AsyncIterable { if (entry.type === 'css') { - return contentSelectorsFromCssConfig(entry, resolver) + return contentSelectorsFromCssConfig(entry, resolver, session) } if (entry.type === 'js') { @@ -582,6 +599,7 @@ async function* contentSelectorsFromJsConfig( async function* contentSelectorsFromCssConfig( entry: ConfigEntry, resolver: Resolver, + session: OxideSession, ): AsyncIterable { let auto = false for (let item of entry.content) { @@ -606,6 +624,7 @@ async function* contentSelectorsFromCssConfig( entry.path, sources, resolver, + session, )) { yield { pattern, @@ -621,6 +640,7 @@ async function* detectContentFiles( inputFile: string, sources: SourcePattern[], resolver: Resolver, + session: OxideSession, ): AsyncIterable { try { let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', base) @@ -628,7 +648,7 @@ async function* detectContentFiles( let oxidePackageJsonPath = await resolver.resolveJsId('@tailwindcss/oxide/package.json', base) let oxidePackageJson = JSON.parse(await fs.readFile(oxidePackageJsonPath, 'utf8')) - let result = await oxide.scan({ + let result = await session.scan({ oxidePath, oxideVersion: oxidePackageJson.version, basePath: base, @@ -654,8 +674,8 @@ async function* detectContentFiles( base = normalizeDriveLetter(base) yield `${base}/${pattern}` } - } catch { - // + } catch (err) { + console.log({ err }) } } @@ -812,8 +832,15 @@ export async function calculateDocumentSelectors( config: ConfigEntry, features: Feature[], resolver: Resolver, + session?: OxideSession, actualConfig?: any, ) { + let hasTemporarySession = false + if (!session) { + hasTemporarySession = true + session = new OxideSession() + } + let selectors: DocumentSelector[] = [] // selectors: @@ -834,7 +861,13 @@ export async function calculateDocumentSelectors( }) // - Content patterns from config - for await (let selector of contentSelectorsFromConfig(config, features, resolver, actualConfig)) { + for await (let selector of contentSelectorsFromConfig( + config, + features, + resolver, + session, + actualConfig, + )) { selectors.push(selector) } @@ -876,5 +909,9 @@ export async function calculateDocumentSelectors( return 0 }) + if (hasTemporarySession) { + await session.stop() + } + return selectors } diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index 1038d31b..3f741e32 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -964,6 +964,7 @@ export async function createProjectService( projectConfig.config, state.features, resolver, + undefined, originalConfig, ) } diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index d4008018..51ed0d3f 100644 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -373,7 +373,7 @@ } }, "scripts": { - "_esbuild": "node ../../esbuild.mjs src/extension.ts src/server.ts src/cssServer.ts --outdir=dist", + "_esbuild": "node ../../esbuild.mjs src/extension.ts src/server.ts src/cssServer.ts src/oxide-helper.ts --outdir=dist", "dev": "concurrently --raw --kill-others \"pnpm run watch\" \"pnpm run check --watch\"", "watch": "pnpm run clean && pnpm run _esbuild --watch", "build": "pnpm run check && pnpm run clean && pnpm run _esbuild --minify && move-file dist/server.js dist/tailwindServer.js && move-file dist/cssServer.js dist/tailwindModeServer.js", diff --git a/packages/vscode-tailwindcss/src/oxide-helper.ts b/packages/vscode-tailwindcss/src/oxide-helper.ts new file mode 100644 index 00000000..4fb9869c --- /dev/null +++ b/packages/vscode-tailwindcss/src/oxide-helper.ts @@ -0,0 +1 @@ +import '@tailwindcss/language-server/src/oxide-helper' From 1f40861d255b9bcc7e7ce7b1ac8325c0d4567ab8 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 10 Oct 2025 13:53:30 -0400 Subject: [PATCH 3/4] Drop log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Didn’t mean to leave this in --- packages/tailwindcss-language-server/src/project-locator.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/tailwindcss-language-server/src/project-locator.ts b/packages/tailwindcss-language-server/src/project-locator.ts index c9079c4e..e0a245bb 100644 --- a/packages/tailwindcss-language-server/src/project-locator.ts +++ b/packages/tailwindcss-language-server/src/project-locator.ts @@ -75,8 +75,6 @@ export class ProjectLocator { } } - console.log(projects[0]) - if (projects.length === 1) { projects[0].additionalSelectors.push({ pattern: normalizePath(path.join(this.base, '**')), From c537a5bf04b9d6b660fc9e6345f706c04ef139e9 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 10 Oct 2025 13:59:23 -0400 Subject: [PATCH 4/4] =?UTF-8?q?Don=E2=80=99t=20log=20resolution=20failure?= =?UTF-8?q?=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are expected if you are just using the plain `tailwindcss` package or don’t have `tailwindcss` installed locally at all — for example when using the Standalone CLI. Other errors should be surfaced though as they could indicate a problem. --- .../src/project-locator.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-server/src/project-locator.ts b/packages/tailwindcss-language-server/src/project-locator.ts index e0a245bb..3f344c9d 100644 --- a/packages/tailwindcss-language-server/src/project-locator.ts +++ b/packages/tailwindcss-language-server/src/project-locator.ts @@ -673,10 +673,22 @@ async function* detectContentFiles( yield `${base}/${pattern}` } } catch (err) { - console.log({ err }) + if (isResolutionError(err)) return + + console.error(err) } } +function isResolutionError(err: unknown): boolean { + return ( + err && + typeof err === 'object' && + 'message' in err && + typeof err.message === 'string' && + err.message.includes("Can't resolve") + ) +} + type ContentItem = | { kind: 'file'; file: string } | { kind: 'raw'; content: string }