Skip to content

Commit 288d027

Browse files
committed
Detect when classes may be written more optimally
This diagnostic is disabled by default
1 parent af53a2a commit 288d027

File tree

10 files changed

+199
-3
lines changed

10 files changed

+199
-3
lines changed

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

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as fs from 'node:fs/promises'
22
import { expect, test } from 'vitest'
33
import { withFixture } from '../common'
4-
import { css, defineTest } from '../../src/testing'
4+
import { css, defineTest, json } from '../../src/testing'
55
import { createClient } from '../utils/client'
66

77
withFixture('basic', (c) => {
@@ -425,3 +425,89 @@ defineTest({
425425
])
426426
},
427427
})
428+
429+
defineTest({
430+
name: 'Non-canonical classes do not warn by default',
431+
fs: {
432+
// TODO: Drop this when the embedded version of tailwindcss is v4.1.15
433+
'package.json': json`
434+
{
435+
"dependencies": {
436+
"tailwindcss": "0.0.0-insiders.efe084b"
437+
}
438+
}
439+
`,
440+
'app.css': css`
441+
@import 'tailwindcss';
442+
`,
443+
},
444+
prepare: async ({ root }) => ({
445+
client: await createClient({ root }),
446+
}),
447+
handle: async ({ client }) => {
448+
let doc = await client.open({
449+
lang: 'html',
450+
text: '<div class="[@media_print]:flex [color:red]/50">',
451+
})
452+
453+
expect(await doc.diagnostics()).toEqual([])
454+
},
455+
})
456+
457+
defineTest({
458+
name: 'Can show warning when using non-canonical classes',
459+
fs: {
460+
// TODO: Drop this when the embedded version of tailwindcss is v4.1.15
461+
'package.json': json`
462+
{
463+
"dependencies": {
464+
"tailwindcss": "0.0.0-insiders.efe084b"
465+
}
466+
}
467+
`,
468+
'app.css': css`
469+
@import 'tailwindcss';
470+
`,
471+
},
472+
prepare: async ({ root }) => ({
473+
client: await createClient({
474+
root,
475+
settings: {
476+
tailwindCSS: {
477+
lint: { preferCanonicalClasses: 'warning' },
478+
},
479+
},
480+
}),
481+
}),
482+
handle: async ({ client }) => {
483+
let doc = await client.open({
484+
lang: 'html',
485+
text: '<div class="[@media_print]:flex [color:red]/50">',
486+
})
487+
488+
let diagnostics = await doc.diagnostics()
489+
490+
expect(diagnostics).toEqual([
491+
{
492+
code: 'preferCanonicalClasses',
493+
message: 'The class `[@media_print]:flex` is usually written as `print:flex`',
494+
range: {
495+
start: { line: 0, character: 12 },
496+
end: { line: 0, character: 31 },
497+
},
498+
severity: 2,
499+
suggestions: ['print:flex'],
500+
},
501+
{
502+
code: 'preferCanonicalClasses',
503+
message: 'The class `[color:red]/50` is usually written as `text-[red]/50`',
504+
range: {
505+
start: { line: 0, character: 32 },
506+
end: { line: 0, character: 46 },
507+
},
508+
severity: 2,
509+
suggestions: ['text-[red]/50'],
510+
},
511+
])
512+
},
513+
})

packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isInvalidScreenDiagnostic,
1414
isInvalidVariantDiagnostic,
1515
isRecommendedVariantOrderDiagnostic,
16+
isPreferCanonicalClasses,
1617
} from '../diagnostics/types'
1718
import { flatten, dedupeBy } from '../util/array'
1819
import { provideCssConflictCodeActions } from './provideCssConflictCodeActions'
@@ -74,7 +75,8 @@ export async function doCodeActions(
7475
isInvalidTailwindDirectiveDiagnostic(diagnostic) ||
7576
isInvalidScreenDiagnostic(diagnostic) ||
7677
isInvalidVariantDiagnostic(diagnostic) ||
77-
isRecommendedVariantOrderDiagnostic(diagnostic)
78+
isRecommendedVariantOrderDiagnostic(diagnostic) ||
79+
isPreferCanonicalClasses(diagnostic)
7880
) {
7981
return provideSuggestionCodeActions(state, params, diagnostic)
8082
}

packages/tailwindcss-language-service/src/codeActions/provideSuggestionCodeActions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
InvalidScreenDiagnostic,
77
InvalidVariantDiagnostic,
88
RecommendedVariantOrderDiagnostic,
9+
PreferCanonicalClassesDiagnostic,
910
} from '../diagnostics/types'
1011

