diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js index afda8837..23ed4d47 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js +++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js @@ -1,7 +1,7 @@ import * as fs from 'node:fs/promises' import { expect, test } from 'vitest' import { withFixture } from '../common' -import { css, defineTest } from '../../src/testing' +import { css, defineTest, json } from '../../src/testing' import { createClient } from '../utils/client' withFixture('basic', (c) => { @@ -425,3 +425,89 @@ defineTest({ ]) }, }) + +defineTest({ + name: 'Non-canonical classes do not warn by default', + 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 }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + 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"