Skip to content

Commit 90fbac0

Browse files
Add support for v4 fallback (#1157)
1 parent 9e52eae commit 90fbac0

18 files changed

+582
-82
lines changed

packages/tailwindcss-language-server/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"color-name": "1.1.4",
6060
"culori": "^4.0.1",
6161
"debounce": "1.2.0",
62+
"dedent": "^1.5.3",
6263
"deepmerge": "4.2.2",
6364
"dlv": "1.1.3",
6465
"dset": "3.1.2",
@@ -80,7 +81,8 @@
8081
"resolve": "1.20.0",
8182
"rimraf": "3.0.2",
8283
"stack-trace": "0.0.10",
83-
"tailwindcss": "3.4.4",
84+
"tailwindcss": "3.4.17",
85+
"tailwindcss-v4": "npm:[email protected]",
8486
"tsconfck": "^3.1.4",
8587
"tsconfig-paths": "^4.2.0",
8688
"typescript": "5.3.3",

packages/tailwindcss-language-server/src/project-locator.ts

+18
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,24 @@ export class ProjectLocator {
454454
}
455455
} catch {}
456456

457+
// A local version of Tailwind CSS was not found so we need to use the
458+
// fallback bundled with the language server. This is especially important
459+
// for projects using the standalone CLI.
460+
461+
// This is a v4-style CSS config
462+
if (config.type === 'css') {
463+
let { version } = require('tailwindcss-v4/package.json')
464+
// @ts-ignore
465+
let mod = await import('tailwindcss-v4')
466+
let features = supportedFeatures(version, mod)
467+
468+
return {
469+
version,
470+
features,
471+
isDefaultVersion: true,
472+
}
473+
}
474+
457475
let { version } = require('tailwindcss/package.json')
458476
let mod = require('tailwindcss')
459477
let features = supportedFeatures(version, mod)

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

+29-2
Original file line numberDiff line numberDiff line change
@@ -489,8 +489,8 @@ export async function createProjectService(
489489
log('CSS-based configuration is not supported before Tailwind CSS v4')
490490
state.enabled = false
491491
enabled = false
492-
// CSS-based configuration is not supported before Tailwind CSS v4 so bail
493-
// TODO: Fall back to built-in version of v4
492+
493+
// The fallback to a bundled v4 is in the catch block
494494
return
495495
}
496496

