Skip to content

Add support for v4 fallback #1157

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 5 commits into from
Feb 4, 2025
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
4 changes: 3 additions & 1 deletion packages/tailwindcss-language-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"color-name": "1.1.4",
"culori": "^4.0.1",
"debounce": "1.2.0",
"dedent": "^1.5.3",
"deepmerge": "4.2.2",
"dlv": "1.1.3",
"dset": "3.1.2",
Expand All @@ -80,7 +81,8 @@
"resolve": "1.20.0",
"rimraf": "3.0.2",
"stack-trace": "0.0.10",
"tailwindcss": "3.4.4",
"tailwindcss": "3.4.17",
"tailwindcss-v4": "npm:[email protected]",
"tsconfck": "^3.1.4",
"tsconfig-paths": "^4.2.0",
"typescript": "5.3.3",
Expand Down
18 changes: 18 additions & 0 deletions packages/tailwindcss-language-server/src/project-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,24 @@ export class ProjectLocator {
}
} catch {}

// A local version of Tailwind CSS was not found so we need to use the
// fallback bundled with the language server. This is especially important
// for projects using the standalone CLI.

// This is a v4-style CSS config
if (config.type === 'css') {
let { version } = require('tailwindcss-v4/package.json')
// @ts-ignore
let mod = await import('tailwindcss-v4')
let features = supportedFeatures(version, mod)

return {
version,
features,
isDefaultVersion: true,
}
}

let { version } = require('tailwindcss/package.json')
let mod = require('tailwindcss')
let features = supportedFeatures(version, mod)
Expand Down
31 changes: 29 additions & 2 deletions packages/tailwindcss-language-server/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,8 @@ export async function createProjectService(
log('CSS-based configuration is not supported before Tailwind CSS v4')
state.enabled = false
enabled = false
// CSS-based configuration is not supported before Tailwind CSS v4 so bail
// TODO: Fall back to built-in version of v4

// The fallback to a bundled v4 is in the catch block
return
}

Expand Down Expand Up @@ -673,6 +673,31 @@ export async function createProjectService(
} catch (_) {}
}
} catch (error) {
if (projectConfig.config.source === 'css') {
// @ts-ignore
let tailwindcss = await import('tailwindcss-v4')
let tailwindcssVersion = require('tailwindcss-v4/package.json').version
let features = supportedFeatures(tailwindcssVersion, tailwindcss)

log('Failed to load workspace modules.')
log(`Using bundled version of \`tailwindcss\`: v${tailwindcssVersion}`)

state.configPath = configPath
state.version = tailwindcssVersion
state.isCssConfig = true
state.v4 = true
state.v4Fallback = true
state.jit = true
state.modules = {
tailwindcss: { version: tailwindcssVersion, module: tailwindcss },
postcss: { version: null, module: null },
resolveConfig: { module: null },
loadConfig: { module: null },
}

return tryRebuild()
}

let util = await import('node:util')

console.error(util.format(error))
Expand Down Expand Up @@ -786,6 +811,7 @@ export async function createProjectService(
state.modules.tailwindcss.module,
state.configPath,
css,
state.v4Fallback ?? false,
)

state.designSystem = designSystem
Expand Down Expand Up @@ -1063,6 +1089,7 @@ export async function createProjectService(
state.modules.tailwindcss.module,
state.configPath,
css,
state.v4Fallback ?? false,
)
} catch (err) {
console.error(err)
Expand Down
107 changes: 107 additions & 0 deletions packages/tailwindcss-language-server/src/testing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { afterAll, onTestFinished, test, TestOptions } from 'vitest'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as proc from 'node:child_process'
import dedent from 'dedent'

export interface TestUtils {
/** The "cwd" for this test */
root: string
}

export interface Storage {
/** A list of files and their content */
[filePath: string]: string | Uint8Array
}

export interface TestConfig<Extras extends {}> {
name: string
fs: Storage
prepare?(utils: TestUtils): Promise<Extras>
handle(utils: TestUtils & Extras): void | Promise<void>

options?: TestOptions
}

export function defineTest<T>(config: TestConfig<T>) {
return test(config.name, config.options ?? {}, async ({ expect }) => {
let utils = await setup(config)
let extras = await config.prepare?.(utils)

await config.handle({
...utils,
...extras,
})
})
}

async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
let randomId = Math.random().toString(36).substring(7)

let baseDir = path.resolve(process.cwd(), `../../.debug/${randomId}`)
let doneDir = path.resolve(process.cwd(), `../../.debug/${randomId}-done`)

await fs.mkdir(baseDir, { recursive: true })

await prepareFileSystem(baseDir, config.fs)
await installDependencies(baseDir, config.fs)

onTestFinished(async (result) => {
// Once done, move all the files to a new location
await fs.rename(baseDir, doneDir)

if (result.state === 'fail') return

if (path.sep === '\\') return

// Remove the directory on *nix systems. Recursive removal on Windows will
// randomly fail b/c its slow and buggy.
await fs.rm(doneDir, { recursive: true })
})

return {
root: baseDir,
}
}

