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) {