@@ -673,6 +673,31 @@ export async function createProjectService(
673673
} catch (_) {}
674674
}
675675
} catch (error) {
676+
if (projectConfig.config.source === 'css') {
677+
// @ts-ignore
678+
let tailwindcss = await import('tailwindcss-v4')
679+
let tailwindcssVersion = require('tailwindcss-v4/package.json').version
680+
let features = supportedFeatures(tailwindcssVersion, tailwindcss)
681+
682+
log('Failed to load workspace modules.')
683+
log(`Using bundled version of \`tailwindcss\`: v${tailwindcssVersion}`)
684+
685+
state.configPath = configPath
686+
state.version = tailwindcssVersion
687+
state.isCssConfig = true
688+
state.v4 = true
689+
state.v4Fallback = true
690+
state.jit = true
691+
state.modules = {
692+
tailwindcss: { version: tailwindcssVersion, module: tailwindcss },
693+
postcss: { version: null, module: null },
694+
resolveConfig: { module: null },
695+
loadConfig: { module: null },
696+
}
697+
698+
return tryRebuild()
699+
}
700+
676701
let util = await import('node:util')
677702

678703
console.error(util.format(error))
@@ -786,6 +811,7 @@ export async function createProjectService(
786811
state.modules.tailwindcss.module,
787812
state.configPath,
788813
css,
814+
state.v4Fallback ?? false,
789815
)
790816

791817
state.designSystem = designSystem
@@ -1063,6 +1089,7 @@ export async function createProjectService(
10631089
state.modules.tailwindcss.module,
10641090
state.configPath,
10651091
css,
1092+
state.v4Fallback ?? false,
10661093
)
10671094
} catch (err) {
10681095
console.error(err)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { afterAll, onTestFinished, test, TestOptions } from 'vitest'
2+
import * as fs from 'node:fs/promises'
3+
import * as path from 'node:path'
4+
import * as proc from 'node:child_process'
5+
import dedent from 'dedent'
6+
7+
export interface TestUtils {
8+
/** The "cwd" for this test */
9+
root: string
10+
}
11+
12+
export interface Storage {
13+
/** A list of files and their content */
14+
[filePath: string]: string | Uint8Array
15+
}
16+
17+
export interface TestConfig<Extras extends {}> {
18+
name: string
19+
fs: Storage
20+
prepare?(utils: TestUtils): Promise<Extras>
21+
handle(utils: TestUtils & Extras): void | Promise<void>
22+
23+
options?: TestOptions
24+
}
25+
26+
export function defineTest<T>(config: TestConfig<T>) {
27+
return test(config.name, config.options ?? {}, async ({ expect }) => {
28+
let utils = await setup(config)
29+
let extras = await config.prepare?.(utils)
30+
31+
await config.handle({
32+
...utils,
33+
...extras,
34+
})
35+
})
36+
}
37+
38+
async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
39+
let randomId = Math.random().toString(36).substring(7)
40+
41+
let baseDir = path.resolve(process.cwd(), `../../.debug/${randomId}`)
42+
let doneDir = path.resolve(process.cwd(), `../../.debug/${randomId}-done`)
43+
44+
await fs.mkdir(baseDir, { recursive: true })
45+
46+
await prepareFileSystem(baseDir, config.fs)
47+
await installDependencies(baseDir, config.fs)
48+
49+
onTestFinished(async (result) => {
50+
// Once done, move all the files to a new location
51+
await fs.rename(baseDir, doneDir)
52+
53+
if (result.state === 'fail') return
54+
55+
if (path.sep === '\\') return
56+
57+
// Remove the directory on *nix systems. Recursive removal on Windows will
58+
// randomly fail b/c its slow and buggy.
59+
await fs.rm(doneDir, { recursive: true })
60+
})
61+
62+
return {
63+
root: baseDir,
64+
}
65+
}
66+
67+
async function prepareFileSystem(base: string, storage: Storage) {
68+
// Create a temporary directory to store the test files
69+
await fs.mkdir(base, { recursive: true })
70+
71+
// Write the files to disk
72+
for (let [filepath, content] of Object.entries(storage)) {
73+
let fullPath = path.resolve(base, filepath)
74+
await fs.mkdir(path.dirname(fullPath), { recursive: true })
75+
await fs.writeFile(fullPath, content, { encoding: 'utf-8' })
76+
}
77+
}
78+
79+
async function installDependencies(base: string, storage: Storage) {
80+
for (let filepath of Object.keys(storage)) {
81+
if (!filepath.endsWith('package.json')) continue
82+
83+
let pkgDir = path.dirname(filepath)
84+
let basePath = path.resolve(pkgDir, base)
85+
86+
await installDependenciesIn(basePath)
87+
}
88+
}
89+
90+
async function installDependenciesIn(dir: string) {
91+
console.log(`Installing dependencies in ${dir}`)
92+
93+
await new Promise((resolve, reject) => {
94+
proc.exec('npm install --package-lock=false', { cwd: dir }, (err, res) => {
95+
if (err) {
96+
reject(err)
97+
} else {
98+
resolve(res)
99+
}
100+
})
101+
})
102+
}
103+
104+
export const css = dedent
105+
export const html = dedent
106+
export const js = dedent
107+
export const json = dedent
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import index from 'tailwindcss-v4/index.css'
2+
import preflight from 'tailwindcss-v4/preflight.css'
3+
import theme from 'tailwindcss-v4/theme.css'
4+
import utilities from 'tailwindcss-v4/utilities.css'
5+
6+
export const assets = {
7+
tailwindcss: index,
8+
'tailwindcss/index': index,
9+
'tailwindcss/index.css': index,
10+
11+
'tailwindcss/preflight': preflight,
12+
'tailwindcss/preflight.css': preflight,
13+
14+
'tailwindcss/theme': theme,
15+
'tailwindcss/theme.css': theme,
16+
17+
'tailwindcss/utilities': utilities,
18+
'tailwindcss/utilities.css': utilities,
19+
}

packages/tailwindcss-language-server/src/util/v4/design-system.ts