async function prepareFileSystem(base: string, storage: Storage) {
// Create a temporary directory to store the test files
await fs.mkdir(base, { recursive: true })

// Write the files to disk
for (let [filepath, content] of Object.entries(storage)) {
let fullPath = path.resolve(base, filepath)
await fs.mkdir(path.dirname(fullPath), { recursive: true })
await fs.writeFile(fullPath, content, { encoding: 'utf-8' })
}
}

async function installDependencies(base: string, storage: Storage) {
for (let filepath of Object.keys(storage)) {
if (!filepath.endsWith('package.json')) continue

let pkgDir = path.dirname(filepath)
let basePath = path.resolve(pkgDir, base)

await installDependenciesIn(basePath)
}
}

async function installDependenciesIn(dir: string) {
console.log(`Installing dependencies in ${dir}`)

await new Promise((resolve, reject) => {
proc.exec('npm install --package-lock=false', { cwd: dir }, (err, res) => {
if (err) {
reject(err)
} else {
resolve(res)
}
})
})
}

export const css = dedent
export const html = dedent
export const js = dedent
export const json = dedent
19 changes: 19 additions & 0 deletions packages/tailwindcss-language-server/src/util/v4/assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import index from 'tailwindcss-v4/index.css'
import preflight from 'tailwindcss-v4/preflight.css'
import theme from 'tailwindcss-v4/theme.css'
import utilities from 'tailwindcss-v4/utilities.css'

export const assets = {
tailwindcss: index,
'tailwindcss/index': index,
'tailwindcss/index.css': index,

'tailwindcss/preflight': preflight,
'tailwindcss/preflight.css': preflight,

'tailwindcss/theme': theme,
'tailwindcss/theme.css': theme,

'tailwindcss/utilities': utilities,
'tailwindcss/utilities.css': utilities,
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { resolveCssImports } from '../../css'
import { Resolver } from '../../resolver'
import { pathToFileURL } from '../../utils'
import type { Jiti } from 'jiti/lib/types'
import { assets } from './assets'

const HAS_V4_IMPORT = /@import\s*(?:'tailwindcss'|"tailwindcss")/
const HAS_V4_THEME = /@theme\s*\{/
Expand Down Expand Up @@ -79,6 +80,7 @@ export async function loadDesignSystem(
tailwindcss: any,
filepath: string,
css: string,
isFallback: boolean,
): Promise<DesignSystem | null> {
// This isn't a v4 project
if (!tailwindcss.__unstable__loadDesignSystem) return null
Expand Down Expand Up @@ -151,6 +153,12 @@ export async function loadDesignSystem(
content: await fs.readFile(resolved, 'utf-8'),
}
} catch (err) {
if (isFallback && id in assets) {
console.error(`Loading fallback stylesheet for: ${id}`)

return { base, content: assets[id] }
}

console.error(`Unable to load stylesheet: ${id}`, err)
return { base, content: '' }
}
Expand Down
40 changes: 36 additions & 4 deletions packages/tailwindcss-language-server/tests/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as path from 'node:path'
import { beforeAll, describe } from 'vitest'
import { connect } from './connection'
import { connect, launch } from './connection'
import {
CompletionRequest,
ConfigurationRequest,
Expand All @@ -12,6 +12,7 @@ import {
RegistrationRequest,
InitializeParams,
DidOpenTextDocumentParams,
MessageType,
} from 'vscode-languageserver-protocol'
import type { ClientCapabilities, ProtocolConnection } from 'vscode-languageclient'
import type { Feature } from '@tailwindcss/language-service/src/features'
Expand Down Expand Up @@ -43,14 +44,45 @@ interface FixtureContext
}
}

export interface InitOptions {
/**
* How to connect to the LSP:
* - `in-band` runs the server in the same process (default)
* - `spawn` launches the binary as a separate process, connects via stdio,
* and requires a rebuild of the server after making changes.
*/
mode?: 'in-band' | 'spawn'

/**
* Extra initialization options to pass to the LSP
*/
options?: Record<string, any>
}

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

const { client } = await connect()
const { client } = opts?.mode === 'spawn' ? await launch() : await connect()

if (opts?.mode === 'spawn') {
client.onNotification('window/logMessage', ({ message, type }) => {
if (type === MessageType.Error) {
console.error(message)
} else if (type === MessageType.Warning) {
console.warn(message)
} else if (type === MessageType.Info) {
console.info(message)
} else if (type === MessageType.Log) {
console.log(message)
} else if (type === MessageType.Debug) {
console.debug(message)
}
})
}

const capabilities: ClientCapabilities = {
textDocument: {
Expand Down Expand Up @@ -162,7 +194,7 @@ export async function init(
workspaceFolders,
initializationOptions: {
testMode: true,
...options,
...(opts.options ?? {}),
},
} as InitializeParams)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ withFixture('basic', (c) => {

expect(resolved).toEqual({
...item,
detail: '--tw-bg-opacity: 1; background-color: rgb(239 68 68 / var(--tw-bg-opacity));',
detail: '--tw-bg-opacity: 1; background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));',
documentation: '#ef4444',
})
})
Expand Down
Loading