From 382b8bb378fc50eebe0afbe91f6dd3dd5aae70e4 Mon Sep 17 00:00:00 2001 From: Alpha1337k Date: Tue, 24 Oct 2023 02:38:51 +0200 Subject: [PATCH 1/5] Feat: basic working invalid value detection --- .../src/diagnostics/diagnosticsProvider.ts | 7 +- .../getUnknownClassesDiagnostics.ts | 223 ++++++++++++++++++ .../src/diagnostics/types.ts | 1 + 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts index c8f993ec..71de07c8 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 { getUnknownClassesDiagnostics } from './getUnknownClassesDiagnostics' 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 getUnknownClassesDiagnostics(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/getUnknownClassesDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts new file mode 100644 index 00000000..96738ec5 --- /dev/null +++ b/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts @@ -0,0 +1,223 @@ +import { joinWithAnd } from '../util/joinWithAnd' +import { State, Settings, DocumentClassName } from '../util/state' +import { CssConflictDiagnostic, DiagnosticKind } from './types' +import { findClassListsInDocument, getClassNamesInClassList } from '../util/find' +import { getClassNameDecls } from '../util/getClassNameDecls' +import { getClassNameMeta } from '../util/getClassNameMeta' +import { equal } from '../util/array' +import * as jit from '../util/jit' +import type { AtRule, Node, Rule } from 'postcss' +import type { TextDocument } from 'vscode-languageserver-textdocument' +import { Position, Range } from 'vscode-languageserver' + +function isAtRule(node: Node): node is AtRule { + return node.type === 'atrule' + } + +function isKeyframes(rule: Rule): boolean { + let parent = rule.parent + if (!parent) { + return false + } + if (isAtRule(parent) && parent.name === 'keyframes') { + return true + } + return false +} + +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 getRuleProperties(rule: Rule): string[] { + let properties: string[] = [] + rule.walkDecls(({ prop }) => { + properties.push(prop) + }) + // if (properties.findIndex((p) => !isCustomProperty(p)) > -1) { + // properties = properties.filter((p) => !isCustomProperty(p)) + // } + return properties + } + +function handleClass(state: State, className: DocumentClassName, chunk: string) +{ + if (chunk.indexOf('[') != -1 || state.classList.find(x => x[0] == chunk)) { + return null; + } + // get similar as suggestion + let closestSuggestion = { + value: 0, + text: "" + }; + for (let i = 0; i < state.classList.length; i++) { + const e = state.classList[i]; + const match = similarity(e[0], className.className); + if (match > 0.5 && match > closestSuggestion.value) { + closestSuggestion = { + value: match, + text: e[0] + } + } + } + + if (closestSuggestion.text) + { + return({ + code: DiagnosticKind.InvalidIdentifier, + severity: 3, + range: className.range, + message: `${chunk} was not found in the registry. Did you mean ${closestSuggestion.text} (${closestSuggestion.value})?`, + className, + otherClassNames: null + }) + } + else + { + return({ + code: DiagnosticKind.InvalidIdentifier, + severity: 3, + range: className.range, + message: `${chunk} was not found in the registry.`, + className, + otherClassNames: null + }) + } +} + +function handleVariant(state: State, className: DocumentClassName, chunk: string) +{ + if (chunk.indexOf('[') != -1 || state.variants.find(x => x.name == chunk)) { + return null; + } + + if (chunk.indexOf('-') != -1 && + state.variants.find(x => { + return x.isArbitrary ? x.values.find(value => `${x.name}-${value}` == chunk) : undefined + }) + ) { + return null; + } + + // get similar as suggestion + let closestSuggestion = { + value: 0, + text: "" + }; + for (let i = 0; i < state.variants.length; i++) { + const e = state.variants[i]; + const match = similarity(e[0], chunk); + if (match > 0.5 && match > closestSuggestion.value) { + closestSuggestion = { + value: match, + text: e[0] + } + } + } + + if (closestSuggestion.text) + { + return{ + code: DiagnosticKind.InvalidIdentifier, + severity: 3, + range: className.range, + message: `${chunk} is an invalid variant. Did you mean ${closestSuggestion.text} (${closestSuggestion.value})?`, + className, + otherClassNames: null + } + } + else + { + return { + code: DiagnosticKind.InvalidIdentifier, + severity: 3, + range: className.range, + message: `${chunk} is an invalid variant.`, + className, + otherClassNames: null + } + } + +} + +export async function getUnknownClassesDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): Promise { + // let severity = settings.tailwindCSS.lint + // if (severity === 'ignore') return []; + + let diagnostics: CssConflictDiagnostic[] = []; + const classLists = await findClassListsInDocument(state, document) + const items = []; + + classLists.forEach((classList) => { + const classNames = getClassNamesInClassList(classList, state.blocklist) + + classNames.forEach((className, index) => { + const splitted = className.className.split(state.separator); + + splitted.forEach((chunk, index) => { + if (chunk == 'group-only') + { + debugger; + } + + // class + if (index == splitted.length - 1) + { + items.push(handleClass(state, className, chunk )); + } + // variant + else + { + items.push(handleVariant(state, className, chunk)); + + } + }) + }); + }) + + 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..8df2a5f1 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', From 513a555c374e9ac3e6a37b1e7ba872851c0f16b0 Mon Sep 17 00:00:00 2001 From: Alpha1337k Date: Wed, 25 Oct 2023 01:17:35 +0200 Subject: [PATCH 2/5] Feat: optimized execution + postfix error checking --- .../getUnknownClassesDiagnostics.ts | 146 +++++++++++++++--- 1 file changed, 121 insertions(+), 25 deletions(-) diff --git a/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts index 96738ec5..fb80bb7f 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts @@ -1,5 +1,5 @@ import { joinWithAnd } from '../util/joinWithAnd' -import { State, Settings, DocumentClassName } from '../util/state' +import { State, Settings, DocumentClassName, Variant } from '../util/state' import { CssConflictDiagnostic, DiagnosticKind } from './types' import { findClassListsInDocument, getClassNamesInClassList } from '../util/find' import { getClassNameDecls } from '../util/getClassNameDecls' @@ -14,15 +14,39 @@ function isAtRule(node: Node): node is AtRule { return node.type === 'atrule' } -function isKeyframes(rule: Rule): boolean { - let parent = rule.parent - if (!parent) { - return false - } - if (isAtRule(parent) && parent.name === 'keyframes') { - return true - } - return false +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) { @@ -80,11 +104,89 @@ function getRuleProperties(rule: Rule): string[] { return properties } -function handleClass(state: State, className: DocumentClassName, chunk: string) +function handleClass(state: State, + className: DocumentClassName, + chunk: string, + classes: {[key: string]: State['classList'][0] }, + noNumericClasses: {[key: string]: string[]}, + ) { - if (chunk.indexOf('[') != -1 || state.classList.find(x => x[0] == chunk)) { + if (chunk.indexOf('[') != -1 || classes[chunk] != undefined) { return null; } + + let nonNumericChunk = chunk.split('-'); + let nonNumericRemainder = nonNumericChunk.pop(); + const nonNumericValue = nonNumericChunk.join('-'); + + if (noNumericClasses[chunk]) + { + return({ + code: DiagnosticKind.InvalidIdentifier, + severity: 3, + range: className.range, + message: `${chunk} requires an postfix. Choose between ${noNumericClasses[chunk].join(', -')}.`, + className, + otherClassNames: null + }) + } + + if (classes[nonNumericValue]) + { + return({ + code: DiagnosticKind.InvalidIdentifier, + severity: 3, + range: className.range, + message: `${chunk} requires no postfix.`, + className, + otherClassNames: null + }) + } + + if (nonNumericValue && noNumericClasses[nonNumericValue]) + { + let closestSuggestion = { + value: 0, + text: "" + }; + + debugger; + + for (let i = 0; i < noNumericClasses[nonNumericValue].length; i++) { + const e = noNumericClasses[nonNumericValue][i]; + const match = similarity(e, nonNumericRemainder); + if (match > 0.5 && match > closestSuggestion.value) { + closestSuggestion = { + value: match, + text: e + } + } + } + + if (closestSuggestion.text) + { + return({ + code: DiagnosticKind.InvalidIdentifier, + severity: 3, + range: className.range, + message: `${chunk} is an invalid value. Did you mean ${nonNumericValue + '-' + closestSuggestion.text}? (${closestSuggestion.value})`, + className, + otherClassNames: null + }) + } + else + { + return({ + code: DiagnosticKind.InvalidIdentifier, + severity: 3, + range: className.range, + message: `${chunk} is an invalid value. Choose between ${noNumericClasses[nonNumericValue].join(', ')}.`, + className, + otherClassNames: null + }) + } + } + // get similar as suggestion let closestSuggestion = { value: 0, @@ -125,20 +227,12 @@ function handleClass(state: State, className: DocumentClassName, chunk: string) } } -function handleVariant(state: State, className: DocumentClassName, chunk: string) +function handleVariant(state: State, className: DocumentClassName, chunk: string, variants: {[key: string]: Variant }) { - if (chunk.indexOf('[') != -1 || state.variants.find(x => x.name == chunk)) { + if (chunk.indexOf('[') != -1 || variants[chunk]) { return null; } - if (chunk.indexOf('-') != -1 && - state.variants.find(x => { - return x.isArbitrary ? x.values.find(value => `${x.name}-${value}` == chunk) : undefined - }) - ) { - return null; - } - // get similar as suggestion let closestSuggestion = { value: 0, @@ -188,6 +282,8 @@ export async function getUnknownClassesDiagnostics( // let severity = settings.tailwindCSS.lint // if (severity === 'ignore') return []; + const { classes, variants, noNumericClasses} = generateHashMaps(state); + let diagnostics: CssConflictDiagnostic[] = []; const classLists = await findClassListsInDocument(state, document) const items = []; @@ -195,6 +291,7 @@ export async function getUnknownClassesDiagnostics( classLists.forEach((classList) => { const classNames = getClassNamesInClassList(classList, state.blocklist) + let offset = 0; classNames.forEach((className, index) => { const splitted = className.className.split(state.separator); @@ -207,13 +304,12 @@ export async function getUnknownClassesDiagnostics( // class if (index == splitted.length - 1) { - items.push(handleClass(state, className, chunk )); + items.push(handleClass(state, className, chunk, classes, noNumericClasses)); } // variant else { - items.push(handleVariant(state, className, chunk)); - + items.push(handleVariant(state, className, chunk, variants)); } }) }); From dab4309dc9e75429aa80fcefd08e7eea4c58166e Mon Sep 17 00:00:00 2001 From: Alpha1337k Date: Wed, 25 Oct 2023 02:12:51 +0200 Subject: [PATCH 3/5] Feat: quick suggestions for typos --- .../tailwindcss-language-server/src/server.ts | 1 + .../src/codeActions/codeActionProvider.ts | 7 + .../provideInvalidIdentifierCodeActions.ts | 35 ++++ .../getUnknownClassesDiagnostics.ts | 162 ++++++------------ .../src/diagnostics/types.ts | 14 ++ .../src/util/state.ts | 1 + 6 files changed, 108 insertions(+), 112 deletions(-) create mode 100644 packages/tailwindcss-language-service/src/codeActions/provideInvalidIdentifierCodeActions.ts 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..d3b30a3c --- /dev/null +++ b/packages/tailwindcss-language-service/src/codeActions/provideInvalidIdentifierCodeActions.ts @@ -0,0 +1,35 @@ +import { State } from '../util/state' +import type { + CodeActionParams, + CodeAction, +} 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 { + if (!diagnostic.suggestion) return []; + + debugger; + return [ + { + title: `Replace with '${diagnostic.suggestion}'`, + kind: 'quickfix', // CodeActionKind.QuickFix, + diagnostics: [diagnostic], + edit: { + changes: { + [params.textDocument.uri]: [ + { + range: diagnostic.range, + newText: diagnostic.suggestion, + }, + ], + }, + }, + }, + ] +} diff --git a/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts index fb80bb7f..4f0cbf8e 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts @@ -1,18 +1,21 @@ -import { joinWithAnd } from '../util/joinWithAnd' import { State, Settings, DocumentClassName, Variant } from '../util/state' -import { CssConflictDiagnostic, DiagnosticKind } from './types' +import { CssConflictDiagnostic, DiagnosticKind, InvalidIdentifierDiagnostic } from './types' import { findClassListsInDocument, getClassNamesInClassList } from '../util/find' -import { getClassNameDecls } from '../util/getClassNameDecls' -import { getClassNameMeta } from '../util/getClassNameMeta' -import { equal } from '../util/array' -import * as jit from '../util/jit' -import type { AtRule, Node, Rule } from 'postcss' import type { TextDocument } from 'vscode-languageserver-textdocument' -import { Position, Range } from 'vscode-languageserver' +import { Range } from 'vscode-languageserver' -function isAtRule(node: Node): node is AtRule { - return node.type === 'atrule' - } +function createDiagnostic(className: DocumentClassName, range: Range, message: string, suggestion?: string): InvalidIdentifierDiagnostic +{ + return({ + code: DiagnosticKind.InvalidIdentifier, + severity: 3, + range: range, + message, + className, + suggestion, + otherClassNames: null + }) +} function generateHashMaps(state: State) { @@ -93,22 +96,12 @@ function editDistance(s1: string, s2: string) { return costs[s2.length]; } -function getRuleProperties(rule: Rule): string[] { - let properties: string[] = [] - rule.walkDecls(({ prop }) => { - properties.push(prop) - }) - // if (properties.findIndex((p) => !isCustomProperty(p)) > -1) { - // properties = properties.filter((p) => !isCustomProperty(p)) - // } - return properties - } - function handleClass(state: State, className: DocumentClassName, chunk: string, classes: {[key: string]: State['classList'][0] }, noNumericClasses: {[key: string]: string[]}, + range: Range ) { if (chunk.indexOf('[') != -1 || classes[chunk] != undefined) { @@ -121,26 +114,12 @@ function handleClass(state: State, if (noNumericClasses[chunk]) { - return({ - code: DiagnosticKind.InvalidIdentifier, - severity: 3, - range: className.range, - message: `${chunk} requires an postfix. Choose between ${noNumericClasses[chunk].join(', -')}.`, - className, - otherClassNames: null - }) + return createDiagnostic(className, range, `${chunk} requires an postfix. Choose between ${noNumericClasses[chunk].join(', -')}.`) } if (classes[nonNumericValue]) { - return({ - code: DiagnosticKind.InvalidIdentifier, - severity: 3, - range: className.range, - message: `${chunk} requires no postfix.`, - className, - otherClassNames: null - }) + return createDiagnostic(className, range, `${chunk} requires no postfix.`) } if (nonNumericValue && noNumericClasses[nonNumericValue]) @@ -150,8 +129,6 @@ function handleClass(state: State, text: "" }; - debugger; - for (let i = 0; i < noNumericClasses[nonNumericValue].length; i++) { const e = noNumericClasses[nonNumericValue][i]; const match = similarity(e, nonNumericRemainder); @@ -165,25 +142,11 @@ function handleClass(state: State, if (closestSuggestion.text) { - return({ - code: DiagnosticKind.InvalidIdentifier, - severity: 3, - range: className.range, - message: `${chunk} is an invalid value. Did you mean ${nonNumericValue + '-' + closestSuggestion.text}? (${closestSuggestion.value})`, - className, - otherClassNames: null - }) + return createDiagnostic(className, range, `${chunk} is an invalid value. Did you mean ${nonNumericValue + '-' + closestSuggestion.text}? (${closestSuggestion.value})`, nonNumericValue + '-' + closestSuggestion.text) } else { - return({ - code: DiagnosticKind.InvalidIdentifier, - severity: 3, - range: className.range, - message: `${chunk} is an invalid value. Choose between ${noNumericClasses[nonNumericValue].join(', ')}.`, - className, - otherClassNames: null - }) + return createDiagnostic(className, range, `${chunk} is an invalid value. Choose between ${noNumericClasses[nonNumericValue].join(', ')}.`) } } @@ -205,29 +168,15 @@ function handleClass(state: State, if (closestSuggestion.text) { - return({ - code: DiagnosticKind.InvalidIdentifier, - severity: 3, - range: className.range, - message: `${chunk} was not found in the registry. Did you mean ${closestSuggestion.text} (${closestSuggestion.value})?`, - className, - otherClassNames: null - }) + return createDiagnostic(className, range, `${chunk} was not found in the registry. Did you mean ${closestSuggestion.text} (${closestSuggestion.value})?`, closestSuggestion.text) } else { - return({ - code: DiagnosticKind.InvalidIdentifier, - severity: 3, - range: className.range, - message: `${chunk} was not found in the registry.`, - className, - otherClassNames: null - }) + return createDiagnostic(className, range, `${chunk} was not found in the registry.`) } } -function handleVariant(state: State, className: DocumentClassName, chunk: string, variants: {[key: string]: Variant }) +function handleVariant(state: State, className: DocumentClassName, chunk: string, variants: {[key: string]: Variant }, range: Range) { if (chunk.indexOf('[') != -1 || variants[chunk]) { return null; @@ -238,38 +187,26 @@ function handleVariant(state: State, className: DocumentClassName, chunk: string value: 0, text: "" }; - for (let i = 0; i < state.variants.length; i++) { - const e = state.variants[i]; - const match = similarity(e[0], chunk); - if (match > 0.5 && match > closestSuggestion.value) { + + Object.keys(variants).forEach(key => { + const variant = variants[key]; + const match = similarity(variant.name, chunk); + if (match >= 0.5 && match > closestSuggestion.value) { closestSuggestion = { value: match, - text: e[0] + text: variant.name } } - } + }) + if (closestSuggestion.text) { - return{ - code: DiagnosticKind.InvalidIdentifier, - severity: 3, - range: className.range, - message: `${chunk} is an invalid variant. Did you mean ${closestSuggestion.text} (${closestSuggestion.value})?`, - className, - otherClassNames: null - } + return createDiagnostic(className, range, `${chunk} is an invalid variant. Did you mean ${closestSuggestion.text} (${closestSuggestion.value})?`, closestSuggestion.text) } else { - return { - code: DiagnosticKind.InvalidIdentifier, - severity: 3, - range: className.range, - message: `${chunk} is an invalid variant.`, - className, - otherClassNames: null - } + return createDiagnostic(className, range, `${chunk} is an invalid variant.`); } } @@ -278,39 +215,40 @@ export async function getUnknownClassesDiagnostics( state: State, document: TextDocument, settings: Settings -): Promise { - // let severity = settings.tailwindCSS.lint - // if (severity === 'ignore') return []; - +): Promise { + let severity = settings.tailwindCSS.lint.invalidClass + if (severity === 'ignore') return []; + + const items = []; const { classes, variants, noNumericClasses} = generateHashMaps(state); - let diagnostics: CssConflictDiagnostic[] = []; const classLists = await findClassListsInDocument(state, document) - const items = []; - classLists.forEach((classList) => { const classNames = getClassNamesInClassList(classList, state.blocklist) - - let offset = 0; classNames.forEach((className, index) => { const splitted = className.className.split(state.separator); + let offset = 0; splitted.forEach((chunk, index) => { - if (chunk == 'group-only') - { - debugger; - } - // class + 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 (index == splitted.length - 1) { - items.push(handleClass(state, className, chunk, classes, noNumericClasses)); + items.push(handleClass(state, className, chunk, classes, noNumericClasses, range)); } - // variant else { - items.push(handleVariant(state, className, chunk, variants)); + items.push(handleVariant(state, className, chunk, variants, range)); } + + offset += chunk.length + 1; }) }); }) diff --git a/packages/tailwindcss-language-service/src/diagnostics/types.ts b/packages/tailwindcss-language-service/src/diagnostics/types.ts index 8df2a5f1..5722bb56 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/types.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/types.ts @@ -12,6 +12,19 @@ export enum DiagnosticKind { RecommendedVariantOrder = 'recommendedVariantOrder', } +export type InvalidIdentifierDiagnostic = Diagnostic & { + code: DiagnosticKind.InvalidIdentifier + className: DocumentClassName, + suggestion?: 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 @@ -95,6 +108,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..f55cae60 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -52,6 +52,7 @@ export type TailwindCssSettings = { rootFontSize: number colorDecorators: boolean lint: { + invalidClass: DiagnosticSeveritySetting cssConflict: DiagnosticSeveritySetting invalidApply: DiagnosticSeveritySetting invalidScreen: DiagnosticSeveritySetting From e2799281f6949094da15ea0acfd6829ccbb80f72 Mon Sep 17 00:00:00 2001 From: Alpha1337k Date: Wed, 25 Oct 2023 02:18:05 +0200 Subject: [PATCH 4/5] Feat: code suggest for prefix --- .../src/diagnostics/getUnknownClassesDiagnostics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts index 4f0cbf8e..1982a467 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts @@ -119,7 +119,7 @@ function handleClass(state: State, if (classes[nonNumericValue]) { - return createDiagnostic(className, range, `${chunk} requires no postfix.`) + return createDiagnostic(className, range, `${nonNumericValue} requires no postfix.`, nonNumericValue) } if (nonNumericValue && noNumericClasses[nonNumericValue]) From 506d0086106250b38b9325a020c6dfa2b73931ae Mon Sep 17 00:00:00 2001 From: Oscar Kruithof Date: Tue, 5 Dec 2023 03:33:20 +0100 Subject: [PATCH 5/5] Ignore css keys (#1) * Feat: working ignore option * Chore: cleanup && improve: better similarity finding * Chore: more styling and wording changes * Chore: more wording changes.. --- .../provideInvalidIdentifierCodeActions.ts | 57 +-- .../src/diagnostics/diagnosticsProvider.ts | 4 +- .../diagnostics/getInvalidValueDiagnostics.ts | 357 ++++++++++++++++++ .../getUnknownClassesDiagnostics.ts | 257 ------------- .../src/diagnostics/types.ts | 1 + .../src/util/state.ts | 5 +- packages/vscode-tailwindcss/package.json | 28 +- packages/vscode-tailwindcss/src/extension.ts | 12 + 8 files changed, 437 insertions(+), 284 deletions(-) create mode 100644 packages/tailwindcss-language-service/src/diagnostics/getInvalidValueDiagnostics.ts delete mode 100644 packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts diff --git a/packages/tailwindcss-language-service/src/codeActions/provideInvalidIdentifierCodeActions.ts b/packages/tailwindcss-language-service/src/codeActions/provideInvalidIdentifierCodeActions.ts index d3b30a3c..2aef1047 100644 --- a/packages/tailwindcss-language-service/src/codeActions/provideInvalidIdentifierCodeActions.ts +++ b/packages/tailwindcss-language-service/src/codeActions/provideInvalidIdentifierCodeActions.ts @@ -1,35 +1,46 @@ import { State } from '../util/state' -import type { - CodeActionParams, - CodeAction, +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 { - if (!diagnostic.suggestion) return []; - - debugger; - return [ - { - title: `Replace with '${diagnostic.suggestion}'`, - kind: 'quickfix', // CodeActionKind.QuickFix, - diagnostics: [diagnostic], - edit: { - changes: { - [params.textDocument.uri]: [ - { - range: diagnostic.range, - newText: diagnostic.suggestion, - }, - ], - }, - }, - }, - ] + 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 71de07c8..400b3f31 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts @@ -8,7 +8,7 @@ import { getInvalidVariantDiagnostics } from './getInvalidVariantDiagnostics' import { getInvalidConfigPathDiagnostics } from './getInvalidConfigPathDiagnostics' import { getInvalidTailwindDirectiveDiagnostics } from './getInvalidTailwindDirectiveDiagnostics' import { getRecommendedVariantOrderDiagnostics } from './getRecommendedVariantOrderDiagnostics' -import { getUnknownClassesDiagnostics } from './getUnknownClassesDiagnostics' +import { getInvalidValueDiagnostics } from './getInvalidValueDiagnostics' export async function doValidate( state: State, @@ -29,7 +29,7 @@ export async function doValidate( return settings.tailwindCSS.validate ? [ ...(only.includes(DiagnosticKind.InvalidIdentifier) - ? await getUnknownClassesDiagnostics(state, document, settings) + ? await getInvalidValueDiagnostics(state, document, settings) : []), ...(only.includes(DiagnosticKind.CssConflict) ? await getCssConflictDiagnostics(state, document, settings) 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/getUnknownClassesDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts deleted file mode 100644 index 1982a467..00000000 --- a/packages/tailwindcss-language-service/src/diagnostics/getUnknownClassesDiagnostics.ts +++ /dev/null @@ -1,257 +0,0 @@ -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 { Range } from 'vscode-languageserver' - -function createDiagnostic(className: DocumentClassName, range: Range, message: string, suggestion?: string): InvalidIdentifierDiagnostic -{ - return({ - code: DiagnosticKind.InvalidIdentifier, - severity: 3, - range: range, - message, - className, - 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 handleClass(state: State, - className: DocumentClassName, - chunk: string, - classes: {[key: string]: State['classList'][0] }, - noNumericClasses: {[key: string]: string[]}, - range: Range - ) -{ - if (chunk.indexOf('[') != -1 || classes[chunk] != undefined) { - return null; - } - - let nonNumericChunk = chunk.split('-'); - let nonNumericRemainder = nonNumericChunk.pop(); - const nonNumericValue = nonNumericChunk.join('-'); - - if (noNumericClasses[chunk]) - { - return createDiagnostic(className, range, `${chunk} requires an postfix. Choose between ${noNumericClasses[chunk].join(', -')}.`) - } - - if (classes[nonNumericValue]) - { - return createDiagnostic(className, range, `${nonNumericValue} requires no postfix.`, nonNumericValue) - } - - if (nonNumericValue && noNumericClasses[nonNumericValue]) - { - let closestSuggestion = { - value: 0, - text: "" - }; - - for (let i = 0; i < noNumericClasses[nonNumericValue].length; i++) { - const e = 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, range, `${chunk} is an invalid value. Did you mean ${nonNumericValue + '-' + closestSuggestion.text}? (${closestSuggestion.value})`, nonNumericValue + '-' + closestSuggestion.text) - } - else - { - return createDiagnostic(className, range, `${chunk} is an invalid value. Choose between ${noNumericClasses[nonNumericValue].join(', ')}.`) - } - } - - // get similar as suggestion - let closestSuggestion = { - value: 0, - text: "" - }; - for (let i = 0; i < state.classList.length; i++) { - const e = state.classList[i]; - const match = similarity(e[0], className.className); - if (match > 0.5 && match > closestSuggestion.value) { - closestSuggestion = { - value: match, - text: e[0] - } - } - } - - if (closestSuggestion.text) - { - return createDiagnostic(className, range, `${chunk} was not found in the registry. Did you mean ${closestSuggestion.text} (${closestSuggestion.value})?`, closestSuggestion.text) - } - else - { - return createDiagnostic(className, range, `${chunk} was not found in the registry.`) - } -} - -function handleVariant(state: State, className: DocumentClassName, chunk: string, variants: {[key: string]: Variant }, range: Range) -{ - if (chunk.indexOf('[') != -1 || variants[chunk]) { - return null; - } - - // get similar as suggestion - let closestSuggestion = { - value: 0, - text: "" - }; - - Object.keys(variants).forEach(key => { - const variant = variants[key]; - const match = similarity(variant.name, chunk); - if (match >= 0.5 && match > closestSuggestion.value) { - closestSuggestion = { - value: match, - text: variant.name - } - } - }) - - - if (closestSuggestion.text) - { - return createDiagnostic(className, range, `${chunk} is an invalid variant. Did you mean ${closestSuggestion.text} (${closestSuggestion.value})?`, closestSuggestion.text) - } - else - { - return createDiagnostic(className, range, `${chunk} is an invalid variant.`); - } - -} - -export async function getUnknownClassesDiagnostics( - state: State, - document: TextDocument, - settings: Settings -): Promise { - let severity = settings.tailwindCSS.lint.invalidClass - 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 (index == splitted.length - 1) - { - items.push(handleClass(state, className, chunk, classes, noNumericClasses, range)); - } - else - { - items.push(handleVariant(state, 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 5722bb56..2a5d260f 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/types.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/types.ts @@ -16,6 +16,7 @@ export type InvalidIdentifierDiagnostic = Diagnostic & { code: DiagnosticKind.InvalidIdentifier className: DocumentClassName, suggestion?: string, + chunk: string, otherClassNames: DocumentClassName[] } diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index f55cae60..b0c4dfee 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -51,12 +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) {