+8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { resolveCssImports } from '../../css'
88
import { Resolver } from '../../resolver'
99
import { pathToFileURL } from '../../utils'
1010
import type { Jiti } from 'jiti/lib/types'
11+
import { assets } from './assets'
1112

1213
const HAS_V4_IMPORT = /@import\s*(?:'tailwindcss'|"tailwindcss")/
1314
const HAS_V4_THEME = /@theme\s*\{/
@@ -79,6 +80,7 @@ export async function loadDesignSystem(
7980
tailwindcss: any,
8081
filepath: string,
8182
css: string,
83+
isFallback: boolean,
8284
): Promise<DesignSystem | null> {
8385
// This isn't a v4 project
8486
if (!tailwindcss.__unstable__loadDesignSystem) return null
@@ -151,6 +153,12 @@ export async function loadDesignSystem(
151153
content: await fs.readFile(resolved, 'utf-8'),
152154
}
153155
} catch (err) {
156+
if (isFallback && id in assets) {
157+
console.error(`Loading fallback stylesheet for: ${id}`)
158+
159+
return { base, content: assets[id] }
160+
}
161+
154162
console.error(`Unable to load stylesheet: ${id}`, err)
155163
return { base, content: '' }
156164
}

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

+36-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as path from 'node:path'
22
import { beforeAll, describe } from 'vitest'
3-
import { connect } from './connection'
3+
import { connect, launch } from './connection'
44
import {
55
CompletionRequest,
66
ConfigurationRequest,
@@ -12,6 +12,7 @@ import {
1212
RegistrationRequest,
1313
InitializeParams,
1414
DidOpenTextDocumentParams,
15+
MessageType,
1516
} from 'vscode-languageserver-protocol'
1617
import type { ClientCapabilities, ProtocolConnection } from 'vscode-languageclient'
1718
import type { Feature } from '@tailwindcss/language-service/src/features'
@@ -43,14 +44,45 @@ interface FixtureContext
4344
}
4445
}
4546

47+
export interface InitOptions {
48+
/**
49+
* How to connect to the LSP:
50+
* - `in-band` runs the server in the same process (default)
51+
* - `spawn` launches the binary as a separate process, connects via stdio,
52+
* and requires a rebuild of the server after making changes.
53+
*/
54+
mode?: 'in-band' | 'spawn'
55+
56+
/**
57+
* Extra initialization options to pass to the LSP
58+
*/
59+
options?: Record<string, any>
60+
}
61+
4662
export async function init(
4763
fixture: string | string[],
48-
options: Record<string, any> = {},
64+
opts: InitOptions = {},
4965
): Promise<FixtureContext> {
5066
let settings = {}
5167
let docSettings = new Map<string, Settings>()
5268

53-
const { client } = await connect()
69+
const { client } = opts?.mode === 'spawn' ? await launch() : await connect()
70+
71+
if (opts?.mode === 'spawn') {
72+
client.onNotification('window/logMessage', ({ message, type }) => {
73+
if (type === MessageType.Error) {
74+
console.error(message)
75+
} else if (type === MessageType.Warning) {
76+
console.warn(message)
77+
} else if (type === MessageType.Info) {
78+
console.info(message)
79+
} else if (type === MessageType.Log) {
80+
console.log(message)
81+
} else if (type === MessageType.Debug) {
82+
console.debug(message)
83+
}
84+
})
85+
}
5486

5587
const capabilities: ClientCapabilities = {
5688
textDocument: {
@@ -162,7 +194,7 @@ export async function init(
162194
workspaceFolders,
163195
initializationOptions: {
164196
testMode: true,
165-
...options,
197+
...(opts.options ?? {}),
166198
},
167199
} as InitializeParams)
168200

packages/tailwindcss-language-server/tests/completions/completions.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ withFixture('basic', (c) => {
281281

282282
expect(resolved).toEqual({
283283
...item,
284-
detail: '--tw-bg-opacity: 1; background-color: rgb(239 68 68 / var(--tw-bg-opacity));',
284+
detail: '--tw-bg-opacity: 1; background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));',
285285
documentation: '#ef4444',
286286
})
287287
})

0 commit comments

Comments
 (0)