Skip to content

Commit

Permalink
Add support for v4 fallback (#1157)
Browse files Browse the repository at this point in the history
  • Loading branch information
thecrypticace authored Feb 4, 2025
1 parent 9e52eae commit 90fbac0
Show file tree
Hide file tree
Showing 18 changed files with 582 additions and 82 deletions.
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:tailwindcss@4.0.0",
"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

0 comments on commit 90fbac0

Please sign in to comment.