1112
export function provideSuggestionCodeActions(
@@ -16,7 +17,8 @@ export function provideSuggestionCodeActions(
1617
| InvalidTailwindDirectiveDiagnostic
1718
| InvalidScreenDiagnostic
1819
| InvalidVariantDiagnostic
19-
| RecommendedVariantOrderDiagnostic,
20+
| RecommendedVariantOrderDiagnostic
21+
| PreferCanonicalClassesDiagnostic,
2022
): CodeAction[] {
2123
return diagnostic.suggestions.map((suggestion) => ({
2224
title: `Replace with '${suggestion}'`,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { TextDocument } from 'vscode-languageserver-textdocument'
2+
import type { State, Settings } from '../util/state'
3+
import { type PreferCanonicalClassesDiagnostic, DiagnosticKind } from './types'
4+
import { findClassListsInDocument, getClassNamesInClassList } from '../util/find'
5+
6+
export async function getPreferCanonicalClassesDiagnostics(
7+
state: State,
8+
document: TextDocument,
9+
settings: Settings,
10+
): Promise<PreferCanonicalClassesDiagnostic[]> {
11+
if (!state.v4) return []
12+
if (!state.designSystem.canonicalizeCandidates) return []
13+
14+
let severity = settings.tailwindCSS.lint.preferCanonicalClasses
15+
if (severity === 'ignore') return []
16+
17+
let diagnostics: PreferCanonicalClassesDiagnostic[] = []
18+
19+
let classLists = await findClassListsInDocument(state, document)
20+
21+
for (let classList of classLists) {
22+
let classNames = getClassNamesInClassList(classList, [])
23+
24+
// NOTES:
25+
//
26+
// A planned enhancement to `canonicalizeCandidates` is to operate on class *lists* which would
27+
// allow `[font-size:0.875rem] [line-height:0.25rem]` to turn into a single `text-sm/3` class.
28+
//
29+
// To account for this future we process class names individually. At some future point we can
30+
// then take the list of individual classes and pass it in *again* to issue a diagnostic for the
31+
// class list as a whole.
32+
//
33+
// This may not allow you to see *which classes* got combined since the inputs/outputs map to
34+
// entire lists but this seems fine to do
35+
//
36+
// We'd probably want to only issue a class list diagnostic once there are no individual class
37+
// diagnostics in a given class list.
38+
39+
for (let className of classNames) {
40+
let canonicalized = state.designSystem.canonicalizeCandidates([className.className])[0]
41+
let isCanonical = canonicalized === className.className
42+
43+
if (isCanonical) continue
44+
45+
diagnostics.push({
46+
code: DiagnosticKind.PreferCanonicalClasses,
47+
range: className.range,
48+
severity:
49+
severity === 'error'
50+
? 1 /* DiagnosticSeverity.Error */
51+
: 2 /* DiagnosticSeverity.Warning */,
52+
message: `The class \`${className.className}\` is usually written as \`${canonicalized}\``,
53+
suggestions: [canonicalized],
54+
})
55+
}
56+
}
57+
58+
return diagnostics
59+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getInvalidTailwindDirectiveDiagnostics } from './getInvalidTailwindDire
1010
import { getRecommendedVariantOrderDiagnostics } from './getRecommendedVariantOrderDiagnostics'
1111
import { getInvalidSourceDiagnostics } from './getInvalidSourceDiagnostics'
1212
import { getUsedBlocklistedClassDiagnostics } from './getUsedBlocklistedClassDiagnostics'
13+
import { getPreferCanonicalClassesDiagnostics } from './canonical-classes'
1314

1415
export async function doValidate(
1516
state: State,
@@ -24,6 +25,7 @@ export async function doValidate(
2425
DiagnosticKind.InvalidSourceDirective,
2526
DiagnosticKind.RecommendedVariantOrder,
2627
DiagnosticKind.UsedBlocklistedClass,
28+
DiagnosticKind.PreferCanonicalClasses,
2729
],
2830
): Promise<AugmentedDiagnostic[]> {
2931
const settings = await state.editor.getConfiguration(document.uri)
@@ -57,6 +59,9 @@ export async function doValidate(
5759
...(only.includes(DiagnosticKind.UsedBlocklistedClass)
5860
? await getUsedBlocklistedClassDiagnostics(state, document, settings)
5961
: []),
62+
...(only.includes(DiagnosticKind.PreferCanonicalClasses)
63+
? await getPreferCanonicalClassesDiagnostics(state, document, settings)
64+
: []),
6065
]
6166
: []
6267
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export enum DiagnosticKind {
1111
InvalidSourceDirective = 'invalidSourceDirective',
1212
RecommendedVariantOrder = 'recommendedVariantOrder',
1313
UsedBlocklistedClass = 'usedBlocklistedClass',
14+
PreferCanonicalClasses = 'preferCanonicalClasses',
1415
}
1516

1617
export type CssConflictDiagnostic = Diagnostic & {
@@ -111,6 +112,17 @@ export function isUsedBlocklistedClass(
111112
return diagnostic.code === DiagnosticKind.UsedBlocklistedClass
112113
}
113114

115+
export type PreferCanonicalClassesDiagnostic = Diagnostic & {
116+
code: DiagnosticKind.PreferCanonicalClasses
117+
suggestions: string[]
118+
}
119+
120+
export function isPreferCanonicalClasses(
121+
diagnostic: AugmentedDiagnostic,
122+
): diagnostic is PreferCanonicalClassesDiagnostic {
123+
return diagnostic.code === DiagnosticKind.PreferCanonicalClasses
124+
}
125+
114126
export type AugmentedDiagnostic =
115127
| CssConflictDiagnostic
116128
| InvalidApplyDiagnostic
@@ -121,3 +133,4 @@ export type AugmentedDiagnostic =
121133
| InvalidSourceDirectiveDiagnostic
122134
| RecommendedVariantOrderDiagnostic
123135
| UsedBlocklistedClassDiagnostic
136+
| PreferCanonicalClassesDiagnostic

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export type TailwindCssSettings = {
6666
invalidSourceDirective: DiagnosticSeveritySetting
6767
recommendedVariantOrder: DiagnosticSeveritySetting
6868
usedBlocklistedClass: DiagnosticSeveritySetting
69+
preferCanonicalClasses: DiagnosticSeveritySetting
6970
}
7071
experimental: {
7172
classRegex: string[] | [string, string][]
@@ -205,6 +206,7 @@ export function getDefaultTailwindSettings(): Settings {
205206
invalidSourceDirective: 'error',
206207
recommendedVariantOrder: 'warning',
207208
usedBlocklistedClass: 'warning',
209+
preferCanonicalClasses: 'ignore',
208210
},
209211
showPixelEquivalents: true,
210212
includeLanguages: {},

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export interface DesignSystem {
4343

4444
// Added in v4.0.0-alpha.26
4545
invalidCandidates?: Set<string>
46+
47+
// Added in v4.1.15
48+
canonicalizeCandidates?(classes: string[]): string[]
4649
}
4750

4851
export interface DesignSystem {

packages/vscode-tailwindcss/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,19 @@ Class variants not in the recommended order (applies in [JIT mode](https://tailw
183183

184184
Usage of class names that have been blocklisted via `@source not inline(…)`. **Default: `warning`**
185185

186+
#### `tailwindCSS.lint.preferCanonicalClasses`
187+
188+
Detect usage of class names that are not in the most canonical / standardized form. **Default: `ignore`**
189+
190+
Some examples of the changes this makes:
191+
192+
| Class | Canonical Form |
193+
| --------------------- | -------------- |
194+
| `[color:red]/100` | `text-[red]` |
195+
| `[@media_print]:flex` | `print:flex` |
196+
197+
This feature is off by default as it has a non-trivial performance and memory cost on startup and when editing your CSS.
198+
186199
### `tailwindCSS.inspectPort`
187200

188201
Enable the Node.js inspector agent for the language server and listen on the specified port. **Default: `null`**

packages/vscode-tailwindcss/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,17 @@
316316
"markdownDescription": "Usage of class names that have been blocklisted via `@source not inline(…)`",
317317
"scope": "language-overridable"
318318
},
319+
"tailwindCSS.lint.preferCanonicalClasses": {
320+
"type": "string",
321+
"enum": [
322+
"ignore",
323+
"warning",
324+
"error"
325+
],
326+
"default": "warning",
327+
"markdownDescription": "Usage of class names that may be written in a more optimal form",
328+
"scope": "language-overridable"
329+
},
319330
"tailwindCSS.experimental.classRegex": {
320331
"type": "array",
321332
"scope": "language-overridable"

0 commit comments

Comments
 (0)