diff --git a/packages/tailwindcss-language-server/src/server.ts b/packages/tailwindcss-language-server/src/server.ts index b1b0c436..f4c2b230 100644 --- a/packages/tailwindcss-language-server/src/server.ts +++ b/packages/tailwindcss-language-server/src/server.ts @@ -256,6 +256,7 @@ async function getConfiguration(uri?: string) { colorDecorators: true, rootFontSize: 16, lint: { + invalidClass: 'error', cssConflict: 'warning', invalidApply: 'error', invalidScreen: 'error', diff --git a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts index e59e21de..55178b2c 100644 --- a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts +++ b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts @@ -13,11 +13,14 @@ import { isInvalidScreenDiagnostic, isInvalidVariantDiagnostic, isRecommendedVariantOrderDiagnostic, + isInvalidIdentifierDiagnostic, } from '../diagnostics/types' import { flatten, dedupeBy } from '../util/array' import { provideCssConflictCodeActions } from './provideCssConflictCodeActions' import { provideInvalidApplyCodeActions } from './provideInvalidApplyCodeActions' import { provideSuggestionCodeActions } from './provideSuggestionCodeActions' +import { provideInvalidIdentifierCodeActions } from './provideInvalidIdentifierCodeActions' + async function getDiagnosticsFromCodeActionParams( state: State, @@ -65,6 +68,10 @@ export async function doCodeActions(state: State, params: CodeActionParams, docu return provideCssConflictCodeActions(state, params, diagnostic) } + if (isInvalidIdentifierDiagnostic(diagnostic)) { + return provideInvalidIdentifierCodeActions(state, params, diagnostic) + } + if ( isInvalidConfigPathDiagnostic(diagnostic) || isInvalidTailwindDirectiveDiagnostic(diagnostic) || diff --git a/packages/tailwindcss-language-service/src/codeActions/provideInvalidIdentifierCodeActions.ts b/packages/tailwindcss-language-service/src/codeActions/provideInvalidIdentifierCodeActions.ts new file mode 100644 index 00000000..2aef1047 --- /dev/null +++ b/packages/tailwindcss-language-service/src/codeActions/provideInvalidIdentifierCodeActions.ts @@ -0,0 +1,46 @@ +import { State } from '../util/state' +import { + type CodeActionParams, CodeAction, + CodeActionKind, + Command, +} from 'vscode-languageserver' +import { CssConflictDiagnostic, InvalidIdentifierDiagnostic } from '../diagnostics/types' +import { joinWithAnd } from '../util/joinWithAnd' +import { removeRangesFromString } from '../util/removeRangesFromString' + + +export async function provideInvalidIdentifierCodeActions( + _state: State, + params: CodeActionParams, + diagnostic: InvalidIdentifierDiagnostic +): Promise { + const actions: CodeAction[] = [{ + title: `Ignore '${diagnostic.chunk}' in this workspace`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + command: Command.create(`Ignore '${diagnostic.chunk}' in this workspace`, 'tailwindCSS.addWordToWorkspaceFileFromServer', diagnostic.chunk) + }]; + + if (typeof diagnostic.suggestion == 'string') { + actions.push({ + title: `Replace with '${diagnostic.suggestion}'`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + isPreferred: true, + edit: { + changes: { + [params.textDocument.uri]: [ + { + range: diagnostic.range, + newText: diagnostic.suggestion, + }, + ], + }, + }, + }) + } else { + // unimplemented. + } + + return actions; +} diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts index c8f993ec..400b3f31 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts @@ -8,6 +8,7 @@ import { getInvalidVariantDiagnostics } from './getInvalidVariantDiagnostics' import { getInvalidConfigPathDiagnostics } from './getInvalidConfigPathDiagnostics' import { getInvalidTailwindDirectiveDiagnostics } from './getInvalidTailwindDirectiveDiagnostics' import { getRecommendedVariantOrderDiagnostics } from './getRecommendedVariantOrderDiagnostics' +import { getInvalidValueDiagnostics } from './getInvalidValueDiagnostics' export async function doValidate( state: State, @@ -18,6 +19,7 @@ export async function doValidate( DiagnosticKind.InvalidScreen, DiagnosticKind.InvalidVariant, DiagnosticKind.InvalidConfigPath, + DiagnosticKind.InvalidIdentifier, DiagnosticKind.InvalidTailwindDirective, DiagnosticKind.RecommendedVariantOrder, ] @@ -26,7 +28,10 @@ export async function doValidate( return settings.tailwindCSS.validate ? [ - ...(only.includes(DiagnosticKind.CssConflict) + ...(only.includes(DiagnosticKind.InvalidIdentifier) + ? await getInvalidValueDiagnostics(state, document, settings) + : []), + ...(only.includes(DiagnosticKind.CssConflict) ? await getCssConflictDiagnostics(state, document, settings) : []), ...(only.includes(DiagnosticKind.InvalidApply) diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidValueDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidValueDiagnostics.ts new file mode 100644 index 00000000..89be0231 --- /dev/null +++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidValueDiagnostics.ts @@ -0,0 +1,357 @@ +import { State, Settings, DocumentClassName, Variant } from '../util/state' +import { CssConflictDiagnostic, DiagnosticKind, InvalidIdentifierDiagnostic } from './types' +import { findClassListsInDocument, getClassNamesInClassList } from '../util/find' +import type { TextDocument } from 'vscode-languageserver-textdocument' +import { DiagnosticSeverity, Range } from 'vscode-languageserver' + +function createDiagnostic(data: { + className: DocumentClassName, + range: Range, + chunk: string, + message: string, + suggestion?: string + severity: 'info' | 'warning' | 'error' | 'ignore' + }): InvalidIdentifierDiagnostic +{ + let severity: DiagnosticSeverity = 1; + + switch (data.severity) { + case "info": + severity = 3; + break + case "warning": + severity = 2; + break + case "error": + severity = 1; + break + } + + return({ + code: DiagnosticKind.InvalidIdentifier, + severity, + range: data.range, + message: data.message, + className: data.className, + chunk: data.chunk, + source: "TailwindCSS", + data: { + name: data.className.className + }, + suggestion: data.suggestion, + otherClassNames: null + }) +} + +function generateHashMaps(state: State) +{ + const classes: {[key: string]: State['classList'][0] } = {}; + const noNumericClasses: {[key: string]: string[]} = {}; + const variants: {[key: string]: Variant } = {}; + + state.classList.forEach((classItem) => { + classes[classItem[0]] = classItem; + const splittedClass = classItem[0].split('-'); + if (splittedClass.length != 1) { + const lastToken = splittedClass.pop(); + const joinedName = splittedClass.join('-') + + if (Array.isArray(noNumericClasses[joinedName])) + { + noNumericClasses[joinedName].push(lastToken); + } else { + noNumericClasses[joinedName] = [lastToken]; + } + } + }) + + state.variants.forEach((variant) => { + if (variant.isArbitrary) { + variant.values.forEach(value => { + variants[`${variant.name}-${value}`] = variant; + }) + } else { + variants[variant.name] = variant; + } + }) + + return {classes, variants, noNumericClasses}; +} + +function similarity(s1: string, s2: string) { + if (!s1 || !s2) + return 0; + + var longer = s1; + var shorter = s2; + if (s1.length < s2.length) { + longer = s2; + shorter = s1; + } + var longerLength = longer.length; + if (longerLength == 0) { + return 1.0; + } + return (longerLength - editDistance(longer, shorter)) / longerLength; + } + +function editDistance(s1: string, s2: string) { + s1 = s1.toLowerCase(); + s2 = s2.toLowerCase(); + + var costs = new Array(); + for (var i = 0; i <= s1.length; i++) { + var lastValue = i; + for (var j = 0; j <= s2.length; j++) { + if (i == 0) + costs[j] = j; + else { + if (j > 0) { + var newValue = costs[j - 1]; + if (s1.charAt(i - 1) != s2.charAt(j - 1)) + newValue = Math.min(Math.min(newValue, lastValue), + costs[j]) + 1; + costs[j - 1] = lastValue; + lastValue = newValue; + } + } + } + if (i > 0) + costs[s2.length] = lastValue; + } + return costs[s2.length]; +} + +function getMinimumSimilarity(str: string) { + if (str.length < 5) { + return 0.5 + } else { + return 0.7 + } +} + + +function handleClass(data: {state: State, + settings: Settings, + className: DocumentClassName, + chunk: string, + classes: {[key: string]: State['classList'][0] }, + noNumericClasses: {[key: string]: string[]}, + range: Range + }) +{ + if (data.chunk.indexOf('[') != -1 || data.classes[data.chunk] != undefined) { + return null; + } + + let nonNumericChunk = data.chunk.split('-'); + let nonNumericRemainder = nonNumericChunk.pop(); + const nonNumericValue = nonNumericChunk.join('-'); + + if (data.noNumericClasses[data.chunk]) + { + return createDiagnostic({ + className: data.className, + range: data.range, + chunk: data.chunk, + message: `${data.chunk} requires an postfix. Choose between ${data.noNumericClasses[data.chunk].join(', -')}.`, + severity: data.settings.tailwindCSS.lint.validateClasses, + }) + } + + if (data.classes[nonNumericValue]) + { + return createDiagnostic({ + className: data.className, + range: data.range, + chunk: data.chunk, + message: `${nonNumericValue} requires no postfix.`, + suggestion: nonNumericValue, + severity: data.settings.tailwindCSS.lint.validateClasses, + }) + } + + if (nonNumericValue && data.noNumericClasses[nonNumericValue]) + { + let closestSuggestion = { + value: 0, + text: "" + }; + + for (let i = 0; i < data.noNumericClasses[nonNumericValue].length; i++) { + const e = data.noNumericClasses[nonNumericValue][i]; + const match = similarity(e, nonNumericRemainder); + if (match > 0.5 && match > closestSuggestion.value) { + closestSuggestion = { + value: match, + text: e + } + } + } + + if (closestSuggestion.text) + { + return createDiagnostic({ + className: data.className, + range: data.range, + chunk: data.chunk, + message: `${data.chunk} is an invalid value. Did you mean ${nonNumericValue + '-' + closestSuggestion.text}?`, + suggestion: nonNumericValue + '-' + closestSuggestion.text, + severity: data.settings.tailwindCSS.lint.validateClasses, + }) + } + else + { + return createDiagnostic({ + className: data.className, + range: data.range, + chunk: data.chunk, + message: `${data.chunk} is an invalid value. Choose between ${data.noNumericClasses[nonNumericValue].join(', ')}.`, + severity: data.settings.tailwindCSS.lint.validateClasses, + }) + } + } + + // get similar as suggestion + let closestSuggestion = { + value: 0, + text: "" + }; + + let minimumSimilarity = getMinimumSimilarity(data.className.className) + for (let i = 0; i < data.state.classList.length; i++) { + const e = data.state.classList[i]; + const match = similarity(e[0], data.className.className); + if (match >= minimumSimilarity && match > closestSuggestion.value) { + closestSuggestion = { + value: match, + text: e[0] + } + } + } + + if (closestSuggestion.text) + { + return createDiagnostic({ + className: data.className, + range: data.range, + chunk: data.chunk, + message: `${data.chunk} was not found in the registry. Did you mean ${closestSuggestion.text}?`, + severity: data.settings.tailwindCSS.lint.validateClasses, + suggestion: closestSuggestion.text + }) + } + else if (data.settings.tailwindCSS.lint.onlyAllowTailwindCSS) + { + return createDiagnostic({ + className: data.className, + range: data.range, + chunk: data.chunk, + message: `${data.chunk} was not found in the registry.`, + severity: data.settings.tailwindCSS.lint.validateClasses + }) + } + return null +} + +function handleVariant(data: { + state: State, + settings: Settings, + className: DocumentClassName, + chunk: string, + variants: {[key: string]: Variant }, + range: Range + }) +{ + if (data.chunk.indexOf('[') != -1 || data.variants[data.chunk]) { + return null; + } + + // get similar as suggestion + let closestSuggestion = { + value: 0, + text: "" + }; + let minimumSimilarity = getMinimumSimilarity(data.className.className) + + Object.keys(data.variants).forEach(key => { + const variant = data.variants[key]; + const match = similarity(variant.name, data.chunk); + if (match >= minimumSimilarity && match > closestSuggestion.value) { + closestSuggestion = { + value: match, + text: variant.name + } + } + }) + + + if (closestSuggestion.text) + { + return createDiagnostic({ + className: data.className, + range: data.range, + chunk: data.chunk, + message: `${data.chunk} is an invalid variant. Did you mean ${closestSuggestion.text}?`, + suggestion: closestSuggestion.text, + severity: data.settings.tailwindCSS.lint.validateClasses + }) + } + else + { + return createDiagnostic({ + className: data.className, + range: data.range, + chunk: data.chunk, + message: `${data.chunk} is an invalid variant.`, + severity: data.settings.tailwindCSS.lint.validateClasses + }); + } + +} + +export async function getInvalidValueDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): Promise { + let severity = settings.tailwindCSS.lint.validateClasses + if (severity === 'ignore') return []; + + const items = []; + const { classes, variants, noNumericClasses} = generateHashMaps(state); + + const classLists = await findClassListsInDocument(state, document) + classLists.forEach((classList) => { + const classNames = getClassNamesInClassList(classList, state.blocklist) + classNames.forEach((className, index) => { + const splitted = className.className.split(state.separator); + + let offset = 0; + splitted.forEach((chunk, index) => { + + const range: Range = {start: { + line: className.range.start.line, + character: className.range.start.character + offset, + }, end: { + line: className.range.start.line, + character: className.range.start.character + offset + chunk.length, + }} + + if (!settings.tailwindCSS.ignoredCSS.find(x => x == chunk)) { + if (index == splitted.length - 1) + { + items.push(handleClass({state, settings, className, chunk, classes, noNumericClasses, range})); + } + else + { + items.push(handleVariant({state, settings, className, chunk, variants, range})); + } + } + offset += chunk.length + 1; + }) + }); + }) + + return items.filter(Boolean); +} \ 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 6f1bc858..2a5d260f 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/types.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/types.ts @@ -2,6 +2,7 @@ import type { Diagnostic } from 'vscode-languageserver' import { DocumentClassName, DocumentClassList } from '../util/state' export enum DiagnosticKind { + InvalidIdentifier = 'invalidIdentifier', CssConflict = 'cssConflict', InvalidApply = 'invalidApply', InvalidScreen = 'invalidScreen', @@ -11,6 +12,20 @@ export enum DiagnosticKind { RecommendedVariantOrder = 'recommendedVariantOrder', } +export type InvalidIdentifierDiagnostic = Diagnostic & { + code: DiagnosticKind.InvalidIdentifier + className: DocumentClassName, + suggestion?: string, + chunk: string, + otherClassNames: DocumentClassName[] +} + +export function isInvalidIdentifierDiagnostic( + diagnostic: AugmentedDiagnostic + ): diagnostic is InvalidIdentifierDiagnostic { + return diagnostic.code === DiagnosticKind.InvalidIdentifier + } + export type CssConflictDiagnostic = Diagnostic & { code: DiagnosticKind.CssConflict className: DocumentClassName @@ -94,6 +109,7 @@ export type AugmentedDiagnostic = | InvalidApplyDiagnostic | InvalidScreenDiagnostic | InvalidVariantDiagnostic + | InvalidIdentifierDiagnostic | InvalidConfigPathDiagnostic | InvalidTailwindDirectiveDiagnostic | RecommendedVariantOrderDiagnostic diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index cc2c416c..b0c4dfee 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -51,11 +51,15 @@ export type TailwindCssSettings = { showPixelEquivalents: boolean rootFontSize: number colorDecorators: boolean + ignoredCSS: string[] lint: { + invalidClass: DiagnosticSeveritySetting cssConflict: DiagnosticSeveritySetting invalidApply: DiagnosticSeveritySetting invalidScreen: DiagnosticSeveritySetting - invalidVariant: DiagnosticSeveritySetting + invalidVariant: DiagnosticSeveritySetting, + validateClasses: DiagnosticSeveritySetting, + onlyAllowTailwindCSS: boolean, invalidConfigPath: DiagnosticSeveritySetting invalidTailwindDirective: DiagnosticSeveritySetting recommendedVariantOrder: DiagnosticSeveritySetting diff --git a/packages/vscode-tailwindcss/package.json b/packages/vscode-tailwindcss/package.json index 0016e5c2..167ab3c7 100755 --- a/packages/vscode-tailwindcss/package.json +++ b/packages/vscode-tailwindcss/package.json @@ -290,6 +290,24 @@ "markdownDescription": "Class variants not in the recommended order (applies in [JIT mode](https://tailwindcss.com/docs/just-in-time-mode) only)", "scope": "language-overridable" }, + "tailwindCSS.lint.validateClasses": { + "type": "string", + "enum": [ + "ignore", + "info", + "warning", + "error" + ], + "default": "warning", + "markdownDescription": "Validate CSS for wrongly typed tailwind classes.", + "scope": "language-overridable" + }, + "tailwindCSS.lint.onlyAllowTailwindCSS": { + "type": "boolean", + "default": true, + "markdownDescription": "Validate CSS for non / invalid tailwindCSS classes. You are able to ignore on an case-by-case basis. Requires `tailwindCSS.lint.validateClasses` to be active.", + "scope": "language-overridable" + }, "tailwindCSS.experimental.classRegex": { "type": "array", "scope": "language-overridable" @@ -320,7 +338,15 @@ ], "default": null, "markdownDescription": "Enable the Node.js inspector agent for the language server and listen on the specified port." - } + }, + "tailwindCSS.ignoredCSS": { + "items": { + "type": "string" + }, + "markdownDescription": "List of CSS classes to be considered correct.", + "scope": "resource", + "type": "array" + } } } }, diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index f3860271..4f194317 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -27,6 +27,8 @@ import { SnippetString, TextEdit, Selection, + workspace, + ConfigurationTarget, } from 'vscode' import { LanguageClient, @@ -703,6 +705,16 @@ export async function activate(context: ExtensionContext) { clients.set(folder.uri.toString(), client) } + context.subscriptions.push( + commands.registerCommand('tailwindCSS.addWordToWorkspaceFileFromServer', (name) => { + const storedKeys: string[] = workspace.getConfiguration().get('tailwindCSS.ignoredCSS') + + storedKeys.push(name); + workspace.getConfiguration() + .update('tailwindCSS.ignoredCSS', [...new Set(storedKeys)], ConfigurationTarget.Workspace) + }) + ) + async function bootClientForFolderIfNeeded(folder: WorkspaceFolder): Promise { let settings = Workspace.getConfiguration('tailwindCSS', folder) if (settings.get('experimental.configFile') !== null) {