Skip to content

Commit 5798007

Browse files
Properly validate theme(…) function paths in v4 (#1074)
In v4 currently using the theme function in your CSS will show diagnostics about the theme config keys not existing regardless of the theme key. We've done two things here: - We compile a candidate with the theme function to see if the theme key exists. While this isn't an optimal solution it works without needing to introduce new APIs into v4 — also prevents us from having to expose the legacy resolved config anywhere in v4. - We look at all currently registered theme keys to offer suggestions in case you type a theme key incorrectly _as long as it's a CSS variable_. In v4 suggestions for legacy theme config values is not implemented. --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent 8a39555 commit 5798007

File tree

8 files changed

+343
-4
lines changed

8 files changed

+343
-4
lines changed

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

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ withFixture('v4/basic', (c) => {
5959
})
6060
}
6161

62+
function testInline(fixture, { code, expected, language = 'html' }) {
63+
test(fixture, async () => {
64+
let promise = new Promise((resolve) => {
65+
c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => {
66+
resolve(diagnostics)
67+
})
68+
})
69+
70+
let doc = await c.openDocument({ text: code, lang: language })
71+
let diagnostics = await promise
72+
73+
expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri))
74+
75+
expect(diagnostics).toEqual(expected)
76+
})
77+
}
78+
6279
testFixture('css-conflict/simple')
6380
testFixture('css-conflict/variants-negative')
6481
testFixture('css-conflict/variants-positive')
@@ -69,5 +86,171 @@ withFixture('v4/basic', (c) => {
6986
// testFixture('css-conflict/css-multi-rule')
7087
// testFixture('css-conflict/css-multi-prop')
7188
// testFixture('invalid-screen/simple')
72-
// testFixture('invalid-theme/simple')
89+
90+
testInline('simple typos in theme keys (in key)', {
91+
code: '.test { color: theme(--color-red-901) }',
92+
language: 'css',
93+
expected: [
94+
{
95+
code: 'invalidConfigPath',
96+
range: { start: { line: 0, character: 21 }, end: { line: 0, character: 36 } },
97+
severity: 1,
98+
message: "'--color-red-901' does not exist in your theme. Did you mean '--color-red-900'?",
99+
suggestions: ['--color-red-900'],
100+
},
101+
],
102+
})
103+
104+
testInline('simple typos in theme keys (in namespace)', {
105+
code: '.test { color: theme(--colors-red-901) }',
106+
language: 'css',
107+
expected: [
108+
{
109+
code: 'invalidConfigPath',
110+
range: { start: { line: 0, character: 21 }, end: { line: 0, character: 37 } },
111+
severity: 1,
112+
message: "'--colors-red-901' does not exist in your theme. Did you mean '--color-red-900'?",
113+
suggestions: ['--color-red-900'],
114+
},
115+
],
116+
})
117+
118+
testInline('No similar theme key exists', {
119+
code: '.test { color: theme(--font-obliqueness-90) }',
120+
language: 'css',
121+
expected: [
122+
{
123+
code: 'invalidConfigPath',
124+
range: { start: { line: 0, character: 21 }, end: { line: 0, character: 42 } },
125+
severity: 1,
126+
message: "'--font-obliqueness-90' does not exist in your theme.",
127+
suggestions: [],
128+
},
129+
],
130+
})
131+
132+
testInline('valid theme keys dont issue diagnostics', {
133+
code: '.test { color: theme(--color-red-900) }',
134+
language: 'css',
135+
expected: [],
136+
})
137+
138+
testInline('types in legacy theme config paths', {
139+
code: '.test { color: theme(colors.red.901) }',
140+
language: 'css',
141+
expected: [
142+
{
143+
code: 'invalidConfigPath',
144+
range: { start: { line: 0, character: 21 }, end: { line: 0, character: 35 } },
145+
severity: 1,
146+
message: "'colors.red.901' does not exist in your theme config.",
147+
suggestions: [],
148+
},
149+
],
150+
})
151+
152+
testInline('valid legacy theme config paths', {
153+
code: '.test { color: theme(colors.red.900) }',
154+
language: 'css',
155+
expected: [],
156+
})
157+
})
158+
159+
withFixture('v4/with-prefix', (c) => {
160+
function testInline(fixture, { code, expected, language = 'html' }) {
161+
test(fixture, async () => {
162+
let promise = new Promise((resolve) => {
163+
c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => {
164+
resolve(diagnostics)
165+
})
166+
})
167+
168+
let doc = await c.openDocument({ text: code, lang: language })
169+
let diagnostics = await promise
170+
171+
expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri))
172+
173+
expect(diagnostics).toEqual(expected)
174+
})
175+
}
176+
177+
// testFixture('css-conflict/simple')
178+
// testFixture('css-conflict/variants-negative')
179+
// testFixture('css-conflict/variants-positive')
180+
// testFixture('css-conflict/jsx-concat-negative')
181+
// testFixture('css-conflict/jsx-concat-positive')
182+
// testFixture('css-conflict/vue-style-lang-sass')
183+
184+
// testFixture('css-conflict/css')
185+
// testFixture('css-conflict/css-multi-rule')
186+
// testFixture('css-conflict/css-multi-prop')
187+
// testFixture('invalid-screen/simple')
188+
189+
testInline('simple typos in theme keys (in key)', {
190+
code: '.test { color: theme(--color-red-901) }',
191+
language: 'css',
192+
expected: [
193+
{
194+
code: 'invalidConfigPath',
195+
range: { start: { line: 0, character: 21 }, end: { line: 0, character: 36 } },
196+
severity: 1,
197+
message: "'--color-red-901' does not exist in your theme. Did you mean '--color-red-900'?",
198+
suggestions: ['--color-red-900'],
199+
},
200+
],
201+
})
202+
203+
testInline('simple typos in theme keys (in namespace)', {
204+
code: '.test { color: theme(--colors-red-901) }',
205+
language: 'css',
206+
expected: [
207+
{
208+
code: 'invalidConfigPath',
209+
range: { start: { line: 0, character: 21 }, end: { line: 0, character: 37 } },
210+
severity: 1,
211+
message: "'--colors-red-901' does not exist in your theme. Did you mean '--color-red-900'?",
212+
suggestions: ['--color-red-900'],
213+
},
214+
],
215+
})
216+
217+
testInline('No similar theme key exists', {
218+
code: '.test { color: theme(--font-obliqueness-90) }',
219+
language: 'css',
220+
expected: [
221+
{
222+
code: 'invalidConfigPath',
223+
range: { start: { line: 0, character: 21 }, end: { line: 0, character: 42 } },
224+
severity: 1,
225+
message: "'--font-obliqueness-90' does not exist in your theme.",
226+
suggestions: [],
227+
},
228+
],
229+
})
230+
231+
testInline('valid theme keys dont issue diagnostics', {
232+
code: '.test { color: theme(--color-red-900) }',
233+
language: 'css',
234+
expected: [],
235+
})
236+
237+
testInline('types in legacy theme config paths', {
238+
code: '.test { color: theme(colors.red.901) }',
239+
language: 'css',
240+
expected: [
241+
{
242+
code: 'invalidConfigPath',
243+
range: { start: { line: 0, character: 21 }, end: { line: 0, character: 35 } },
244+
severity: 1,
245+
message: "'colors.red.901' does not exist in your theme config.",
246+
suggestions: [],
247+
},
248+
],
249+
})
250+
251+
testInline('valid legacy theme config paths', {
252+
code: '.test { color: theme(colors.red.900) }',
253+
language: 'css',
254+
expected: [],
255+
})
73256
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@import 'tailwindcss';
2+
3+
@theme prefix(tw) {
4+
--color-potato: #907a70;
5+
}

packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"dependencies": {
3+
"tailwindcss": "^4.0.0-alpha.30"
4+
}
5+
}

packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@ import { type InvalidConfigPathDiagnostic, DiagnosticKind } from './types'
33
import { findHelperFunctionsInDocument } from '../util/find'
44
import { stringToPath } from '../util/stringToPath'
55
import isObject from '../util/isObject'
6-
import { closest } from '../util/closest'
6+
import { closest, distance } from '../util/closest'
77
import { combinations } from '../util/combinations'
88
import dlv from 'dlv'
99
import type { TextDocument } from 'vscode-languageserver-textdocument'
10+
import type { DesignSystem } from '../util/v4'
11+
12+
type ValidationResult =
13+
| { isValid: true; value: any }
14+
| { isValid: false; reason: string; suggestions: string[] }
1015

1116
function pathToString(path: string | string[]): string {
1217
if (typeof path === 'string') return path
@@ -21,8 +26,13 @@ export function validateConfigPath(
2126
state: State,
2227
path: string | string[],
2328
base: string[] = [],
24-
): { isValid: true; value: any } | { isValid: false; reason: string; suggestions: string[] } {
29+
): ValidationResult {
2530
let keys = Array.isArray(path) ? path : stringToPath(path)
31+
32+
if (state.v4) {
33+
return validateV4ThemePath(state, pathToString(keys))
34+
}
35+
2636
let fullPath = [...base, ...keys]
2737
let value = dlv(state.config, fullPath)
2838
let suggestions: string[] = []
@@ -195,3 +205,113 @@ export function getInvalidConfigPathDiagnostics(
195205

196206
return diagnostics
197207
}
208+
209+
function resolveThemeValue(design: DesignSystem, path: string) {
210+
let prefix = design.theme.prefix ?? null
211+
let candidate = prefix ? `${prefix}:[--custom:theme(${path})]` : `[--custom:theme(${path})]`
212+
213+
// Compile a dummy candidate that uses the theme function with the given path.
214+
//
215+
// We'll get a rule with a declaration from which we read the value. No rule
216+
// will be generated and the root will be empty if the path is invalid.
217+
//
218+
// Non-CSS representable values are not a concern here because the validation
219+
// only happens for calls in a CSS context.
220+
let [root] = design.compile([candidate])
221+
222+
let value: string | null = null
223+
224+
root.walkDecls((decl) => {
225+
value = decl.value
226+
})
227+
228+
return value
229+
}
230+
231+
function resolveKnownThemeKeys(design: DesignSystem): string[] {
232+
let validThemeKeys = Array.from(design.theme.entries(), ([key]) => key)
233+
234+
let prefixLength = design.theme.prefix?.length ?? 0
235+
236+
return prefixLength > 0
237+
? // Strip the configured prefix from the list of valid theme keys
238+
validThemeKeys.map((key) => `--${key.slice(prefixLength + 3)}`)
239+
: validThemeKeys
240+
}
241+
242+
function validateV4ThemePath(state: State, path: string): ValidationResult {
243+
let prefix = state.designSystem.theme.prefix ?? null
244+
245+
let value = resolveThemeValue(state.designSystem, path)
246+
247+
if (value !== null && value !== undefined) {
248+
return { isValid: true, value }
249+
}
250+
251+
let reason = path.startsWith('--')
252+
? `'${path}' does not exist in your theme.`
253+
: `'${path}' does not exist in your theme config.`
254+
255+
let suggestions = suggestAlternativeThemeKeys(state, path)
256+
257+
if (suggestions.length > 0) {
258+
reason += ` Did you mean '${suggestions[0]}'?`
259+
}
260+
261+
return {
262+
isValid: false,
263+
reason,
264+
suggestions,
265+
}
266+
}
267+
268+
function suggestAlternativeThemeKeys(state: State, path: string): string[] {
269+
// Non-v4 projects don't contain CSS variable theme keys
270+
if (!state.v4) return []
271+
272+
// v4 only supports suggesting keys currently known by the theme
273+
// it does not support suggesting keys from the config as that is not
274+
// exposed in any v4 API
275+
if (!path.startsWith('--')) return []
276+
277+
let parts = path.slice(2).split('-')
278+
parts[0] = `--${parts[0]}`
279+
280+
let validThemeKeys = resolveKnownThemeKeys(state.designSystem)
281+
let potentialThemeKey: string | null = null
282+
283+
while (parts.length > 1) {
284+
// Slice off the end of the theme key at the `-`
285+
parts.pop()
286+
287+
// Look at all theme keys that start with that
288+
let prefix = parts.join('-')
289+
290+
let possibleKeys = validThemeKeys.filter((key) => key.startsWith(prefix))
291+
292+
// If there are none, slice again and repeat
293+
if (possibleKeys.length === 0) continue
294+
295+
// Find the closest match using the Fast String Distance (SIFT) algorithm
296+
// ensuring `--color-red-901` suggests `--color-red-900` instead of
297+
// `--color-red-950`. We could in theory use the algorithm directly but it
298+
// does not make sense to suggest keys from an unrelated namespace which is
299+
// why we do filtering beforehand.
300+
potentialThemeKey = closest(path, possibleKeys)!
301+
302+
break
303+
}
304+
305+
// If we haven't found a key based on prefix matching, we'll do one more
306+
// search based on the full list of available keys. This is useful if the
307+
// namespace itself has a typo.
308+
potentialThemeKey ??= closest(path, validThemeKeys)!
309+
310+
// This number was chosen arbitrarily. From some light testing it seems like
311+
// it's a decent threshold for determine if a key is a reasonable suggestion.
312+
// This wasn't chosen by rigorous testing so if it needs to be adjusted it can
313+
// be. Chances are it'll need to be increased instead of decreased.
314+
const MAX_DISTANCE = 5
315+
316+
return distance(path, potentialThemeKey) <= MAX_DISTANCE ? [potentialThemeKey] : []
317+
}

packages/tailwindcss-language-service/src/util/closest.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ import sift from 'sift-string'
33
export function closest(input: string, options: string[]): string | undefined {
44
return options.concat([]).sort((a, b) => sift(input, a) - sift(input, b))[0]
55
}
6+
7+
export function distance(a: string, b: string): number {
8+
return sift(a, b)
9+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import postcss from 'postcss'
22
import type { Rule } from './ast'
33
import type { NamedVariant } from './candidate'
44

5-
export interface Theme {}
5+
export interface Theme {
6+
// Prefix didn't exist on
7+
prefix?: string
8+
entries(): [string, any][]
9+
}
610

711
export interface ClassMetadata {
812
modifiers: string[]

0 commit comments

Comments
 (0)