diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js index 957185d0..ae3f3b05 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js +++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js @@ -37,6 +37,15 @@ withFixture('basic', (c) => { testFixture('css-conflict/vue-style-lang-sass') testFixture('invalid-screen/simple') testFixture('invalid-theme/simple') + testFixture('invalid-class/simple') + testFixture('invalid-class/whitespace-negative') + testFixture('invalid-class/variants') + testFixture('invalid-class/jsx-concat-positive') + testFixture('invalid-class/jsx-template-literal') + testFixture('invalid-class/css') + testFixture('invalid-class/css-multi-prop') + testFixture('invalid-class/css-multi-rule') + testFixture('invalid-class/vue-style-lang-sass') }) withFixture('v4/basic', (c) => { @@ -88,6 +97,15 @@ withFixture('v4/basic', (c) => { // testFixture('css-conflict/css-multi-rule') // testFixture('css-conflict/css-multi-prop') // testFixture('invalid-screen/simple') + testFixture('invalid-class/simple') + testFixture('invalid-class/whitespace-negative') + testFixture('invalid-class/variants') + testFixture('invalid-class/jsx-concat-positive') + testFixture('invalid-class/jsx-template-literal') + testFixture('invalid-class/css') + testFixture('invalid-class/css-multi-prop') + testFixture('invalid-class/css-multi-rule') + testFixture('invalid-class/vue-style-lang-sass') testInline('simple typos in theme keys (in key)', { code: '.test { color: theme(--color-red-901) }', @@ -324,6 +342,17 @@ withFixture('v4/basic', (c) => { }, ], }, + { + code: 'invalidClass', + source: 'tailwindcss', + message: "Unknown utility class 'foo'.", + className: { + className: 'foo', + classList: { + classList: 'foo max-w-4xl max-w-6xl hover:underline', + }, + }, + }, ], }) diff --git a/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/css-multi-prop.json b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/css-multi-prop.json new file mode 100644 index 00000000..5def25bf --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/css-multi-prop.json @@ -0,0 +1,59 @@ +{ + "code": ".test { @apply px-4; color: red; @apply nonexistent }", + "language": "css", + "expected": [ + { + "code": "invalidClass", + "source": "tailwindcss", + "className": { + "className": "nonexistent", + "classList": { + "classList": "nonexistent", + "important": false, + "range": { + "start": { + "line": 0, + "character": 40 + }, + "end": { + "line": 0, + "character": 51 + } + } + }, + "range": { + "start": { + "line": 0, + "character": 40 + }, + "end": { + "line": 0, + "character": 51 + } + }, + "relativeRange": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 11 + } + } + }, + "range": { + "start": { + "line": 0, + "character": 40 + }, + "end": { + "line": 0, + "character": 51 + } + }, + "severity": 3, + "message": "Unknown utility class 'nonexistent'." + } + ] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/css-multi-rule.json b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/css-multi-rule.json new file mode 100644 index 00000000..57e27f1a --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/css-multi-rule.json @@ -0,0 +1,59 @@ +{ + "code": ".test { @apply px-4 }\n.test { @apply nonexistent }", + "language": "css", + "expected": [ + { + "code": "invalidClass", + "source": "tailwindcss", + "className": { + "className": "nonexistent", + "classList": { + "classList": "nonexistent", + "important": false, + "range": { + "start": { + "line": 1, + "character": 15 + }, + "end": { + "line": 1, + "character": 26 + } + } + }, + "range": { + "start": { + "line": 1, + "character": 15 + }, + "end": { + "line": 1, + "character": 26 + } + }, + "relativeRange": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 11 + } + } + }, + "range": { + "start": { + "line": 1, + "character": 15 + }, + "end": { + "line": 1, + "character": 26 + } + }, + "severity": 3, + "message": "Unknown utility class 'nonexistent'." + } + ] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/css.json b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/css.json new file mode 100644 index 00000000..02f87336 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/css.json @@ -0,0 +1,59 @@ +{ + "code": ".test { @apply nonexistent; }", + "language": "css", + "expected": [ + { + "code": "invalidClass", + "source": "tailwindcss", + "className": { + "className": "nonexistent", + "classList": { + "classList": "nonexistent", + "important": false, + "range": { + "start": { + "line": 0, + "character": 15 + }, + "end": { + "line": 0, + "character": 26 + } + } + }, + "range": { + "start": { + "line": 0, + "character": 15 + }, + "end": { + "line": 0, + "character": 26 + } + }, + "relativeRange": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 11 + } + } + }, + "range": { + "start": { + "line": 0, + "character": 15 + }, + "end": { + "line": 0, + "character": 26 + } + }, + "severity": 3, + "message": "Unknown utility class 'nonexistent'." + } + ] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/jsx-concat-positive.json b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/jsx-concat-positive.json new file mode 100644 index 00000000..c3624af2 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/jsx-concat-positive.json @@ -0,0 +1,58 @@ +{ + "code": "
", + "language": "javascriptreact", + "expected": [ + { + "className": { + "classList": { + "classList": "nonexistent $", + "range": { + "end": { + "character": 30, + "line": 0 + }, + "start": { + "character": 17, + "line": 0 + } + } + }, + "className": "nonexistent", + "range": { + "end": { + "character": 28, + "line": 0 + }, + "start": { + "character": 17, + "line": 0 + } + }, + "relativeRange": { + "end": { + "character": 11, + "line": 0 + }, + "start": { + "character": 0, + "line": 0 + } + } + }, + "code": "invalidClass", + "message": "Unknown utility class 'nonexistent'.", + "range": { + "end": { + "character": 28, + "line": 0 + }, + "start": { + "character": 17, + "line": 0 + } + }, + "severity": 3, + "source": "tailwindcss" + } + ] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/jsx-template-literal.json b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/jsx-template-literal.json new file mode 100644 index 00000000..c3624af2 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/jsx-template-literal.json @@ -0,0 +1,58 @@ +{ + "code": "
", + "language": "javascriptreact", + "expected": [ + { + "className": { + "classList": { + "classList": "nonexistent $", + "range": { + "end": { + "character": 30, + "line": 0 + }, + "start": { + "character": 17, + "line": 0 + } + } + }, + "className": "nonexistent", + "range": { + "end": { + "character": 28, + "line": 0 + }, + "start": { + "character": 17, + "line": 0 + } + }, + "relativeRange": { + "end": { + "character": 11, + "line": 0 + }, + "start": { + "character": 0, + "line": 0 + } + } + }, + "code": "invalidClass", + "message": "Unknown utility class 'nonexistent'.", + "range": { + "end": { + "character": 28, + "line": 0 + }, + "start": { + "character": 17, + "line": 0 + } + }, + "severity": 3, + "source": "tailwindcss" + } + ] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/simple.json b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/simple.json new file mode 100644 index 00000000..9c8f70d4 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/simple.json @@ -0,0 +1,27 @@ +{ + "code": "
", + "expected": [ + { + "code": "invalidClass", + "source": "tailwindcss", + "className": { + "className": "nonexistent-class", + "classList": { + "classList": "px-4 nonexistent-class", + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 34 } + } + }, + "relativeRange": { + "start": { "line": 0, "character": 5 }, + "end": { "line": 0, "character": 22 } + }, + "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 34 } } + }, + "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 34 } }, + "severity": 3, + "message": "Unknown utility class 'nonexistent-class'." + } + ] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/variants.json b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/variants.json new file mode 100644 index 00000000..b8c469a7 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/variants.json @@ -0,0 +1,57 @@ +{ + "code": "
", + "expected": [ + { + "code": "invalidClass", + "source": "tailwindcss", + "className": { + "className": "hover:nonexistent", + "classList": { + "classList": "hover:nonexistent", + "range": { + "start": { + "line": 0, + "character": 12 + }, + "end": { + "line": 0, + "character": 29 + } + } + }, + "range": { + "start": { + "line": 0, + "character": 12 + }, + "end": { + "line": 0, + "character": 29 + } + }, + "relativeRange": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 17 + } + } + }, + "range": { + "start": { + "line": 0, + "character": 12 + }, + "end": { + "line": 0, + "character": 29 + } + }, + "severity": 3, + "message": "Unknown utility class 'hover:nonexistent'." + } + ] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/vue-style-lang-sass.json b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/vue-style-lang-sass.json new file mode 100644 index 00000000..2a25ad04 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/vue-style-lang-sass.json @@ -0,0 +1,59 @@ +{ + "code": "", + "language": "vue", + "expected": [ + { + "code": "invalidClass", + "source": "tailwindcss", + "className": { + "className": "nonexistent", + "classList": { + "classList": "nonexistent", + "important": false, + "range": { + "start": { + "line": 2, + "character": 9 + }, + "end": { + "line": 2, + "character": 20 + } + } + }, + "range": { + "start": { + "line": 2, + "character": 9 + }, + "end": { + "line": 2, + "character": 20 + } + }, + "relativeRange": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 11 + } + } + }, + "range": { + "start": { + "line": 2, + "character": 9 + }, + "end": { + "line": 2, + "character": 20 + } + }, + "severity": 3, + "message": "Unknown utility class 'nonexistent'." + } + ] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/whitespace-negative.json b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/whitespace-negative.json new file mode 100644 index 00000000..f6d8d799 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/diagnostics/invalid-class/whitespace-negative.json @@ -0,0 +1,4 @@ +{ + "code": "
", + "expected": [] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts index b25e9f63..53cba044 100644 --- a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts +++ b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts @@ -14,11 +14,13 @@ import { isInvalidVariantDiagnostic, isRecommendedVariantOrderDiagnostic, isSuggestCanonicalClasses, + isInvalidClassDiagnostic, } from '../diagnostics/types' import { flatten, dedupeBy } from '../util/array' import { provideCssConflictCodeActions } from './provideCssConflictCodeActions' import { provideInvalidApplyCodeActions } from './provideInvalidApplyCodeActions' import { provideSuggestionCodeActions } from './provideSuggestionCodeActions' +import { provideInvalidClassCodeActions } from './provideInvalidClassCodeActions' async function getDiagnosticsFromCodeActionParams( state: State, @@ -70,6 +72,10 @@ export async function doCodeActions( return provideCssConflictCodeActions(state, params, diagnostic) } + if (isInvalidClassDiagnostic(diagnostic)) { + return provideInvalidClassCodeActions(state, params, diagnostic) + } + if ( isInvalidConfigPathDiagnostic(diagnostic) || isInvalidTailwindDirectiveDiagnostic(diagnostic) || diff --git a/packages/tailwindcss-language-service/src/codeActions/provideInvalidClassCodeActions.ts b/packages/tailwindcss-language-service/src/codeActions/provideInvalidClassCodeActions.ts new file mode 100644 index 00000000..03fb6a4a --- /dev/null +++ b/packages/tailwindcss-language-service/src/codeActions/provideInvalidClassCodeActions.ts @@ -0,0 +1,31 @@ +import type { CodeAction, CodeActionParams } from 'vscode-languageserver' +import type { State } from '../util/state' +import type { InvalidClassDiagnostic } from '../diagnostics/types' +import { removeRangesFromString } from '../util/removeRangesFromString' + +export function provideInvalidClassCodeActions( + _state: State, + params: CodeActionParams, + diagnostic: InvalidClassDiagnostic, +): CodeAction[] { + return [ + { + title: `Remove unknown utility class '${diagnostic.className.className}'`, + kind: 'quickfix', + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: [ + { + range: diagnostic.className.classList.range, + newText: removeRangesFromString( + diagnostic.className.classList.classList, + [diagnostic.className.relativeRange], + ), + }, + ], + }, + }, + }, + ] +} \ No newline at end of file diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts index 1244b181..c44932ce 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts @@ -11,6 +11,7 @@ import { getRecommendedVariantOrderDiagnostics } from './getRecommendedVariantOr import { getInvalidSourceDiagnostics } from './getInvalidSourceDiagnostics' import { getUsedBlocklistedClassDiagnostics } from './getUsedBlocklistedClassDiagnostics' import { getSuggestCanonicalClassesDiagnostics } from './canonical-classes' +import { getInvalidClassDiagnostics } from './getInvalidClassDiagnostics' export async function doValidate( state: State, @@ -26,6 +27,7 @@ export async function doValidate( DiagnosticKind.RecommendedVariantOrder, DiagnosticKind.UsedBlocklistedClass, DiagnosticKind.SuggestCanonicalClasses, + DiagnosticKind.InvalidClass, ], ): Promise { const settings = await state.editor.getConfiguration(document.uri) @@ -62,6 +64,9 @@ export async function doValidate( ...(only.includes(DiagnosticKind.SuggestCanonicalClasses) ? await getSuggestCanonicalClassesDiagnostics(state, document, settings) : []), + ...(only.includes(DiagnosticKind.InvalidClass) + ? await getInvalidClassDiagnostics(state, document, settings) + : []), ] : [] } diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidClassDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidClassDiagnostics.ts new file mode 100644 index 00000000..90be2469 --- /dev/null +++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidClassDiagnostics.ts @@ -0,0 +1,78 @@ +import type { State, Settings } from '../util/state' +import { type InvalidClassDiagnostic, DiagnosticKind } from './types' +import { findClassListsInDocument, getClassNamesInClassList } from '../util/find' +import { visit } from './getCssConflictDiagnostics' +import { getClassNameDecls } from '../util/getClassNameDecls' +import * as jit from '../util/jit' +import type { TextDocument } from 'vscode-languageserver-textdocument' + +function isCustomProperty(property: string): boolean { + return property.startsWith('--') +} + +function isClassValid(state: State, className: string): boolean { + if (state.v4) { + // V4: Use design system compilation + let roots = state.designSystem.compile([className]) + let hasDeclarations = false + let hasNonCustomProperties = false + + visit([roots[0]], (node) => { + if ((node.type === 'rule' || node.type === 'atrule') && node.nodes) { + for (let child of node.nodes) { + if (child.type === 'decl') { + hasDeclarations = true + if (!isCustomProperty(child.prop)) { + hasNonCustomProperties = true + } + } + } + } + }) + return hasDeclarations && hasNonCustomProperties + } else if (state.jit) { + // JIT: Try to generate rules + let { rules } = jit.generateRules(state, [className]) + return rules.length > 0 + } else { + // Static: Check if decls exist + let decls = getClassNameDecls(state, className) + return !!decls + } +} + +export async function getInvalidClassDiagnostics( + state: State, + document: TextDocument, + settings: Settings, +): Promise { + let severity = settings.tailwindCSS.lint.invalidClass || 'warning' // Assuming a new setting, default to warning + if (severity === 'ignore') return [] + + let diagnostics: InvalidClassDiagnostic[] = [] + const classLists = await findClassListsInDocument(state, document) + + classLists.forEach((classList) => { + const classNames = getClassNamesInClassList(classList, state.blocklist) + + classNames.forEach((className) => { + if (!isClassValid(state, className.className)) { + diagnostics.push({ + code: DiagnosticKind.InvalidClass, + source: 'tailwindcss', + className, + range: className.range, + severity: + severity === 'error' + ? 1 /* DiagnosticSeverity.Error */ + : severity === 'warning' + ? 2 /* DiagnosticSeverity.Warning */ + : 3 /* DiagnosticSeverity.Information */, + message: `Unknown utility class '${className.className}'.`, + }) + } + }) + }) + + return diagnostics +} \ No newline at end of file diff --git a/packages/tailwindcss-language-service/src/diagnostics/types.ts b/packages/tailwindcss-language-service/src/diagnostics/types.ts index 444d8021..705d3374 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/types.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/types.ts @@ -12,6 +12,7 @@ export enum DiagnosticKind { RecommendedVariantOrder = 'recommendedVariantOrder', UsedBlocklistedClass = 'usedBlocklistedClass', SuggestCanonicalClasses = 'suggestCanonicalClasses', + InvalidClass = 'invalidClass', } export type CssConflictDiagnostic = Diagnostic & { @@ -123,6 +124,17 @@ export function isSuggestCanonicalClasses( return diagnostic.code === DiagnosticKind.SuggestCanonicalClasses } +export type InvalidClassDiagnostic = Diagnostic & { + code: DiagnosticKind.InvalidClass + className: DocumentClassName +} + +export function isInvalidClassDiagnostic( + diagnostic: AugmentedDiagnostic, +): diagnostic is InvalidClassDiagnostic { + return diagnostic.code === DiagnosticKind.InvalidClass +} + export type AugmentedDiagnostic = | CssConflictDiagnostic | InvalidApplyDiagnostic @@ -134,3 +146,4 @@ export type AugmentedDiagnostic = | RecommendedVariantOrderDiagnostic | UsedBlocklistedClassDiagnostic | SuggestCanonicalClassesDiagnostic + | InvalidClassDiagnostic diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index 1bd51cfa..487e16da 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -40,7 +40,7 @@ export function getClassNamesInClassList( const names: DocumentClassName[] = [] let index = 0 for (let i = 0; i < parts.length; i++) { - if (i % 2 === 0 && !blocklist.includes(parts[i])) { + if (i % 2 === 0 && parts[i] && parts[i] !== '$' && !blocklist.includes(parts[i])) { const start = indexToPosition(classList, index) const end = indexToPosition(classList, index + parts[i].length) names.push({ diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 10fe81a7..70c8f444 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -36,7 +36,7 @@ export type EditorState = { ) => Promise> } -type DiagnosticSeveritySetting = 'ignore' | 'warning' | 'error' +type DiagnosticSeveritySetting = 'ignore' | 'info' | 'warning' | 'error' export type EditorSettings = { tabSize: number @@ -67,6 +67,7 @@ export type TailwindCssSettings = { recommendedVariantOrder: DiagnosticSeveritySetting usedBlocklistedClass: DiagnosticSeveritySetting suggestCanonicalClasses: DiagnosticSeveritySetting + invalidClass: DiagnosticSeveritySetting } experimental: { classRegex: string[] | [string, string][] @@ -207,6 +208,7 @@ export function getDefaultTailwindSettings(): Settings { recommendedVariantOrder: 'warning', usedBlocklistedClass: 'warning', suggestCanonicalClasses: 'warning', + invalidClass: 'info', }, showPixelEquivalents: true, includeLanguages: {}, diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 2f8cba5f..14571829 100644 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -327,6 +327,18 @@ "markdownDescription": "Indicate when utilities may be written in a more optimal form", "scope": "language-overridable" }, + "tailwindCSS.lint.invalidClass": { + "type": "string", + "enum": [ + "ignore", + "info", + "warning", + "error" + ], + "default": "info", + "markdownDescription": "Classes that are not recognized as valid Tailwind CSS classes", + "scope": "language-overridable" + }, "tailwindCSS.experimental.classRegex": { "type": "array", "scope": "language-overridable"