diff --git a/src/lsp/providers/codeActionProvider/index.ts b/src/lsp/providers/codeActions/codeActionProvider.ts similarity index 85% rename from src/lsp/providers/codeActionProvider/index.ts rename to src/lsp/providers/codeActions/codeActionProvider.ts index 22298da..62d2f09 100644 --- a/src/lsp/providers/codeActionProvider/index.ts +++ b/src/lsp/providers/codeActions/codeActionProvider.ts @@ -33,6 +33,7 @@ import { joinWithAnd } from '../../util/joinWithAnd' import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' import { isCssDoc } from '../../util/css' import { absoluteRange } from '../../util/absoluteRange' +import type { NodeSource, Root } from 'postcss' async function getDiagnosticsFromCodeActionParams( state: State, @@ -201,6 +202,19 @@ async function provideUtilityConflictsCodeActions( ] } +function postcssSourceToRange(source: NodeSource): Range { + return { + start: { + line: source.start.line - 1, + character: source.start.column - 1, + }, + end: { + line: source.end.line - 1, + character: source.end.column, + }, + } +} + async function provideInvalidApplyCodeActions( state: State, params: CodeActionParams, @@ -211,7 +225,7 @@ async function provideInvalidApplyCodeActions( let cssRange: Range let cssText = documentText const { postcss } = state.modules - let change: TextEdit + let changes: TextEdit[] = [] let totalClassNamesInClassList = diagnostic.className.classList.classList.split( /\s+/ @@ -238,30 +252,18 @@ async function provideInvalidApplyCodeActions( try { await postcss([ postcss.plugin('', (_options = {}) => { - return (root) => { + return (root: Root) => { root.walkRules((rule) => { - if (change) return false + if (changes.length) return false rule.walkAtRules('apply', (atRule) => { - let { start, end } = atRule.source - let atRuleRange: Range = { - start: { - line: start.line - 1, - character: start.column - 1, - }, - end: { - line: end.line - 1, - character: end.column - 1, - }, - } + let atRuleRange = postcssSourceToRange(atRule.source) if (cssRange) { atRuleRange = absoluteRange(atRuleRange, cssRange) } - if (!isWithinRange(diagnostic.range.start, atRuleRange)) { - // keep looking + if (!isWithinRange(diagnostic.range.start, atRuleRange)) return true - } let ast = classNameToAst( state, @@ -270,35 +272,33 @@ async function provideInvalidApplyCodeActions( diagnostic.className.classList.important ) - if (!ast) { - return false - } + if (!ast) return false rule.after(ast.nodes) let insertedRule = rule.next() + if (!insertedRule) return false if (totalClassNamesInClassList === 1) { atRule.remove() + } else { + changes.push({ + range: diagnostic.className.classList.range, + newText: removeRangesFromString( + diagnostic.className.classList.classList, + diagnostic.className.relativeRange + ), + }) + } + + let ruleRange = postcssSourceToRange(rule.source) + if (cssRange) { + ruleRange = absoluteRange(ruleRange, cssRange) } let outputIndent: string let documentIndent = detectIndent(documentText) - let ruleRange: Range = { - start: { - line: rule.source.start.line - 1, - character: rule.source.start.column - 1, - }, - end: { - line: rule.source.end.line - 1, - character: rule.source.end.column, - }, - } - if (cssRange) { - ruleRange = absoluteRange(ruleRange, cssRange) - } - - change = { + changes.push({ range: ruleRange, newText: rule.toString() + @@ -315,7 +315,7 @@ async function provideInvalidApplyCodeActions( documentIndent.indent ) }), - } + }) return false }) @@ -327,7 +327,7 @@ async function provideInvalidApplyCodeActions( return [] } - if (!change) { + if (!changes.length) { return [] } @@ -338,20 +338,7 @@ async function provideInvalidApplyCodeActions( diagnostics: [diagnostic], edit: { changes: { - [params.textDocument.uri]: [ - ...(totalClassNamesInClassList > 1 - ? [ - { - range: diagnostic.className.classList.range, - newText: removeRangesFromString( - diagnostic.className.classList.classList, - diagnostic.className.relativeRange - ), - }, - ] - : []), - change, - ], + [params.textDocument.uri]: changes, }, }, }, diff --git a/src/lsp/providers/diagnostics/diagnosticsProvider.ts b/src/lsp/providers/diagnostics/diagnosticsProvider.ts index 91ca0e3..60956f0 100644 --- a/src/lsp/providers/diagnostics/diagnosticsProvider.ts +++ b/src/lsp/providers/diagnostics/diagnosticsProvider.ts @@ -1,516 +1,13 @@ -import { TextDocument, DiagnosticSeverity, Range } from 'vscode-languageserver' -import { State, Settings } from '../../util/state' -import { isCssDoc } from '../../util/css' -import { - findClassNamesInRange, - findClassListsInDocument, - getClassNamesInClassList, - findAll, - indexToPosition, -} from '../../util/find' -import { getClassNameMeta } from '../../util/getClassNameMeta' -import { getClassNameDecls } from '../../util/getClassNameDecls' -import { equal } from '../../../util/array' +import { TextDocument } from 'vscode-languageserver' +import { State } from '../../util/state' import { getDocumentSettings } from '../../util/getDocumentSettings' -const dlv = require('dlv') -import semver from 'semver' -import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' -import { absoluteRange } from '../../util/absoluteRange' -import { isObject } from '../../../class-names/isObject' -import { stringToPath } from '../../util/stringToPath' -import { closest } from '../../util/closest' -import { - InvalidApplyDiagnostic, - DiagnosticKind, - UtilityConflictsDiagnostic, - InvalidScreenDiagnostic, - InvalidVariantDiagnostic, - InvalidConfigPathDiagnostic, - InvalidTailwindDirectiveDiagnostic, - AugmentedDiagnostic, -} from './types' -import { joinWithAnd } from '../../util/joinWithAnd' - -function getInvalidApplyDiagnostics( - state: State, - document: TextDocument, - settings: Settings -): InvalidApplyDiagnostic[] { - let severity = settings.lint.invalidApply - if (severity === 'ignore') return [] - - const classNames = findClassNamesInRange(document, undefined, 'css') - - let diagnostics: InvalidApplyDiagnostic[] = classNames.map((className) => { - const meta = getClassNameMeta(state, className.className) - if (!meta) return null - - let message: string - - if (Array.isArray(meta)) { - message = `'@apply' cannot be used with '${className.className}' because it is included in multiple rulesets.` - } else if (meta.source !== 'utilities') { - message = `'@apply' cannot be used with '${className.className}' because it is not a utility.` - } else if (meta.context && meta.context.length > 0) { - if (meta.context.length === 1) { - message = `'@apply' cannot be used with '${className.className}' because it is nested inside of an at-rule ('${meta.context[0]}').` - } else { - message = `'@apply' cannot be used with '${ - className.className - }' because it is nested inside of at-rules (${meta.context - .map((c) => `'${c}'`) - .join(', ')}).` - } - } else if (meta.pseudo && meta.pseudo.length > 0) { - if (meta.pseudo.length === 1) { - message = `'@apply' cannot be used with '${className.className}' because its definition includes a pseudo-selector ('${meta.pseudo[0]}')` - } else { - message = `'@apply' cannot be used with '${ - className.className - }' because its definition includes pseudo-selectors (${meta.pseudo - .map((p) => `'${p}'`) - .join(', ')}).` - } - } - - if (!message) return null - - return { - code: DiagnosticKind.InvalidApply, - severity: - severity === 'error' - ? DiagnosticSeverity.Error - : DiagnosticSeverity.Warning, - range: className.range, - message, - className, - } - }) - - return diagnostics.filter(Boolean) -} - -function getUtilityConflictDiagnostics( - state: State, - document: TextDocument, - settings: Settings -): UtilityConflictsDiagnostic[] { - let severity = settings.lint.utilityConflicts - if (severity === 'ignore') return [] - - let diagnostics: UtilityConflictsDiagnostic[] = [] - const classLists = findClassListsInDocument(state, document) - - classLists.forEach((classList) => { - const classNames = getClassNamesInClassList(classList) - - classNames.forEach((className, index) => { - let decls = getClassNameDecls(state, className.className) - if (!decls) return - - let properties = Object.keys(decls) - let meta = getClassNameMeta(state, className.className) - - let otherClassNames = classNames.filter((_className, i) => i !== index) - - let conflictingClassNames = otherClassNames.filter((otherClassName) => { - let otherDecls = getClassNameDecls(state, otherClassName.className) - if (!otherDecls) return false - - let otherMeta = getClassNameMeta(state, otherClassName.className) - - return ( - equal(properties, Object.keys(otherDecls)) && - !Array.isArray(meta) && - !Array.isArray(otherMeta) && - equal(meta.context, otherMeta.context) && - equal(meta.pseudo, otherMeta.pseudo) - ) - }) - - if (conflictingClassNames.length === 0) return - - diagnostics.push({ - code: DiagnosticKind.UtilityConflicts, - className, - otherClassNames: conflictingClassNames, - range: className.range, - severity: - severity === 'error' - ? DiagnosticSeverity.Error - : DiagnosticSeverity.Warning, - message: `'${className.className}' applies the same CSS ${ - properties.length === 1 ? 'property' : 'properties' - } as ${joinWithAnd( - conflictingClassNames.map( - (conflictingClassName) => `'${conflictingClassName.className}'` - ) - )}.`, - relatedInformation: conflictingClassNames.map( - (conflictingClassName) => { - return { - message: conflictingClassName.className, - location: { - uri: document.uri, - range: conflictingClassName.range, - }, - } - } - ), - }) - }) - }) - - return diagnostics -} - -function getInvalidScreenDiagnostics( - state: State, - document: TextDocument, - settings: Settings -): InvalidScreenDiagnostic[] { - let severity = settings.lint.invalidScreen - if (severity === 'ignore') return [] - - let diagnostics: InvalidScreenDiagnostic[] = [] - let ranges: Range[] = [] - - if (isCssDoc(state, document)) { - ranges.push(undefined) - } else { - let boundaries = getLanguageBoundaries(state, document) - if (!boundaries) return [] - ranges.push(...boundaries.css) - } - - ranges.forEach((range) => { - let text = document.getText(range) - let matches = findAll(/(?:\s|^)@screen\s+(?[^\s{]+)/g, text) - - let screens = Object.keys( - dlv(state.config, 'theme.screens', dlv(state.config, 'screens', {})) - ) - - matches.forEach((match) => { - if (screens.includes(match.groups.screen)) { - return null - } - - let message = `The screen '${match.groups.screen}' does not exist in your theme config.` - let suggestions: string[] = [] - let suggestion = closest(match.groups.screen, screens) - - if (suggestion) { - suggestions.push(suggestion) - message += ` Did you mean '${suggestion}'?` - } - - diagnostics.push({ - code: DiagnosticKind.InvalidScreen, - range: absoluteRange( - { - start: indexToPosition( - text, - match.index + match[0].length - match.groups.screen.length - ), - end: indexToPosition(text, match.index + match[0].length), - }, - range - ), - severity: - severity === 'error' - ? DiagnosticSeverity.Error - : DiagnosticSeverity.Warning, - message, - suggestions, - }) - }) - }) - - return diagnostics -} - -function getInvalidVariantDiagnostics( - state: State, - document: TextDocument, - settings: Settings -): InvalidVariantDiagnostic[] { - let severity = settings.lint.invalidVariant - if (severity === 'ignore') return [] - - let diagnostics: InvalidVariantDiagnostic[] = [] - let ranges: Range[] = [] - - if (isCssDoc(state, document)) { - ranges.push(undefined) - } else { - let boundaries = getLanguageBoundaries(state, document) - if (!boundaries) return [] - ranges.push(...boundaries.css) - } - - ranges.forEach((range) => { - let text = document.getText(range) - let matches = findAll(/(?:\s|^)@variants\s+(?[^{]+)/g, text) - - matches.forEach((match) => { - let variants = match.groups.variants.split(/(\s*,\s*)/) - let listStartIndex = - match.index + match[0].length - match.groups.variants.length - - for (let i = 0; i < variants.length; i += 2) { - let variant = variants[i].trim() - if (state.variants.includes(variant)) { - continue - } - - let message = `The variant '${variant}' does not exist.` - let suggestions: string[] = [] - let suggestion = closest(variant, state.variants) - - if (suggestion) { - suggestions.push(suggestion) - message += ` Did you mean '${suggestion}'?` - } - - let variantStartIndex = - listStartIndex + variants.slice(0, i).join('').length - - diagnostics.push({ - code: DiagnosticKind.InvalidVariant, - range: absoluteRange( - { - start: indexToPosition(text, variantStartIndex), - end: indexToPosition(text, variantStartIndex + variant.length), - }, - range - ), - severity: - severity === 'error' - ? DiagnosticSeverity.Error - : DiagnosticSeverity.Warning, - message, - suggestions, - }) - } - }) - }) - - return diagnostics -} - -function getInvalidConfigPathDiagnostics( - state: State, - document: TextDocument, - settings: Settings -): InvalidConfigPathDiagnostic[] { - let severity = settings.lint.invalidConfigPath - if (severity === 'ignore') return [] - - let diagnostics: InvalidConfigPathDiagnostic[] = [] - let ranges: Range[] = [] - - if (isCssDoc(state, document)) { - ranges.push(undefined) - } else { - let boundaries = getLanguageBoundaries(state, document) - if (!boundaries) return [] - ranges.push(...boundaries.css) - } - - ranges.forEach((range) => { - let text = document.getText(range) - let matches = findAll( - /(?\s|^)(?config|theme)\((?['"])(?[^)]+)\k\)/g, - text - ) - - matches.forEach((match) => { - let base = match.groups.helper === 'theme' ? ['theme'] : [] - let keys = stringToPath(match.groups.key) - let value = dlv(state.config, [...base, ...keys]) - - const isValid = (val: unknown): boolean => - typeof val === 'string' || - typeof val === 'number' || - val instanceof String || - val instanceof Number || - Array.isArray(val) - - const stitch = (keys: string[]): string => - keys.reduce((acc, cur, i) => { - if (i === 0) return cur - if (cur.includes('.')) return `${acc}[${cur}]` - return `${acc}.${cur}` - }, '') - - let message: string - let suggestions: string[] = [] - - if (isValid(value)) { - // The value resolves successfully, but we need to check that there - // wasn't any funny business. If you have a theme object: - // { msg: 'hello' } and do theme('msg.0') - // this will resolve to 'h', which is probably not intentional, so we - // check that all of the keys are object or array keys (i.e. not string - // indexes) - let valid = true - for (let i = keys.length - 1; i >= 0; i--) { - let key = keys[i] - let parentValue = dlv(state.config, [...base, ...keys.slice(0, i)]) - if (/^[0-9]+$/.test(key)) { - if (!isObject(parentValue) && !Array.isArray(parentValue)) { - valid = false - break - } - } else if (!isObject(parentValue)) { - valid = false - break - } - } - if (!valid) { - message = `'${match.groups.key}' does not exist in your theme config.` - } - } else if (typeof value === 'undefined') { - message = `'${match.groups.key}' does not exist in your theme config.` - let parentValue = dlv(state.config, [ - ...base, - ...keys.slice(0, keys.length - 1), - ]) - if (isObject(parentValue)) { - let closestValidKey = closest( - keys[keys.length - 1], - Object.keys(parentValue).filter((key) => isValid(parentValue[key])) - ) - if (closestValidKey) { - suggestions.push( - stitch([...keys.slice(0, keys.length - 1), closestValidKey]) - ) - message += ` Did you mean '${suggestions[0]}'?` - } - } - } else { - message = `'${match.groups.key}' was found but does not resolve to a string.` - - if (isObject(value)) { - let validKeys = Object.keys(value).filter((key) => - isValid(value[key]) - ) - if (validKeys.length) { - suggestions.push( - ...validKeys.map((validKey) => stitch([...keys, validKey])) - ) - message += ` Did you mean something like '${suggestions[0]}'?` - } - } - } - - if (!message) { - return null - } - - let startIndex = - match.index + - match.groups.prefix.length + - match.groups.helper.length + - 1 + // open paren - match.groups.quote.length - - diagnostics.push({ - code: DiagnosticKind.InvalidConfigPath, - range: absoluteRange( - { - start: indexToPosition(text, startIndex), - end: indexToPosition(text, startIndex + match.groups.key.length), - }, - range - ), - severity: - severity === 'error' - ? DiagnosticSeverity.Error - : DiagnosticSeverity.Warning, - message, - suggestions, - }) - }) - }) - - return diagnostics -} - -function getInvalidTailwindDirectiveDiagnostics( - state: State, - document: TextDocument, - settings: Settings -): InvalidTailwindDirectiveDiagnostic[] { - let severity = settings.lint.invalidTailwindDirective - if (severity === 'ignore') return [] - - let diagnostics: InvalidTailwindDirectiveDiagnostic[] = [] - let ranges: Range[] = [] - - if (isCssDoc(state, document)) { - ranges.push(undefined) - } else { - let boundaries = getLanguageBoundaries(state, document) - if (!boundaries) return [] - ranges.push(...boundaries.css) - } - - ranges.forEach((range) => { - let text = document.getText(range) - let matches = findAll(/(?:\s|^)@tailwind\s+(?[^;]+)/g, text) - - let valid = [ - 'utilities', - 'components', - 'screens', - semver.gte(state.version, '1.0.0-beta.1') ? 'base' : 'preflight', - ] - - matches.forEach((match) => { - if (valid.includes(match.groups.value)) { - return null - } - - let message = `'${match.groups.value}' is not a valid group.` - let suggestions: string[] = [] - - if (match.groups.value === 'preflight') { - suggestions.push('base') - message += ` Did you mean 'base'?` - } else { - let suggestion = closest(match.groups.value, valid) - if (suggestion) { - suggestions.push(suggestion) - message += ` Did you mean '${suggestion}'?` - } - } - - diagnostics.push({ - code: DiagnosticKind.InvalidTailwindDirective, - range: absoluteRange( - { - start: indexToPosition( - text, - match.index + match[0].length - match.groups.value.length - ), - end: indexToPosition(text, match.index + match[0].length), - }, - range - ), - severity: - severity === 'error' - ? DiagnosticSeverity.Error - : DiagnosticSeverity.Warning, - message, - suggestions, - }) - }) - }) - - return diagnostics -} +import { DiagnosticKind, AugmentedDiagnostic } from './types' +import { getUtilityConflictDiagnostics } from './getUtilityConflictDiagnostics' +import { getInvalidApplyDiagnostics } from './getInvalidApplyDiagnostics' +import { getInvalidScreenDiagnostics } from './getInvalidScreenDiagnostics' +import { getInvalidVariantDiagnostics } from './getInvalidVariantDiagnostics' +import { getInvalidConfigPathDiagnostics } from './getInvalidConfigPathDiagnostics' +import { getInvalidTailwindDirectiveDiagnostics } from './getInvalidTailwindDirectiveDiagnostics' export async function getDiagnostics( state: State, diff --git a/src/lsp/providers/diagnostics/getInvalidApplyDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidApplyDiagnostics.ts new file mode 100644 index 0000000..66fc540 --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidApplyDiagnostics.ts @@ -0,0 +1,64 @@ +import { findClassNamesInRange } from '../../util/find' +import { InvalidApplyDiagnostic, DiagnosticKind } from './types' +import { Settings, State } from '../../util/state' +import { TextDocument, DiagnosticSeverity } from 'vscode-languageserver' +import { getClassNameMeta } from '../../util/getClassNameMeta' + +export function getInvalidApplyDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidApplyDiagnostic[] { + let severity = settings.lint.invalidApply + if (severity === 'ignore') return [] + + const classNames = findClassNamesInRange(document, undefined, 'css') + + let diagnostics: InvalidApplyDiagnostic[] = classNames.map((className) => { + const meta = getClassNameMeta(state, className.className) + if (!meta) return null + + let message: string + + if (Array.isArray(meta)) { + message = `'@apply' cannot be used with '${className.className}' because it is included in multiple rulesets.` + } else if (meta.source !== 'utilities') { + message = `'@apply' cannot be used with '${className.className}' because it is not a utility.` + } else if (meta.context && meta.context.length > 0) { + if (meta.context.length === 1) { + message = `'@apply' cannot be used with '${className.className}' because it is nested inside of an at-rule ('${meta.context[0]}').` + } else { + message = `'@apply' cannot be used with '${ + className.className + }' because it is nested inside of at-rules (${meta.context + .map((c) => `'${c}'`) + .join(', ')}).` + } + } else if (meta.pseudo && meta.pseudo.length > 0) { + if (meta.pseudo.length === 1) { + message = `'@apply' cannot be used with '${className.className}' because its definition includes a pseudo-selector ('${meta.pseudo[0]}')` + } else { + message = `'@apply' cannot be used with '${ + className.className + }' because its definition includes pseudo-selectors (${meta.pseudo + .map((p) => `'${p}'`) + .join(', ')}).` + } + } + + if (!message) return null + + return { + code: DiagnosticKind.InvalidApply, + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + range: className.range, + message, + className, + } + }) + + return diagnostics.filter(Boolean) +} diff --git a/src/lsp/providers/diagnostics/getInvalidConfigPathDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidConfigPathDiagnostics.ts new file mode 100644 index 0000000..9529334 --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidConfigPathDiagnostics.ts @@ -0,0 +1,150 @@ +import { State, Settings } from '../../util/state' +import { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' +import { InvalidConfigPathDiagnostic, DiagnosticKind } from './types' +import { isCssDoc } from '../../util/css' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { findAll, indexToPosition } from '../../util/find' +import { stringToPath } from '../../util/stringToPath' +import isObject from '../../../util/isObject' +import { closest } from '../../util/closest' +import { absoluteRange } from '../../util/absoluteRange' +const dlv = require('dlv') + +export function getInvalidConfigPathDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidConfigPathDiagnostic[] { + let severity = settings.lint.invalidConfigPath + if (severity === 'ignore') return [] + + let diagnostics: InvalidConfigPathDiagnostic[] = [] + let ranges: Range[] = [] + + if (isCssDoc(state, document)) { + ranges.push(undefined) + } else { + let boundaries = getLanguageBoundaries(state, document) + if (!boundaries) return [] + ranges.push(...boundaries.css) + } + + ranges.forEach((range) => { + let text = document.getText(range) + let matches = findAll( + /(?\s|^)(?config|theme)\((?['"])(?[^)]+)\k\)/g, + text + ) + + matches.forEach((match) => { + let base = match.groups.helper === 'theme' ? ['theme'] : [] + let keys = stringToPath(match.groups.key) + let value = dlv(state.config, [...base, ...keys]) + + const isValid = (val: unknown): boolean => + typeof val === 'string' || + typeof val === 'number' || + val instanceof String || + val instanceof Number || + Array.isArray(val) + + const stitch = (keys: string[]): string => + keys.reduce((acc, cur, i) => { + if (i === 0) return cur + if (cur.includes('.')) return `${acc}[${cur}]` + return `${acc}.${cur}` + }, '') + + let message: string + let suggestions: string[] = [] + + if (isValid(value)) { + // The value resolves successfully, but we need to check that there + // wasn't any funny business. If you have a theme object: + // { msg: 'hello' } and do theme('msg.0') + // this will resolve to 'h', which is probably not intentional, so we + // check that all of the keys are object or array keys (i.e. not string + // indexes) + let valid = true + for (let i = keys.length - 1; i >= 0; i--) { + let key = keys[i] + let parentValue = dlv(state.config, [...base, ...keys.slice(0, i)]) + if (/^[0-9]+$/.test(key)) { + if (!isObject(parentValue) && !Array.isArray(parentValue)) { + valid = false + break + } + } else if (!isObject(parentValue)) { + valid = false + break + } + } + if (!valid) { + message = `'${match.groups.key}' does not exist in your theme config.` + } + } else if (typeof value === 'undefined') { + message = `'${match.groups.key}' does not exist in your theme config.` + let parentValue = dlv(state.config, [ + ...base, + ...keys.slice(0, keys.length - 1), + ]) + if (isObject(parentValue)) { + let closestValidKey = closest( + keys[keys.length - 1], + Object.keys(parentValue).filter((key) => isValid(parentValue[key])) + ) + if (closestValidKey) { + suggestions.push( + stitch([...keys.slice(0, keys.length - 1), closestValidKey]) + ) + message += ` Did you mean '${suggestions[0]}'?` + } + } + } else { + message = `'${match.groups.key}' was found but does not resolve to a string.` + + if (isObject(value)) { + let validKeys = Object.keys(value).filter((key) => + isValid(value[key]) + ) + if (validKeys.length) { + suggestions.push( + ...validKeys.map((validKey) => stitch([...keys, validKey])) + ) + message += ` Did you mean something like '${suggestions[0]}'?` + } + } + } + + if (!message) { + return null + } + + let startIndex = + match.index + + match.groups.prefix.length + + match.groups.helper.length + + 1 + // open paren + match.groups.quote.length + + diagnostics.push({ + code: DiagnosticKind.InvalidConfigPath, + range: absoluteRange( + { + start: indexToPosition(text, startIndex), + end: indexToPosition(text, startIndex + match.groups.key.length), + }, + range + ), + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message, + suggestions, + }) + }) + }) + + return diagnostics +} diff --git a/src/lsp/providers/diagnostics/getInvalidScreenDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidScreenDiagnostics.ts new file mode 100644 index 0000000..b0e4252 --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidScreenDiagnostics.ts @@ -0,0 +1,75 @@ +import { State, Settings } from '../../util/state' +import { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' +import { InvalidScreenDiagnostic, DiagnosticKind } from './types' +import { isCssDoc } from '../../util/css' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { findAll, indexToPosition } from '../../util/find' +import { closest } from '../../util/closest' +import { absoluteRange } from '../../util/absoluteRange' +const dlv = require('dlv') + +export function getInvalidScreenDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidScreenDiagnostic[] { + let severity = settings.lint.invalidScreen + if (severity === 'ignore') return [] + + let diagnostics: InvalidScreenDiagnostic[] = [] + let ranges: Range[] = [] + + if (isCssDoc(state, document)) { + ranges.push(undefined) + } else { + let boundaries = getLanguageBoundaries(state, document) + if (!boundaries) return [] + ranges.push(...boundaries.css) + } + + ranges.forEach((range) => { + let text = document.getText(range) + let matches = findAll(/(?:\s|^)@screen\s+(?[^\s{]+)/g, text) + + let screens = Object.keys( + dlv(state.config, 'theme.screens', dlv(state.config, 'screens', {})) + ) + + matches.forEach((match) => { + if (screens.includes(match.groups.screen)) { + return null + } + + let message = `The screen '${match.groups.screen}' does not exist in your theme config.` + let suggestions: string[] = [] + let suggestion = closest(match.groups.screen, screens) + + if (suggestion) { + suggestions.push(suggestion) + message += ` Did you mean '${suggestion}'?` + } + + diagnostics.push({ + code: DiagnosticKind.InvalidScreen, + range: absoluteRange( + { + start: indexToPosition( + text, + match.index + match[0].length - match.groups.screen.length + ), + end: indexToPosition(text, match.index + match[0].length), + }, + range + ), + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message, + suggestions, + }) + }) + }) + + return diagnostics +} diff --git a/src/lsp/providers/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts new file mode 100644 index 0000000..9b88bdb --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts @@ -0,0 +1,83 @@ +import { State, Settings } from '../../util/state' +import { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' +import { InvalidTailwindDirectiveDiagnostic, DiagnosticKind } from './types' +import { isCssDoc } from '../../util/css' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { findAll, indexToPosition } from '../../util/find' +import semver from 'semver' +import { closest } from '../../util/closest' +import { absoluteRange } from '../../util/absoluteRange' + +export function getInvalidTailwindDirectiveDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidTailwindDirectiveDiagnostic[] { + let severity = settings.lint.invalidTailwindDirective + if (severity === 'ignore') return [] + + let diagnostics: InvalidTailwindDirectiveDiagnostic[] = [] + let ranges: Range[] = [] + + if (isCssDoc(state, document)) { + ranges.push(undefined) + } else { + let boundaries = getLanguageBoundaries(state, document) + if (!boundaries) return [] + ranges.push(...boundaries.css) + } + + ranges.forEach((range) => { + let text = document.getText(range) + let matches = findAll(/(?:\s|^)@tailwind\s+(?[^;]+)/g, text) + + let valid = [ + 'utilities', + 'components', + 'screens', + semver.gte(state.version, '1.0.0-beta.1') ? 'base' : 'preflight', + ] + + matches.forEach((match) => { + if (valid.includes(match.groups.value)) { + return null + } + + let message = `'${match.groups.value}' is not a valid group.` + let suggestions: string[] = [] + + if (match.groups.value === 'preflight') { + suggestions.push('base') + message += ` Did you mean 'base'?` + } else { + let suggestion = closest(match.groups.value, valid) + if (suggestion) { + suggestions.push(suggestion) + message += ` Did you mean '${suggestion}'?` + } + } + + diagnostics.push({ + code: DiagnosticKind.InvalidTailwindDirective, + range: absoluteRange( + { + start: indexToPosition( + text, + match.index + match[0].length - match.groups.value.length + ), + end: indexToPosition(text, match.index + match[0].length), + }, + range + ), + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message, + suggestions, + }) + }) + }) + + return diagnostics +} diff --git a/src/lsp/providers/diagnostics/getInvalidVariantDiagnostics.ts b/src/lsp/providers/diagnostics/getInvalidVariantDiagnostics.ts new file mode 100644 index 0000000..0067401 --- /dev/null +++ b/src/lsp/providers/diagnostics/getInvalidVariantDiagnostics.ts @@ -0,0 +1,77 @@ +import { State, Settings } from '../../util/state' +import { TextDocument, Range, DiagnosticSeverity } from 'vscode-languageserver' +import { InvalidVariantDiagnostic, DiagnosticKind } from './types' +import { isCssDoc } from '../../util/css' +import { getLanguageBoundaries } from '../../util/getLanguageBoundaries' +import { findAll, indexToPosition } from '../../util/find' +import { closest } from '../../util/closest' +import { absoluteRange } from '../../util/absoluteRange' + +export function getInvalidVariantDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): InvalidVariantDiagnostic[] { + let severity = settings.lint.invalidVariant + if (severity === 'ignore') return [] + + let diagnostics: InvalidVariantDiagnostic[] = [] + let ranges: Range[] = [] + + if (isCssDoc(state, document)) { + ranges.push(undefined) + } else { + let boundaries = getLanguageBoundaries(state, document) + if (!boundaries) return [] + ranges.push(...boundaries.css) + } + + ranges.forEach((range) => { + let text = document.getText(range) + let matches = findAll(/(?:\s|^)@variants\s+(?[^{]+)/g, text) + + matches.forEach((match) => { + let variants = match.groups.variants.split(/(\s*,\s*)/) + let listStartIndex = + match.index + match[0].length - match.groups.variants.length + + for (let i = 0; i < variants.length; i += 2) { + let variant = variants[i].trim() + if (state.variants.includes(variant)) { + continue + } + + let message = `The variant '${variant}' does not exist.` + let suggestions: string[] = [] + let suggestion = closest(variant, state.variants) + + if (suggestion) { + suggestions.push(suggestion) + message += ` Did you mean '${suggestion}'?` + } + + let variantStartIndex = + listStartIndex + variants.slice(0, i).join('').length + + diagnostics.push({ + code: DiagnosticKind.InvalidVariant, + range: absoluteRange( + { + start: indexToPosition(text, variantStartIndex), + end: indexToPosition(text, variantStartIndex + variant.length), + }, + range + ), + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message, + suggestions, + }) + } + }) + }) + + return diagnostics +} diff --git a/src/lsp/providers/diagnostics/getUtilityConflictDiagnostics.ts b/src/lsp/providers/diagnostics/getUtilityConflictDiagnostics.ts new file mode 100644 index 0000000..80216e0 --- /dev/null +++ b/src/lsp/providers/diagnostics/getUtilityConflictDiagnostics.ts @@ -0,0 +1,85 @@ +import { joinWithAnd } from '../../util/joinWithAnd' +import { State, Settings } from '../../util/state' +import { TextDocument, DiagnosticSeverity } from 'vscode-languageserver' +import { UtilityConflictsDiagnostic, DiagnosticKind } from './types' +import { + findClassListsInDocument, + getClassNamesInClassList, +} from '../../util/find' +import { getClassNameDecls } from '../../util/getClassNameDecls' +import { getClassNameMeta } from '../../util/getClassNameMeta' +import { equal } from '../../../util/array' + +export function getUtilityConflictDiagnostics( + state: State, + document: TextDocument, + settings: Settings +): UtilityConflictsDiagnostic[] { + let severity = settings.lint.utilityConflicts + if (severity === 'ignore') return [] + + let diagnostics: UtilityConflictsDiagnostic[] = [] + const classLists = findClassListsInDocument(state, document) + + classLists.forEach((classList) => { + const classNames = getClassNamesInClassList(classList) + + classNames.forEach((className, index) => { + let decls = getClassNameDecls(state, className.className) + if (!decls) return + + let properties = Object.keys(decls) + let meta = getClassNameMeta(state, className.className) + + let otherClassNames = classNames.filter((_className, i) => i !== index) + + let conflictingClassNames = otherClassNames.filter((otherClassName) => { + let otherDecls = getClassNameDecls(state, otherClassName.className) + if (!otherDecls) return false + + let otherMeta = getClassNameMeta(state, otherClassName.className) + + return ( + equal(properties, Object.keys(otherDecls)) && + !Array.isArray(meta) && + !Array.isArray(otherMeta) && + equal(meta.context, otherMeta.context) && + equal(meta.pseudo, otherMeta.pseudo) + ) + }) + + if (conflictingClassNames.length === 0) return + + diagnostics.push({ + code: DiagnosticKind.UtilityConflicts, + className, + otherClassNames: conflictingClassNames, + range: className.range, + severity: + severity === 'error' + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + message: `'${className.className}' applies the same CSS ${ + properties.length === 1 ? 'property' : 'properties' + } as ${joinWithAnd( + conflictingClassNames.map( + (conflictingClassName) => `'${conflictingClassName.className}'` + ) + )}.`, + relatedInformation: conflictingClassNames.map( + (conflictingClassName) => { + return { + message: conflictingClassName.className, + location: { + uri: document.uri, + range: conflictingClassName.range, + }, + } + } + ), + }) + }) + }) + + return diagnostics +} diff --git a/src/lsp/server.ts b/src/lsp/server.ts index f4b8013..d543c30 100644 --- a/src/lsp/server.ts +++ b/src/lsp/server.ts @@ -34,7 +34,7 @@ import { clearAllDiagnostics, } from './providers/diagnostics/diagnosticsProvider' import { createEmitter } from '../lib/emitter' -import { provideCodeActions } from './providers/codeActionProvider' +import { provideCodeActions } from './providers/codeActions/codeActionProvider' let connection = createConnection(ProposedFeatures.all) let state: State = { enabled: false, emitter: createEmitter(connection) }