diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js index 4a37ee2d..67d83fc4 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js +++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js @@ -59,6 +59,23 @@ withFixture('v4/basic', (c) => { }) } + function testInline(fixture, { code, expected, language = 'html' }) { + test(fixture, async () => { + let promise = new Promise((resolve) => { + c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { + resolve(diagnostics) + }) + }) + + let doc = await c.openDocument({ text: code, lang: language }) + let diagnostics = await promise + + expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri)) + + expect(diagnostics).toEqual(expected) + }) + } + testFixture('css-conflict/simple') testFixture('css-conflict/variants-negative') testFixture('css-conflict/variants-positive') @@ -69,5 +86,171 @@ withFixture('v4/basic', (c) => { // testFixture('css-conflict/css-multi-rule') // testFixture('css-conflict/css-multi-prop') // testFixture('invalid-screen/simple') - // testFixture('invalid-theme/simple') + + testInline('simple typos in theme keys (in key)', { + code: '.test { color: theme(--color-red-901) }', + language: 'css', + expected: [ + { + code: 'invalidConfigPath', + range: { start: { line: 0, character: 21 }, end: { line: 0, character: 36 } }, + severity: 1, + message: "'--color-red-901' does not exist in your theme. Did you mean '--color-red-900'?", + suggestions: ['--color-red-900'], + }, + ], + }) + + testInline('simple typos in theme keys (in namespace)', { + code: '.test { color: theme(--colors-red-901) }', + language: 'css', + expected: [ + { + code: 'invalidConfigPath', + range: { start: { line: 0, character: 21 }, end: { line: 0, character: 37 } }, + severity: 1, + message: "'--colors-red-901' does not exist in your theme. Did you mean '--color-red-900'?", + suggestions: ['--color-red-900'], + }, + ], + }) + + testInline('No similar theme key exists', { + code: '.test { color: theme(--font-obliqueness-90) }', + language: 'css', + expected: [ + { + code: 'invalidConfigPath', + range: { start: { line: 0, character: 21 }, end: { line: 0, character: 42 } }, + severity: 1, + message: "'--font-obliqueness-90' does not exist in your theme.", + suggestions: [], + }, + ], + }) + + testInline('valid theme keys dont issue diagnostics', { + code: '.test { color: theme(--color-red-900) }', + language: 'css', + expected: [], + }) + + testInline('types in legacy theme config paths', { + code: '.test { color: theme(colors.red.901) }', + language: 'css', + expected: [ + { + code: 'invalidConfigPath', + range: { start: { line: 0, character: 21 }, end: { line: 0, character: 35 } }, + severity: 1, + message: "'colors.red.901' does not exist in your theme config.", + suggestions: [], + }, + ], + }) + + testInline('valid legacy theme config paths', { + code: '.test { color: theme(colors.red.900) }', + language: 'css', + expected: [], + }) +}) + +withFixture('v4/with-prefix', (c) => { + function testInline(fixture, { code, expected, language = 'html' }) { + test(fixture, async () => { + let promise = new Promise((resolve) => { + c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { + resolve(diagnostics) + }) + }) + + let doc = await c.openDocument({ text: code, lang: language }) + let diagnostics = await promise + + expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri)) + + expect(diagnostics).toEqual(expected) + }) + } + + // testFixture('css-conflict/simple') + // testFixture('css-conflict/variants-negative') + // testFixture('css-conflict/variants-positive') + // testFixture('css-conflict/jsx-concat-negative') + // testFixture('css-conflict/jsx-concat-positive') + // testFixture('css-conflict/vue-style-lang-sass') + + // testFixture('css-conflict/css') + // testFixture('css-conflict/css-multi-rule') + // testFixture('css-conflict/css-multi-prop') + // testFixture('invalid-screen/simple') + + testInline('simple typos in theme keys (in key)', { + code: '.test { color: theme(--color-red-901) }', + language: 'css', + expected: [ + { + code: 'invalidConfigPath', + range: { start: { line: 0, character: 21 }, end: { line: 0, character: 36 } }, + severity: 1, + message: "'--color-red-901' does not exist in your theme. Did you mean '--color-red-900'?", + suggestions: ['--color-red-900'], + }, + ], + }) + + testInline('simple typos in theme keys (in namespace)', { + code: '.test { color: theme(--colors-red-901) }', + language: 'css', + expected: [ + { + code: 'invalidConfigPath', + range: { start: { line: 0, character: 21 }, end: { line: 0, character: 37 } }, + severity: 1, + message: "'--colors-red-901' does not exist in your theme. Did you mean '--color-red-900'?", + suggestions: ['--color-red-900'], + }, + ], + }) + + testInline('No similar theme key exists', { + code: '.test { color: theme(--font-obliqueness-90) }', + language: 'css', + expected: [ + { + code: 'invalidConfigPath', + range: { start: { line: 0, character: 21 }, end: { line: 0, character: 42 } }, + severity: 1, + message: "'--font-obliqueness-90' does not exist in your theme.", + suggestions: [], + }, + ], + }) + + testInline('valid theme keys dont issue diagnostics', { + code: '.test { color: theme(--color-red-900) }', + language: 'css', + expected: [], + }) + + testInline('types in legacy theme config paths', { + code: '.test { color: theme(colors.red.901) }', + language: 'css', + expected: [ + { + code: 'invalidConfigPath', + range: { start: { line: 0, character: 21 }, end: { line: 0, character: 35 } }, + severity: 1, + message: "'colors.red.901' does not exist in your theme config.", + suggestions: [], + }, + ], + }) + + testInline('valid legacy theme config paths', { + code: '.test { color: theme(colors.red.900) }', + language: 'css', + expected: [], + }) }) diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/app.css b/packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/app.css new file mode 100644 index 00000000..ca9f3ab3 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/app.css @@ -0,0 +1,5 @@ +@import 'tailwindcss'; + +@theme prefix(tw) { + --color-potato: #907a70; +} diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/package-lock.json b/packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/package-lock.json new file mode 100644 index 00000000..3dd65ed8 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/package-lock.json @@ -0,0 +1,17 @@ +{ + "name": "with-prefix", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "tailwindcss": "^4.0.0-alpha.30" + } + }, + "node_modules/tailwindcss": { + "version": "4.0.0-alpha.30", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0-alpha.30.tgz", + "integrity": "sha512-e6OsN8n1nRLca2X8ix1QSa+oZA1IYktHw+epLI07+CeQzbLbIDNJieRdbcctM32TAy3vHKvEgdp0rM1WUbpIMQ==" + } + } +} diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/package.json b/packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/package.json new file mode 100644 index 00000000..5e7c8cc9 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/with-prefix/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "tailwindcss": "^4.0.0-alpha.30" + } +} diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts index edb92074..1b3ecb16 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts @@ -3,10 +3,15 @@ import { type InvalidConfigPathDiagnostic, DiagnosticKind } from './types' import { findHelperFunctionsInDocument } from '../util/find' import { stringToPath } from '../util/stringToPath' import isObject from '../util/isObject' -import { closest } from '../util/closest' +import { closest, distance } from '../util/closest' import { combinations } from '../util/combinations' import dlv from 'dlv' import type { TextDocument } from 'vscode-languageserver-textdocument' +import type { DesignSystem } from '../util/v4' + +type ValidationResult = + | { isValid: true; value: any } + | { isValid: false; reason: string; suggestions: string[] } function pathToString(path: string | string[]): string { if (typeof path === 'string') return path @@ -21,8 +26,13 @@ export function validateConfigPath( state: State, path: string | string[], base: string[] = [], -): { isValid: true; value: any } | { isValid: false; reason: string; suggestions: string[] } { +): ValidationResult { let keys = Array.isArray(path) ? path : stringToPath(path) + + if (state.v4) { + return validateV4ThemePath(state, pathToString(keys)) + } + let fullPath = [...base, ...keys] let value = dlv(state.config, fullPath) let suggestions: string[] = [] @@ -195,3 +205,113 @@ export function getInvalidConfigPathDiagnostics( return diagnostics } + +function resolveThemeValue(design: DesignSystem, path: string) { + let prefix = design.theme.prefix ?? null + let candidate = prefix ? `${prefix}:[--custom:theme(${path})]` : `[--custom:theme(${path})]` + + // Compile a dummy candidate that uses the theme function with the given path. + // + // We'll get a rule with a declaration from which we read the value. No rule + // will be generated and the root will be empty if the path is invalid. + // + // Non-CSS representable values are not a concern here because the validation + // only happens for calls in a CSS context. + let [root] = design.compile([candidate]) + + let value: string | null = null + + root.walkDecls((decl) => { + value = decl.value + }) + + return value +} + +function resolveKnownThemeKeys(design: DesignSystem): string[] { + let validThemeKeys = Array.from(design.theme.entries(), ([key]) => key) + + let prefixLength = design.theme.prefix?.length ?? 0 + + return prefixLength > 0 + ? // Strip the configured prefix from the list of valid theme keys + validThemeKeys.map((key) => `--${key.slice(prefixLength + 3)}`) + : validThemeKeys +} + +function validateV4ThemePath(state: State, path: string): ValidationResult { + let prefix = state.designSystem.theme.prefix ?? null + + let value = resolveThemeValue(state.designSystem, path) + + if (value !== null && value !== undefined) { + return { isValid: true, value } + } + + let reason = path.startsWith('--') + ? `'${path}' does not exist in your theme.` + : `'${path}' does not exist in your theme config.` + + let suggestions = suggestAlternativeThemeKeys(state, path) + + if (suggestions.length > 0) { + reason += ` Did you mean '${suggestions[0]}'?` + } + + return { + isValid: false, + reason, + suggestions, + } +} + +function suggestAlternativeThemeKeys(state: State, path: string): string[] { + // Non-v4 projects don't contain CSS variable theme keys + if (!state.v4) return [] + + // v4 only supports suggesting keys currently known by the theme + // it does not support suggesting keys from the config as that is not + // exposed in any v4 API + if (!path.startsWith('--')) return [] + + let parts = path.slice(2).split('-') + parts[0] = `--${parts[0]}` + + let validThemeKeys = resolveKnownThemeKeys(state.designSystem) + let potentialThemeKey: string | null = null + + while (parts.length > 1) { + // Slice off the end of the theme key at the `-` + parts.pop() + + // Look at all theme keys that start with that + let prefix = parts.join('-') + + let possibleKeys = validThemeKeys.filter((key) => key.startsWith(prefix)) + + // If there are none, slice again and repeat + if (possibleKeys.length === 0) continue + + // Find the closest match using the Fast String Distance (SIFT) algorithm + // ensuring `--color-red-901` suggests `--color-red-900` instead of + // `--color-red-950`. We could in theory use the algorithm directly but it + // does not make sense to suggest keys from an unrelated namespace which is + // why we do filtering beforehand. + potentialThemeKey = closest(path, possibleKeys)! + + break + } + + // If we haven't found a key based on prefix matching, we'll do one more + // search based on the full list of available keys. This is useful if the + // namespace itself has a typo. + potentialThemeKey ??= closest(path, validThemeKeys)! + + // This number was chosen arbitrarily. From some light testing it seems like + // it's a decent threshold for determine if a key is a reasonable suggestion. + // This wasn't chosen by rigorous testing so if it needs to be adjusted it can + // be. Chances are it'll need to be increased instead of decreased. + const MAX_DISTANCE = 5 + + return distance(path, potentialThemeKey) <= MAX_DISTANCE ? [potentialThemeKey] : [] +} diff --git a/packages/tailwindcss-language-service/src/util/closest.ts b/packages/tailwindcss-language-service/src/util/closest.ts index ebdfacc2..15d51b2c 100644 --- a/packages/tailwindcss-language-service/src/util/closest.ts +++ b/packages/tailwindcss-language-service/src/util/closest.ts @@ -3,3 +3,7 @@ import sift from 'sift-string' export function closest(input: string, options: string[]): string | undefined { return options.concat([]).sort((a, b) => sift(input, a) - sift(input, b))[0] } + +export function distance(a: string, b: string): number { + return sift(a, b) +} diff --git a/packages/tailwindcss-language-service/src/util/v4/design-system.ts b/packages/tailwindcss-language-service/src/util/v4/design-system.ts index 0f47d30c..75da6316 100644 --- a/packages/tailwindcss-language-service/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-service/src/util/v4/design-system.ts @@ -2,7 +2,11 @@ import postcss from 'postcss' import type { Rule } from './ast' import type { NamedVariant } from './candidate' -export interface Theme {} +export interface Theme { + // Prefix didn't exist on + prefix?: string + entries(): [string, any][] +} export interface ClassMetadata { modifiers: string[] diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 066f3a3a..5155e1dd 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -3,6 +3,7 @@ ## Prerelease - Fix display of color swatches using new v4 oklch color palette ([#1073](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1073)) +- Properly validate `theme(…)` function paths in v4 ([#1074](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1074)) ## 0.12.12