Skip to content

Commit 0610bf4

Browse files
Handle when project config is re-created (#1300)
If a project's main CSS config file is deleted and then re-created the server won't re-create the project. For now we'll restart the server when events like this happen which will ensure that the filesystem is re-scanned and the files can be picked up again.
1 parent 2332990 commit 0610bf4

File tree

6 files changed

+253
-16
lines changed

6 files changed

+253
-16
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,11 @@ export async function createProjectService(
11641164
let elapsed = process.hrtime.bigint() - start
11651165

11661166
console.log(`---- RELOADED IN ${(Number(elapsed) / 1e6).toFixed(2)}ms ----`)
1167+
1168+
let isTestMode = params.initializationOptions?.testMode ?? false
1169+
if (!isTestMode) return
1170+
1171+
connection.sendNotification('@/tailwindCSS/projectReloaded')
11671172
},
11681173

11691174
state,

packages/tailwindcss-language-server/src/testing/index.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
import { onTestFinished, test, TestOptions } from 'vitest'
1+
import { onTestFinished, test, TestContext, TestOptions } from 'vitest'
22
import * as os from 'node:os'
33
import * as fs from 'node:fs/promises'
44
import * as path from 'node:path'
55
import * as proc from 'node:child_process'
66
import dedent from 'dedent'
77

8-
export interface TestUtils {
8+
export interface TestUtils<TestInput extends Record<string, any>> {
99
/** The "cwd" for this test */
1010
root: string
11+
12+
/**
13+
* The input for this test — taken from the `inputs` in the test config
14+
*
15+
* @see {TestConfig}
16+
*/
17+
input?: TestInput
1118
}
1219

1320
export interface StorageSymlink {
@@ -21,29 +28,39 @@ export interface Storage {
2128
[filePath: string]: string | Uint8Array | StorageSymlink
2229
}
2330

24-
export interface TestConfig<Extras extends {}> {
31+
export interface TestConfig<Extras extends {}, TestInput extends Record<string, any>> {
2532
name: string
33+
inputs?: TestInput[]
34+
2635
fs?: Storage
2736
debug?: boolean
28-
prepare?(utils: TestUtils): Promise<Extras>
29-
handle(utils: TestUtils & Extras): void | Promise<void>
37+
prepare?(utils: TestUtils<TestInput>): Promise<Extras>
38+
handle(utils: TestUtils<TestInput> & Extras): void | Promise<void>
3039

3140
options?: TestOptions
3241
}
3342

34-
export function defineTest<T>(config: TestConfig<T>) {
35-
return test(config.name, config.options ?? {}, async ({ expect }) => {
36-
let utils = await setup(config)
43+
export function defineTest<T, I>(config: TestConfig<T, I>) {
44+
async function runTest(ctx: TestContext, input?: I) {
45+
let utils = await setup(config, input)
3746
let extras = await config.prepare?.(utils)
3847

3948
await config.handle({
4049
...utils,
4150
...extras,
4251
})
43-
})
52+
}
53+
54+
if (config.inputs) {
55+
return test.for(config.inputs ?? [])(config.name, config.options ?? {}, (input, ctx) =>
56+
runTest(ctx, input),
57+
)
58+
}
59+
60+
return test(config.name, config.options ?? {}, runTest)
4461
}
4562

46-
async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
63+
async function setup<T, I>(config: TestConfig<T, I>, input: I): Promise<TestUtils<I>> {
4764
let randomId = Math.random().toString(36).substring(7)
4865

4966
let baseDir = path.resolve(process.cwd(), `../../.debug/${randomId}`)
@@ -56,7 +73,7 @@ async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
5673
await installDependencies(baseDir, config.fs)
5774
}
5875

59-
onTestFinished(async (result) => {
76+
onTestFinished(async (ctx) => {
6077
// Once done, move all the files to a new location
6178
try {
6279
await fs.rename(baseDir, doneDir)
@@ -66,7 +83,7 @@ async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
6683
console.error('Failed to move test files to done directory')
6784
}
6885

69-
if (result.state === 'fail') return
86+
if (ctx.task.result?.state === 'fail') return
7087

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

@@ -79,6 +96,7 @@ async function setup<T>(config: TestConfig<T>): Promise<TestUtils> {
7996

8097
return {
8198
root: baseDir,
99+
input,
82100
}
83101
}
84102

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

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import { readCssFile } from './util/css'
5252
import { ProjectLocator, type ProjectConfig } from './project-locator'
5353
import type { TailwindCssSettings } from '@tailwindcss/language-service/src/util/state'
5454
import { createResolver, Resolver } from './resolver'
55-
import { retry } from './util/retry'
55+
import { analyzeStylesheet } from './version-guesser.js'
5656

5757
const TRIGGER_CHARACTERS = [
5858
// class attributes
@@ -382,6 +382,13 @@ export class TW {
382382
for (let [, project] of this.projects) {
383383
if (!project.state.v4) continue
384384

385+
if (
386+
change.type === FileChangeType.Deleted &&
387+
changeAffectsFile(normalizedFilename, [project.projectConfig.configPath])
388+
) {
389+
continue
390+
}
391+
385392
if (!changeAffectsFile(normalizedFilename, project.dependencies())) continue
386393

387394
needsSoftRestart = true
@@ -405,6 +412,31 @@ export class TW {
405412
needsRestart = true
406413
break
407414
}
415+
416+
//
417+
else {
418+
// If the main CSS file in a project is deleted and then re-created
419+
// the server won't restart because the project is gone by now and
420+
// there's no concept of a "config file" for us to compare with
421+
//
422+
// So we'll check if the stylesheet could *potentially* create
423+
// a new project but we'll only do so if no projects were found
424+
//
425+
// If we did this all the time we'd potentially restart the server
426+
// unncessarily a lot while the user is editing their stylesheets
427+
if (this.projects.size > 0) continue
428+
429+
let content = await readCssFile(change.file)
430+
if (!content) continue
431+
432+
let stylesheet = analyzeStylesheet(content)
433+
if (!stylesheet.root) continue
434+
435+
if (!stylesheet.versions.includes('4')) continue
436+
437+
needsRestart = true
438+
break
439+
}
408440
}
409441

410442
let isConfigFile = isConfigMatcher(normalizedFilename)
@@ -1041,11 +1073,17 @@ export class TW {
10411073
this.watched.length = 0
10421074
}
10431075

1044-
restart(): void {
1076+
async restart(): void {
1077+
let isTestMode = this.initializeParams.initializationOptions?.testMode ?? false
1078+
10451079
console.log('----------\nRESTARTING\n----------')
10461080
this.dispose()
10471081
this.initPromise = undefined
1048-
this.init()
1082+
await this.init()
1083+
1084+
if (isTestMode) {
1085+
this.connection.sendNotification('@/tailwindCSS/serverRestarted')
1086+
}
10491087
}
10501088

10511089
async softRestart(): Promise<void> {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { expect } from 'vitest'
2+
import * as fs from 'node:fs/promises'
3+
import * as path from 'node:path'
4+
import { css, defineTest } from '../../src/testing'
5+
import dedent from 'dedent'
6+
import { createClient } from '../utils/client'
7+
8+
defineTest({
9+
name: 'The design system is reloaded when the CSS changes ($watcher)',
10+
fs: {
11+
'app.css': css`
12+
@import 'tailwindcss';
13+
14+
@theme {
15+
--color-primary: #c0ffee;
16+
}
17+
`,
18+
},
19+
prepare: async ({ root }) => ({
20+
client: await createClient({
21+
root,
22+
capabilities(caps) {
23+
caps.workspace!.didChangeWatchedFiles!.dynamicRegistration = false
24+
},
25+
}),
26+
}),
27+
handle: async ({ root, client }) => {
28+
let doc = await client.open({
29+
lang: 'html',
30+
text: '<div class="text-primary">',
31+
})
32+
33+
// <div class="text-primary">
34+
// ^
35+
let hover = await doc.hover({ line: 0, character: 13 })
36+
37+
expect(hover).toEqual({
38+
contents: {
39+
language: 'css',
40+
value: dedent`
41+
.text-primary {
42+
color: var(--color-primary) /* #c0ffee */;
43+
}
44+
`,
45+
},
46+
range: {
47+
start: { line: 0, character: 12 },
48+
end: { line: 0, character: 24 },
49+
},
50+
})
51+
52+
let didReload = new Promise((resolve) => {
53+
client.conn.onNotification('@/tailwindCSS/projectReloaded', resolve)
54+
})
55+
56+
// Update the CSS
57+
await fs.writeFile(
58+
path.resolve(root, 'app.css'),
59+
css`
60+
@import 'tailwindcss';
61+
62+
@theme {
63+
--color-primary: #bada55;
64+
}
65+
`,
66+
)
67+
68+
await didReload
69+
70+
// <div class="text-primary">
71+
// ^
72+
let hover2 = await doc.hover({ line: 0, character: 13 })
73+
74+
expect(hover2).toEqual({
75+
contents: {
76+
language: 'css',
77+
value: dedent`
78+
.text-primary {
79+
color: var(--color-primary) /* #bada55 */;
80+
}
81+
`,
82+
},
83+
range: {
84+
start: { line: 0, character: 12 },
85+
end: { line: 0, character: 24 },
86+
},
87+
})
88+
},
89+
})
90+
91+
defineTest({
92+
options: {
93+
retry: 3,
94+
},
95+
name: 'Server is "restarted" when a config file is removed',
96+
fs: {
97+
'app.css': css`
98+
@import 'tailwindcss';
99+
100+
@theme {
101+
--color-primary: #c0ffee;
102+
}
103+
`,
104+
},
105+
prepare: async ({ root }) => ({
106+
client: await createClient({
107+
root,
108+
capabilities(caps) {
109+
caps.workspace!.didChangeWatchedFiles!.dynamicRegistration = false
110+
},
111+
}),
112+
}),
113+
handle: async ({ root, client }) => {
114+
let doc = await client.open({
115+
lang: 'html',
116+
text: '<div class="text-primary">',
117+
})
118+
119+
// <div class="text-primary">
120+
// ^
121+
let hover = await doc.hover({ line: 0, character: 13 })
122+
123+
expect(hover).toEqual({
124+
contents: {
125+
language: 'css',
126+
value: dedent`
127+
.text-primary {
128+
color: var(--color-primary) /* #c0ffee */;
129+
}
130+
`,
131+
},
132+
range: {
133+
start: { line: 0, character: 12 },
134+
end: { line: 0, character: 24 },
135+
},
136+
})
137+
138+
// Remove the CSS file
139+
let didRestart = new Promise((resolve) => {
140+
client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve)
141+
})
142+
await fs.unlink(path.resolve(root, 'app.css'))
143+
await didRestart
144+
145+
// <div class="text-primary">
146+
// ^
147+
let hover2 = await doc.hover({ line: 0, character: 13 })
148+
expect(hover2).toEqual(null)
149+
150+
// Re-create the CSS file
151+
let didRestartAgain = new Promise((resolve) => {
152+
client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve)
153+
})
154+
await fs.writeFile(
155+
path.resolve(root, 'app.css'),
156+
css`
157+
@import 'tailwindcss';
158+
`,
159+
)
160+
await didRestartAgain
161+
162+
await new Promise((resolve) => setTimeout(resolve, 500))
163+
164+
// <div class="text-primary">
165+
// ^
166+
let hover3 = await doc.hover({ line: 0, character: 13 })
167+
expect(hover3).toEqual(null)
168+
},
169+
})

packages/tailwindcss-language-server/tests/utils/client.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
CompletionList,
77
CompletionParams,
88
Diagnostic,
9-
DidChangeWorkspaceFoldersNotification,
109
Disposable,
1110
DocumentLink,
1211
DocumentLinkRequest,
@@ -179,6 +178,11 @@ export interface ClientOptions extends ConnectOptions {
179178
* and the Tailwind CSS version it detects
180179
*/
181180
features?: Feature[]
181+
182+
/**
183+
* Tweak the client capabilities presented to the server
184+
*/
185+
capabilities?(caps: ClientCapabilities): ClientCapabilities | Promise<ClientCapabilities> | void
182186
}
183187

184188
export interface Client extends ClientWorkspace {
@@ -394,6 +398,8 @@ export async function createClient(opts: ClientOptions): Promise<Client> {
394398
},
395399
}
396400

401+
capabilities = (await opts.capabilities?.(capabilities)) ?? capabilities
402+
397403
trace('Client initializing')
398404
await conn.sendRequest(InitializeRequest.type, {
399405
processId: process.pid,

packages/vscode-tailwindcss/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Only scan the file system once when needed ([#1287](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1287))
66
- Don't follow recursive symlinks when searching for projects ([#1270](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1270))
7+
- Correctly re-create a project when its main config file is removed then re-created ([#1300](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1300))
78

89
# 0.14.13
910

0 commit comments

Comments
 (0)