Skip to content

Commit 310167e

Browse files
committed
Detect when classes may be written more optimally
1 parent af53a2a commit 310167e

File tree

10 files changed

+169
-3
lines changed

10 files changed

+169
-3
lines changed

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

Lines changed: 59 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,61 @@ defineTest({
425425
])
426426
},
427427
})
428+
429+
defineTest({
430+
name: 'Shows warning when using non-canonical classes',
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({
446+
root,
447+
settings: {
448+
tailwindCSS: {
449+
lint: { suggestCanonicalClasses: 'warning' },
450+
},
451+
},
452+
}),
453+
}),
454+
handle: async ({ client }) => {
455+
let doc = await client.open({
456+
lang: 'html',
457+
text: '<div class="[@media_print]:flex [color:red]/50">',
458+
})
459+
460+
let diagnostics = await doc.diagnostics()
461+
462+
expect(diagnostics).toEqual([
463+
{
464+
code: 'suggestCanonicalClasses',
465+
message: 'The class `[@media_print]:flex` can be written as `print:flex`',
466+
range: {
467+
start: { line: 0, character: 12 },
468+
end: { line: 0, character: 31 },
469+
},
470+
severity: 2,
471+
suggestions: ['print:flex'],
472+
},
473+
{
474+
code: 'suggestCanonicalClasses',
475+
message: 'The class `[color:red]/50` can be written as `text-[red]/50`',
476+
range: {
477+
start: { line: 0, character: 32 },
478+
end: { line: 0, character: 46 },
479+
},
480+
severity: 2,
481+
suggestions: ['text-[red]/50'],
482+
},
483+
])
484+
},
485+
})

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+
isSuggestCanonicalClasses,
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+
isSuggestCanonicalClasses(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+
SuggestCanonicalClassesDiagnostic,
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+
| SuggestCanonicalClassesDiagnostic,
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 SuggestCanonicalClassesDiagnostic, DiagnosticKind } from './types'
4+
import { findClassListsInDocument, getClassNamesInClassList } from '../util/find'
5+
6+
export async function getSuggestCanonicalClassesDiagnostics(
7+
state: State,
8+
document: TextDocument,
9+
settings: Settings,
10+
): Promise<SuggestCanonicalClassesDiagnostic[]> {
11+
if (!state.v4) return []
12+
if (!state.designSystem.canonicalizeCandidates) return []
13+
14+
let severity = settings.tailwindCSS.lint.suggestCanonicalClasses
15+
if (severity === 'ignore') return []
16+
17+
let diagnostics: SuggestCanonicalClassesDiagnostic[] = []
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.SuggestCanonicalClasses,
47+
range: className.range,
48+
severity:
49+
severity === 'error'
50+
? 1 /* DiagnosticSeverity.Error */
51+
: 2 /* DiagnosticSeverity.Warning */,
52+
message: `The class \`${className.className}\` can be 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 { getSuggestCanonicalClassesDiagnostics } 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.SuggestCanonicalClasses,
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.SuggestCanonicalClasses)
63+
? await getSuggestCanonicalClassesDiagnostics(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+
SuggestCanonicalClasses = 'suggestCanonicalClasses',
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 SuggestCanonicalClassesDiagnostic = Diagnostic & {
116+
code: DiagnosticKind.SuggestCanonicalClasses
117+
suggestions: string[]
118+
}
119+
120+
export function isSuggestCanonicalClasses(
121+
diagnostic: AugmentedDiagnostic,
122+
): diagnostic is SuggestCanonicalClassesDiagnostic {
123+
return diagnostic.code === DiagnosticKind.SuggestCanonicalClasses
124+
}
125+
114126
export type AugmentedDiagnostic =
115127
| CssConflictDiagnostic
116128
| InvalidApplyDiagnostic
@@ -121,3 +133,4 @@ export type AugmentedDiagnostic =
121133
| InvalidSourceDirectiveDiagnostic
122134
| RecommendedVariantOrderDiagnostic
123135
| UsedBlocklistedClassDiagnostic
136+
| SuggestCanonicalClassesDiagnostic

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+
suggestCanonicalClasses: 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+
suggestCanonicalClasses: 'warning',
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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,17 @@ 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.suggestCanonicalClasses`
187+
188+
Detect usage of class names that are not in the most optimal form. **Default: `warning`**
189+
190+
Some examples of the changes this makes:
191+
192+
| Class | Canonical Form |
193+
| ------------------------------- | -------------- |
194+
| `[color:red]/100` | `text-[red]` |
195+
| `[@media_print]:[display:flex]` | `print:flex` |
196+
186197
### `tailwindCSS.inspectPort`
187198

188199
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.suggestCanonicalClasses": {
320+
"type": "string",
321+
"enum": [
322+
"ignore",
323+
"warning",
324+
"error"
325+
],
326+
"default": "warning",
327+
"markdownDescription": "Indicate when utilities 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)