',
+ })
+
+ expect(await doc.diagnostics()).toEqual([])
+ },
+})
+
+defineTest({
+ name: 'Can show warning when using non-canonical classes',
+ fs: {
+ // TODO: Drop this when the embedded version of tailwindcss is v4.1.15
+ 'package.json': json`
+ {
+ "dependencies": {
+ "tailwindcss": "0.0.0-insiders.efe084b"
+ }
+ }
+ `,
+ 'app.css': css`
+ @import 'tailwindcss';
+ `,
+ },
+ prepare: async ({ root }) => ({
+ client: await createClient({
+ root,
+ settings: {
+ tailwindCSS: {
+ lint: { preferCanonicalClasses: 'warning' },
+ },
+ },
+ }),
+ }),
+ handle: async ({ client }) => {
+ let doc = await client.open({
+ lang: 'html',
+ text: '
',
+ })
+
+ let diagnostics = await doc.diagnostics()
+
+ expect(diagnostics).toEqual([
+ {
+ code: 'preferCanonicalClasses',
+ message: 'The class `[@media_print]:flex` is usually written as `print:flex`',
+ range: {
+ start: { line: 0, character: 12 },
+ end: { line: 0, character: 31 },
+ },
+ severity: 2,
+ suggestions: ['print:flex'],
+ },
+ {
+ code: 'preferCanonicalClasses',
+ message: 'The class `[color:red]/50` is usually written as `text-[red]/50`',
+ range: {
+ start: { line: 0, character: 32 },
+ end: { line: 0, character: 46 },
+ },
+ severity: 2,
+ suggestions: ['text-[red]/50'],
+ },
+ ])
+ },
+})
diff --git a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts
index 7ebf79cb..53b62183 100644
--- a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts
+++ b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts
@@ -13,6 +13,7 @@ import {
isInvalidScreenDiagnostic,
isInvalidVariantDiagnostic,
isRecommendedVariantOrderDiagnostic,
+ isPreferCanonicalClasses,
} from '../diagnostics/types'
import { flatten, dedupeBy } from '../util/array'
import { provideCssConflictCodeActions } from './provideCssConflictCodeActions'
@@ -74,7 +75,8 @@ export async function doCodeActions(
isInvalidTailwindDirectiveDiagnostic(diagnostic) ||
isInvalidScreenDiagnostic(diagnostic) ||
isInvalidVariantDiagnostic(diagnostic) ||
- isRecommendedVariantOrderDiagnostic(diagnostic)
+ isRecommendedVariantOrderDiagnostic(diagnostic) ||
+ isPreferCanonicalClasses(diagnostic)
) {
return provideSuggestionCodeActions(state, params, diagnostic)
}
diff --git a/packages/tailwindcss-language-service/src/codeActions/provideSuggestionCodeActions.ts b/packages/tailwindcss-language-service/src/codeActions/provideSuggestionCodeActions.ts
index a0201699..19acb24f 100644
--- a/packages/tailwindcss-language-service/src/codeActions/provideSuggestionCodeActions.ts
+++ b/packages/tailwindcss-language-service/src/codeActions/provideSuggestionCodeActions.ts
@@ -6,6 +6,7 @@ import type {
InvalidScreenDiagnostic,
InvalidVariantDiagnostic,
RecommendedVariantOrderDiagnostic,
+ PreferCanonicalClassesDiagnostic,
} from '../diagnostics/types'
export function provideSuggestionCodeActions(
@@ -16,7 +17,8 @@ export function provideSuggestionCodeActions(
| InvalidTailwindDirectiveDiagnostic
| InvalidScreenDiagnostic
| InvalidVariantDiagnostic
- | RecommendedVariantOrderDiagnostic,
+ | RecommendedVariantOrderDiagnostic
+ | PreferCanonicalClassesDiagnostic,
): CodeAction[] {
return diagnostic.suggestions.map((suggestion) => ({
title: `Replace with '${suggestion}'`,
diff --git a/packages/tailwindcss-language-service/src/diagnostics/canonical-classes.ts b/packages/tailwindcss-language-service/src/diagnostics/canonical-classes.ts
new file mode 100644
index 00000000..817649bc
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/diagnostics/canonical-classes.ts
@@ -0,0 +1,59 @@
+import type { TextDocument } from 'vscode-languageserver-textdocument'
+import type { State, Settings } from '../util/state'
+import { type PreferCanonicalClassesDiagnostic, DiagnosticKind } from './types'
+import { findClassListsInDocument, getClassNamesInClassList } from '../util/find'
+
+export async function getPreferCanonicalClassesDiagnostics(
+ state: State,
+ document: TextDocument,
+ settings: Settings,
+): Promise
{
+ if (!state.v4) return []
+ if (!state.designSystem.canonicalizeCandidates) return []
+
+ let severity = settings.tailwindCSS.lint.preferCanonicalClasses
+ if (severity === 'ignore') return []
+
+ let diagnostics: PreferCanonicalClassesDiagnostic[] = []
+
+ let classLists = await findClassListsInDocument(state, document)
+
+ for (let classList of classLists) {
+ let classNames = getClassNamesInClassList(classList, [])
+
+ // NOTES:
+ //
+ // A planned enhancement to `canonicalizeCandidates` is to operate on class *lists* which would
+ // allow `[font-size:0.875rem] [line-height:0.25rem]` to turn into a single `text-sm/3` class.
+ //
+ // To account for this future we process class names individually. At some future point we can
+ // then take the list of individual classes and pass it in *again* to issue a diagnostic for the
+ // class list as a whole.
+ //
+ // This may not allow you to see *which classes* got combined since the inputs/outputs map to
+ // entire lists but this seems fine to do
+ //
+ // We'd probably want to only issue a class list diagnostic once there are no individual class
+ // diagnostics in a given class list.
+
+ for (let className of classNames) {
+ let canonicalized = state.designSystem.canonicalizeCandidates([className.className])[0]
+ let isCanonical = canonicalized === className.className
+
+ if (isCanonical) continue
+
+ diagnostics.push({
+ code: DiagnosticKind.PreferCanonicalClasses,
+ range: className.range,
+ severity:
+ severity === 'error'
+ ? 1 /* DiagnosticSeverity.Error */
+ : 2 /* DiagnosticSeverity.Warning */,
+ message: `The class \`${className.className}\` is usually written as \`${canonicalized}\``,
+ suggestions: [canonicalized],
+ })
+ }
+ }
+
+ return diagnostics
+}
diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts
index 075cea38..cbfb3dca 100644
--- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts
+++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts
@@ -10,6 +10,7 @@ import { getInvalidTailwindDirectiveDiagnostics } from './getInvalidTailwindDire
import { getRecommendedVariantOrderDiagnostics } from './getRecommendedVariantOrderDiagnostics'
import { getInvalidSourceDiagnostics } from './getInvalidSourceDiagnostics'
import { getUsedBlocklistedClassDiagnostics } from './getUsedBlocklistedClassDiagnostics'
+import { getPreferCanonicalClassesDiagnostics } from './canonical-classes'
export async function doValidate(
state: State,
@@ -24,6 +25,7 @@ export async function doValidate(
DiagnosticKind.InvalidSourceDirective,
DiagnosticKind.RecommendedVariantOrder,
DiagnosticKind.UsedBlocklistedClass,
+ DiagnosticKind.PreferCanonicalClasses,
],
): Promise {
const settings = await state.editor.getConfiguration(document.uri)
@@ -57,6 +59,9 @@ export async function doValidate(
...(only.includes(DiagnosticKind.UsedBlocklistedClass)
? await getUsedBlocklistedClassDiagnostics(state, document, settings)
: []),
+ ...(only.includes(DiagnosticKind.PreferCanonicalClasses)
+ ? await getPreferCanonicalClassesDiagnostics(state, document, settings)
+ : []),
]
: []
}
diff --git a/packages/tailwindcss-language-service/src/diagnostics/types.ts b/packages/tailwindcss-language-service/src/diagnostics/types.ts
index f022503a..fce11792 100644
--- a/packages/tailwindcss-language-service/src/diagnostics/types.ts
+++ b/packages/tailwindcss-language-service/src/diagnostics/types.ts
@@ -11,6 +11,7 @@ export enum DiagnosticKind {
InvalidSourceDirective = 'invalidSourceDirective',
RecommendedVariantOrder = 'recommendedVariantOrder',
UsedBlocklistedClass = 'usedBlocklistedClass',
+ PreferCanonicalClasses = 'preferCanonicalClasses',
}
export type CssConflictDiagnostic = Diagnostic & {
@@ -111,6 +112,17 @@ export function isUsedBlocklistedClass(
return diagnostic.code === DiagnosticKind.UsedBlocklistedClass
}
+export type PreferCanonicalClassesDiagnostic = Diagnostic & {
+ code: DiagnosticKind.PreferCanonicalClasses
+ suggestions: string[]
+}
+
+export function isPreferCanonicalClasses(
+ diagnostic: AugmentedDiagnostic,
+): diagnostic is PreferCanonicalClassesDiagnostic {
+ return diagnostic.code === DiagnosticKind.PreferCanonicalClasses
+}
+
export type AugmentedDiagnostic =
| CssConflictDiagnostic
| InvalidApplyDiagnostic
@@ -121,3 +133,4 @@ export type AugmentedDiagnostic =
| InvalidSourceDirectiveDiagnostic
| RecommendedVariantOrderDiagnostic
| UsedBlocklistedClassDiagnostic
+ | PreferCanonicalClassesDiagnostic
diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts
index ecd9d0d8..e562b252 100644
--- a/packages/tailwindcss-language-service/src/util/state.ts
+++ b/packages/tailwindcss-language-service/src/util/state.ts
@@ -66,6 +66,7 @@ export type TailwindCssSettings = {
invalidSourceDirective: DiagnosticSeveritySetting
recommendedVariantOrder: DiagnosticSeveritySetting
usedBlocklistedClass: DiagnosticSeveritySetting
+ preferCanonicalClasses: DiagnosticSeveritySetting
}
experimental: {
classRegex: string[] | [string, string][]
@@ -205,6 +206,7 @@ export function getDefaultTailwindSettings(): Settings {
invalidSourceDirective: 'error',
recommendedVariantOrder: 'warning',
usedBlocklistedClass: 'warning',
+ preferCanonicalClasses: 'ignore',
},
showPixelEquivalents: true,
includeLanguages: {},
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 13c657c3..3b3e3153 100644
--- a/packages/tailwindcss-language-service/src/util/v4/design-system.ts
+++ b/packages/tailwindcss-language-service/src/util/v4/design-system.ts
@@ -38,9 +38,14 @@ export interface DesignSystem {
getClassList(): ClassEntry[]
getVariants(): VariantEntry[]
- // Optional because it did not exist in earlier v4 alpha versions
+ // Added in v4.0.0-alpha.24
resolveThemeValue?(path: string, forceInline?: boolean): string | undefined
+
+ // Added in v4.0.0-alpha.26
invalidCandidates?: Set
+
+ // Added in v4.1.15
+ canonicalizeCandidates?(classes: string[]): string[]
}
export interface DesignSystem {
diff --git a/packages/vscode-tailwindcss/README.md b/packages/vscode-tailwindcss/README.md
index 6ce777d1..d33dca05 100644
--- a/packages/vscode-tailwindcss/README.md
+++ b/packages/vscode-tailwindcss/README.md
@@ -183,6 +183,19 @@ Class variants not in the recommended order (applies in [JIT mode](https://tailw
Usage of class names that have been blocklisted via `@source not inline(…)`. **Default: `warning`**
+#### `tailwindCSS.lint.preferCanonicalClasses`
+
+Detect usage of class names that are not in the most canonical / standardized form. **Default: `ignore`**
+
+Some examples of the changes this makes:
+
+| Class | Canonical Form |
+| --------------------- | -------------- |
+| `[color:red]/100` | `text-[red]` |
+| `[@media_print]:flex` | `print:flex` |
+
+This feature is off by default as it has a non-trivial performance and memory cost on startup and when editing your CSS.
+
### `tailwindCSS.inspectPort`
Enable the Node.js inspector agent for the language server and listen on the specified port. **Default: `null`**
diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json
index 9ba7ca76..8090a6e3 100644
--- a/packages/vscode-tailwindcss/package.json
+++ b/packages/vscode-tailwindcss/package.json
@@ -316,6 +316,17 @@
"markdownDescription": "Usage of class names that have been blocklisted via `@source not inline(…)`",
"scope": "language-overridable"
},
+ "tailwindCSS.lint.preferCanonicalClasses": {
+ "type": "string",
+ "enum": [
+ "ignore",
+ "warning",
+ "error"
+ ],
+ "default": "warning",
+ "markdownDescription": "Usage of class names that may be written in a more optimal form",
+ "scope": "language-overridable"
+ },
"tailwindCSS.experimental.classRegex": {
"type": "array",
"scope": "language-overridable"